(frontend) make components accessible to screen readers

adds proper aria props and translation keys for accessibility support

Signed-off-by: Cyril <[email protected]>
This commit is contained in:
Cyril
2025-09-04 11:42:45 +02:00
parent a5e22fddf9
commit 5b0e2e37e4
10 changed files with 168 additions and 49 deletions
@@ -307,6 +307,11 @@ test.describe('Doc Editor', () => {
}
});
// Ensure AI feature is enabled
await overrideConfig(page, {
AI_FEATURE_ENABLED: true,
});
await createDoc(page, 'doc-ai', browserName, 1);
await page.locator('.bn-block-outer').last().fill('Hello World');
@@ -314,7 +319,7 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
@@ -400,11 +405,11 @@ test.describe('Doc Editor', () => {
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
await expect(page.locator('[data-test="ai-actions"]')).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();
if (ai_transform) {
await expect(
@@ -175,10 +175,10 @@ test.describe('Document search', () => {
// Expect to find the first doc
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -196,13 +196,13 @@ test.describe('Document search', () => {
// Now there is a sub page - expect to have the focus on the current doc
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondChildDocTitle),
page.getByRole('presentation').getByText(secondChildDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeHidden();
});
});
@@ -9,7 +9,6 @@ import {
updateDocTitle,
verifyDocName,
} from './utils-common';
import { addNewMember } from './utils-share';
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
test.describe('Doc Tree', () => {
@@ -185,14 +184,13 @@ test.describe('Doc Tree', () => {
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
const child = docTree
.getByRole('treeitem')
.locator('.--docs-sub-page-item')
.filter({
hasText: docChild,
});
const child = docTree.locator('.--docs-sub-page-item').filter({
hasText: docChild,
});
await child.hover();
// Wait a bit for the hover effect to take place
const menu = child.getByText(`more_horiz`);
await expect(menu).toBeVisible();
await menu.click();
await page.getByText('Move to my docs').click();
@@ -215,43 +213,38 @@ test.describe('Doc Tree', () => {
await verifyDocName(page, docParent);
await page.getByRole('button', { name: 'Share' }).click();
await addNewMember(page, 0, 'Owner', 'impress');
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
await page.getByLabel('Administrator').click();
await list.click();
await page.getByRole('button', { name: 'Ok' }).click();
// Create a child document first
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-detach-child',
);
// Now try to detach the child document - this should work for the owner
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
const child = docTree
.getByRole('treeitem')
.locator('.--docs-sub-page-item')
.filter({
hasText: docChild,
});
const child = docTree.locator('.--docs-sub-page-item').filter({
hasText: docChild,
});
await child.hover();
// Wait a bit for the hover effect to take place
const menu = child.getByText(`more_horiz`);
await expect(menu).toBeVisible();
await menu.click();
// The owner should be able to detach the document
await page.getByRole('menuitem', { name: 'Move to my docs' }).click();
// Verify the document was detached - it should no longer be in the current tree
await expect(
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
page.getByRole('textbox', { name: 'doc title input' }),
).not.toHaveText(docChild);
// Verify the document is now on the home page
const header = page.locator('header').first();
await header.locator('h1').getByText('Docs').click();
await expect(page.getByText(docChild)).toBeVisible();
});
});
@@ -10,10 +10,6 @@ type FocusableNode<T> = NodeRendererProps<TreeDataItem<T>>['node'] & {
/**
* Hook to manage keyboard navigation for actionable items in a tree view.
*
* Provides two modes:
* 1. Activation: F2/Enter moves focus to first actionable element
* 2. Navigation: Arrow keys navigate between actions, Escape returns to tree node
*
* Disables navigation when dropdown menu is open to prevent conflicts.
*/
export const useActionableMode = <T>(
@@ -23,11 +19,21 @@ export const useActionableMode = <T>(
const actionsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!node?.isFocused) {
const modalOpen = document.querySelector(
'[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content',
);
if (!node?.isFocused || modalOpen) {
return;
}
const toActions = (e: KeyboardEvent) => {
const modalOpen = document.querySelector(
'[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content',
);
if (modalOpen) {
return;
}
if (e.key === 'F2' || e.key === 'Enter') {
const isAlreadyInActions = actionsRef.current?.contains(
document.activeElement,
@@ -61,6 +67,13 @@ export const useActionableMode = <T>(
return;
}
const modal = document.querySelector(
'[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content',
);
if (modal) {
return;
}
if (e.key === 'Escape') {
e.stopPropagation();
node?.focus?.();
@@ -56,7 +56,14 @@ export const useDropdownFocusManagement = ({
}
const timer = setTimeout(() => {
// Try to find sub-document by closest ancestor
const modal = document.querySelector(
'[role="dialog"], .c__modal, [data-modal], .c__modal__overlay, .ReactModal_Content',
);
if (modal) {
return;
}
// Only handle focus return if no modal is open
let subPageItem = actionsRef?.current?.closest('.--docs-sub-page-item');
// If not found, try to find by data-testid
@@ -49,6 +49,7 @@ export const SimpleDocItem = ({
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
role="presentation"
>
<Box
$direction="row"
@@ -59,6 +60,7 @@ export const SimpleDocItem = ({
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
@@ -88,6 +90,7 @@ export const SimpleDocItem = ({
$variation="1000"
$weight="500"
$css={ItemTextCss}
aria-describedby="doc-title"
>
{displayTitle}
</Text>
@@ -97,6 +100,7 @@ export const SimpleDocItem = ({
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
@@ -5,6 +5,7 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon, Text } from '@/components';
@@ -40,6 +41,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
@@ -88,11 +90,23 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
useTreeItemKeyboardActivate(isActive, handleActivate);
// prepare the text for the screen reader
const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = treeContext?.treeData.selectedNode?.id === doc.id;
const ariaLabel = `${docTitle}${hasChildren ? `, ${isExpanded ? t('expanded') : t('collapsed')}` : ''}${isSelected ? `, ${t('selected')}` : ''}`;
return (
<Box
className="--docs-sub-page-item"
draggable={doc.abilities.move && isDesktop}
$position="relative"
role="treeitem"
aria-label={ariaLabel}
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
$css={css`
background-color: ${isActive
? 'var(--c--theme--colors--greyscale-100)'
@@ -114,6 +128,8 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
&:focus-within .light-doc-item-actions {
display: flex;
opacity: 1;
visibility: visible;
background: var(--c--theme--colors--greyscale-100);
}
@@ -130,6 +146,8 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
.light-doc-item-actions {
display: flex;
opacity: 1;
visibility: visible;
background: var(--c--theme--colors--greyscale-100);
}
}
@@ -141,7 +159,6 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
>
<TreeViewItem {...props} onClick={handleActivate}>
<BoxButton
as="button"
onClick={(e) => {
e.stopPropagation();
handleActivate();
@@ -152,6 +169,8 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
$align="center"
$minHeight="24px"
data-testid={`doc-sub-page-item-${doc.id}`}
aria-label={`${t('Open document')} ${docTitle}`}
role="button"
>
<Box $width="16px" $height="16px">
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
@@ -190,6 +209,8 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
$direction="row"
$align="center"
className="light-doc-item-actions"
role="group"
aria-label={`${t('Actions for')} ${docTitle}`}
>
<DocTreeItemActions
doc={doc}
@@ -7,6 +7,7 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
@@ -31,6 +32,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
const router = useRouter();
const { isDesktop } = useResponsive();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
const { t } = useTranslation();
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
@@ -152,6 +154,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
ref={setTreeRoot}
data-testid="doc-tree"
$height="100%"
role="tree"
aria-label={t('Document tree')}
$css={css`
.c__tree-view--container {
z-index: 1;
@@ -171,6 +175,9 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
>
<Box
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document')}: ${treeContext.root?.title || t('Untitled document')}`}
aria-selected={rootIsSelected}
$css={css`
padding: ${spacingsTokens['2xs']};
border-radius: 4px;
@@ -212,6 +219,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`}
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
@@ -211,7 +211,11 @@ export const DocTreeItemActions = ({
className="icon-button"
tabIndex={0}
role="button"
aria-label={t('More options')}
aria-label={
t('More options for') + ` ${doc.title || t('Untitled document')}`
}
aria-haspopup="true"
aria-expanded={isOpen}
onKeyDown={handleMoreOptionsKeyDown}
/>
</DropdownMenu>
@@ -223,7 +227,10 @@ export const DocTreeItemActions = ({
onClick={handleAddChildClick}
onKeyDown={handleAddChildKeyDown}
color="primary"
aria-label={t('Add child document')}
aria-label={
t('Add child document to') +
` ${doc.title || t('Untitled document')}`
}
$hasTransition={false}
>
<Icon
@@ -231,6 +238,7 @@ export const DocTreeItemActions = ({
$variation="800"
$theme="primary"
iconName="add_box"
aria-hidden="true"
/>
</BoxButton>
)}
@@ -157,7 +157,17 @@
"Document access mode": "Mod aotreet an teul",
"Warning": "Diwallit",
"Why you can't edit the document?": "Perak ne c'hellit ket aozañ ar restr?",
"Write": "Skrivañ"
"Write": "Skrivañ",
"Document tree": "Gwezennadur ar restroù",
"Root document": "Restr gwrizienn",
"Open root document": "Digeriñ ar restr gwrizienn",
"Open document": "Digeriñ ar restr",
"Actions for": "Obererezhioù evit",
"Add child document to": "Ouzhpennañ ur restr bugel da",
"More options for": "Dibarzhioù ouzhpenn evit",
"expanded": "digeret",
"collapsed": "serret",
"selected": "diuzet"
}
},
"de": {
@@ -365,6 +375,16 @@
"Warning": "Warnung",
"Why you can't edit the document?": "Warum können Sie dieses Dokument nicht bearbeiten?",
"Write": "Schreiben",
"Document tree": "Dokumentenbaum",
"Root document": "Stammdokument",
"Open root document": "Stammdokument öffnen",
"Open document": "Dokument öffnen",
"Actions for": "Aktionen für",
"Add child document to": "Unterdokument hinzufügen zu",
"More options for": "Weitere Optionen für",
"expanded": "erweitert",
"collapsed": "zusammengeklappt",
"selected": "ausgewählt",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Sie sind der einzige Besitzer dieser Gruppe. Machen Sie ein anderes Mitglied zum Gruppenbesitzer, bevor Sie Ihre eigene Rolle ändern oder aus Ihrem Dokument entfernen können.",
"You must be at least the editor of the target document": "Sie müssen dafür mindestens die Rolle \"Mitbearbeiter\" haben",
"You must be the owner to move the document": "Sie müssen Besitzer des Dokuments sein, um es zu verschieben",
@@ -390,7 +410,17 @@
"Shared with {{count}} users_one": "Shared with {{count}} user",
"Shared with {{count}} users_other": "Shared with {{count}} users",
"Updated": "Updated",
"Add child document": "Add child document"
"Add child document": "Add child document",
"Document tree": "Document tree",
"Root document": "Root document",
"Open root document": "Open root document",
"Open document": "Open document",
"Actions for": "Actions for",
"Add child document to": "Add child document to",
"More options for": "More options for",
"expanded": "expanded",
"collapsed": "collapsed",
"selected": "selected"
}
},
"es": {
@@ -575,6 +605,16 @@
"Document access mode": "Modo de acceso al documento",
"Warning": "Aviso",
"Write": "Escribe",
"Document tree": "Árbol de documentos",
"Root document": "Documento raíz",
"Open root document": "Abrir documento raíz",
"Open document": "Abrir documento",
"Actions for": "Acciones para",
"Add child document to": "Añadir documento hijo a",
"More options for": "Más opciones para",
"expanded": "expandido",
"collapsed": "contraído",
"selected": "seleccionado",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Eres el único propietario de este grupo, haz que otro miembro sea el propietario del grupo para poder cambiar tu propio rol o ser eliminado del documento.",
"Your current document will revert to this version.": "Tu documento actual se revertirá a esta versión.",
"Your {{format}} was downloaded succesfully": "Su {{format}} se ha descargado correctamente",
@@ -814,6 +854,16 @@
"Warning": "Attention",
"Why you can't edit the document?": "Pourquoi vous ne pouvez pas modifier le document ?",
"Write": "Écrire",
"Document tree": "Arborescence des documents",
"Root document": "Document racine",
"Open root document": "Ouvrir le document racine",
"Open document": "Ouvrir le document",
"Actions for": "Actions pour",
"Add child document to": "Ajouter un document enfant à",
"More options for": "Plus d'options pour",
"expanded": "développé",
"collapsed": "replié",
"selected": "sélectionné",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.",
"You can view this document but need additional access to see its members or modify settings.": "Vous pouvez voir ce document mais vous avez besoin d'un accès supplémentaire pour voir ses membres ou modifier les paramètres.",
"You cannot restrict access to a subpage relative to its parent page.": "Vous ne pouvez pas restreindre l'accès à une sous-page par rapport à sa page parente.",
@@ -1155,6 +1205,16 @@
"Document access mode": "Document toegangsmodus",
"Warning": "Waarschuwing",
"Write": "Schrijf",
"Document tree": "Documentenboom",
"Root document": "Hoofddocument",
"Open root document": "Hoofddocument openen",
"Open document": "Document openen",
"Actions for": "Acties voor",
"Add child document to": "Onderliggend document toevoegen aan",
"More options for": "Meer opties voor",
"expanded": "uitgeklapt",
"collapsed": "ingeklapt",
"selected": "geselecteerd",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "U bent de enige eigenaar van deze groep, maak een ander lid de groepseigenaar voordat u uw eigen rol kunt wijzigen of kan worden verwijderd van het document.",
"Your current document will revert to this version.": "Uw huidige document wordt teruggezet naar deze versie.",
"Your {{format}} was downloaded succesfully": "Jouw {{format}} is succesvol gedownload",