feat: add compact Toolbox tiles and details overlay

This commit is contained in:
2026-07-23 13:02:29 +02:00
parent 803397ff52
commit 2c13c56362
20 changed files with 1009 additions and 486 deletions

View File

@@ -13,35 +13,48 @@ import { catalogue, catalogueFetch } from './test/fixtures';
afterEach(() => vi.unstubAllGlobals());
function toolLink(name: string) {
return screen.getByRole('link', { name: `Open ${name}` });
}
function queryToolLink(name: string) {
return screen.queryByRole('link', { name: `Open ${name}` });
}
function findToolLink(name = 'PDF Workbench') {
return screen.findByRole('link', { name: `Open ${name}` });
}
describe('portal UI', () => {
it('renders app cards and persists accessible pin/hide controls', async () => {
it('renders compact actionable tiles and persists pin/hide controls', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
const launch = await findToolLink();
const card = screen.getByTestId('app-card-de.add-ideas.pdf-tools');
expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent(
'PDF'
);
expect(within(card).getByText('Arrange PDF pages locally.')).toBeVisible();
expect(
await screen.findByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
screen.getByTestId('app-card-de.add-ideas.xslt-tools')
).toHaveTextContent('XSLT');
expect(
screen.getAllByText(
screen.getByTestId('app-card-de.add-ideas.onenote-tools')
).toHaveTextContent('OneNote');
expect(
screen.queryByText(
'Page-level PDF operations performed locally in the browser.'
)
).not.toBeInTheDocument();
expect(
screen.queryByText(
'Local processing · no file uploads declared · no telemetry declared'
)
).toHaveLength(3);
const sourceLinks = screen.getAllByRole('link', {
name: 'Source and license',
});
expect(sourceLinks).toHaveLength(4);
).not.toBeInTheDocument();
expect(
sourceLinks.find(
(link) =>
link.getAttribute('href') ===
'https://git.add-ideas.de/zemion/pdf-tools'
)
).toHaveAttribute('rel', 'noopener noreferrer');
expect(
sourceLinks.find((link) =>
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.4.2')
)
).toBeDefined();
screen.getAllByRole('link', { name: 'Source and license' })
).toHaveLength(1);
expect(
screen.queryByRole('region', { name: 'Pinned' })
).not.toBeInTheDocument();
@@ -50,12 +63,20 @@ describe('portal UI', () => {
});
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
const launch = screen
.getAllByRole('link', { name: 'PDF Workbench' })
.find((link) => link.classList.contains('app-card__launch-link'))!;
expect(launch).toHaveClass('app-card__launch-link');
expect(
new URL(launch.getAttribute('href')!).searchParams.get('toolbox')
).toContain('toolbox.catalog.json');
const actions = within(card).getByRole('group', {
name: 'Actions for PDF Workbench',
});
const info = within(actions).getByRole('button', {
name: 'More information about PDF Workbench',
});
expect(info).toHaveAttribute('aria-haspopup', 'dialog');
for (const control of within(actions).getAllByRole('button'))
expect(control.closest('a')).toBeNull();
expect(launch).not.toContainElement(info);
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
await waitFor(() =>
@@ -69,6 +90,12 @@ describe('portal UI', () => {
expect(
screen.queryByRole('button', { name: /Move PDF Workbench/i })
).not.toBeInTheDocument();
const unpin = screen.getByRole('button', {
name: 'Unpin PDF Workbench',
});
expect(unpin).toHaveAttribute('aria-pressed', 'true');
expect(unpin).toHaveClass('is-active');
await waitFor(() => expect(unpin).toHaveFocus());
expect(
screen.getByRole('button', { name: 'Reorder PDF Workbench' })
).toBeInTheDocument();
@@ -76,27 +103,92 @@ describe('portal UI', () => {
await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
);
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
expect(showHidden).toBeEnabled();
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
await waitFor(() => expect(showHidden).toHaveFocus());
fireEvent.click(showHidden);
expect(
screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
expect(toolLink('PDF Workbench')).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Show PDF Workbench' })
);
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
).toHaveFocus()
);
});
it('moves the full disclosure into an accessible modal', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
const { container } = render(<App />);
await findToolLink();
const info = screen.getByRole('button', {
name: 'More information about PDF Workbench',
});
await user.click(info);
const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveTextContent(
'Page-level PDF operations performed locally in the browser.'
);
expect(dialog).toHaveTextContent(
'Local processing · no file uploads declared · no telemetry declared'
);
expect(dialog).toHaveTextContent(
'Needs Secure context · Web workers · IndexedDB'
);
expect(dialog).toHaveTextContent('v0.4.3');
expect(
within(dialog).getByRole('button', { name: 'documents' })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: '#merge' })
).toBeInTheDocument();
const sourceLink = within(dialog).getByRole('link', {
name: 'Source and license',
});
expect(sourceLink).toHaveAttribute(
'href',
'https://git.add-ideas.de/zemion/pdf-tools'
);
expect(sourceLink).toHaveAttribute('rel', 'noopener noreferrer');
expect(
within(dialog).getByRole('link', { name: 'Open PDF' })
).toBeInTheDocument();
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
expect(container.querySelector('.site-shell')).toHaveAttribute(
'aria-hidden',
'true'
);
expect(document.documentElement).toHaveStyle({ overflow: 'hidden' });
const close = within(dialog).getByRole('button', {
name: 'Close information for PDF Workbench',
});
expect(close).toHaveFocus();
within(dialog).getByRole('link', { name: 'Open PDF' }).focus();
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(
screen.queryByRole('dialog', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(info).toHaveFocus();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(document.documentElement).not.toHaveStyle({ overflow: 'hidden' });
});
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' });
await findToolLink();
const search = screen.getByRole('searchbox', { name: 'Search tools' });
const category = screen.getByRole('combobox', {
@@ -107,28 +199,37 @@ describe('portal UI', () => {
);
await user.type(search, 'xslt');
expect(
screen.getByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
expect(queryToolLink('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();
expect(toolLink('OneNote Reader')).toBeInTheDocument();
expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
await user.selectOptions(category, '');
await user.click(
screen.getByRole('button', {
name: 'More information about PDF Workbench',
})
);
await user.click(
within(screen.getByRole('dialog', { name: 'PDF Workbench' })).getByRole(
'button',
{ name: 'pdf' }
)
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(category).toHaveValue('pdf');
expect(toolLink('PDF Workbench')).toBeInTheDocument();
expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
});
it('keeps remaining hidden tools visible while editing the hidden set', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await findToolLink();
await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
@@ -147,16 +248,14 @@ describe('portal UI', () => {
expect(showHidden).toBeChecked();
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
expect(
screen.getByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect(toolLink('XSLT Workbench')).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 findToolLink();
await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
);
@@ -166,12 +265,8 @@ describe('portal UI', () => {
);
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(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: 'Show hidden (1)' })
).not.toBeChecked();
@@ -181,7 +276,7 @@ describe('portal UI', () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await findToolLink();
await user.click(screen.getByRole('button', { name: 'Apps' }));
const navigation = screen.getByRole('navigation', {
name: 'Toolbox applications',
@@ -197,7 +292,7 @@ describe('portal UI', () => {
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' });
await findToolLink();
const header = document.querySelector<HTMLElement>('.portal-header')!;
expect(
@@ -263,7 +358,7 @@ describe('portal UI', () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await findToolLink();
const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize);
expect(
@@ -299,7 +394,7 @@ describe('portal UI', () => {
it('supports keyboard drag ordering from each tile grip', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' });
await findToolLink();
const cards = [
screen.getByTestId('app-card-de.add-ideas.pdf-tools'),
screen.getByTestId('app-card-de.add-ideas.xslt-tools'),
@@ -309,11 +404,11 @@ describe('portal UI', () => {
const rect = {
x: index * 220,
y: 0,
width: 200,
height: 320,
width: 188,
height: 188,
top: 0,
right: index * 220 + 200,
bottom: 320,
right: index * 220 + 188,
bottom: 188,
left: index * 220,
toJSON: () => ({}),
} as DOMRect;
@@ -334,7 +429,7 @@ describe('portal UI', () => {
within(tools)
.getAllByRole('heading', { level: 3 })
.map((heading) => heading.textContent)
).toEqual(['XSLT Workbench', 'PDF Workbench', 'OneNote Reader']);
).toEqual(['XSLT', 'PDF', 'OneNote']);
});
expect(
JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order
@@ -349,41 +444,53 @@ describe('portal UI', () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
const merge = await screen.findByRole('button', { name: '#merge' });
await user.click(merge);
expect(merge).toHaveAttribute('aria-pressed', 'true');
expect(
screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'XSLT Workbench' })
).not.toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'OneNote Reader' })
).not.toBeInTheDocument();
await findToolLink();
await user.click(
screen.getByRole('button', {
name: 'More information about PDF Workbench',
})
);
const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
await user.click(within(dialog).getByRole('button', { name: '#merge' }));
await user.click(screen.getByRole('button', { name: '#merge' }));
expect(
await screen.findByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'OneNote Reader' })
).toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(toolLink('PDF Workbench')).toBeInTheDocument();
expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
const activeTag = screen.getByRole('button', {
name: /#merge.*Clear tag filter/i,
});
await user.click(activeTag);
expect(await findToolLink('XSLT Workbench')).toBeInTheDocument();
expect(toolLink('OneNote Reader')).toBeInTheDocument();
});
it('opens the standard help control and explains tile interaction', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
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');
await findToolLink();
const help = screen.getByRole('button', { name: 'Help' });
await user.click(help);
const dialog = screen.getByRole('dialog', {
name: 'Choose and arrange your tools',
});
expect(dialog).toHaveTextContent('click anywhere on a tool tile');
expect(dialog).toHaveTextContent('use the light bulb');
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
await user.click(screen.getByRole('button', { name: 'Close help' }));
expect(container.querySelector('.site-shell')).toHaveAttribute(
'aria-hidden',
'true'
);
const close = within(dialog).getByRole('button', { name: 'Close help' });
expect(close).toHaveFocus();
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(help).toHaveFocus();
});
it('shows a recoverable catalogue error', async () => {
@@ -397,9 +504,7 @@ describe('portal UI', () => {
await screen.findByRole('heading', { name: 'Toolbox unavailable' })
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(
await screen.findByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
expect(await findToolLink()).toBeInTheDocument();
});
it('shows a dedicated empty state for a valid catalogue', async () => {