✨(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:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user