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'

View File

@ -143,16 +143,14 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
</div>
<div className='app-form__toggle__toggle'>
<div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descId}
options={options}
/>
</div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descId}
options={options}
/>
</div>
</label>
);

View File

@ -198,8 +198,11 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
onClose();
}, [onChange, onClose, visibility, quotePolicy]);
const privacyDropdownId = useId();
const quoteDropdownId = useId();
const uniqueId = useId();
const visibilityLabelId = `${uniqueId}-visibility-label`;
const visibilityDescriptionId = `${uniqueId}-visibility-desc`;
const quoteLabelId = `${uniqueId}-quote-label`;
const quoteDescriptionId = `${uniqueId}-quote-desc`;
return (
<div className='modal-root__modal dialog-modal visibility-modal'>
@ -234,28 +237,36 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
/>
</div>
<div className='dialog-modal__content__form'>
<label
htmlFor={privacyDropdownId}
className={classNames('visibility-dropdown__label', {
<div
className={classNames('visibility-dropdown', {
disabled: disableVisibility,
})}
>
<FormattedMessage
id='visibility_modal.privacy_label'
defaultMessage='Visibility'
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
className='visibility-dropdown__label'
id={visibilityLabelId}
>
<FormattedMessage
id='visibility_modal.privacy_label'
defaultMessage='Visibility'
/>
</label>
<Dropdown
items={visibilityItems}
classPrefix='visibility-dropdown'
current={visibility}
onChange={handleVisibilityChange}
title={intl.formatMessage(privacyMessages.change_privacy)}
labelId={visibilityLabelId}
descriptionId={visibilityDescriptionId}
classPrefix='visibility-dropdown'
disabled={disableVisibility}
id={privacyDropdownId}
/>
{!!statusId && (
<p className='visibility-dropdown__helper'>
<p
className='visibility-dropdown__helper'
id='visibilityDescriptionId'
>
<FormattedMessage
id='visibility_modal.helper.privacy_editing'
defaultMessage="Visibility can't be changed after a post is published."
@ -263,37 +274,47 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
</p>
)}
{!statusId && disablePublicVisibilities && (
<p className='visibility-dropdown__helper'>
<p
className='visibility-dropdown__helper'
id='visibilityDescriptionId'
>
<FormattedMessage
id='visibility_modal.helper.privacy_private_self_quote'
defaultMessage='Self-quotes of private posts cannot be made public.'
/>
</p>
)}
</label>
</div>
<label
htmlFor={quoteDropdownId}
className={classNames('visibility-dropdown__label', {
<div
className={classNames('visibility-dropdown', {
disabled: disableQuotePolicy,
})}
>
<FormattedMessage
id='visibility_modal.quote_label'
defaultMessage='Who can quote'
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='visibility-dropdown__label' id={quoteLabelId}>
<FormattedMessage
id='visibility_modal.quote_label'
defaultMessage='Who can quote'
/>
</label>
<Dropdown
items={quoteItems}
onChange={handleQuotePolicyChange}
classPrefix='visibility-dropdown'
current={disableQuotePolicy ? 'nobody' : quotePolicy}
title={intl.formatMessage(messages.buttonTitle)}
onChange={handleQuotePolicyChange}
labelId={quoteLabelId}
descriptionId={quoteDescriptionId}
classPrefix='visibility-dropdown'
disabled={disableQuotePolicy}
id={quoteDropdownId}
/>
<QuotePolicyHelper policy={quotePolicy} visibility={visibility} />
</label>
<QuotePolicyHelper
policy={quotePolicy}
visibility={visibility}
className='visibility-dropdown__helper'
id={quoteDescriptionId}
/>
</div>
</div>
<div className='dialog-modal__content__actions'>
<Button onClick={onClose} secondary>
@ -316,42 +337,44 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
);
VisibilityModal.displayName = 'VisibilityModal';
const QuotePolicyHelper: FC<{
policy: ApiQuotePolicy;
visibility: StatusVisibility;
}> = ({ policy, visibility }) => {
const QuotePolicyHelper: FC<
{
policy: ApiQuotePolicy;
visibility: StatusVisibility;
} & React.ComponentPropsWithoutRef<'p'>
> = ({ policy, visibility, ...otherProps }) => {
let hintText: React.ReactElement | undefined;
if (visibility === 'unlisted' && policy !== 'nobody') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.unlisted_quoting'
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.unlisted_quoting'
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
/>
);
}
if (visibility === 'private') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.private_quoting'
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.private_quoting'
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
/>
);
}
if (visibility === 'direct') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.direct_quoting'
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.direct_quoting'
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
/>
);
}
return null;
if (!hintText) {
return null;
}
return <p {...otherProps}>{hintText}</p>;
};

View File

@ -5694,20 +5694,15 @@ a.status-card {
z-index: 9999;
}
&.disabled {
opacity: 0.6;
cursor: default;
}
&__label {
cursor: pointer;
display: block;
> span {
display: block;
font-weight: 500;
margin-bottom: 8px;
}
&.disabled {
cursor: default;
opacity: 0.5;
}
font-weight: 500;
margin-bottom: 8px;
}
&__button {