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

View File

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

View File

@ -143,7 +143,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
</div> </div>
<div className='app-form__toggle__toggle'> <div className='app-form__toggle__toggle'>
<div>
<Dropdown <Dropdown
value={value} value={value}
onChange={onChange} onChange={onChange}
@ -153,7 +152,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
options={options} options={options}
/> />
</div> </div>
</div>
</label> </label>
); );
}; };

View File

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

View File

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