diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts
index 8cb102d10..3e71ea92f 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts
@@ -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);
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts
index 8423551bb..f97eefd2e 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-trashbin.spec.ts
@@ -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();
diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
index 37757c4d4..674b83efc 100644
--- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
+++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx
@@ -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}
>
{!isSmallMobile && (
-
+
-
+
)}
{
);
};
+/**
+ * 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 (
{
placement="top"
className="--docs--doc-tooltip-grid-item-shared-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).
+ */}
+
+
+
{shareModal.isOpen && (
({
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock('../DocsGridActions', () => ({
+ DocsGridActions: ({ doc }: { doc: { title?: string } }) => (
+
+ ),
+}));
+
+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(, { 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(
+ <>
+
+
+
+ >,
+ { 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();
+ });
+});