fix: stabilize Toolbox navigation layout

This commit is contained in:
2026-07-23 01:21:16 +02:00
parent 67ba5e3eca
commit e44d35fdfb
16 changed files with 407 additions and 314 deletions

View File

@@ -39,12 +39,17 @@ describe('portal UI', () => {
).toHaveAttribute('rel', 'noopener noreferrer');
expect(
sourceLinks.find((link) =>
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.4.0')
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.4.1')
)
).toBeDefined();
expect(
screen.queryByRole('region', { name: 'Pinned' })
).not.toBeInTheDocument();
const showHidden = screen.getByRole('checkbox', {
name: 'Show hidden (0)',
});
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
const launch = screen
.getAllByRole('link', { name: 'PDF Workbench' })
.find((link) => link.classList.contains('app-card__launch-link'))!;
@@ -74,10 +79,74 @@ describe('portal UI', () => {
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
expect(showHidden).toBeEnabled();
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
fireEvent.click(showHidden);
expect(
screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Show PDF Workbench' })
);
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
});
it('keeps search and category controls together and filters with each', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
const search = screen.getByRole('searchbox', { name: 'Search tools' });
const category = screen.getByRole('combobox', {
name: 'Filter by category',
});
expect(search.closest('.tool-controls')).toBe(
category.closest('.tool-controls')
);
await user.type(search, 'xslt');
expect(
screen.getByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
await user.clear(search);
await user.selectOptions(category, 'notes');
expect(
screen.getByRole('heading', { name: 'OneNote Reader' })
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'XSLT Workbench' })
).not.toBeInTheDocument();
});
it('clears search filters without revealing hidden tools', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
);
await user.type(
screen.getByRole('searchbox', { name: 'Search tools' }),
'no tool has this phrase'
);
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: 'Show hidden (1)' })
).not.toBeChecked();
});
it('lists contextual destinations in the Apps menu', async () => {
@@ -85,7 +154,7 @@ describe('portal UI', () => {
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(screen.getByText('Apps').closest('summary')!);
await user.click(screen.getByRole('button', { name: 'Apps' }));
const navigation = screen.getByRole('navigation', {
name: 'Toolbox applications',
});
@@ -97,18 +166,70 @@ describe('portal UI', () => {
).toContain('toolbox.catalog.json');
});
it('uses the shared accessible header identity and control order', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
const header = document.querySelector<HTMLElement>('.portal-header')!;
expect(
within(header).getByRole('heading', { level: 1, name: 'Toolbox' })
).toBeInTheDocument();
expect(
within(header).getByText('Local-first browser tools')
).toBeInTheDocument();
expect(
screen.getByRole('heading', {
level: 2,
name: 'Your document tools, in one calm place.',
})
).toBeInTheDocument();
expect(
screen.getAllByRole('heading', {
name: 'Your document tools, in one calm place.',
})
).toHaveLength(1);
const personalize = within(header).getByRole('button', {
name: 'Personalize',
});
const apps = within(header).getByRole('button', { name: 'Apps' });
const source = within(header).getByRole('link', {
name: 'Toolbox source on Gitea',
});
const help = within(header).getByRole('button', { name: 'Help' });
const brand = within(header).getByRole('link', {
name: 'add·ideas Toolbox',
});
expect(
personalize.compareDocumentPosition(apps) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
apps.compareDocumentPosition(source) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
source.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg');
});
it('persists an explicit appearance and accepts cross-tab updates', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(screen.getByRole('button', { name: 'Personalize' }));
const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize);
expect(
screen.getByRole('heading', { name: 'Personalize your toolbox' })
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
await waitFor(() =>
expect(localStorage.getItem(PREFERENCES_KEY)).toContain('"theme":"dark"')
);
await user.click(screen.getByRole('button', { name: 'Close preferences' }));
await user.click(personalize);
fireEvent(
window,
@@ -208,14 +329,16 @@ describe('portal UI', () => {
it('opens the standard help control and explains tile interaction', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
const { container } = render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(screen.getByRole('button', { name: 'Help' }));
expect(
screen.getByRole('dialog', { name: 'Choose and arrange your tools' })
).toHaveTextContent('click anywhere on a tool tile');
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
await user.click(screen.getByRole('button', { name: 'Close help' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
});
it('shows a recoverable catalogue error', async () => {
@@ -249,31 +372,35 @@ describe('portal UI', () => {
).toBeInTheDocument();
});
it('contains preference focus and restores it to Personalize', async () => {
it('hosts compact preferences in the shared Personalize popover', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
const { container } = render(<App />);
const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize);
const dialog = screen.getByRole('dialog', {
const heading = screen.getByRole('heading', {
name: 'Personalize your toolbox',
});
const close = screen.getByRole('button', { name: 'Close preferences' });
expect(close).toHaveFocus();
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
expect(dialog).toBeInTheDocument();
const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' });
expect(popover).toContainElement(heading);
expect(popover).not.toHaveAttribute('aria-modal');
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
/Trusted code in another Toolbox app on this site can access same-origin storage/i
);
const reset = screen.getByRole('button', { name: 'Reset' });
reset.focus();
await user.tab();
expect(close).toHaveFocus();
expect(
screen.getByRole('button', { name: 'Export JSON' })
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Import JSON' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(
screen.queryByRole('dialog', { name: 'Personalize Toolbox' })
).not.toBeInTheDocument();
expect(personalize).toHaveFocus();
});
});