feat: add compact Toolbox tiles and details overlay
This commit is contained in:
285
src/App.test.tsx
285
src/App.test.tsx
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user