️(frontend) improve keyboard tab order in document list

Reduced to one tab stop with clear label; actions, share count remain accessible.
This commit is contained in:
Ovgodd
2026-09-07 15:19:37 +02:00
parent b32b3f08c2
commit e3d2bd3a49
5 changed files with 175 additions and 57 deletions
@@ -242,9 +242,7 @@ test.describe('Doc grid move', () => {
.click();
await expect(docsGrid.getByText(titleDoc1)).toBeHidden();
await docsGrid
.getByRole('link', { name: `Open document ${titleDoc2}` })
.click();
await docsGrid.getByRole('link', { name: new RegExp(titleDoc2) }).click();
await verifyDocName(page, titleDoc2);
@@ -386,9 +384,7 @@ test.describe('Doc grid move', () => {
await page.keyboard.press('Enter');
await expect(docsGrid.getByText(titleDoc1)).toBeHidden();
await docsGrid
.getByRole('link', { name: `Open document ${titleDoc2}` })
.click();
await docsGrid.getByRole('link', { name: new RegExp(titleDoc2) }).click();
await verifyDocName(page, titleDoc2);
@@ -98,7 +98,7 @@ test.describe('Doc Trashbin', () => {
await page.getByRole('link', { name: 'Recent' }).click();
const row2Restored = await getGridRow(page, title2);
await expect(row2Restored.getByText(title2)).toBeVisible();
await row2Restored.getByRole('link', { name: /Open document/ }).click();
await row2Restored.getByRole('link', { name: new RegExp(title2) }).click();
await verifyDocName(page, title2);
await page.getByRole('button', { name: 'Back to homepage' }).click();
@@ -33,11 +33,9 @@ export const DocsGridItem = ({
const searchParams = useSearchParams();
const target = searchParams.get('target');
const isInTrashbin = target === 'trashbin';
const { untitledDocument } = useTrans();
const { t } = useTranslation();
const { isSmallMobile, isLargeScreen } = useResponsiveStore();
const dateToDisplay = useDateToDisplay(doc, isInTrashbin);
const docItemAriaLabel = useDocItemAriaLabel(doc, isInTrashbin);
const { openPanel } = useLeftPanelStore();
const handleKeyDown = (e: KeyboardEvent) => {
@@ -78,15 +76,11 @@ export const DocsGridItem = ({
${$css}
`}
className="--docs--doc-grid-item"
aria-label={t('Open document: {{title}}', {
title: doc.title || untitledDocument,
})}
{...boxProps}
role="listitem"
tabIndex={-1}
>
<Box
tabIndex={0}
$display="grid"
$direction="row"
$align="center"
@@ -122,22 +116,16 @@ export const DocsGridItem = ({
href={`/docs/${doc.id}`}
onKeyDown={handleKeyDown}
onClick={handleClick}
aria-label={docItemAriaLabel}
>
<DocsGridItemTitle doc={doc} withTooltip={!dragMode} />
</StyledLink>
</Box>
{!isSmallMobile && (
<StyledLink
href={`/docs/${doc.id}`}
tabIndex={-1}
aria-label={t('{{title}}, updated {{date}}', {
title: doc.title || untitledDocument,
date: dateToDisplay,
})}
>
<Box aria-hidden="true">
<DocsGridItemDate doc={doc} isInTrashbin={isInTrashbin} />
</StyledLink>
</Box>
)}
<Box
@@ -241,23 +229,50 @@ const IconPublic = ({ isPublic }: { isPublic: boolean }) => {
);
};
/**
* In the trashbin the date column counts down to the permanent deletion
* instead of showing the last update, so callers need to know which of the
* two `dateToDisplay` carries before wording it.
*/
const useDateToDisplay = (doc: Doc, isInTrashbin: boolean) => {
const { data: config } = useConfig();
const { t } = useTranslation();
const { relativeDate, calculateDaysLeft } = useDate();
let dateToDisplay = relativeDate(doc.updated_at);
if (isInTrashbin && config?.TRASHBIN_CUTOFF_DAYS && doc.deleted_at) {
const daysLeft = calculateDaysLeft(
doc.deleted_at,
config.TRASHBIN_CUTOFF_DAYS,
);
dateToDisplay = `${daysLeft} ${t('days', { count: daysLeft })}`;
return {
dateToDisplay: `${daysLeft} ${t('days', { count: daysLeft })}`,
isDaysLeft: true,
};
}
return dateToDisplay;
return { dateToDisplay: relativeDate(doc.updated_at), isDaysLeft: false };
};
const useDocItemAriaLabel = (doc: Doc, isInTrashbin: boolean) => {
const { t } = useTranslation();
const { untitledDocument } = useTrans();
const { dateToDisplay, isDaysLeft } = useDateToDisplay(doc, isInTrashbin);
const title = doc.title || untitledDocument;
// Matches the count shown by the shared button and its tooltip.
const count = doc.nb_accesses_direct;
if (isDaysLeft) {
return t(
'{{title}}, {{date}} left before deletion, shared with {{count}} participant(s)',
{ title, date: dateToDisplay, count },
);
}
return t(
'{{title}}, updated {{date}}, shared with {{count}} participant(s)',
{ title, date: dateToDisplay, count },
);
};
export const DocsGridItemDate = ({
@@ -267,7 +282,7 @@ export const DocsGridItemDate = ({
doc: Doc;
isInTrashbin: boolean;
}) => {
const dateToDisplay = useDateToDisplay(doc, isInTrashbin);
const { dateToDisplay } = useDateToDisplay(doc, isInTrashbin);
return (
<Text
@@ -40,35 +40,43 @@ export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => {
placement="top"
className="--docs--doc-tooltip-grid-item-shared-button"
>
<Button
className="--docs--doc-grid-item-shared-button"
aria-label={t('Open the sharing settings for the document')}
data-testid={`docs-grid-item-shared-button-${doc.id}`}
style={{
padding: `0 var(--c--globals--spacings--xxxs) 0 var(--c--globals--spacings--xxxs)`,
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
addLastFocus(event.currentTarget);
shareModal.open();
}}
color="brand"
variant="secondary"
size="nano"
icon={
<Icon
$theme="brand"
$variation="secondary"
iconName="group"
disabled={disabled}
variant="filled"
/>
}
disabled={disabled}
>
{sharedCount}
</Button>
{/*
Tooltip clones its child and overwrites tabIndex. Keep the trigger
on a non-interactive wrapper so the button can stay out of the
tab order (reached from the row's actions menu).
*/}
<span>
<Button
className="--docs--doc-grid-item-shared-button"
aria-label={t('Open the sharing settings for the document')}
data-testid={`docs-grid-item-shared-button-${doc.id}`}
tabIndex={-1}
style={{
padding: `0 var(--c--globals--spacings--xxxs) 0 var(--c--globals--spacings--xxxs)`,
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
addLastFocus(event.currentTarget);
shareModal.open();
}}
color="brand"
variant="secondary"
size="nano"
icon={
<Icon
$theme="brand"
$variation="secondary"
iconName="group"
disabled={disabled}
variant="filled"
/>
}
disabled={disabled}
>
{sharedCount}
</Button>
</span>
</Tooltip>
{shareModal.isOpen && (
<DocShareModal
@@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { DateTime } from 'luxon';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Doc, LinkReach } from '@/docs/doc-management';
import { AppWrapper } from '@/tests/utils';
vi.mock('next/navigation', () => ({
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('../DocsGridActions', () => ({
DocsGridActions: ({ doc }: { doc: { title?: string } }) => (
<button
type="button"
aria-label={`Open the document options: ${doc.title}`}
>
more
</button>
),
}));
import { DocsGridItem } from '../DocsGridItem';
const doc = {
id: 'doc-1',
title: 'My document',
updated_at: DateTime.now().minus({ days: 2 }).toISO(),
is_favorite: false,
nb_accesses_direct: 3,
link_reach: LinkReach.RESTRICTED,
depth: 1,
numchild: 0,
path: '0001',
} as Doc;
describe('DocsGridItem keyboard navigation', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.hardReset();
fetchMock.mockGlobal();
fetchMock.get('http://test.jest/api/v1.0/config/', {
body: JSON.stringify({}),
});
});
afterEach(() => {
fetchMock.hardReset();
});
it('announces the title, date and participants on the document link', () => {
render(<DocsGridItem doc={doc} />, { wrapper: AppWrapper });
const link = screen.getByRole('link', {
name: /My document, updated .+ ago, shared with 3 participant\(s\)/,
});
expect(link).toBeInTheDocument();
expect(
screen.queryByRole('link', { name: /Open document/ }),
).not.toBeInTheDocument();
});
it('tabs from the document item to its actions, skipping the share count', async () => {
const user = userEvent.setup();
render(
<>
<button type="button">before</button>
<DocsGridItem doc={doc} />
<button type="button">after</button>
</>,
{ wrapper: AppWrapper },
);
const link = screen.getByRole('link', { name: /My document, updated/ });
const options = screen.getByRole('button', {
name: 'Open the document options: My document',
});
const share = screen.getByRole('button', {
name: 'Open the sharing settings for the document',
});
expect(share).toHaveAttribute('tabindex', '-1');
await user.tab();
expect(screen.getByRole('button', { name: 'before' })).toHaveFocus();
await user.tab();
expect(link).toHaveFocus();
await user.tab();
expect(options).toHaveFocus();
await user.tab();
expect(screen.getByRole('button', { name: 'after' })).toHaveFocus();
});
});