️(a11y) improve keyboard access for language menu and action buttons

Enhances nav for language switch and makes DocsGridActions buttons accessible

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-07-29 15:06:21 +02:00
parent f434d78b5d
commit 5181bba083
11 changed files with 243 additions and 20 deletions

View File

@@ -15,6 +15,7 @@ and this project adheres to
- #1235 - #1235
- #1255 - #1255
- #1262 - #1262
- #1244
- #1270 - #1270
## [3.5.0] - 2025-07-31 ## [3.5.0] - 2025-07-31
@@ -35,7 +36,9 @@ and this project adheres to
- 🔧(project) change env.d system by using local files #1200 - 🔧(project) change env.d system by using local files #1200
- ⚡️(frontend) improve tree stability #1207 - ⚡️(frontend) improve tree stability #1207
- ⚡️(frontend) improve accessibility #1232 - ⚡️(frontend) improve accessibility #1232
- 🛂(frontend) block drag n drop when not desktop #1239 - 🛂(frontend) block drag n drop when not desktop
#1239
### Fixed ### Fixed

View File

@@ -15,10 +15,13 @@ test.describe('Home page', () => {
const header = page.locator('header').first(); const header = page.locator('header').first();
const footer = page.locator('footer').first(); const footer = page.locator('footer').first();
await expect(header).toBeVisible(); await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }), const languageButton = page.getByRole('button', {
).toBeVisible(); name: /Language|Select language/,
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible(); });
await expect(languageButton).toBeVisible();
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible(); await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
// Check the titles // Check the titles
@@ -65,20 +68,31 @@ test.describe('Home page', () => {
await page.goto('/docs/'); await page.goto('/docs/');
// Wait for the page to be fully loaded and responsive store to be initialized
await page.waitForLoadState('domcontentloaded');
// Wait a bit more for the responsive store to be initialized
await page.waitForTimeout(500);
// Check header content // Check header content
const header = page.locator('header').first(); const header = page.locator('header').first();
const footer = page.locator('footer').first(); const footer = page.locator('footer').first();
await expect(header).toBeVisible(); await expect(header).toBeVisible();
await expect(
header.getByRole('button', { name: /Language/ }), // Check for language picker - it should be visible on desktop
).toBeVisible(); // Use a more flexible selector that works with both Header and HomeHeader
const languageButton = page.getByRole('button', {
name: /Language|Select language/,
});
await expect(languageButton).toBeVisible();
await expect( await expect(
header.getByRole('button', { name: 'Les services de La Suite numé' }), header.getByRole('button', { name: 'Les services de La Suite numé' }),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
header.getByRole('img', { name: 'Gouvernement Logo' }), header.getByRole('img', { name: 'Gouvernement Logo' }),
).toBeVisible(); ).toBeVisible();
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible(); await expect(header.getByTestId('header-icon-docs')).toBeVisible();
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible(); await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
// Check the titles // Check the titles

View File

@@ -9,6 +9,7 @@ test.describe('Language', () => {
test('checks language switching', async ({ page }) => { test('checks language switching', async ({ page }) => {
const header = page.locator('header').first(); const header = page.locator('header').first();
const languagePicker = header.locator('.--docs--language-picker-text');
await expect(page.locator('html')).toHaveAttribute('lang', 'en-us'); await expect(page.locator('html')).toHaveAttribute('lang', 'en-us');
@@ -30,13 +31,49 @@ test.describe('Language', () => {
await expect(page.getByLabel('Se déconnecter')).toBeVisible(); await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('button').getByText('Français').click(); // Switch to German using the utility function for consistency
await page.getByLabel('Deutsch').click(); await waitForLanguageSwitch(page, TestLanguage.German);
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible(); await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible(); await expect(page.getByLabel('Abmelden')).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('lang', 'de'); await expect(page.locator('html')).toHaveAttribute('lang', 'de');
await languagePicker.click();
await expect(page.locator('[role="menu"]')).toBeVisible();
const menuItems = page.getByRole('menuitem');
await expect(menuItems.first()).toBeVisible();
await menuItems.first().click();
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
await expect(languagePicker).toContainText('English');
});
test('can switch language using only keyboard', async ({ page }) => {
await page.goto('/');
await waitForLanguageSwitch(page, TestLanguage.English);
const languagePicker = page.getByRole('button', {
name: /select language/i,
});
await expect(languagePicker).toBeVisible();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.locator('html')).not.toHaveAttribute('lang', 'en-us');
}); });
test('checks that backend uses the same language as the frontend', async ({ test('checks that backend uses the same language as the frontend', async ({

View File

@@ -29,7 +29,9 @@ test.describe('Left panel mobile', () => {
const header = page.locator('header').first(); const header = page.locator('header').first();
const homeButton = page.getByTestId('home-button'); const homeButton = page.getByTestId('home-button');
const newDocButton = page.getByTestId('new-doc-button'); const newDocButton = page.getByTestId('new-doc-button');
const languageButton = page.getByRole('button', { name: /Language/ }); const languageButton = page.getByRole('button', {
name: 'Select language',
});
const logoutButton = page.getByRole('button', { name: 'Logout' }); const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport(); await expect(homeButton).not.toBeInViewport();

View File

@@ -1,10 +1,19 @@
import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit'; import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import { Fragment, PropsWithChildren, useRef, useState } from 'react'; import {
Fragment,
PropsWithChildren,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components'; import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = { export type DropdownMenuOption = {
icon?: string; icon?: string;
label: string; label: string;
@@ -46,12 +55,40 @@ export const DropdownMenu = ({
}: PropsWithChildren<DropdownMenuProps>) => { }: PropsWithChildren<DropdownMenuProps>) => {
const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(opened ?? false); const [isOpen, setIsOpen] = useState(opened ?? false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const blockButtonRef = useRef<HTMLDivElement>(null); const blockButtonRef = useRef<HTMLDivElement>(null);
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
const onOpenChange = (isOpen: boolean) => { const onOpenChange = useCallback(
setIsOpen(isOpen); (isOpen: boolean) => {
afterOpenChange?.(isOpen); setIsOpen(isOpen);
}; setFocusedIndex(-1);
afterOpenChange?.(isOpen);
},
[afterOpenChange],
);
useDropdownKeyboardNav({
isOpen,
focusedIndex,
options,
menuItemRefs,
setFocusedIndex,
onOpenChange,
});
// Focus selected menu item when menu opens
useEffect(() => {
if (isOpen && menuItemRefs.current.length > 0) {
const selectedIndex = options.findIndex((option) => option.isSelected);
if (selectedIndex !== -1) {
setFocusedIndex(selectedIndex);
setTimeout(() => {
menuItemRefs.current[selectedIndex]?.focus();
}, 0);
}
}
}, [isOpen, options]);
if (disabled) { if (disabled) {
return children; return children;
@@ -95,6 +132,7 @@ export const DropdownMenu = ({
$maxWidth="320px" $maxWidth="320px"
$minWidth={`${blockButtonRef.current?.clientWidth}px`} $minWidth={`${blockButtonRef.current?.clientWidth}px`}
role="menu" role="menu"
aria-label={label}
> >
{topMessage && ( {topMessage && (
<Text <Text
@@ -115,14 +153,20 @@ export const DropdownMenu = ({
return; return;
} }
const isDisabled = option.disabled !== undefined && option.disabled; const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
return ( return (
<Fragment key={option.label}> <Fragment key={option.label}>
<BoxButton <BoxButton
ref={(el) => {
menuItemRefs.current[index] = el;
}}
role="menuitem" role="menuitem"
aria-label={option.label} aria-label={option.label}
data-testid={option.testId} data-testid={option.testId}
$direction="row" $direction="row"
disabled={isDisabled} disabled={isDisabled}
$hasTransition={false}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -158,6 +202,19 @@ export const DropdownMenu = ({
&:hover { &:hover {
background-color: var(--c--theme--colors--greyscale-050); background-color: var(--c--theme--colors--greyscale-050);
} }
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
}
${isFocused &&
css`
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: -2px;
background-color: var(--c--theme--colors--greyscale-050);
`}
`} `}
> >
<Box <Box

View File

@@ -0,0 +1,88 @@
import { RefObject, useEffect } from 'react';
import { DropdownMenuOption } from '../DropdownMenu';
type UseDropdownKeyboardNavProps = {
isOpen: boolean;
focusedIndex: number;
options: DropdownMenuOption[];
menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
setFocusedIndex: (index: number) => void;
onOpenChange: (isOpen: boolean) => void;
};
export const useDropdownKeyboardNav = ({
isOpen,
focusedIndex,
options,
menuItemRefs,
setFocusedIndex,
onOpenChange,
}: UseDropdownKeyboardNavProps) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) {
return;
}
const enabledIndices = options
.map((option, index) =>
option.show !== false && !option.disabled ? index : -1,
)
.filter((index) => index !== -1);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex =
focusedIndex < enabledIndices.length - 1 ? focusedIndex + 1 : 0;
const nextEnabledIndex = enabledIndices[nextIndex];
setFocusedIndex(nextIndex);
menuItemRefs.current[nextEnabledIndex]?.focus();
break;
case 'ArrowUp':
event.preventDefault();
const prevIndex =
focusedIndex > 0 ? focusedIndex - 1 : enabledIndices.length - 1;
const prevEnabledIndex = enabledIndices[prevIndex];
setFocusedIndex(prevIndex);
menuItemRefs.current[prevEnabledIndex]?.focus();
break;
case 'Enter':
case ' ':
event.preventDefault();
if (focusedIndex >= 0 && focusedIndex < enabledIndices.length) {
const selectedOptionIndex = enabledIndices[focusedIndex];
const selectedOption = options[selectedOptionIndex];
if (selectedOption && selectedOption.callback) {
onOpenChange(false);
void selectedOption.callback();
}
}
break;
case 'Escape':
event.preventDefault();
onOpenChange(false);
break;
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [
isOpen,
focusedIndex,
options,
menuItemRefs,
setFocusedIndex,
onOpenChange,
]);
};

View File

@@ -1,9 +1,12 @@
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box } from '../Box'; import { Box } from '../Box';
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { Text } from '../Text'; import { Text } from '../Text';
import {
DropdownMenu,
DropdownMenuOption,
} from '../dropdown-menu/DropdownMenu';
export type FilterDropdownProps = { export type FilterDropdownProps = {
options: DropdownMenuOption[]; options: DropdownMenuOption[];

View File

@@ -3,7 +3,7 @@ export * from './Box';
export * from './BoxButton'; export * from './BoxButton';
export * from './Card'; export * from './Card';
export * from './DropButton'; export * from './DropButton';
export * from './DropdownMenu'; export * from './dropdown-menu/DropdownMenu';
export * from './quick-search'; export * from './quick-search';
export * from './Icon'; export * from './Icon';
export * from './InfiniteScroll'; export * from './InfiniteScroll';

View File

@@ -76,9 +76,14 @@ export const DocsGridActions = ({
}, },
]; ];
const documentTitle = doc.title || t('Untitled document');
const menuLabel = t('Open the menu of actions for the document: {{title}}', {
title: documentTitle,
});
return ( return (
<> <>
<DropdownMenu options={options}> <DropdownMenu options={options} label={menuLabel}>
<Icon <Icon
data-testid={`docs-grid-actions-button-${doc.id}`} data-testid={`docs-grid-actions-button-${doc.id}`}
iconName="more_horiz" iconName="more_horiz"

View File

@@ -39,12 +39,17 @@ export const LanguagePicker = () => {
<DropdownMenu <DropdownMenu
options={optionsPicker} options={optionsPicker}
showArrow showArrow
label={t('Select language')}
buttonCss={css` buttonCss={css`
&:hover { &:hover {
background-color: var( background-color: var(
--c--components--button--primary-text--background--color-hover --c--components--button--primary-text--background--color-hover
); );
} }
&:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
}
border-radius: 4px; border-radius: 4px;
padding: 0.5rem 0.6rem; padding: 0.5rem 0.6rem;
& > div { & > div {

View File

@@ -363,6 +363,7 @@
"Your access request for this document is pending.": "Ihre Zugriffsanfrage für dieses Dokument steht noch aus.", "Your access request for this document is pending.": "Ihre Zugriffsanfrage für dieses Dokument steht noch aus.",
"Your current document will revert to this version.": "Ihr aktuelles Dokument wird auf diese Version zurückgesetzt.", "Your current document will revert to this version.": "Ihr aktuelles Dokument wird auf diese Version zurückgesetzt.",
"Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen", "Your {{format}} was downloaded succesfully": "Ihr {{format}} wurde erfolgreich heruntergeladen",
"Open the menu of actions for the document: {{title}}": "Öffnen Sie das Aktionsmenü für das Dokument: {{title}}",
"home-content-open-source-part1": "Doms ist auf <2>Django Rest Framework</2> und <6>Next.js</6> aufgebaut. Wir verwenden auch <9>Yjs</9> und <13>BlockNote.js</13>, zwei Projekte, die wir mit Stolz sponsern.", "home-content-open-source-part1": "Doms ist auf <2>Django Rest Framework</2> und <6>Next.js</6> aufgebaut. Wir verwenden auch <9>Yjs</9> und <13>BlockNote.js</13>, zwei Projekte, die wir mit Stolz sponsern.",
"home-content-open-source-part2": "Sie können Docs ganz einfach selbst hosten (lesen Sie unsere <2>Installationsdokumentation</2>).<br/>Docs verwendet eine <7>Lizenz</7> (MIT), die auf Innovation und Unternehmen zugeschnitten ist.<br/>Beiträge sind willkommen (lesen Sie unsere Roadmap <13>hier</13>).\n", "home-content-open-source-part2": "Sie können Docs ganz einfach selbst hosten (lesen Sie unsere <2>Installationsdokumentation</2>).<br/>Docs verwendet eine <7>Lizenz</7> (MIT), die auf Innovation und Unternehmen zugeschnitten ist.<br/>Beiträge sind willkommen (lesen Sie unsere Roadmap <13>hier</13>).\n",
"home-content-open-source-part3": "Docs ist das Ergebnis einer gemeinsamen Anstrengung, die von der französischen Regierung 🇫🇷🥖 <1>(DINUM)</1> und der deutschen Regierung 🇩🇪🥨 <5>(ZenDiS)</5> geleitet wurde. “, „home-content-open-source-part3“: „Docs ist das Ergebnis einer gemeinsamen Anstrengung, die von der französischen Regierung 🇫🇷🥖 <1>(DINUM)</1> und der deutschen Regierung 🇩🇪🥨 <5>(ZenDiS)</5> geleitet wird." "home-content-open-source-part3": "Docs ist das Ergebnis einer gemeinsamen Anstrengung, die von der französischen Regierung 🇫🇷🥖 <1>(DINUM)</1> und der deutschen Regierung 🇩🇪🥨 <5>(ZenDiS)</5> geleitet wurde. “, „home-content-open-source-part3“: „Docs ist das Ergebnis einer gemeinsamen Anstrengung, die von der französischen Regierung 🇫🇷🥖 <1>(DINUM)</1> und der deutschen Regierung 🇩🇪🥨 <5>(ZenDiS)</5> geleitet wird."
@@ -374,6 +375,8 @@
"Search docs": "Search docs", "Search docs": "Search docs",
"More options": "More options", "More options": "More options",
"Pinned documents": "Pinned documents", "Pinned documents": "Pinned documents",
"Open actions menu for document: {{title}}": "Open actions menu for document: {{title}}",
"Open the menu of actions for the document: {{title}}": "Open the menu of actions for the document: {{title}}",
"Share with {{count}} users_one": "Share with {{count}} user", "Share with {{count}} users_one": "Share with {{count}} user",
"Shared with {{count}} users_many": "Shared with {{count}} users", "Shared with {{count}} users_many": "Shared with {{count}} users",
"Shared with {{count}} users_one": "Shared with {{count}} user", "Shared with {{count}} users_one": "Shared with {{count}} user",
@@ -492,6 +495,8 @@
"Offline ?!": "¿¡Sin conexión!?", "Offline ?!": "¿¡Sin conexión!?",
"Only invited people can access": "Solo las personas invitadas pueden acceder", "Only invited people can access": "Solo las personas invitadas pueden acceder",
"Open Source": "Código abierto", "Open Source": "Código abierto",
"Open actions menu for document: {{title}}": "Abrir menú de acciones para el documento: {{title}}",
"Open the menu of actions for the document: {{title}}": "Abrir el menú de acciones para el documento: {{title}}",
"Open the document options": "Abrir las opciones del documento", "Open the document options": "Abrir las opciones del documento",
"Open the header menu": "Abrir el menú de encabezado", "Open the header menu": "Abrir el menú de encabezado",
"Organize": "Organiza", "Organize": "Organiza",
@@ -668,6 +673,8 @@
"Image 403": "Image 403", "Image 403": "Image 403",
"Insufficient access rights to view the document.": "Droits d'accès insuffisants pour voir le document.", "Insufficient access rights to view the document.": "Droits d'accès insuffisants pour voir le document.",
"Invite": "Inviter", "Invite": "Inviter",
"Open actions menu for document: {{title}}": "Ouvrir le menu d'actions pour le document : {{title}}",
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu d'actions du document",
"It is the card information about the document.": "Il s'agit de la carte d'information du document.", "It is the card information about the document.": "Il s'agit de la carte d'information du document.",
"It is the document title": "Il s'agit du titre du document", "It is the document title": "Il s'agit du titre du document",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Il semble que la page que vous cherchez n'existe pas ou ne puisse pas être affichée correctement.", "It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Il semble que la page que vous cherchez n'existe pas ou ne puisse pas être affichée correctement.",
@@ -1057,6 +1064,8 @@
"Offline ?!": "Offline ?!", "Offline ?!": "Offline ?!",
"Only invited people can access": "Alleen uitgenodigde gebruikers hebben toegang", "Only invited people can access": "Alleen uitgenodigde gebruikers hebben toegang",
"Open Source": "Open Source", "Open Source": "Open Source",
"Open actions menu for document: {{title}}": "Open actiemenu voor document: {{title}}",
"Open the menu of actions for the document: {{title}}": "Open het menu van acties voor het document: {{title}}",
"Open the document options": "Open document opties", "Open the document options": "Open document opties",
"Open the header menu": "Open het hoofdmenu", "Open the header menu": "Open het hoofdmenu",
"Organize": "Organiseer", "Organize": "Organiseer",