Align the notebook browser with the physical LCD, refresh real project captures and navigation, simplify service content, and stabilize hero hydration.
This commit is contained in:
1 parent
e2b20bc699
commit
8b12e51bc5
35 files changed
+2359
-1299
No files matched your search
@@ -1,61 +1,103 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CinematicHome from "./CinematicHome";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
jest.mock("next/dynamic", () => {
|
||||
const labels = [
|
||||
"About",
|
||||
"Skills",
|
||||
"Experience",
|
||||
"WorkModalities",
|
||||
"Contact",
|
||||
"LighthouseMetrics",
|
||||
"Footer",
|
||||
"ScrollVelocityText",
|
||||
"SectionDivider",
|
||||
];
|
||||
let index = 0;
|
||||
function HomeNavigation() {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={onLayoutReady}>
|
||||
Establish notebook layout
|
||||
</button>
|
||||
<a href="/#about">Sobre</a>
|
||||
<a href="/#contact">Contato</a>
|
||||
<section id="about" aria-label="Sobre">
|
||||
<button type="button">Read biography</button>
|
||||
</section>
|
||||
<section id="contact" aria-label="Contato">
|
||||
<button type="button">Send message</button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const label = labels[index++] ?? "DynamicSection";
|
||||
return function DynamicMock() {
|
||||
return <section data-testid={`home-section-${label}`}>{label}</section>;
|
||||
};
|
||||
};
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
});
|
||||
|
||||
jest.mock("../Hero", () => () => <section data-testid="home-section-Hero" />);
|
||||
jest.mock("../Projects", () => () => (
|
||||
<section data-testid="home-section-Projects" />
|
||||
));
|
||||
jest.mock("../story/NotebookStory", () => () => (
|
||||
<section data-testid="home-section-NotebookStory" />
|
||||
));
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", "/");
|
||||
if (originalScrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
} else {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView");
|
||||
}
|
||||
});
|
||||
|
||||
describe("CinematicHome", () => {
|
||||
it("renders each primary section directly in the published order", () => {
|
||||
const { container } = render(
|
||||
<CinematicHome experiences={[]} projects={[]} skillCategories={[]} />,
|
||||
);
|
||||
const main = container.querySelector("main");
|
||||
const sectionNames = [
|
||||
"Hero",
|
||||
"NotebookStory",
|
||||
"About",
|
||||
"Skills",
|
||||
"Experience",
|
||||
"Projects",
|
||||
"LighthouseMetrics",
|
||||
"WorkModalities",
|
||||
"Contact",
|
||||
];
|
||||
const sectionIds = sectionNames.map((name) => `home-section-${name}`);
|
||||
const directSections = Array.from(main?.children ?? []).filter((element) =>
|
||||
sectionIds.includes(element.getAttribute("data-testid") ?? ""),
|
||||
);
|
||||
describe("home fragment navigation", () => {
|
||||
it("waits for the notebook layout on fragment arrival, then never steals focus on refresh", () => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
render(<HomeNavigation />);
|
||||
const contact = screen.getByRole("region", { name: "Contato" });
|
||||
expect(contact).not.toHaveFocus();
|
||||
|
||||
expect(screen.getAllByTestId("home-section-NotebookStory")).toHaveLength(1);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(contact).toHaveFocus();
|
||||
screen.getByRole("button", { name: "Read biography" }).focus();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(
|
||||
directSections.map((section) => section.getAttribute("data-testid")),
|
||||
).toEqual(sectionIds);
|
||||
screen.getByRole("button", { name: "Read biography" }),
|
||||
).toHaveFocus();
|
||||
});
|
||||
|
||||
it("abandons a pending arrival when the visitor intentionally scrolls", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
const message = screen.getByRole("button", { name: "Send message" });
|
||||
message.focus();
|
||||
fireEvent.wheel(window, { deltaY: 200 });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps repeated same-fragment links native while moving focus to the destination", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
const link = screen.getByRole("link", { name: "Sobre" });
|
||||
link.focus();
|
||||
expect(fireEvent.click(link)).toBe(true);
|
||||
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
|
||||
link.focus();
|
||||
expect(fireEvent.click(link)).toBe(true);
|
||||
expect(screen.getByRole("region", { name: "Sobre" })).toHaveFocus();
|
||||
link.focus();
|
||||
fireEvent.click(link, { ctrlKey: true });
|
||||
expect(link).toHaveFocus();
|
||||
});
|
||||
|
||||
it("does not resurrect a stale fragment when the URL changes before readiness", () => {
|
||||
window.history.replaceState(null, "", "/#about");
|
||||
render(<HomeNavigation />);
|
||||
const message = screen.getByRole("button", { name: "Send message" });
|
||||
message.focus();
|
||||
act(() => {
|
||||
window.history.replaceState(null, "", "/#contact");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Establish notebook layout" }),
|
||||
);
|
||||
expect(message).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type {
|
||||
PortfolioExperience,
|
||||
PortfolioProjectView,
|
||||
PortfolioSkillCategory,
|
||||
} from "@/lib/portfolio-data";
|
||||
import About from "../About";
|
||||
import Contact from "../Contact";
|
||||
import Experience from "../Experience";
|
||||
import Footer from "../Footer";
|
||||
import Hero from "../Hero";
|
||||
import LighthouseMetrics from "../LighthouseMetrics";
|
||||
import Projects from "../Projects";
|
||||
import { SectionLoading } from "../SectionLoading";
|
||||
import Skills from "../Skills";
|
||||
import NotebookStory from "../story/NotebookStory";
|
||||
|
||||
const About = dynamic(() => import("../About"), {
|
||||
loading: () => <SectionLoading message="Carregando..." />,
|
||||
});
|
||||
const Skills = dynamic(() => import("../Skills"), {
|
||||
loading: () => <SectionLoading message="Carregando..." />,
|
||||
});
|
||||
const Experience = dynamic(() => import("../Experience"), {
|
||||
loading: () => <SectionLoading message="Carregando..." />,
|
||||
});
|
||||
const WorkModalities = dynamic(
|
||||
() => import("../WorkModalities").then((mod) => mod.WorkModalities),
|
||||
{ loading: () => <SectionLoading message="Carregando..." /> },
|
||||
);
|
||||
const Contact = dynamic(() => import("../Contact"), {
|
||||
loading: () => <SectionLoading message="Carregando..." />,
|
||||
});
|
||||
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), {
|
||||
loading: () => <SectionLoading message="Carregando..." />,
|
||||
});
|
||||
const Footer = dynamic(() => import("../Footer"));
|
||||
|
||||
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), {
|
||||
ssr: false,
|
||||
});
|
||||
const SectionDivider = dynamic(() => import("./SectionDivider"), {
|
||||
ssr: false,
|
||||
});
|
||||
import { WorkModalities } from "../WorkModalities";
|
||||
import ScrollVelocityText from "./ScrollVelocityText";
|
||||
import SectionDivider from "./SectionDivider";
|
||||
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
|
||||
|
||||
export default function CinematicHome({
|
||||
projects,
|
||||
@@ -48,6 +28,8 @@ export default function CinematicHome({
|
||||
experiences: PortfolioExperience[];
|
||||
skillCategories: PortfolioSkillCategory[];
|
||||
}) {
|
||||
const onLayoutReady = useHomeFragmentNavigation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
<main className="ds-main">
|
||||
@@ -58,7 +40,7 @@ export default function CinematicHome({
|
||||
<ScrollVelocityText text="SOFTWARE ENGINEERING" direction="left" />
|
||||
</div>
|
||||
|
||||
<NotebookStory projects={projects} />
|
||||
<NotebookStory projects={projects} onLayoutReady={onLayoutReady} />
|
||||
|
||||
<About />
|
||||
|
||||
|
||||
@@ -17,14 +17,11 @@ export default function ScrollVelocityText({
|
||||
}: ScrollVelocityTextProps) {
|
||||
const trackId = useId();
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setShouldReduceMotion(media.matches);
|
||||
const updatePreference = (event: MediaQueryListEvent) => {
|
||||
setShouldReduceMotion(event.matches);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
function fragmentTarget(hash: string) {
|
||||
if (!hash) return null;
|
||||
try {
|
||||
return document.getElementById(decodeURIComponent(hash.slice(1)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function focusFragment(target: HTMLElement) {
|
||||
if (!target.hasAttribute("tabindex")) target.tabIndex = -1;
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/** Correct only an outstanding arrival; ordinary links and history keep native scrolling. */
|
||||
export function useHomeFragmentNavigation() {
|
||||
const ready = useRef(false);
|
||||
const resolveArrival = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const navigation = performance.getEntriesByType?.("navigation")[0] as
|
||||
| PerformanceNavigationTiming
|
||||
| undefined;
|
||||
// A history restoration owns its saved position, not the URL's old fragment.
|
||||
let pending =
|
||||
navigation?.type === "back_forward" ? "" : window.location.hash;
|
||||
|
||||
const resolve = () => {
|
||||
if (!ready.current || !pending) return;
|
||||
const hash = pending;
|
||||
pending = "";
|
||||
if (hash !== window.location.hash) return;
|
||||
const target = fragmentTarget(hash);
|
||||
if (!target) return;
|
||||
focusFragment(target);
|
||||
target.scrollIntoView({ block: "start", behavior: "instant" });
|
||||
};
|
||||
|
||||
const cancelArrival = () => {
|
||||
pending = "";
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
[
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
" ",
|
||||
"Tab",
|
||||
].includes(event.key)
|
||||
) {
|
||||
cancelArrival();
|
||||
}
|
||||
};
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
)
|
||||
return;
|
||||
const link =
|
||||
event.target instanceof Element
|
||||
? event.target.closest("a[href]")
|
||||
: null;
|
||||
if (
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
link.hasAttribute("download") ||
|
||||
(link.target && link.target !== "_self")
|
||||
)
|
||||
return;
|
||||
const url = new URL(link.href);
|
||||
if (
|
||||
url.origin !== window.location.origin ||
|
||||
url.pathname !== window.location.pathname ||
|
||||
url.search !== window.location.search
|
||||
)
|
||||
return;
|
||||
const target = fragmentTarget(url.hash);
|
||||
if (!target) return;
|
||||
pending = ready.current ? "" : url.hash;
|
||||
// Capture before a mobile menu removes its activated link. Do not cancel navigation.
|
||||
focusFragment(target);
|
||||
};
|
||||
const onHashChange = () => {
|
||||
if (pending && pending !== window.location.hash) cancelArrival();
|
||||
if (!ready.current) return;
|
||||
const target = fragmentTarget(window.location.hash);
|
||||
if (target) focusFragment(target);
|
||||
};
|
||||
|
||||
resolveArrival.current = resolve;
|
||||
resolve();
|
||||
document.addEventListener("click", onClick, true);
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
window.addEventListener("wheel", cancelArrival, { passive: true });
|
||||
window.addEventListener("touchmove", cancelArrival, { passive: true });
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("pointerdown", cancelArrival, { passive: true });
|
||||
return () => {
|
||||
resolveArrival.current = null;
|
||||
document.removeEventListener("click", onClick, true);
|
||||
window.removeEventListener("hashchange", onHashChange);
|
||||
window.removeEventListener("wheel", cancelArrival);
|
||||
window.removeEventListener("touchmove", cancelArrival);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("pointerdown", cancelArrival);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback(() => {
|
||||
ready.current = true;
|
||||
resolveArrival.current?.();
|
||||
}, []);
|
||||
}
|
||||
Reference in new issue
Block a user