mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-05 17:31:12 +00:00
fix: Improve a11y of custom select menus in notifications settings (#35403)
This commit is contained in:
parent
02a4e30594
commit
faffb73cbd
|
@ -122,98 +122,93 @@ export const PolicyControls: React.FC = () => {
|
||||||
value={notificationPolicy.for_not_following}
|
value={notificationPolicy.for_not_following}
|
||||||
onChange={handleFilterNotFollowing}
|
onChange={handleFilterNotFollowing}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_following_title'
|
id='notifications.policy.filter_not_following_title'
|
||||||
defaultMessage="People you don't follow"
|
defaultMessage="People you don't follow"
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_following_hint'
|
id='notifications.policy.filter_not_following_hint'
|
||||||
defaultMessage='Until you manually approve them'
|
defaultMessage='Until you manually approve them'
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_not_followers}
|
value={notificationPolicy.for_not_followers}
|
||||||
onChange={handleFilterNotFollowers}
|
onChange={handleFilterNotFollowers}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_followers_title'
|
id='notifications.policy.filter_not_followers_title'
|
||||||
defaultMessage='People not following you'
|
defaultMessage='People not following you'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_followers_hint'
|
id='notifications.policy.filter_not_followers_hint'
|
||||||
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
|
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
|
||||||
values={{ days: 3 }}
|
values={{ days: 3 }}
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_new_accounts}
|
value={notificationPolicy.for_new_accounts}
|
||||||
onChange={handleFilterNewAccounts}
|
onChange={handleFilterNewAccounts}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_new_accounts_title'
|
id='notifications.policy.filter_new_accounts_title'
|
||||||
defaultMessage='New accounts'
|
defaultMessage='New accounts'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_new_accounts.hint'
|
id='notifications.policy.filter_new_accounts.hint'
|
||||||
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
|
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
|
||||||
values={{ days: 30 }}
|
values={{ days: 30 }}
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_private_mentions}
|
value={notificationPolicy.for_private_mentions}
|
||||||
onChange={handleFilterPrivateMentions}
|
onChange={handleFilterPrivateMentions}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_private_mentions_title'
|
id='notifications.policy.filter_private_mentions_title'
|
||||||
defaultMessage='Unsolicited private mentions'
|
defaultMessage='Unsolicited private mentions'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_private_mentions_hint'
|
id='notifications.policy.filter_private_mentions_hint'
|
||||||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_limited_accounts}
|
value={notificationPolicy.for_limited_accounts}
|
||||||
onChange={handleFilterLimitedAccounts}
|
onChange={handleFilterLimitedAccounts}
|
||||||
options={options}
|
options={options}
|
||||||
>
|
label={
|
||||||
<strong>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_limited_accounts_title'
|
id='notifications.policy.filter_limited_accounts_title'
|
||||||
defaultMessage='Moderated accounts'
|
defaultMessage='Moderated accounts'
|
||||||
/>
|
/>
|
||||||
</strong>
|
}
|
||||||
<span className='hint'>
|
hint={
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_limited_accounts_hint'
|
id='notifications.policy.filter_limited_accounts_hint'
|
||||||
defaultMessage='Limited by server moderators'
|
defaultMessage='Limited by server moderators'
|
||||||
/>
|
/>
|
||||||
</span>
|
}
|
||||||
</SelectWithLabel>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useState, useRef, useId } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -16,6 +16,8 @@ interface DropdownProps {
|
||||||
options: SelectItem[];
|
options: SelectItem[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
'aria-labelledby': string;
|
||||||
|
'aria-describedby'?: string;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,51 +26,33 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
options,
|
options,
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
|
'aria-labelledby': ariaLabelledBy,
|
||||||
|
'aria-describedby': ariaDescribedBy,
|
||||||
placement: initialPlacement = 'bottom-end',
|
placement: initialPlacement = 'bottom-end',
|
||||||
}) => {
|
}) => {
|
||||||
const activeElementRef = useRef<Element | null>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const [isOpen, setOpen] = useState<boolean>(false);
|
const [isOpen, setOpen] = useState<boolean>(false);
|
||||||
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
||||||
|
const uniqueId = useId();
|
||||||
const handleToggle = useCallback(() => {
|
const menuId = `${uniqueId}-menu`;
|
||||||
if (
|
const buttonLabelId = `${uniqueId}-button`;
|
||||||
isOpen &&
|
|
||||||
activeElementRef.current &&
|
|
||||||
activeElementRef.current instanceof HTMLElement
|
|
||||||
) {
|
|
||||||
activeElementRef.current.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(!isOpen);
|
|
||||||
}, [isOpen, setOpen]);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback(() => {
|
|
||||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
switch (e.key) {
|
|
||||||
case ' ':
|
|
||||||
case 'Enter':
|
|
||||||
if (!isOpen) activeElementRef.current = document.activeElement;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (
|
if (isOpen && buttonRef.current) {
|
||||||
isOpen &&
|
buttonRef.current.focus({ preventScroll: true });
|
||||||
activeElementRef.current &&
|
}
|
||||||
activeElementRef.current instanceof HTMLElement
|
|
||||||
)
|
|
||||||
activeElementRef.current.focus({ preventScroll: true });
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
const handleOverlayEnter = useCallback(
|
const handleOverlayEnter = useCallback(
|
||||||
(state: Partial<PopperState>) => {
|
(state: Partial<PopperState>) => {
|
||||||
if (state.placement) setPlacement(state.placement);
|
if (state.placement) setPlacement(state.placement);
|
||||||
|
@ -82,13 +66,18 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
|
ref={buttonRef}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-controls={menuId}
|
||||||
|
aria-labelledby={`${ariaLabelledBy} ${buttonLabelId}`}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
className={classNames('dropdown-button', { active: isOpen })}
|
className={classNames('dropdown-button', { active: isOpen })}
|
||||||
>
|
>
|
||||||
<span className='dropdown-button__label'>{valueOption?.text}</span>
|
<span id={buttonLabelId} className='dropdown-button__label'>
|
||||||
|
{valueOption?.text}
|
||||||
|
</span>
|
||||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@ -101,7 +90,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
||||||
>
|
>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
<div {...props}>
|
<div {...props} id={menuId}>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
||||||
>
|
>
|
||||||
|
@ -123,6 +112,8 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
options: SelectItem[];
|
options: SelectItem[];
|
||||||
|
label: string | React.ReactElement;
|
||||||
|
hint: string | React.ReactElement;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
@ -130,13 +121,26 @@ interface Props {
|
||||||
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const uniqueId = useId();
|
||||||
|
const labelId = `${uniqueId}-label`;
|
||||||
|
const descId = `${uniqueId}-desc`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// This label is only used for its click-forwarding behaviour,
|
||||||
|
// accessible names are assigned manually
|
||||||
|
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||||
<label className='app-form__toggle'>
|
<label className='app-form__toggle'>
|
||||||
<div className='app-form__toggle__label'>{children}</div>
|
<div className='app-form__toggle__label'>
|
||||||
|
<strong id={labelId}>{label}</strong>
|
||||||
|
<span className='hint' id={descId}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='app-form__toggle__toggle'>
|
<div className='app-form__toggle__toggle'>
|
||||||
<div>
|
<div>
|
||||||
|
@ -144,6 +148,8 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
aria-describedby={descId}
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue
Block a user