mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-10 11:47:52 +02:00
♿️(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:
@@ -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
|
||||
|
||||
+37
-29
@@ -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
|
||||
|
||||
+99
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user