feat: navigate workspaces without page reloads

This commit is contained in:
Codex
2026-07-30 22:10:59 +08:00
parent 3a6652be0f
commit 144e17c220
3 changed files with 147 additions and 3 deletions
+11 -3
View File
@@ -4,15 +4,23 @@ import 'animal-island-ui/style';
import { ConsoleAuthGate } from './auth/console-auth.js'; import { ConsoleAuthGate } from './auth/console-auth.js';
import { AppShell } from './app-shell.js'; import { AppShell } from './app-shell.js';
import { useBrowserPathname } from './navigation/use-browser-pathname.js';
import './styles.css'; import './styles.css';
const root = document.querySelector<HTMLElement>('#root'); const root = document.querySelector<HTMLElement>('#root');
if (!root) throw new Error('Missing application root'); if (!root) throw new Error('Missing application root');
function BrowserApp() {
const pathname = useBrowserPathname();
return (
<ConsoleAuthGate>
<AppShell pathname={pathname} />
</ConsoleAuthGate>
);
}
createRoot(root).render( createRoot(root).render(
<StrictMode> <StrictMode>
<ConsoleAuthGate> <BrowserApp />
<AppShell pathname={window.location.pathname} />
</ConsoleAuthGate>
</StrictMode>, </StrictMode>,
); );
@@ -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 = <a href="/automation">Automation</a> }: { link?: React.ReactNode }) {
const pathname = useBrowserPathname();
return (
<>
{link}
<main id="main-content" tabIndex={-1}>
{pathname}
</main>
</>
);
}
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(<Harness />);
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(<Harness />);
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', <a href="/automation" target="_blank">Excluded</a>, {}],
['download', <a href="/automation" download>Excluded</a>, {}],
['external origin', <a href="https://example.com/automation">Excluded</a>, {}],
['hash-only navigation', <a href="/fleet#cards">Excluded</a>, {}],
['middle click', <a href="/automation">Excluded</a>, { button: 1 }],
['meta click', <a href="/automation">Excluded</a>, { metaKey: true }],
['control click', <a href="/automation">Excluded</a>, { ctrlKey: true }],
['shift click', <a href="/automation">Excluded</a>, { shiftKey: true }],
['alt click', <a href="/automation">Excluded</a>, { altKey: true }],
])('leaves %s to the browser', (_label, link, eventInit) => {
window.history.replaceState(null, '', '/fleet');
const pushState = vi.spyOn(window.history, 'pushState');
render(<Harness link={link} />);
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');
});
});
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
function restorePageStart(): void {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
document.querySelector<HTMLElement>('#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<HTMLAnchorElement>('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;
}