️(frontend) improve accessibility of selected document's sub-menu

adds focus style to make the sub-menu accessible unify dropdownmenu

Signed-off-by: Cyril <[email protected]>
This commit is contained in:
Cyril
2025-09-04 11:31:43 +02:00
parent 39ef6d10ff
commit d84a9cb24a
7 changed files with 90 additions and 32 deletions
+2 -1
View File
@@ -25,8 +25,9 @@ and this project adheres to
- #1244
- #1270
- #1282
- #1261
- ♻️(backend) fallback to email identifier when no name #1298
- 🐛(backend) allow ASCII characters in user sub field #1295
- 🐛(backend) allow ASCII characters in user sub field #1295
- ⚡️(frontend) improve fallback width calculation #1333
### Fixed
@@ -63,5 +63,6 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
await rootItem.hover();
await rootItem.getByRole('button', { name: 'add_box' }).click();
await rootItem.getByTestId('add-child-doc').click();
};
@@ -2,6 +2,7 @@ import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import {
Fragment,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useRef,
@@ -15,7 +16,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
export type DropdownMenuOption = {
icon?: string;
icon?: string | ReactNode;
label: string;
testId?: string;
value?: string;
@@ -81,14 +82,28 @@ export const DropdownMenu = ({
// 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);
}
if (!isOpen || menuItemRefs.current.length === 0) {
return;
}
const selectedIndex = options.findIndex((option) => option.isSelected);
if (selectedIndex !== -1) {
setFocusedIndex(selectedIndex);
setTimeout(() => {
menuItemRefs.current[selectedIndex]?.focus();
}, 0);
return;
}
// Fallback: focus first enabled/visible option
const firstEnabledIndex = options.findIndex(
(opt) => opt.show !== false && !opt.disabled,
);
if (firstEnabledIndex !== -1) {
setFocusedIndex(firstEnabledIndex);
setTimeout(() => {
menuItemRefs.current[firstEnabledIndex]?.focus();
}, 0);
}
}, [isOpen, options]);
@@ -156,7 +171,6 @@ export const DropdownMenu = ({
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;
return (
<Fragment key={option.label}>
@@ -207,17 +221,8 @@ export const DropdownMenu = ({
}
&: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
@@ -225,14 +230,17 @@ export const DropdownMenu = ({
$align="center"
$gap={spacingsTokens['base']}
>
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
)}
{option.icon &&
(typeof option.icon === 'string' ? (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
) : (
option.icon
))}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>
@@ -95,13 +95,19 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
: 'var(--c--theme--colors--greyscale-000)'};
}
&:focus-within .light-doc-item-actions {
display: flex;
background: var(--c--theme--colors--greyscale-100);
}
.c__tree-view--node.isSelected {
.light-doc-item-actions {
background: var(--c--theme--colors--greyscale-100);
}
}
&:hover {
&:hover,
&:focus-within {
background-color: var(--c--theme--colors--greyscale-100);
border-radius: 4px;
@@ -191,7 +191,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
opacity: 1;
}
}
&:hover {
&:hover,
&:focus-within {
.doc-tree-root-item-actions {
opacity: 1;
}
@@ -150,6 +150,25 @@ export const DocTreeItemActions = ({
$align="center"
className="--docs--doc-tree-item-actions"
$gap="4px"
$css={css`
&:focus-within {
opacity: 1;
visibility: visible;
}
button:focus-visible,
[role='button']:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
background-color: var(--c--theme--colors--greyscale-050);
border-radius: 4px;
}
.icon-button:focus-visible {
outline: 2px solid var(--c--theme--colors--primary-500);
outline-offset: 2px;
background-color: var(--c--theme--colors--greyscale-050);
border-radius: 4px;
}
`}
>
<DropdownMenu
options={options}
@@ -166,10 +185,22 @@ export const DocTreeItemActions = ({
variant="filled"
$theme="primary"
$variation="600"
className="icon-button"
tabIndex={0}
role="button"
aria-label={t('More options')}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onOpenChange?.(!isOpen);
}
}}
/>
</DropdownMenu>
{doc.abilities.children_create && (
<BoxButton
data-testid="add-child-doc"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
@@ -179,6 +210,7 @@ export const DocTreeItemActions = ({
});
}}
color="primary"
aria-label={t('Add child document')}
>
<Icon
variant="filled"
@@ -274,6 +274,8 @@
"Move": "Verschieben",
"Move document": "Dokument verschieben",
"Move to my docs": "In \"Meine Dokumente\" verschieben",
"More options": "Weitere Optionen",
"Add child document": "Unterdokument hinzufügen",
"My docs": "Meine Dokumente",
"Name": "Name",
"New doc": "Neues Dokument",
@@ -387,7 +389,8 @@
"Shared with {{count}} users_many": "Shared with {{count}} users",
"Shared with {{count}} users_one": "Shared with {{count}} user",
"Shared with {{count}} users_other": "Shared with {{count}} users",
"Updated": "Updated"
"Updated": "Updated",
"Add child document": "Add child document"
}
},
"es": {
@@ -575,6 +578,8 @@
"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",
"More options": "Más opciones",
"Add child document": "Añadir documento hijo",
"home-content-open-source-part1": "Docs está construido sobre <2>Django Rest Framework</2> y <6>Next.js</6>. También utilizamos <9>Yjs</9> y <13>BlockNote.js</13>, dos proyectos que estamos orgullosos de patrocinar.",
"home-content-open-source-part2": "Puede autoalojar fácilmente Docs (consulte nuestra <2>documentación</2> de instalación).<br/>Docs utiliza una <7>licencia</7> (MIT) adecuada para la innovación y las empresas.<br/>Se aceptan contribuciones (consulte nuestra hoja de ruta <13>aquí</13>).",
"home-content-open-source-part3": "Docs es el resultado de un esfuerzo conjunto llevado a cabo por los gobiernos francés 🇫🇷🥖 <1>(DINUM)</1> y alemán 🇩🇪🥨 <5>(ZenDiS)</5>."
@@ -712,6 +717,8 @@
"Move": "Déplacer",
"Move document": "Déplacer le document",
"Move to my docs": "Déplacer vers mes docs",
"More options": "Plus d'options",
"Add child document": "Ajouter un document enfant",
"My docs": "Mes documents",
"Name": "Nom",
"New doc": "Nouveau doc",
@@ -1151,6 +1158,8 @@
"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",
"More options": "Meer opties",
"Add child document": "Onderliggend document toevoegen",
"home-content-open-source-part1": "Docs is gebouwd op <2>Django Rest Framework</2> en <6>Next.js</6>. We gebruiken ook <9>Yjs</9> en <13>BlockNote.js</13>, twee projecten die we met trots sponsoren.",
"home-content-open-source-part2": "U kunt Docs eenvoudig zelf hosten (zie onze <2>installatiedocumentatie</2>).<br/>Docs gebruikt een <7>licentie</7> (MIT) die is afgestemd op innovatie en ondernemingen.<br/>Bijdragen zijn welkom (zie onze routekaart <13>hier</13>).",
"home-content-open-source-part3": "Docs is het resultaat van een gezamenlijke inspanning geleid door de Franse 🇫🇷🥖 <1>(DINUM)</1> en Duitse 🇩🇪🥨 <5>(ZenDiS)</5> overheden."