Convert PrivacyDropdownMenu to Typescript and generalize it to DropdownSelector component (#31338)
This commit is contained in:
parent
a95fe931d7
commit
2edae5ea28
3 changed files with 187 additions and 131 deletions
185
app/javascript/mastodon/components/dropdown_selector.tsx
Normal file
185
app/javascript/mastodon/components/dropdown_selector.tsx
Normal file
|
@ -0,0 +1,185 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
import { Icon } from './icon';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents
|
||||
? { passive: true, capture: true }
|
||||
: true;
|
||||
|
||||
interface SelectItem {
|
||||
value: string;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
text: string;
|
||||
meta: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
classNamePrefix: string;
|
||||
style?: React.CSSProperties;
|
||||
items: SelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DropdownSelector: React.FC<Props> = ({
|
||||
style,
|
||||
items,
|
||||
value,
|
||||
classNamePrefix = 'privacy-dropdown',
|
||||
onClose,
|
||||
onChange,
|
||||
}) => {
|
||||
const nodeRef = 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>,
|
||||
) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
if (value) onChange(value);
|
||||
},
|
||||
[onClose, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLLIElement>) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex((item) => item.value === value);
|
||||
|
||||
let element: Element | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
} else {
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element && element instanceof HTMLElement) {
|
||||
const selectedValue = element.getAttribute('data-index');
|
||||
element.focus();
|
||||
if (selectedValue) setCurrentValue(selectedValue);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener(
|
||||
'touchend',
|
||||
handleDocumentClick,
|
||||
listenerOptions,
|
||||
);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
return (
|
||||
<ul style={style} role='listbox' ref={nodeRef}>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={classNames(`${classNamePrefix}__option`, {
|
||||
active: item.value === currentValue,
|
||||
})}
|
||||
aria-selected={item.value === currentValue}
|
||||
ref={item.value === currentValue ? focusedItemRef : null}
|
||||
>
|
||||
{item.icon && item.iconComponent && (
|
||||
<div className={`${classNamePrefix}__option__icon`}>
|
||||
<Icon id={item.icon} icon={item.iconComponent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${classNamePrefix}__option__content`}>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
|
||||
{item.extra && (
|
||||
<div
|
||||
className={`${classNamePrefix}__option__additional`}
|
||||
title={item.extra}
|
||||
>
|
||||
<Icon id='info-circle' icon={InfoIcon} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
|
@ -11,10 +11,9 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
|||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
import { DropdownSelector } from 'mastodon/components/dropdown_selector';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
import { PrivacyDropdownMenu } from './privacy_dropdown_menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||
|
@ -143,7 +142,7 @@ class PrivacyDropdown extends PureComponent {
|
|||
{({ props, placement }) => (
|
||||
<div {...props}>
|
||||
<div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
|
||||
<PrivacyDropdownMenu
|
||||
<DropdownSelector
|
||||
items={this.options}
|
||||
value={value}
|
||||
onClose={this.handleClose}
|
||||
|
|
|
@ -1,128 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||
|
||||
export const PrivacyDropdownMenu = ({ style, items, value, onClose, onChange }) => {
|
||||
const nodeRef = useRef(null);
|
||||
const focusedItemRef = useRef(null);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const handleDocumentClick = useCallback((e) => {
|
||||
if (nodeRef.current && !nodeRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [nodeRef, onClose]);
|
||||
|
||||
const handleClick = useCallback((e) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
onChange(value);
|
||||
}, [onClose, onChange]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex(item => (item.value === value));
|
||||
|
||||
let element = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element = nodeRef.current.childNodes[index + 1] || nodeRef.current.firstChild;
|
||||
} else {
|
||||
element = nodeRef.current.childNodes[index - 1] || nodeRef.current.lastChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current.firstChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current.lastChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
setCurrentValue(element.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, [nodeRef, items, onClose, handleClick, setCurrentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.removeEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
return (
|
||||
<ul style={{ ...style }} role='listbox' ref={nodeRef}>
|
||||
{items.map(item => (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={classNames('privacy-dropdown__option', { active: item.value === currentValue })}
|
||||
aria-selected={item.value === currentValue}
|
||||
ref={item.value === currentValue ? focusedItemRef : null}
|
||||
>
|
||||
<div className='privacy-dropdown__option__icon'>
|
||||
<Icon id={item.icon} icon={item.iconComponent} />
|
||||
</div>
|
||||
|
||||
<div className='privacy-dropdown__option__content'>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
|
||||
{item.extra && (
|
||||
<div className='privacy-dropdown__option__additional' title={item.extra}>
|
||||
<Icon id='info-circle' icon={InfoIcon} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
PrivacyDropdownMenu.propTypes = {
|
||||
style: PropTypes.object,
|
||||
items: PropTypes.array.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
};
|
Loading…
Reference in a new issue