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 { 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}
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -143,16 +143,14 @@ 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}
|
disabled={disabled}
|
||||||
disabled={disabled}
|
aria-labelledby={labelId}
|
||||||
aria-labelledby={labelId}
|
aria-describedby={descId}
|
||||||
aria-describedby={descId}
|
options={options}
|
||||||
options={options}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
id='visibility_modal.privacy_label'
|
<label
|
||||||
defaultMessage='Visibility'
|
className='visibility-dropdown__label'
|
||||||
/>
|
id={visibilityLabelId}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='visibility_modal.privacy_label'
|
||||||
|
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,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
id='visibility_modal.quote_label'
|
<label className='visibility-dropdown__label' id={quoteLabelId}>
|
||||||
defaultMessage='Who can quote'
|
<FormattedMessage
|
||||||
/>
|
id='visibility_modal.quote_label'
|
||||||
|
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;
|
{
|
||||||
visibility: StatusVisibility;
|
policy: ApiQuotePolicy;
|
||||||
}> = ({ policy, visibility }) => {
|
visibility: StatusVisibility;
|
||||||
|
} & 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (!hintText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p {...otherProps}>{hintText}</p>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -5694,20 +5694,15 @@ a.status-card {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
> span {
|
margin-bottom: 8px;
|
||||||
display: block;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__button {
|
&__button {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user