Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b47c730e19 | |||
| cef83067e6 | |||
| 4cabfcc921 | |||
| b8d4b0a044 | |||
| 71c4d2921b | |||
| d1636dee13 | |||
| bf93640af8 | |||
| da79c310ae | |||
| 99c486571d | |||
| cdf3161869 | |||
| ef108227b3 |
@@ -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);
|
||||
|
||||
+14
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+12
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+12
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+11
-1
@@ -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,
|
||||
|
||||
+10
-4
@@ -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>
|
||||
)}
|
||||
|
||||
+36
-31
@@ -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 && (
|
||||
|
||||
+7
-5
@@ -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={
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ export const ModalSelectVersion = ({
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
aria-label="version list"
|
||||
aria-label={t('Version list')}
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
+39
-23
@@ -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)}
|
||||
|
||||
+32
-26
@@ -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
|
||||
|
||||
+4
-5
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user