diff --git a/app/javascript/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx new file mode 100644 index 000000000..f8bf96c63 --- /dev/null +++ b/app/javascript/mastodon/components/dropdown_selector.tsx @@ -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> + ); +}; diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index 071f0a6fa..f474aecf2 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx @@ -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} diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown_menu.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown_menu.jsx deleted file mode 100644 index 1a5ff1fa8..000000000 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown_menu.jsx +++ /dev/null @@ -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, -};