3 changed files with 187 additions and 131 deletions
@ -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> |
||||
); |
||||
}; |
||||
@ -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 new issue