mirror of
https://github.com/yingziwu/mastodon.git
synced 2026-02-24 03:02:42 +00:00
Improve accessibility of visibility modal dropdowns (#36068)
This commit is contained in:
parent
66d73fc213
commit
377e870348
5 changed files with 150 additions and 120 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue