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…
	
	Add table
		Add a link
		
	
		Reference in a new issue