(frontend) improve NVDA navigation in DocShareModal

fix NVDA focus and announcement issues in search modal combobox

Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
Cyril
2025-09-16 10:41:12 +02:00
parent c23ff546d8
commit ee3b05cb55
9 changed files with 60 additions and 55 deletions

View File

@@ -1,4 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
@@ -27,6 +26,12 @@ and this project adheres to
- 🐛(frontend) fix legacy role computation #1376 - 🐛(frontend) fix legacy role computation #1376
- 🐛(frontend) scroll back to top when navigate to a document #1406 - 🐛(frontend) scroll back to top when navigate to a document #1406
### Changed
- ♿(frontend) improve accessibility:
- ♿improve NVDA navigation in DocShareModal #1396
## [3.7.0] - 2025-09-12 ## [3.7.0] - 2025-09-12
### Added ### Added

View File

@@ -757,15 +757,21 @@ test.describe('Doc Editor', () => {
await expect(searchContainer.getByText(docChild2)).toBeVisible(); await expect(searchContainer.getByText(docChild2)).toBeVisible();
await expect(searchContainer.getByText(randomDoc)).toBeHidden(); await expect(searchContainer.getByText(randomDoc)).toBeHidden();
// use keydown to select the second result await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
const interlink = page.getByRole('link', { // Wait for the search container to disappear, indicating selection was made
name: 'child-2', await expect(searchContainer).toBeHidden();
// Wait for the interlink to be created and rendered
const editor = page.locator('.ProseMirror.bn-editor');
const interlink = editor.getByRole('link', {
name: docChild2,
}); });
await expect(interlink).toBeVisible(); await expect(interlink).toBeVisible({ timeout: 10000 });
await interlink.click(); await interlink.click();
await verifyDocName(page, docChild2); await verifyDocName(page, docChild2);

View File

@@ -26,9 +26,8 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', { const inputSearch = page.getByTestId('quick-search-input');
name: 'Quick search input',
});
await expect(inputSearch).toBeVisible(); await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag // Select user 1 and verify tag
@@ -118,9 +117,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', { const inputSearch = page.getByTestId('quick-search-input');
name: 'Quick search input',
});
const [email] = randomName('test@test.fr', browserName, 1); const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email); await inputSearch.fill(email);
@@ -168,9 +165,7 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', { const inputSearch = page.getByTestId('quick-search-input');
name: 'Quick search input',
});
const email = randomName('test@test.fr', browserName, 1)[0]; const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email); await inputSearch.fill(email);

View File

@@ -23,9 +23,7 @@ export const addNewMember = async (
response.status() === 200, response.status() === 200,
); );
const inputSearch = page.getByRole('combobox', { const inputSearch = page.getByTestId('quick-search-input');
name: 'Quick search input',
});
// Select a new user // Select a new user
await inputSearch.fill(fillText); await inputSearch.fill(fillText);

View File

