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(); + }); +});