From 2edae5ea28bcddb59f2d38416d03d2b52175b58c Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 8 Aug 2024 12:57:21 +0200 Subject: [PATCH] Convert PrivacyDropdownMenu to Typescript and generalize it to DropdownSelector component (#31338) --- .../mastodon/components/dropdown_selector.tsx | 185 ++++++++++++++++++ .../compose/components/privacy_dropdown.jsx | 5 +- .../components/privacy_dropdown_menu.jsx | 128 ------------ 3 files changed, 187 insertions(+), 131 deletions(-) create mode 100644 app/javascript/mastodon/components/dropdown_selector.tsx delete mode 100644 app/javascript/mastodon/features/compose/components/privacy_dropdown_menu.jsx diff --git a/app/javascript/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx new file mode 100644 index 00000000000000..f8bf96c634294a --- /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 = ({ + style, + items, + value, + classNamePrefix = 'privacy-dropdown', + onClose, + onChange, +}) => { + const nodeRef = useRef(null); + const focusedItemRef = useRef(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 | React.KeyboardEvent, + ) => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + onClose(); + if (value) onChange(value); + }, + [onClose, onChange], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + 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 ( +
    + {items.map((item) => ( +
  • + {item.icon && item.iconComponent && ( +
    + +
    + )} + +
    + {item.text} + {item.meta} +
    + + {item.extra && ( +
    + +
    + )} +
  • + ))} +
+ ); +}; diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index 071f0a6fabf399..f474aecf284932 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 }) => (
- { - 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 ( -
    - {items.map(item => ( -
  • -
    - -
    - -
    - {item.text} - {item.meta} -
    - - {item.extra && ( -
    - -
    - )} -
  • - ))} -
- ); -}; - -PrivacyDropdownMenu.propTypes = { - style: PropTypes.object, - items: PropTypes.array.isRequired, - value: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, -};