Compare commits

..

11 Commits

Author SHA1 Message Date
Cyril b47c730e19 ️(frontend) announce search results through a live region
Announce result count updates while focus stays in input (#2043)
2026-03-20 10:47:18 +01:00
Cyril cef83067e6 ️(frontend) restore focus to input after search filter reset
Move filters out of listbox and refocus the search input (#2044)
2026-03-20 10:47:03 +01:00
Cyril 4cabfcc921 ️(frontend) update aria-expanded dynamically on search combobox
Override cmdk aria-expanded via ref to reflect list state (#2039)
2026-03-20 10:47:02 +01:00
Cyril b8d4b0a044 ️(frontend) add label text to search input field
Pass label prop to QuickSearch to render non-empty label (#2041)
2026-03-20 10:47:01 +01:00
Cyril 71c4d2921b ️(frontend) add explicit aria-label to search reset button
Add aria-label to clarify the reset button resets filters (#2042)
2026-03-20 10:47:01 +01:00
Cyril d1636dee13 ️(frontend) set empty alt on decorative search image
Set alt="" on decorative empty state image in search modal (#2038)
2026-03-20 10:46:49 +01:00
Cyril bf93640af8 ️(frontend) skip link as anchor instead of button
Replace button with anchor link
2026-03-20 10:05:42 +01:00
Cyril da79c310ae ️(frontend) focus skip link on headings and skip grid dropzone
We land keyboard users on page headings and keep the grid dropzone untabbable.
2026-03-20 10:05:33 +01:00
Cyril 99c486571d ️(frontend) fix more options menu feedback for screen readers
Pin/unpin: vocal announce only. Duplicate, copy: toast only.
2026-03-19 18:34:24 +01:00
Cyril cdf3161869 ️(frontend) use aria-label trad for version history modal #2023
Replace hardcoded aria-label with aria-lab trad.
2026-03-19 15:52:28 +01:00
Cyril ef108227b3 ️(frontend) improve version history list accessibility
Dynamic aria-label per version, aria-pressed + live region
2026-03-19 14:04:59 +01:00
28 changed files with 293 additions and 198 deletions
+4
View File
@@ -9,6 +9,10 @@ and this project adheres to
### Changed
- 💫(frontend) fix the help button to the bottom in tree #2073
- ♿️(frontend) improve version history list accessibility #2033
- ♿️(frontend) fix more options menu feedback for screen readers #2071
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
- ♿️(frontend) fix search modal accessibility issues #2054
## [v4.8.2] - 2026-03-19
@@ -474,7 +474,9 @@ test.describe('Doc Header', () => {
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await getMenuItem(page, 'Copy as Markdown').click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
await expect(
page.getByText('Copied as Markdown to clipboard'),
).toBeVisible();
// Test that clipboard is in Markdown format
const handle = await page.evaluateHandle(() =>
@@ -29,7 +29,7 @@ test.describe('Document search', () => {
await page.getByTestId('search-docs-button').click();
await expect(
page.getByRole('img', { name: 'No active search' }),
page.getByLabel('Search modal').locator('img[alt=""]'),
).toBeVisible();
await expect(
@@ -107,7 +107,7 @@ test.describe('Document search', () => {
await searchButton.click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
page.getByRole('combobox', { name: 'Search documents' }),
).toBeVisible();
await expect(filters).toBeHidden();
@@ -120,7 +120,7 @@ test.describe('Document search', () => {
await searchButton.click();
await expect(
page.getByRole('combobox', { name: 'Quick search input' }),
page.getByRole('combobox', { name: 'Search documents' }),
).toBeVisible();
await expect(filters).toBeHidden();
@@ -164,9 +164,9 @@ test.describe('Document search', () => {
const searchButton = page.getByTestId('search-docs-button');
await searchButton.click();
await page.getByRole('combobox', { name: 'Quick search input' }).click();
await page.getByRole('combobox', { name: 'Search documents' }).click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.getByRole('combobox', { name: 'Search documents' })
.fill('sub page search');
// Expect to find the first and second docs in the results list
@@ -188,7 +188,7 @@ test.describe('Document search', () => {
);
await searchButton.click();
await page
.getByRole('combobox', { name: 'Quick search input' })
.getByRole('combobox', { name: 'Search documents' })
.fill('second');
// Now there is a sub page - expect to have the focus on the current doc
@@ -25,7 +25,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('version list');
const panel = modal.getByLabel('Version list');
await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible();
@@ -155,15 +155,15 @@ test.describe('Doc Version', () => {
await getMenuItem(page, 'Version history').click();
const modal = page.getByRole('dialog', { name: 'Version history' });
const panel = modal.getByLabel('version list');
const panel = modal.getByLabel('Version list');
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await panel.getByRole('button', { name: 'version item' }).click();
await panel.locator('.version-item').first().click();
await expect(modal.getByText('World')).toBeHidden();
await page.getByRole('button', { name: 'Restore' }).click();
await page.getByRole('button', { name: 'Restore', exact: true }).click();
await expect(page.getByText('Your current document will')).toBeVisible();
await page.getByText('If a member is editing, his').click();
@@ -191,25 +191,27 @@ test.describe('Header: Override configuration', () => {
});
test.describe('Header: Skip to Content', () => {
test('it displays skip link on first TAB and focuses main content on click', async ({
test('it displays skip link on first TAB and focuses page heading on click', async ({
page,
}) => {
await page.goto('/');
// Wait for skip button to be mounted (client-side only component)
const skipButton = page.getByRole('button', { name: 'Go to content' });
await skipButton.waitFor({ state: 'attached' });
// Wait for skip link to be mounted (client-side only component)
const skipLink = page.getByRole('link', { name: 'Go to content' });
await skipLink.waitFor({ state: 'attached' });
// First TAB shows the skip button
// First TAB shows the skip link
await page.keyboard.press('Tab');
// The skip button should be visible and focused
await expect(skipButton).toBeFocused();
await expect(skipButton).toBeVisible();
// Clicking moves focus to the main content
await skipButton.click();
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
// The skip link should be visible and focused
await expect(skipLink).toBeFocused();
await expect(skipLink).toBeVisible();
// Clicking moves focus to the page heading
await skipLink.click();
const pageHeading = page.getByRole('heading', {
name: 'All docs',
level: 2,
});
await expect(pageHeading).toBeFocused();
});
});
@@ -18,7 +18,7 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
});
test('focuses main content after switching the docs filter', async ({
test('focuses page heading after switching the docs filter', async ({
page,
}) => {
await page.goto('/');
@@ -28,8 +28,11 @@ test.describe('Left panel desktop', () => {
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/target=my_docs/);
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
const pageHeading = page.getByRole('heading', {
name: 'My docs',
level: 2,
});
await expect(pageHeading).toBeFocused();
});
test('checks resize handle is present and functional on document page', async ({
@@ -1,63 +1,33 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { focusMainContentStart } from '@/layouts/utils';
export const SkipToContent = () => {
const { t } = useTranslation();
const router = useRouter();
const { spacingsTokens } = useCunninghamTheme();
const [isVisible, setIsVisible] = useState(false);
// Reset focus after route change so first TAB goes to skip link
useEffect(() => {
const handleRouteChange = () => {
(document.activeElement as HTMLElement)?.blur();
document.body.setAttribute('tabindex', '-1');
document.body.focus({ preventScroll: true });
setTimeout(() => {
document.body.removeAttribute('tabindex');
}, 100);
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events]);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
mainContent.focus();
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
const focusTarget = focusMainContentStart();
if (focusTarget instanceof HTMLElement) {
focusTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<Box
$css={css`
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
box-shadow:
0 0 0 1px var(--c--globals--colors--white-000),
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Box>
<Button
onClick={handleClick}
type="button"
href={`#${MAIN_LAYOUT_ID}`}
color="brand"
className="--docs--skip-to-content"
onClick={handleClick}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
style={{
@@ -65,7 +35,6 @@ export const SkipToContent = () => {
pointerEvents: isVisible ? 'auto' : 'none',
position: 'fixed',
top: spacingsTokens['2xs'],
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
zIndex: 9999,
whiteSpace: 'nowrap',
@@ -46,7 +46,7 @@ export const FilterDropdown = ({
$direction="row"
$align="center"
>
<Text $weight={400} $variation="tertiary" $theme="neutral" $size="sm">
<Text $weight={400} $variation="tertiary" $theme="neutral">
{selectedOption?.label ?? options[0].label}
</Text>
<Icon
@@ -32,6 +32,7 @@ export type QuickSearchProps = {
label?: string;
placeholder?: string;
groupKey?: string;
beforeList?: ReactNode;
};
export const QuickSearch = ({
@@ -41,6 +42,7 @@ export const QuickSearch = ({
showInput = true,
label,
placeholder,
beforeList,
children,
}: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
@@ -76,6 +78,7 @@ export const QuickSearch = ({
{inputContent}
</QuickSearchInput>
)}
{beforeList}
<Command.List id={listId} aria-label={label} role="listbox">
<Box>{children}</Box>
</Command.List>
@@ -1,9 +1,10 @@
import { Command } from 'cmdk';
import { PropsWithChildren } from 'react';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useFocusStore } from '@/stores';
import { Box } from '../Box';
import { Icon } from '../Icon';
@@ -14,7 +15,6 @@ type QuickSearchInputProps = {
placeholder?: string;
withSeparator?: boolean;
listId?: string;
isExpanded?: boolean;
};
export const QuickSearchInput = ({
inputValue,
@@ -26,6 +26,12 @@ export const QuickSearchInput = ({
}: PropsWithChildren<QuickSearchInputProps>) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const inputRef = useRef<HTMLInputElement>(null);
const addLastFocus = useFocusStore((state) => state.addLastFocus);
useEffect(() => {
addLastFocus(inputRef.current);
}, [addLastFocus]);
if (children) {
return (
@@ -43,10 +49,11 @@ export const QuickSearchInput = ({
$align="center"
className="quick-search-input"
$gap={spacingsTokens['2xs']}
$padding={{ horizontal: 'base', vertical: 'xs' }}
$padding={{ horizontal: 'base', vertical: 'xxs' }}
>
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
<Command.Input
ref={inputRef}
autoFocus={true}
aria-label={t('Quick search input')}
aria-controls={listId}
@@ -58,7 +65,7 @@ export const QuickSearchInput = ({
data-testid="quick-search-input"
/>
</Box>
{separator && <HorizontalSeparator $margin={{ top: '2xs' }} />}
{separator && <HorizontalSeparator $margin={{ top: 'base' }} />}
</>
);
};
@@ -16,10 +16,9 @@ export const QuickSearchStyle = createGlobalStyle`
}
[cmdk-input] {
font-family: var(--c--globals--font--families--base);
border: none;
width: 100%;
font-size: 16px;
font-size: 17px;
background: white;
outline: none;
color: var(--c--contextuals--content--semantic--neutral--primary);
@@ -13,7 +13,8 @@ export const useCopyCurrentEditorToClipboard = () => {
return async (asFormat: 'html' | 'markdown') => {
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
const message = t('Editor unavailable');
toast(message, VariantType.ERROR, { duration: 3000 });
return;
}
@@ -23,10 +24,20 @@ export const useCopyCurrentEditorToClipboard = () => {
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
const successMessage =
asFormat === 'markdown'
? t('Copied as Markdown to clipboard')
: t('Copied to clipboard');
toast(successMessage, VariantType.SUCCESS, { duration: 3000 });
} catch (error) {
console.error(error);
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
const errorMessage =
asFormat === 'markdown'
? t('Failed to copy as Markdown to clipboard')
: t('Failed to copy to clipboard');
toast(errorMessage, VariantType.ERROR, {
duration: 3000,
});
}
@@ -1,4 +1,6 @@
import { announce } from '@react-aria/live-announcer';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
@@ -29,6 +31,8 @@ export function useCreateFavoriteDoc({
listInvalidQueries,
}: CreateFavoriteDocProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, APIError, CreateFavoriteDocParams>({
mutationFn: createFavoriteDoc,
onSuccess: () => {
@@ -37,7 +41,15 @@ export function useCreateFavoriteDoc({
queryKey: [queryKey],
});
});
const message = t('Document pinned successfully!');
announce(message, 'polite');
onSuccess?.();
},
onError: () => {
const message = t('Failed to pin the document.');
announce(message, 'assertive');
},
});
}
@@ -1,4 +1,6 @@
import { announce } from '@react-aria/live-announcer';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
@@ -29,6 +31,8 @@ export function useDeleteFavoriteDoc({
listInvalidQueries,
}: DeleteFavoriteDocProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, APIError, DeleteFavoriteDocParams>({
mutationFn: deleteFavoriteDoc,
onSuccess: () => {
@@ -37,7 +41,15 @@ export function useDeleteFavoriteDoc({
queryKey: [queryKey],
});
});
const message = t('Document unpinned successfully!');
announce(message, 'polite');
onSuccess?.();
},
onError: () => {
const message = t('Failed to unpin the document.');
announce(message, 'assertive');
},
});
}
@@ -88,14 +88,16 @@ export function useDuplicateDoc(options?: DuplicateDocOptions) {
queryKey: [KEY_LIST_DOC],
});
toast(t('Document duplicated successfully!'), VariantType.SUCCESS, {
const message = t('Document duplicated successfully!');
toast(message, VariantType.SUCCESS, {
duration: 3000,
});
void options?.onSuccess?.(data, variables, onMutateResult, context);
},
onError: (error, variables, onMutateResult, context) => {
toast(t('Failed to duplicate the document...'), VariantType.ERROR, {
const message = t('Failed to duplicate the document...');
toast(message, VariantType.ERROR, {
duration: 3000,
});
@@ -1,3 +1,4 @@
import { announce } from '@react-aria/live-announcer';
import { t } from 'i18next';
import { useEffect, useState } from 'react';
import { InView } from 'react-intersection-observer';
@@ -73,10 +74,12 @@ export const DocSearchContent = ({
docs = docs.filter(filterResults);
}
const elements = search || isSearchNotMandatory ? docs : [];
setDocsData({
groupName: docs.length > 0 ? groupName : '',
groupKey: 'docs',
elements: search || isSearchNotMandatory ? docs : [],
elements,
emptyString: t('No document found'),
endActions: hasNextPage
? [
@@ -90,6 +93,13 @@ export const DocSearchContent = ({
]
: [],
});
if (search) {
announce(
t('{{count}} result(s) available', { count: elements.length }),
'polite',
);
}
}, [
search,
data?.pages,
@@ -38,19 +38,19 @@ export const DocSearchFilters = ({
$justify="space-between"
$gap="10px"
data-testid="doc-search-filters"
$margin={{ vertical: 'sm' }}
$margin={{ vertical: 'base' }}
>
<Box $direction="row" $align="center" $gap="10px">
<FilterDropdown
selectedValue={values?.target}
options={[
{
label: t('All documents'),
label: t('All docs'),
value: DocSearchTarget.ALL,
callback: () => handleTargetChange(DocSearchTarget.ALL),
},
{
label: t('Current document only'),
label: t('Current doc'),
value: DocSearchTarget.CURRENT,
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
},
@@ -58,7 +58,13 @@ export const DocSearchFilters = ({
/>
</Box>
{hasFilters && (
<Button color="brand" variant="tertiary" size="small" onClick={onReset}>
<Button
color="brand"
variant="tertiary"
size="small"
onClick={onReset}
aria-label={t('Reset search filters')}
>
{t('Reset')}
</Button>
)}
@@ -14,7 +14,7 @@ import {
DocSearchFiltersValues,
DocSearchTarget,
} from '@/docs/doc-search';
import { useResponsiveStore } from '@/stores';
import { useFocusStore, useResponsiveStore } from '@/stores';
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
@@ -36,6 +36,7 @@ const DocSearchModalGlobal = ({
}: DocSearchModalGlobalProps) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const restoreFocus = useFocusStore((state) => state.restoreFocus);
const router = useRouter();
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<DocSearchFiltersValues>(
@@ -51,6 +52,7 @@ const DocSearchModalGlobal = ({
const handleResetFilters = () => {
setFilters({});
restoreFocus();
};
return (
@@ -60,45 +62,52 @@ const DocSearchModalGlobal = ({
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
hideCloseButton
aria-describedby="doc-search-modal-title"
title={
<>
<Text as="h2" $margin="0" $size="s" $align="flex-start">
{t('Search for a document')}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal
aria-label={t('Close the search modal')}
onClick={modalProps.onClose}
size="small"
color="brand"
variant="tertiary"
/>
</Box>
</>
}
>
<Box
aria-label={t('Search modal')}
$direction="column"
$justify="space-between"
className="--docs--doc-search-modal"
$padding={{ bottom: 'base' }}
$padding={{ vertical: 'base' }}
>
<Text
as="h1"
$margin="0"
id="doc-search-modal-title"
className="sr-only"
>
{t('Search docs')}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal
aria-label={t('Close the search modal')}
onClick={modalProps.onClose}
size="small"
color="brand"
variant="tertiary"
/>
</Box>
<QuickSearch
label={t('Search documents')}
placeholder={t('Type the name of a document')}
loading={loading}
onFilter={handleInputSearch}
beforeList={
showFilters ? (
<Box $padding={{ horizontal: '10px' }}>
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
/>
</Box>
) : undefined
}
>
<Box
$padding={{ horizontal: 'sm', vertical: 'base' }}
$padding={{ horizontal: '10px', vertical: 'base' }}
$height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}
>
{showFilters && (
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
/>
)}
{search.length === 0 && (
<Box
$direction="column"
@@ -106,11 +115,7 @@ const DocSearchModalGlobal = ({
$align="center"
$justify="center"
>
<Image
width={320}
src={EmptySearchIcon}
alt={t('No active search')}
/>
<Image width={320} src={EmptySearchIcon} alt="" />
</Box>
)}
{search && (
@@ -51,11 +51,13 @@ export const Heading = ({
editor.setTextCursorPosition(headingId, 'end');
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
behavior: 'smooth',
inline: 'start',
block: 'start',
});
document
.querySelector<HTMLElement>(`[data-id="${headingId}"]`)
?.scrollIntoView({
behavior: 'smooth',
inline: 'start',
block: 'start',
});
}}
$radius="var(--c--globals--spacings--st)"
$background={
@@ -118,7 +118,7 @@ export const ModalSelectVersion = ({
`}
>
<Box
aria-label="version list"
aria-label={t('Version list')}
$css={css`
overflow-y: auto;
flex: 1;
@@ -1,7 +1,8 @@
import dynamic from 'next/dynamic';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { Box, BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/docs/doc-management';
@@ -18,54 +19,69 @@ const ModalConfirmationVersion = dynamic(
interface VersionItemProps {
docId: Doc['id'];
text: string;
versionId?: Versions['version_id'];
isActive: boolean;
onSelect?: () => void;
}
export const VersionItem = ({
docId,
versionId,
text,
isActive,
onSelect,
}: VersionItemProps) => {
const { t } = useTranslation();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
return (
<>
<Box
<BoxButton
aria-label={t('Restore version of {{date}}', { date: text })}
aria-pressed={isActive}
$width="100%"
as="li"
$background={isActive ? colorsTokens['gray-100'] : 'transparent'}
$radius={spacingsTokens['3xs']}
$css={`
cursor: pointer;
&:hover {
background: ${colorsTokens['gray-100']};
&:focus-visible {
background: var(--c--globals--colors--gray-100);
border-radius: var(--c--globals--spacings--st);
}
`}
$hasTransition
$minWidth="13rem"
className="--docs--version-item"
className="version-item"
onClick={onSelect}
>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
$direction="row"
$justify="space-between"
$width="100%"
as="span"
$background={isActive ? colorsTokens['gray-100'] : 'transparent'}
$radius={spacingsTokens['3xs']}
$css={`
cursor: pointer;
&:hover {
background: ${colorsTokens['gray-100']};
}
`}
$hasTransition
$minWidth="13rem"
className="--docs--version-item"
>
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $weight="bold" $size="sm">
{text}
</Text>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
$direction="row"
$justify="space-between"
$width="100%"
>
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $weight="bold" $size="sm">
{text}
</Text>
</Box>
</Box>
</Box>
</Box>
</BoxButton>
{isModalVersionOpen && versionId && (
<ModalConfirmationVersion
onClose={() => setIsModalVersionOpen(false)}
@@ -3,14 +3,7 @@ import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import {
Box,
BoxButton,
Icon,
InfiniteScroll,
Text,
TextErrors,
} from '@/components';
import { Box, Icon, InfiniteScroll, Text, TextErrors } from '@/components';
import { Doc } from '@/docs/doc-management';
import { useDate } from '@/hooks';
@@ -49,23 +42,24 @@ const VersionListState = ({
return (
<Box $gap="10px" $padding="xs">
{versions?.map((version) => (
<BoxButton
aria-label="version item"
className="version-item"
key={version.version_id}
onClick={() => {
onSelectVersion?.(version.version_id);
}}
>
<VersionItem
versionId={version.version_id}
text={formatDate(version.last_modified, DateTime.DATETIME_MED)}
docId={doc.id}
isActive={version.version_id === selectedVersionId}
/>
</BoxButton>
))}
{versions?.map((version) => {
const formattedDate = formatDate(
version.last_modified,
DateTime.DATETIME_MED,
);
const isSelected = version.version_id === selectedVersionId;
return (
<Box as="li" key={version.version_id} $css="list-style: none;">
<VersionItem
versionId={version.version_id}
text={formattedDate}
docId={doc.id}
isActive={isSelected}
onSelect={() => onSelectVersion?.(version.version_id)}
/>
</Box>
);
})}
{error && (
<Box
$justify="center"
@@ -97,6 +91,7 @@ export const VersionList = ({
selectedVersionId,
}: VersionListProps) => {
const { t } = useTranslation();
const { formatDate } = useDate();
const {
data,
@@ -112,6 +107,12 @@ export const VersionList = ({
const versions = data?.pages.reduce((acc, page) => {
return acc.concat(page.versions);
}, [] as Versions[]);
const selectedVersion = versions?.find(
(version) => version.version_id === selectedVersionId,
);
const selectedVersionDate = selectedVersion
? formatDate(selectedVersion.last_modified, DateTime.DATETIME_MED)
: null;
return (
<Box
@@ -127,7 +128,7 @@ export const VersionList = ({
as="ul"
$padding="none"
$margin={{ top: 'none' }}
role="listbox"
role="list"
>
{versions?.length === 0 && (
<Box $align="center" $margin="large">
@@ -145,6 +146,11 @@ export const VersionList = ({
selectedVersionId={selectedVersionId}
/>
</InfiniteScroll>
<Text className="sr-only" aria-live="polite">
{selectedVersionDate
? t('Selected version {{date}}', { date: selectedVersionDate })
: ''}
</Text>
</Box>
);
};
@@ -116,7 +116,9 @@ export const DocsGrid = ({
$padding={{
bottom: 'md',
}}
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
{...(withUpload
? getRootProps({ className: 'dropzone', tabIndex: -1 })
: {})}
>
{withUpload && <input {...getInputProps()} />}
<DocGridTitleBar
@@ -19,7 +19,7 @@ import {
useDuplicateDoc,
useTrans,
} from '@/docs/doc-management';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { focusMainContentStart } from '@/layouts/utils';
import { useFocusStore } from '@/stores';
import { DocMoveModal } from './DocMoveModal';
@@ -55,10 +55,9 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: () => {
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
requestAnimationFrame(() => mainContent.focus());
}
requestAnimationFrame(() => {
focusMainContentStart({ preventScroll: true });
});
},
});
@@ -2,7 +2,6 @@ import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { announce } from '@react-aria/live-announcer';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -19,14 +18,12 @@ export const useClipboard = () => {
toast(message, VariantType.SUCCESS, {
duration: 3000,
});
announce(message, 'polite');
})
.catch(() => {
const message = errorMessage ?? t('Failed to copy to clipboard');
toast(message, VariantType.ERROR, {
duration: 3000,
});
announce(message, 'assertive');
});
},
[t, toast],
@@ -1,7 +1,10 @@
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import {
focusMainContentStart,
getMainContentFocusTarget,
} from '@/layouts/utils';
export const useRouteChangeCompleteFocus = () => {
const router = useRouter();
@@ -25,27 +28,24 @@ export const useRouteChangeCompleteFocus = () => {
lastCompletedPathRef.current = normalizedUrl;
requestAnimationFrame(() => {
const mainContent =
document.getElementById(MAIN_LAYOUT_ID) ??
document.getElementsByTagName('main')[0];
const focusTarget = getMainContentFocusTarget();
if (!mainContent) {
if (!focusTarget) {
return;
}
const firstHeading = mainContent.querySelector('h1, h2, h3');
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
if (isKeyboardNavigationRef.current) {
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
focusMainContentStart({ preventScroll: true });
isKeyboardNavigationRef.current = false;
}
if (router.pathname === '/docs/[id]') {
return;
}
(firstHeading ?? mainContent)?.scrollIntoView({
focusTarget.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start',
});
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Header } from '@/features/header';
import { HEADER_HEIGHT } from '@/features/header/conf';
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
@@ -94,7 +93,6 @@ const MainContent = ({
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
return (
@@ -103,7 +101,6 @@ const MainContent = ({
role="main"
aria-label={t('Main content')}
id={MAIN_LAYOUT_ID}
tabIndex={-1}
$align="center"
$flex={1}
$width="100%"
@@ -120,14 +117,6 @@ const MainContent = ({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus-visible::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid ${colorsTokens['brand-400']};
pointer-events: none;
z-index: 2001;
}
`}
>
<Skeleton>
@@ -0,0 +1,37 @@
import { MAIN_LAYOUT_ID } from './conf';
export const getMainContentElement = (): HTMLElement | null =>
document.getElementById(MAIN_LAYOUT_ID) ??
document.getElementsByTagName('main')[0] ??
null;
export const getMainContentFocusTarget = (): HTMLElement | null => {
const mainContent = getMainContentElement();
if (!mainContent) {
return null;
}
const firstHeading =
mainContent.querySelector('h1') ?? mainContent.querySelector('h2');
return firstHeading instanceof HTMLElement ? firstHeading : mainContent;
};
export const focusMainContentStart = (
options?: FocusOptions,
): HTMLElement | null => {
const focusTarget = getMainContentFocusTarget();
if (!focusTarget) {
return null;
}
if (!focusTarget.hasAttribute('tabindex')) {
focusTarget.setAttribute('tabindex', '-1');
}
focusTarget.focus(options);
return focusTarget;
};