import { useEffect, useRef, useState, useLayoutEffect, html, useMemo, useCallback } from '../../ui'; import { closest as domClosest, matches as domMatches } from 'min-dom'; import PopupMenuList from './PopupMenuList'; import classNames from 'clsx'; import { isDefined } from 'min-dash'; /** * A component that renders the popup menus. * * @param {function} onClose * @param {function} position * @param {string} className * @param {Array} entries * @param {Array} headerEntries * @param {number} scale * @param {string} [title] * @param {boolean} [search] * @param {number} [width] */ export default function PopupMenuComponent(props) { const { onClose, onSelect, className, headerEntries, position, title, width, scale, search, entries: originalEntries, onOpened, onClosed } = props; const searchable = useMemo(() => { if (!isDefined(search)) { return false; } return originalEntries.length > 5; }, [ search, originalEntries ]); const inputRef = useRef(); const [ value, setValue ] = useState(''); const filterEntries = useCallback((originalEntries, value) => { if (!searchable) { return originalEntries; } const filter = entry => { if (!value) { return (entry.rank || 0) >= 0; } const search = [ entry.description || '', entry.label || '', entry.search || '' ] .join('---') .toLowerCase(); return value .toLowerCase() .split(/\s/g) .every(term => search.includes(term)); }; return originalEntries.filter(filter); }, [ searchable ]); const [ entries, setEntries ] = useState(filterEntries(originalEntries, value)); const [ selectedEntry, setSelectedEntry ] = useState(entries[0]); const updateEntries = useCallback((newEntries) => { // select first entry if non is selected if (!selectedEntry || !newEntries.includes(selectedEntry)) { setSelectedEntry(newEntries[0]); } setEntries(newEntries); }, [ selectedEntry, setEntries, setSelectedEntry ]); // filter entries on value change useEffect(() => { updateEntries(filterEntries(originalEntries, value)); }, [ value, originalEntries ]); // register global handler useEffect(() => { const handleKeyDown = event => { if (event.key === 'Escape') { event.preventDefault(); return onClose(); } }; document.documentElement.addEventListener('keydown', handleKeyDown); return () => { document.documentElement.removeEventListener('keydown', handleKeyDown); }; }, []); // focus input on initial mount useLayoutEffect(() => { inputRef.current && inputRef.current.focus(); }, []); // handle keyboard seleciton const keyboardSelect = useCallback(direction => { const idx = entries.indexOf(selectedEntry); let nextIdx = idx + direction; if (nextIdx < 0) { nextIdx = entries.length - 1; } if (nextIdx >= entries.length) { nextIdx = 0; } setSelectedEntry(entries[nextIdx]); }, [ entries, selectedEntry, setSelectedEntry ]); const handleKeyDown = useCallback(event => { if (event.key === 'Enter' && selectedEntry) { return onSelect(event, selectedEntry); } // ARROW_UP or SHIFT + TAB navigation if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) { keyboardSelect(-1); return event.preventDefault(); } // ARROW_DOWN or TAB navigation if (event.key === 'ArrowDown' || event.key === 'Tab') { keyboardSelect(1); return event.preventDefault(); } }, [ onSelect, onClose, selectedEntry, keyboardSelect ]); const handleKey = useCallback(event => { if (domMatches(event.target, 'input')) { setValue(() => event.target.value); } }, [ setValue ]); useEffect(() => { onOpened(); return () => { onClosed(); }; }, []); const displayHeader = useMemo(() => title || headerEntries.length > 0, [ title, headerEntries ]); return html` <${PopupMenuWrapper} onClose=${ onClose } onKeyup=${ handleKey } onKeydown=${ handleKeyDown } className=${ className } position=${position} width=${ width } scale=${ scale } > ${ displayHeader && html`

${ title }

${ headerEntries.map(entry => html` onSelect(event, entry) } title=${ entry.title || entry.label } data-id=${ entry.id } onMouseEnter=${ () => setSelectedEntry(entry) } onMouseLeave=${ () => setSelectedEntry(null) } > ${ entry.imageUrl ? html` ` : null } ${ entry.label ? html` ${ entry.label } ` : null } `) }
` } ${ originalEntries.length > 0 && html`
${ searchable && html` ` } <${PopupMenuList} entries=${ entries } selectedEntry=${ selectedEntry } setSelectedEntry=${ setSelectedEntry } onAction=${ onSelect } />
${ entries.length === 0 && html`
No matching entries found.
` } ` } `; } /** * A component that wraps the popup menu. * * @param {any} props */ function PopupMenuWrapper(props) { const { onClose, onKeydown, onKeyup, className, children, position: positionGetter } = props; const popupRef = useRef(); const checkClose = useCallback((event) => { const popup = domClosest(event.target, '.djs-popup', true); if (popup) { return; } onClose(); }, [ onClose ]); useLayoutEffect(() => { if (typeof positionGetter !== 'function') { return; } const popupEl = popupRef.current; const position = positionGetter(popupEl); popupEl.style.left = `${position.x}px`; popupEl.style.top = `${position.y}px`; }, [ popupRef.current, positionGetter ]); // focus popup initially, on mount useLayoutEffect(() => { popupRef.current && popupRef.current.focus(); }, []); return html`
${children}
`; } // helpers ////////////////////// function getPopupStyle(props) { return { transform: `scale(${props.scale})`, width: `${props.width}px` }; } function getHeaderClasses(entry, selected) { return classNames( 'entry', entry.className, entry.active ? 'active' : '', entry.disabled ? 'disabled' : '', selected ? 'selected' : '' ); }