diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 190ef76..16c5490 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,15 +4,23 @@ import 'animal-island-ui/style'; import { ConsoleAuthGate } from './auth/console-auth.js'; import { AppShell } from './app-shell.js'; +import { useBrowserPathname } from './navigation/use-browser-pathname.js'; import './styles.css'; const root = document.querySelector('#root'); if (!root) throw new Error('Missing application root'); +function BrowserApp() { + const pathname = useBrowserPathname(); + return ( + + + + ); +} + createRoot(root).render( - - - + , ); diff --git a/apps/web/src/navigation/use-browser-pathname.test.tsx b/apps/web/src/navigation/use-browser-pathname.test.tsx new file mode 100644 index 0000000..d2f1908 --- /dev/null +++ b/apps/web/src/navigation/use-browser-pathname.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useBrowserPathname } from './use-browser-pathname.js'; + +function Harness({ link = Automation }: { link?: React.ReactNode }) { + const pathname = useBrowserPathname(); + return ( + <> + {link} +
+ {pathname} +
+ + ); +} + +afterEach(() => { + cleanup(); + window.history.replaceState(null, '', '/'); + vi.restoreAllMocks(); +}); + +describe('useBrowserPathname', () => { + it('pushes eligible same-origin links and updates pathname without reloading', () => { + window.history.replaceState(null, '', '/fleet'); + const pushState = vi.spyOn(window.history, 'pushState'); + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); + render(); + + const link = screen.getByRole('link', { name: 'Automation' }); + const accepted = fireEvent.click(link); + + expect(accepted).toBe(false); + expect(pushState).toHaveBeenCalledWith(null, '', '/automation'); + expect(screen.getByRole('main').textContent).toBe('/automation'); + expect(document.activeElement).toBe(screen.getByRole('main')); + expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' }); + }); + + it('tracks browser history traversal and restores the page start', () => { + window.history.replaceState(null, '', '/automation'); + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined); + render(); + + window.history.replaceState(null, '', '/fleet'); + fireEvent(window, new PopStateEvent('popstate')); + + expect(screen.getByRole('main').textContent).toBe('/fleet'); + expect(document.activeElement).toBe(screen.getByRole('main')); + expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' }); + }); + + it.each([ + ['target window', Excluded, {}], + ['download', Excluded, {}], + ['external origin', Excluded, {}], + ['hash-only navigation', Excluded, {}], + ['middle click', Excluded, { button: 1 }], + ['meta click', Excluded, { metaKey: true }], + ['control click', Excluded, { ctrlKey: true }], + ['shift click', Excluded, { shiftKey: true }], + ['alt click', Excluded, { altKey: true }], + ])('leaves %s to the browser', (_label, link, eventInit) => { + window.history.replaceState(null, '', '/fleet'); + const pushState = vi.spyOn(window.history, 'pushState'); + render(); + + let hookPrevented = true; + const suppressBrowserNavigation = (event: MouseEvent) => { + hookPrevented = event.defaultPrevented; + event.preventDefault(); + }; + document.addEventListener('click', suppressBrowserNavigation, { once: true }); + const event = new MouseEvent('click', { bubbles: true, cancelable: true, ...eventInit }); + screen.getByRole('link', { name: 'Excluded' }).dispatchEvent(event); + + expect(hookPrevented).toBe(false); + expect(pushState).not.toHaveBeenCalled(); + expect(screen.getByRole('main').textContent).toBe('/fleet'); + }); +}); diff --git a/apps/web/src/navigation/use-browser-pathname.ts b/apps/web/src/navigation/use-browser-pathname.ts new file mode 100644 index 0000000..6a787f9 --- /dev/null +++ b/apps/web/src/navigation/use-browser-pathname.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +function restorePageStart(): void { + window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + document.querySelector('#main-content')?.focus({ preventScroll: true }); +} + +function isModifiedClick(event: MouseEvent): boolean { + return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; +} + +export function useBrowserPathname(): string { + const [pathname, setPathname] = useState(() => window.location.pathname); + + useEffect(() => { + function handleClick(event: MouseEvent): void { + if (event.defaultPrevented || event.button !== 0 || isModifiedClick(event)) return; + if (!(event.target instanceof Element)) return; + + const anchor = event.target.closest('a[href]'); + if (!anchor || anchor.hasAttribute('download')) return; + if (anchor.target && anchor.target !== '_self') return; + + const url = new URL(anchor.href, window.location.href); + if (url.origin !== window.location.origin) return; + if ( + url.pathname === window.location.pathname && + url.search === window.location.search && + url.hash !== window.location.hash + ) + return; + + event.preventDefault(); + window.history.pushState(null, '', `${url.pathname}${url.search}${url.hash}`); + setPathname(window.location.pathname); + restorePageStart(); + } + + function handlePopState(): void { + setPathname(window.location.pathname); + restorePageStart(); + } + + document.addEventListener('click', handleClick); + window.addEventListener('popstate', handlePopState); + return () => { + document.removeEventListener('click', handleClick); + window.removeEventListener('popstate', handlePopState); + }; + }, []); + + return pathname; +}