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

View File

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

View File

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

View File

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