@@ -64,7 +64,7 @@ export const clickOnAddRootSubPage = async (page: Page) => {
const rootItem = page.getByTestId('doc-tree-root-item'); const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible(); await expect(rootItem).toBeVisible();
await rootItem.hover(); await rootItem.hover();
await rootItem.getByRole('button', { name: /add subpage/i }).click(); await rootItem.getByTestId('doc-tree-item-actions-add-child').click();
}; };
export const navigateToPageFromTree = async ({ export const navigateToPageFromTree = async ({

View File

@@ -1,11 +1,5 @@
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { import { PropsWithChildren, ReactNode, useId, useRef, useState } from 'react';
PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { hasChildrens } from '@/utils/children'; import { hasChildrens } from '@/utils/children';
@@ -49,32 +43,23 @@ export const QuickSearch = ({
children, children,
}: PropsWithChildren<QuickSearchProps>) => { }: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
const [selectedValue, setSelectedValue] = useState<string>(''); const listId = useId();
const NO_SELECTION_VALUE = '__none__';
const [userInteracted, setUserInteracted] = useState(false);
const [selectedValue, setSelectedValue] = useState(NO_SELECTION_VALUE);
const isExpanded = userInteracted;
// Auto-select first item when children change const handleValueChange = (val: string) => {
useEffect(() => { if (userInteracted) {
if (!children) { setSelectedValue(val);
setSelectedValue('');
return;
} }
};
// Small delay for DOM to update const handleUserInteract = () => {
const timeoutId = setTimeout(() => { if (!userInteracted) {
const firstItem = ref.current?.querySelector('[cmdk-item]'); setUserInteracted(true);
if (firstItem) { }
const value = };
firstItem.getAttribute('data-value') ||
firstItem.getAttribute('value') ||
firstItem.textContent?.trim() ||
'';
if (value) {
setSelectedValue(value);
}
}
}, 50);
return () => clearTimeout(timeoutId);
}, [children]);
return ( return (
<> <>
@@ -84,9 +69,9 @@ export const QuickSearch = ({
label={label} label={label}
shouldFilter={false} shouldFilter={false}
ref={ref} ref={ref}
value={selectedValue}
onValueChange={setSelectedValue}
tabIndex={0} tabIndex={0}
value={selectedValue}
onValueChange={handleValueChange}
> >
{showInput && ( {showInput && (
<QuickSearchInput <QuickSearchInput
@@ -95,11 +80,14 @@ export const QuickSearch = ({
inputValue={inputValue} inputValue={inputValue}
onFilter={onFilter} onFilter={onFilter}
placeholder={placeholder} placeholder={placeholder}
listId={listId}
isExpanded={isExpanded}
onUserInteract={handleUserInteract}
> >
{inputContent} {inputContent}
</QuickSearchInput> </QuickSearchInput>
)} )}
<Command.List> <Command.List id={listId} aria-label={label} role="listbox">
<Box>{children}</Box> <Box>{children}</Box>
</Command.List> </Command.List>
</Command> </Command>

View File

@@ -16,6 +16,9 @@ type Props = {
placeholder?: string; placeholder?: string;
children?: ReactNode; children?: ReactNode;
withSeparator?: boolean; withSeparator?: boolean;
listId?: string;
onUserInteract?: () => void;
isExpanded?: boolean;
}; };
export const QuickSearchInput = ({ export const QuickSearchInput = ({
loading, loading,
@@ -24,6 +27,9 @@ export const QuickSearchInput = ({
placeholder, placeholder,
children, children,
withSeparator: separator = true, withSeparator: separator = true,
listId,
onUserInteract,
isExpanded,
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme(); const { spacingsTokens } = useCunninghamTheme();
@@ -57,14 +63,19 @@ export const QuickSearchInput = ({
<Command.Input <Command.Input
autoFocus={true} autoFocus={true}
aria-label={t('Quick search input')} aria-label={t('Quick search input')}
aria-expanded={isExpanded}
aria-controls={listId}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onUserInteract?.();
}} }}
onKeyDown={() => onUserInteract?.()}
value={inputValue} value={inputValue}
role="combobox" role="combobox"
placeholder={placeholder ?? t('Search')} placeholder={placeholder ?? t('Search')}
onValueChange={onFilter} onValueChange={onFilter}
maxLength={254} maxLength={254}
data-testid="quick-search-input"
/> />
</Box> </Box>
{separator && <HorizontalSeparator $withPadding={false} />} {separator && <HorizontalSeparator $withPadding={false} />}

View File

@@ -135,8 +135,9 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
isOpen isOpen
closeOnClickOutside closeOnClickOutside
data-testid="doc-share-modal" data-testid="doc-share-modal"
aria-describedby="doc-share-modal-title" aria-labelledby="doc-share-modal-title"
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL} size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
aria-modal="true"
onClose={onClose} onClose={onClose}
title={ title={
<Box $direction="row" $justify="space-between" $align="center"> <Box $direction="row" $justify="space-between" $align="center">
@@ -160,13 +161,13 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
> >
<ShareModalStyle /> <ShareModalStyle />
<Box <Box
role="dialog"
aria-label={t('Share modal content')}
$height="auto" $height="auto"
$maxHeight={canViewAccesses ? modalContentHeight : 'none'} $maxHeight={canViewAccesses ? modalContentHeight : 'none'}
$overflow="hidden" $overflow="hidden"
className="--docs--doc-share-modal noPadding " className="--docs--doc-share-modal noPadding "
$justify="space-between" $justify="space-between"
role="dialog"
aria-label={t('Share modal content')}
> >
<Box <Box
$flex={1} $flex={1}
@@ -223,6 +224,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
)} )}
{canViewAccesses && ( {canViewAccesses && (
<QuickSearch <QuickSearch
label={t('Search results')}
onFilter={(str) => { onFilter={(str) => {
setInputValue(str); setInputValue(str);
onFilter(str); onFilter(str);

View File

@@ -181,7 +181,7 @@ export const DocTreeItemActions = ({
}); });
}} }}
color="primary" color="primary"
aria-label={t('Add subpage')} data-testid="doc-tree-item-actions-add-child"
> >
<Icon <Icon
variant="filled" variant="filled"