Skip to content

Commit 5181bba

Browse files
committed
⚡️(a11y) improve keyboard access for language menu and action buttons
Enhances nav for language switch and makes DocsGridActions buttons accessible Signed-off-by: Cyril <[email protected]>
1 parent f434d78 commit 5181bba

File tree

11 files changed

+243
-20
lines changed

11 files changed

+243
-20
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to
1515
- #1235
1616
- #1255
1717
- #1262
18+
- #1244
1819
- #1270
1920

2021
## [3.5.0] - 2025-07-31
@@ -35,7 +36,9 @@ and this project adheres to
3536
- 🔧(project) change env.d system by using local files #1200
3637
- ⚡️(frontend) improve tree stability #1207
3738
- ⚡️(frontend) improve accessibility #1232
38-
- 🛂(frontend) block drag n drop when not desktop #1239
39+
- 🛂(frontend) block drag n drop when not desktop
40+
#1239
41+
3942

4043
### Fixed
4144

src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ test.describe('Home page', () => {
1515
const header = page.locator('header').first();
1616
const footer = page.locator('footer').first();
1717
await expect(header).toBeVisible();
18-
await expect(
19-
header.getByRole('button', { name: /Language/ }),
20-
).toBeVisible();
21-
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible();
18+
19+
const languageButton = page.getByRole('button', {
20+
name: /Language|Select language/,
21+
});
22+
await expect(languageButton).toBeVisible();
23+
24+
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
2225
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
2326

2427
// Check the titles
@@ -65,20 +68,31 @@ test.describe('Home page', () => {
6568

6669
await page.goto('/docs/');
6770

71+
// Wait for the page to be fully loaded and responsive store to be initialized
72+
await page.waitForLoadState('domcontentloaded');
73+
74+
// Wait a bit more for the responsive store to be initialized
75+
await page.waitForTimeout(500);
76+
6877
// Check header content
6978
const header = page.locator('header').first();
7079
const footer = page.locator('footer').first();
7180
await expect(header).toBeVisible();
72-
await expect(
73-
header.getByRole('button', { name: /Language/ }),
74-
).toBeVisible();
81+
82+
// Check for language picker - it should be visible on desktop
83+
// Use a more flexible selector that works with both Header and HomeHeader
84+
const languageButton = page.getByRole('button', {
85+
name: /Language|Select language/,
86+
});
87+
await expect(languageButton).toBeVisible();
88+
7589
await expect(
7690
header.getByRole('button', { name: 'Les services de La Suite numé' }),
7791
).toBeVisible();
7892
await expect(
7993
header.getByRole('img', { name: 'Gouvernement Logo' }),
8094
).toBeVisible();
81-
await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible();
95+
await expect(header.getByTestId('header-icon-docs')).toBeVisible();
8296
await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible();
8397

8498
// Check the titles

src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ test.describe('Language', () => {
99

1010
test('checks language switching', async ({ page }) => {
1111
const header = page.locator('header').first();
12+
const languagePicker = header.locator('.--docs--language-picker-text');
1213

1314
await expect(page.locator('html')).toHaveAttribute('lang', 'en-us');
1415

@@ -30,13 +31,49 @@ test.describe('Language', () => {
3031

3132
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
3233

33-
await header.getByRole('button').getByText('Français').click();
34-
await page.getByLabel('Deutsch').click();
34+
// Switch to German using the utility function for consistency
35+
await waitForLanguageSwitch(page, TestLanguage.German);
3536
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
3637

3738
await expect(page.getByLabel('Abmelden')).toBeVisible();
3839

3940
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
41+
42+
await languagePicker.click();
43+
44+
await expect(page.locator('[role="menu"]')).toBeVisible();
45+
46+
const menuItems = page.getByRole('menuitem');
47+
await expect(menuItems.first()).toBeVisible();
48+
49+
await menuItems.first().click();
50+
51+
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
52+
await expect(languagePicker).toContainText('English');
53+
});
54+
test('can switch language using only keyboard', async ({ page }) => {
55+
await page.goto('/');
56+
await waitForLanguageSwitch(page, TestLanguage.English);
57+
58+
const languagePicker = page.getByRole('button', {
59+
name: /select language/i,
60+
});
61+
62+
await expect(languagePicker).toBeVisible();
63+
64+
await page.keyboard.press('Tab');
65+
await page.keyboard.press('Tab');
66+
await page.keyboard.press('Tab');
67+
68+
await page.keyboard.press('Enter');
69+
70+
const menu = page.getByRole('menu');
71+
await expect(menu).toBeVisible();
72+
73+
await page.keyboard.press('ArrowDown');
74+
await page.keyboard.press('Enter');
75+
76+
await expect(page.locator('html')).not.toHaveAttribute('lang', 'en-us');
4077
});
4178

4279
test('checks that backend uses the same language as the frontend', async ({

src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ test.describe('Left panel mobile', () => {
2929
const header = page.locator('header').first();
3030
const homeButton = page.getByTestId('home-button');
3131
const newDocButton = page.getByTestId('new-doc-button');
32-
const languageButton = page.getByRole('button', { name: /Language/ });
32+
const languageButton = page.getByRole('button', {
33+
name: 'Select language',
34+
});
3335
const logoutButton = page.getByRole('button', { name: 'Logout' });
3436

3537
await expect(homeButton).not.toBeInViewport();

src/frontend/apps/impress/src/components/DropdownMenu.tsx renamed to src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
2-
import { Fragment, PropsWithChildren, useRef, useState } from 'react';
2+
import {
3+
Fragment,
4+
PropsWithChildren,
5+
useCallback,
6+
useEffect,
7+
useRef,
8+
useState,
9+
} from 'react';
310
import { css } from 'styled-components';
411

512
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
613
import { useCunninghamTheme } from '@/cunningham';
714

15+
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
16+
817
export type DropdownMenuOption = {
918
icon?: string;
1019
label: string;
@@ -46,12 +55,40 @@ export const DropdownMenu = ({
4655
}: PropsWithChildren<DropdownMenuProps>) => {
4756
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
4857
const [isOpen, setIsOpen] = useState(opened ?? false);
58+
const [focusedIndex, setFocusedIndex] = useState(-1);
4959
const blockButtonRef = useRef<HTMLDivElement>(null);
60+
const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]);
61+
62+
const onOpenChange = useCallback(
63+
(isOpen: boolean) => {
64+
setIsOpen(isOpen);
65+
setFocusedIndex(-1);
66+
afterOpenChange?.(isOpen);
67+
},
68+
[afterOpenChange],
69+
);
5070

51-
const onOpenChange = (isOpen: boolean) => {
52-
setIsOpen(isOpen);
53-
afterOpenChange?.(isOpen);
54-
};
71+
useDropdownKeyboardNav({
72+
isOpen,
73+
focusedIndex,
74+
options,
75+
menuItemRefs,
76+
setFocusedIndex,
77+
onOpenChange,
78+
});
79+
80+
// Focus selected menu item when menu opens
81+
useEffect(() => {
82+
if (isOpen && menuItemRefs.current.length > 0) {
83+
const selectedIndex = options.findIndex((option) => option.isSelected);
84+
if (selectedIndex !== -1) {
85+
setFocusedIndex(selectedIndex);
86+
setTimeout(() => {
87+
menuItemRefs.current[selectedIndex]?.focus();
88+
}, 0);
89+
}
90+
}
91+
}, [isOpen, options]);
5592

5693
if (disabled) {
5794
return children;
@@ -95,6 +132,7 @@ export const DropdownMenu = ({
95132
$maxWidth="320px"
96133
$minWidth={`${blockButtonRef.current?.clientWidth}px`}
97134
role="menu"
135+
aria-label={label}
98136
>
99137
{topMessage && (
100138
<Text
@@ -115,14 +153,20 @@ export const DropdownMenu = ({
115153
return;
116154
}
117155
const isDisabled = option.disabled !== undefined && option.disabled;
156+
const isFocused = index === focusedIndex;
157+
118158
return (
119159
<Fragment key={option.label}>
120160
<BoxButton
161+
ref={(el) => {
162+
menuItemRefs.current[index] = el;
163+
}}
121164
role="menuitem"
122165
aria-label={option.label}
123166
data-testid={option.testId}
124167
$direction="row"
125168
disabled={isDisabled}
169+
$hasTransition={false}
126170
onClick={(event) => {
127171
event.preventDefault();
128172
event.stopPropagation();
@@ -158,6 +202,19 @@ export const DropdownMenu = ({
158202
&:hover {
159203
background-color: var(--c--theme--colors--greyscale-050);
160204
}
205+
206+
&:focus-visible {
207+
outline: 2px solid var(--c--theme--colors--primary-500);
208+
outline-offset: -2px;
209+
background-color: var(--c--theme--colors--greyscale-050);
210+
}
211+
212+
${isFocused &&
213+
css`
214+
outline: 2px solid var(--c--theme--colors--primary-500);
215+
outline-offset: -2px;
216+
background-color: var(--c--theme--colors--greyscale-050);
217+
`}
161218
`}
162219
>
163220
<Box
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { RefObject, useEffect } from 'react';
2+
3+
import { DropdownMenuOption } from '../DropdownMenu';
4+
5+
type UseDropdownKeyboardNavProps = {
6+
isOpen: boolean;
7+
focusedIndex: number;
8+
options: DropdownMenuOption[];
9+
menuItemRefs: RefObject<(HTMLDivElement | null)[]>;
10+
setFocusedIndex: (index: number) => void;
11+
onOpenChange: (isOpen: boolean) => void;
12+
};
13+
14+
export const useDropdownKeyboardNav = ({
15+
isOpen,
16+
focusedIndex,
17+
options,
18+
menuItemRefs,
19+
setFocusedIndex,
20+
onOpenChange,
21+
}: UseDropdownKeyboardNavProps) => {
22+
useEffect(() => {
23+
const handleKeyDown = (event: KeyboardEvent) => {
24+
if (!isOpen) {
25+
return;
26+
}
27+
28+
const enabledIndices = options
29+
.map((option, index) =>
30+
option.show !== false && !option.disabled ? index : -1,
31+
)
32+
.filter((index) => index !== -1);
33+
34+
switch (event.key) {
35+
case 'ArrowDown':
36+
event.preventDefault();
37+
const nextIndex =
38+
focusedIndex < enabledIndices.length - 1 ? focusedIndex + 1 : 0;
39+
const nextEnabledIndex = enabledIndices[nextIndex];
40+
setFocusedIndex(nextIndex);
41+
menuItemRefs.current[nextEnabledIndex]?.focus();
42+
break;
43+
44+
case 'ArrowUp':
45+
event.preventDefault();
46+
const prevIndex =
47+
focusedIndex > 0 ? focusedIndex - 1 : enabledIndices.length - 1;
48+
const prevEnabledIndex = enabledIndices[prevIndex];
49+
setFocusedIndex(prevIndex);
50+
menuItemRefs.current[prevEnabledIndex]?.focus();
51+
break;
52+
53+
case 'Enter':
54+
case ' ':
55+
event.preventDefault();
56+
if (focusedIndex >= 0 && focusedIndex < enabledIndices.length) {
57+
const selectedOptionIndex = enabledIndices[focusedIndex];
58+
const selectedOption = options[selectedOptionIndex];
59+
if (selectedOption && selectedOption.callback) {
60+
onOpenChange(false);
61+
void selectedOption.callback();
62+
}
63+
}
64+
break;
65+
66+
case 'Escape':
67+
event.preventDefault();
68+
onOpenChange(false);
69+
break;
70+
}
71+
};
72+
73+
if (isOpen) {
74+
document.addEventListener('keydown', handleKeyDown);
75+
}
76+
77+
return () => {
78+
document.removeEventListener('keydown', handleKeyDown);
79+
};
80+
}, [
81+
isOpen,
82+
focusedIndex,
83+
options,
84+
menuItemRefs,
85+
setFocusedIndex,
86+
onOpenChange,
87+
]);
88+
};

src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { css } from 'styled-components';
22

33
import { Box } from '../Box';
4-
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
54
import { Icon } from '../Icon';
65
import { Text } from '../Text';
6+
import {
7+
DropdownMenu,
8+
DropdownMenuOption,
9+
} from '../dropdown-menu/DropdownMenu';
710

811
export type FilterDropdownProps = {
912
options: DropdownMenuOption[];

src/frontend/apps/impress/src/components/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export * from './Box';
33
export * from './BoxButton';
44
export * from './Card';
55
export * from './DropButton';
6-
export * from './DropdownMenu';
6+
export * from './dropdown-menu/DropdownMenu';
77
export * from './quick-search';
88
export * from './Icon';
99
export * from './InfiniteScroll';

src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,14 @@ export const DocsGridActions = ({
7676
},
7777
];
7878

79+
const documentTitle = doc.title || t('Untitled document');
80+
const menuLabel = t('Open the menu of actions for the document: {{title}}', {
81+
title: documentTitle,
82+
});
83+
7984
return (
8085
<>
81-
<DropdownMenu options={options}>
86+
<DropdownMenu options={options} label={menuLabel}>
8287
<Icon
8388
data-testid={`docs-grid-actions-button-${doc.id}`}
8489
iconName="more_horiz"

0 commit comments

Comments
 (0)