Improve accessibility of visibility modal dropdowns (#36068)

This commit is contained in:
diondiondion 2025-09-09 19:44:43 +02:00 committed by GitHub
parent 66d73fc213
commit 377e870348
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 150 additions and 120 deletions

View file

@ -1,7 +1,7 @@
import { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { ComponentPropsWithoutRef, FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useIntl } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import classNames from 'classnames';
@ -17,11 +17,12 @@ import { Icon } from '../icon';
import { matchWidth } from './utils';
interface DropdownProps {
title: string;
disabled?: boolean;
items: SelectItem[];
onChange: (value: string) => void;
current: string;
labelId: string;
descriptionId?: string;
emptyText?: MessageDescriptor;
classPrefix: string;
}
@ -29,39 +30,59 @@ interface DropdownProps {
export const Dropdown: FC<
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
> = ({
title,
disabled,
items,
current,
onChange,
labelId,
descriptionId,
classPrefix,
className,
id,
...buttonProps
}) => {
const intl = useIntl();
const buttonRef = useRef<HTMLButtonElement>(null);
const accessibilityId = useId();
const uniqueId = useId();
const buttonId = id ?? `${uniqueId}-button`;
const listboxId = `${uniqueId}-listbox`;
const [open, setOpen] = useState(false);
const handleToggle = useCallback(() => {
if (!disabled) {
setOpen((prevOpen) => !prevOpen);
setOpen((prevOpen) => {
buttonRef.current?.focus();
return !prevOpen;
});
}
}, [disabled]);
const handleClose = useCallback(() => {
setOpen(false);
buttonRef.current?.focus();
}, []);
const currentText = useMemo(
() => items.find((i) => i.value === current)?.text,
[current, items],
() =>
items.find((i) => i.value === current)?.text ??
intl.formatMessage({
id: 'dropdown.empty',
defaultMessage: 'Select an option',
}),
[current, intl, items],
);
return (
<>
<button
type='button'
{...buttonProps}
title={title}
id={buttonId}
aria-labelledby={`${labelId} ${buttonId}`}
aria-describedby={descriptionId}
aria-expanded={open}
aria-controls={accessibilityId}
aria-controls={listboxId}
onClick={handleToggle}
disabled={disabled}
className={classNames(
@ -74,12 +95,7 @@ export const Dropdown: FC<
)}
ref={buttonRef}
>
{currentText ?? (
<FormattedMessage
id='dropdown.empty'
defaultMessage='Select an option'
/>
)}
{currentText}
<Icon
id='unfold-icon'
icon={UnfoldMoreIcon}
@ -107,7 +123,7 @@ export const Dropdown: FC<
`${classPrefix}__dropdown`,
placement,
)}
id={accessibilityId}
id={listboxId}
>
<DropdownSelector
items={items}

View file

@ -39,24 +39,10 @@ export const DropdownSelector: React.FC<Props> = ({
onClose,
onChange,
}) => {
const nodeRef = useRef<HTMLUListElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const focusedItemRef = useRef<HTMLLIElement>(null);
const [currentValue, setCurrentValue] = useState(value);
const handleDocumentClick = useCallback(
(e: MouseEvent | TouchEvent) => {
if (
nodeRef.current &&
e.target instanceof Node &&
!nodeRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
},
[nodeRef, onClose],
);
const handleClick = useCallback(
(
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
@ -88,30 +74,30 @@ export const DropdownSelector: React.FC<Props> = ({
break;
case 'ArrowDown':
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
listRef.current?.children[index + 1] ??
listRef.current?.firstElementChild;
break;
case 'ArrowUp':
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
listRef.current?.children[index - 1] ??
listRef.current?.lastElementChild;
break;
case 'Tab':
if (e.shiftKey) {
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
listRef.current?.children[index - 1] ??
listRef.current?.lastElementChild;
} else {
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
listRef.current?.children[index + 1] ??
listRef.current?.firstElementChild;
}
break;
case 'Home':
element = nodeRef.current?.firstElementChild;
element = listRef.current?.firstElementChild;
break;
case 'End':
element = nodeRef.current?.lastElementChild;
element = listRef.current?.lastElementChild;
break;
}
@ -123,12 +109,24 @@ export const DropdownSelector: React.FC<Props> = ({
e.stopPropagation();
}
},
[nodeRef, items, onClose, handleClick, setCurrentValue],
[items, onClose, handleClick, setCurrentValue],
);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
if (
listRef.current &&
e.target instanceof Node &&
!listRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
};
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
focusedItemRef.current?.focus({ preventScroll: true });
return () => {
@ -141,10 +139,10 @@ export const DropdownSelector: React.FC<Props> = ({
listenerOptions,
);
};
}, [handleDocumentClick]);
}, [onClose]);
return (
<ul style={style} role='listbox' ref={nodeRef}>
<ul style={style} role='listbox' ref={listRef}>
{items.map((item) => (
<li
role='option'