mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-05 16:42:47 +00:00
Improve accessibility of visibility modal dropdowns (#36068)
This commit is contained in:
parent
66d73fc213
commit
377e870348
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue
Block a user