Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ and this project adheres to
- 🐛(makefile) Windows compatibility fix for Docker volume mounting #1264
- 🐛(minio) fix user permission error with Minio and Windows #1264

- #1261


## [3.5.0] - 2025-07-31

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();

await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();

await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
Expand Down Expand Up @@ -393,11 +393,11 @@ test.describe('Doc Editor', () => {
/* eslint-disable playwright/no-conditional-expect */
/* eslint-disable playwright/no-conditional-in-test */
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
await expect(page.locator('[data-test="ai-actions"]')).toBeHidden();
return;
}

await page.getByRole('button', { name: 'AI' }).click();
await page.locator('[data-test="ai-actions"]').click();

if (ai_transform) {
await expect(
Expand Down
10 changes: 5 additions & 5 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,10 @@ test.describe('Document search', () => {

// Expect to find the first doc
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();

await page.getByRole('button', { name: 'close' }).click();
Expand All @@ -196,13 +196,13 @@ test.describe('Document search', () => {

// Now there is a sub page - expect to have the focus on the current doc
await expect(
page.getByRole('presentation').getByLabel(secondDocTitle),
page.getByRole('presentation').getByText(secondDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(secondChildDocTitle),
page.getByRole('presentation').getByText(secondChildDocTitle),
).toBeVisible();
await expect(
page.getByRole('presentation').getByLabel(firstDocTitle),
page.getByRole('presentation').getByText(firstDocTitle),
).toBeHidden();
});
});
78 changes: 78 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,81 @@ test.describe('Doc Tree: Inheritance', () => {
await expect(docTree.getByText(docParent)).toBeVisible();
});
});

test.describe('Doc tree keyboard interactions (subdocs)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('navigates in the tree and actions with keyboard and toggles menu (options and create childDoc)', async ({
page,
browserName,
}) => {
const [rootDocTitle] = await createDoc(
page,
'doc-tree-keyboard',
browserName,
1,
);
await verifyDocName(page, rootDocTitle);

const { name: childTitle } = await createRootSubPage(
page,
browserName,
'subdoc-tree-actions',
);

await verifyDocName(page, childTitle);

const docTree = page.getByTestId('doc-tree');

const actionsGroup = page.getByRole('group', {
name: `Actions for ${childTitle}`,
});
await expect(actionsGroup).toBeVisible();

const moreOptions = actionsGroup.getByRole('button', {
name: `More options for ${childTitle}`,
});
await expect(moreOptions).toBeVisible();

await moreOptions.focus();
await expect(moreOptions).toBeFocused();

await page.keyboard.press('ArrowRight');
const addChild = actionsGroup.getByTestId('add-child-doc');
await expect(addChild).toBeFocused();

await page.keyboard.press('ArrowLeft');
await expect(moreOptions).toBeFocused();

await page.keyboard.press('Enter');
await expect(page.getByText('Copy link')).toBeVisible();

await page.keyboard.press('Escape');
await expect(page.getByText('Copy link')).toBeHidden();

await page.keyboard.press('ArrowRight');
await expect(addChild).toBeFocused();

const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);

await page.keyboard.press('Enter');

const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const newChildDoc = (await response.json()) as { id: string };

const childButton = page.getByTestId(`doc-sub-page-item-${newChildDoc.id}`);
const childTreeItem = docTree
.locator('.c__tree-view--row')
.filter({ has: childButton })
.first();

await childTreeItem.focus();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HorizontalSeparator } from '@gouvfr-lasuite/ui-kit';
import {
Fragment,
PropsWithChildren,
ReactNode,
useCallback,
useEffect,
useRef,
Expand All @@ -11,11 +12,10 @@ import { css } from 'styled-components';

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

import { useDropdownKeyboardNav } from './hook/useDropdownKeyboardNav';
import { useDropdownKeyboardNav } from '@/hook/useDropdownKeyboardNav';

export type DropdownMenuOption = {
icon?: string;
icon?: string | ReactNode;
label: string;
testId?: string;
value?: string;
Expand Down Expand Up @@ -79,14 +79,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]);

Expand Down Expand Up @@ -153,7 +167,6 @@ export const DropdownMenu = ({
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
const isFocused = index === focusedIndex;

return (
<Fragment key={option.label}>
Expand Down Expand Up @@ -204,32 +217,26 @@ 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
$direction="row"
$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>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
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[];
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/apps/impress/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './dropdown-menu/DropdownMenu';
export * from './DropdownMenu';
export * from './quick-search';
export * from './Icon';
export * from './InfiniteScroll';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const SimpleDocItem = ({
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
role="presentation"
>
<Box
$direction="row"
Expand All @@ -53,6 +54,7 @@ export const SimpleDocItem = ({
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
Expand All @@ -70,12 +72,11 @@ export const SimpleDocItem = ({
</Box>
<Box $justify="center" $overflow="auto">
<Text
aria-describedby="doc-title"
aria-label={doc.title}
$size="sm"
$variation="1000"
$weight="500"
$css={ItemTextCss}
aria-describedby="doc-title"
>
{doc.title || untitledDocument}
</Text>
Expand All @@ -85,6 +86,7 @@ export const SimpleDocItem = ({
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
Expand Down
Loading
Loading