diff --git a/CHANGELOG.md b/CHANGELOG.md index e93488c1..fde66911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to - #1235 - #1255 - #1262 + - #1244 - #1270 ## [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 - ⚡️(frontend) improve tree stability #1207 - ⚡️(frontend) improve accessibility #1232 -- 🛂(frontend) block drag n drop when not desktop #1239 +- 🛂(frontend) block drag n drop when not desktop +#1239 + ### Fixed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts index ec812ca1..28f479f4 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/home.spec.ts @@ -15,10 +15,13 @@ test.describe('Home page', () => { const header = page.locator('header').first(); const footer = page.locator('footer').first(); await expect(header).toBeVisible(); - await expect( - header.getByRole('button', { name: /Language/ }), - ).toBeVisible(); - await expect(header.getByRole('img', { name: 'Docs logo' })).toBeVisible(); + + const languageButton = page.getByRole('button', { + name: /Language|Select language/, + }); + await expect(languageButton).toBeVisible(); + + await expect(header.getByTestId('header-icon-docs')).toBeVisible(); await expect(header.getByRole('heading', { name: 'Docs' })).toBeVisible(); // Check the titles @@ -65,20 +68,31 @@ test.describe('Home page', () => { 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 const header = page.locator('header').first(); const footer = page.locator('footer').first(); await expect(header).toBeVisible(); - await expect( - header.getByRole('button', { name: /Language/ }), - ).toBeVisible(); + + // Check for language picker - it should be visible on desktop + // 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( header.getByRole('button', { name: 'Les services de La Suite numé' }), ).toBeVisible(); await expect( header.getByRole('img', { name: 'Gouvernement Logo' }), ).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(); // Check the titles diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index c88ade2a..d03f729f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -9,6 +9,7 @@ test.describe('Language', () => { test('checks language switching', async ({ page }) => { const header = page.locator('header').first(); + const languagePicker = header.locator('.--docs--language-picker-text'); 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 header.getByRole('button').getByText('Français').click(); - await page.getByLabel('Deutsch').click(); + // Switch to German using the utility function for consistency + await waitForLanguageSwitch(page, TestLanguage.German); await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible(); await expect(page.getByLabel('Abmelden')).toBeVisible(); 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 ({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts index 7ee2253d..bfed1af1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts @@ -29,7 +29,9 @@ test.describe('Left panel mobile', () => { const header = page.locator('header').first(); const homeButton = page.getByTestId('home-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' }); await expect(homeButton).not.toBeInViewport(); diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx similarity index 75% rename from src/frontend/apps/impress/src/components/DropdownMenu.tsx rename to src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx index 5700aafe..207c962d 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/dropdown-menu/DropdownMenu.tsx @@ -1,10 +1,19 @@ 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 { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; +import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav'; + export type DropdownMenuOption = { icon?: string; label: string; @@ -46,12 +55,40 @@ export const DropdownMenu = ({ }: PropsWithChildren) => { const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const [isOpen, setIsOpen] = useState(opened ?? false); + const [focusedIndex, setFocusedIndex] = useState(-1); const blockButtonRef = useRef(null); + const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]); - const onOpenChange = (isOpen: boolean) => { - setIsOpen(isOpen); - afterOpenChange?.(isOpen); - }; + const onOpenChange = useCallback( + (isOpen: boolean) => { + 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) { return children; @@ -95,6 +132,7 @@ export const DropdownMenu = ({ $maxWidth="320px" $minWidth={`${blockButtonRef.current?.clientWidth}px`} role="menu" + aria-label={label} > {topMessage && ( { + menuItemRefs.current[index] = el; + }} role="menuitem" aria-label={option.label} data-testid={option.testId} $direction="row" disabled={isDisabled} + $hasTransition={false} onClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -158,6 +202,19 @@ export const DropdownMenu = ({ &:hover { 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); + `} `} > ; + 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, + ]); +}; diff --git a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx index 313209bf..ce71f208 100644 --- a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx +++ b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx @@ -1,9 +1,12 @@ import { css } from 'styled-components'; import { Box } from '../Box'; -import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu'; import { Icon } from '../Icon'; import { Text } from '../Text'; +import { + DropdownMenu, + DropdownMenuOption, +} from '../dropdown-menu/DropdownMenu'; export type FilterDropdownProps = { options: DropdownMenuOption[]; diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts index bb426724..cf076e9f 100644 --- a/src/frontend/apps/impress/src/components/index.ts +++ b/src/frontend/apps/impress/src/components/index.ts @@ -3,7 +3,7 @@ export * from './Box'; export * from './BoxButton'; export * from './Card'; export * from './DropButton'; -export * from './DropdownMenu'; +export * from './dropdown-menu/DropdownMenu'; export * from './quick-search'; export * from './Icon'; export * from './InfiniteScroll'; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index 0d0ea77f..ed51923e 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -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 ( <> - + { div { diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 31523a6e..794f9a96 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -363,6 +363,7 @@ "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 {{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 und <6>Next.js aufgebaut. Wir verwenden auch <9>Yjs und <13>BlockNote.js, 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).
Docs verwendet eine <7>Lizenz (MIT), die auf Innovation und Unternehmen zugeschnitten ist.
Beiträge sind willkommen (lesen Sie unsere Roadmap <13>hier).\n", "home-content-open-source-part3": "Docs ist das Ergebnis einer gemeinsamen Anstrengung, die von der französischen Regierung 🇫🇷🥖 <1>(DINUM) und der deutschen Regierung 🇩🇪🥨 <5>(ZenDiS) geleitet wurde. “, „home-content-open-source-part3“: „Docs ist das Ergebnis einer gemeinsamen Anstrengung, die von der französischen Regierung 🇫🇷🥖 <1>(DINUM) und der deutschen Regierung 🇩🇪🥨 <5>(ZenDiS) geleitet wird." @@ -374,6 +375,8 @@ "Search docs": "Search docs", "More options": "More options", "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", "Shared with {{count}} users_many": "Shared with {{count}} users", "Shared with {{count}} users_one": "Shared with {{count}} user", @@ -492,6 +495,8 @@ "Offline ?!": "¿¡Sin conexión!?", "Only invited people can access": "Solo las personas invitadas pueden acceder", "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 header menu": "Abrir el menú de encabezado", "Organize": "Organiza", @@ -668,6 +673,8 @@ "Image 403": "Image 403", "Insufficient access rights to view the document.": "Droits d'accès insuffisants pour voir le document.", "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 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.", @@ -1057,6 +1064,8 @@ "Offline ?!": "Offline ?!", "Only invited people can access": "Alleen uitgenodigde gebruikers hebben toegang", "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 header menu": "Open het hoofdmenu", "Organize": "Organiseer",