Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions frontend/src/components/DashboardLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("DashboardLayout", () => {
let container: HTMLDivElement | null = null;

afterEach(() => {
vi.useRealTimers();
if (root) {
act(() => root?.unmount());
}
Expand All @@ -35,6 +36,84 @@ describe("DashboardLayout", () => {
Reflect.deleteProperty(window, "__naruonMobileWorkspace");
});

it("keeps the active desktop destination inside the navigation inset", () => {
vi.useFakeTimers();
window.history.replaceState(null, "", "/search");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root?.render(
<DashboardLayout>
<section>Search workspace content</section>
</DashboardLayout>,
);
});

const primaryNav = container.querySelector<HTMLElement>(
'nav[aria-label="Primary workspace navigation"]',
);
expect(primaryNav?.className).toContain("px-4");
const activeLink = container.querySelector<HTMLAnchorElement>('a[href="/search"]');
vi.spyOn(primaryNav!, "getBoundingClientRect").mockReturnValue({
left: 0,
right: 100,
} as DOMRect);
vi.spyOn(activeLink!, "getBoundingClientRect").mockReturnValue({
left: 90,
right: 110,
} as DOMRect);
act(() => {
vi.runOnlyPendingTimers();
});
expect(primaryNav?.scrollLeft).toBe(26);
expect(
container.querySelector<HTMLAnchorElement>('a[href="/search"]')
?.getAttribute("aria-current"),
).toBe("page");
});

it("restores active destination visibility after the desktop navigation narrows", () => {
vi.useFakeTimers();
window.history.replaceState(null, "", "/search");
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

act(() => {
root?.render(
<DashboardLayout>
<section>Search workspace content</section>
</DashboardLayout>,
);
});

const primaryNav = container.querySelector<HTMLElement>(
'nav[aria-label="Primary workspace navigation"]',
);
const activeLink = container.querySelector<HTMLAnchorElement>('a[href="/search"]');
vi.spyOn(primaryNav!, "getBoundingClientRect").mockReturnValue({
left: 0,
right: 100,
} as DOMRect);
const activeLinkRect = vi
.spyOn(activeLink!, "getBoundingClientRect")
.mockReturnValue({ left: 20, right: 60 } as DOMRect);
act(() => {
vi.runOnlyPendingTimers();
});
expect(primaryNav?.scrollLeft).toBe(0);

activeLinkRect.mockReturnValue({ left: 90, right: 110 } as DOMRect);
act(() => {
window.dispatchEvent(new Event("resize"));
vi.runOnlyPendingTimers();
});

expect(primaryNav?.scrollLeft).toBe(26);
});

it("renders the Naruon branded shell with accessible navigation landmarks", () => {
container = document.createElement("div");
document.body.appendChild(container);
Expand Down
38 changes: 34 additions & 4 deletions frontend/src/components/DashboardLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,44 @@ function PrimaryNavLink({
}) {
const pathname = usePathname();
const searchParams = useCurrentSearchParams();
const active = isActivePath(pathname, href, '', searchParams);
const isActiveDestination = isActivePath(pathname, href, '', searchParams);
const linkRef = useRef<HTMLAnchorElement>(null);

useEffect(() => {
if (!isActiveDestination) return;
let animationFrame = 0;
const scheduleVisibilityCorrection = () => {
window.cancelAnimationFrame(animationFrame);
animationFrame = window.requestAnimationFrame(() => {
animationFrame = 0;
const activeLink = linkRef.current;
const primaryNavigation = activeLink?.closest('nav');
if (!activeLink || !primaryNavigation) return;
const navigationRect = primaryNavigation.getBoundingClientRect();
const activeLinkRect = activeLink.getBoundingClientRect();
const navigationInset = 16;
if (activeLinkRect.left < navigationRect.left + navigationInset) {
primaryNavigation.scrollLeft -= navigationRect.left + navigationInset - activeLinkRect.left;
} else if (activeLinkRect.right > navigationRect.right - navigationInset) {
primaryNavigation.scrollLeft += activeLinkRect.right - (navigationRect.right - navigationInset);
}
});
};
scheduleVisibilityCorrection();
window.addEventListener('resize', scheduleVisibilityCorrection);
return () => {
window.removeEventListener('resize', scheduleVisibilityCorrection);
window.cancelAnimationFrame(animationFrame);
};
}, [isActiveDestination]);

return (
<Link
ref={linkRef}
href={href}
aria-current={active ? 'page' : undefined}
aria-current={isActiveDestination ? 'page' : undefined}
className={`inline-flex h-10 shrink-0 whitespace-nowrap items-center gap-2 rounded-xl px-3 text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 ${
active ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-primary/10 hover:text-primary'
isActiveDestination ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-primary/10 hover:text-primary'
}`}
>
<Icon className="size-4" aria-hidden={true} />
Expand Down Expand Up @@ -353,7 +383,7 @@ export function DashboardLayout({
<Image src="/brand/naruon-symbol.svg" alt="Naruon" width={32} height={32} style={{ width: '32px', height: '32px' }} />
<span className="text-lg font-black tracking-tight">Naruon</span>
</div>
<nav aria-label="Primary workspace navigation" className="hidden max-w-[44vw] items-center gap-1 overflow-x-auto xl:flex 2xl:max-w-none">
<nav aria-label="Primary workspace navigation" className="hidden max-w-[44vw] items-center gap-1 overflow-x-auto px-4 xl:flex 2xl:max-w-none">
{primaryNavItems.map((item) => (
<PrimaryNavLink key={item.href} {...item} />
))}
Expand Down