import { Check, WarningRounded } from '@mui/icons-material' import clsx from 'clsx' import Fuse from 'fuse.js' import { ComponentType, Dispatch, ReactNode, SetStateAction, useCallback, useEffect, useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' import { PopupTrigger } from '@dao-dao/types' import { toAccessibleImageUrl } from '@dao-dao/utils' import { useSearchFilter, useUpdatingRef } from '../../hooks' import { Button } from '../buttons/Button' import { SearchBar } from '../inputs/SearchBar' import { Modal } from '../modals' import { NoContent } from '../NoContent' import { TriggerRenderer } from './Popup' export type FilterableItem = { key: string | number Icon?: ComponentType<{ className?: string }> iconUrl?: string iconClassName?: string label: ReactNode description?: ReactNode rightNode?: ReactNode selected?: boolean className?: string contentContainerClassName?: string } export type FilterableItemPopupProps< T extends FilterableItem = FilterableItem, > = { trigger: PopupTrigger items: T[] filterableItemKeys: Fuse.FuseOptionKey[] onSelect: (item: T, index: number) => void searchPlaceholder?: string listClassName?: string labelClassName?: string closeOnSelect?: boolean noItemsLabel?: string getKeydownEventListener?: ( open: boolean, setOpen: Dispatch> ) => (event: KeyboardEvent) => any } export const FilterableItemPopup = ({ trigger, items, filterableItemKeys, onSelect, searchPlaceholder, listClassName, labelClassName, closeOnSelect = true, noItemsLabel, getKeydownEventListener, }: FilterableItemPopupProps) => { const { t } = useTranslation() searchPlaceholder ??= t('info.searchPlaceholder') const [_open, _setOpen] = useState(false) const open = trigger.type === 'manual' ? trigger.open : _open const setOpen = trigger.type === 'manual' ? trigger.setOpen : _setOpen const { searchBarProps, filteredData, setFilter } = useSearchFilter({ data: items, filterableKeys: filterableItemKeys, }) const itemsListRef = useRef(null) const searchBarRef = useRef(null) // Default nothing selected. const [selectedIndex, setSelectedIndex] = useState(-1) // When filtered items length updates, reset selection. The reference to // `filteredData` may change on every render, so we don't want to rely on it // directly, or else we'll create a render loop. useEffect(() => setSelectedIndex(-1), [filteredData.length]) // Ensure selected item is scrolled into view. useEffect(() => { const item = itemsListRef.current?.children[selectedIndex] if (!item) { return } // Only scroll if not already visible. const { bottom, top } = item.getBoundingClientRect() const containerRect = itemsListRef.current.getBoundingClientRect() if (top >= containerRect.top && bottom <= containerRect.bottom) { return } item.scrollIntoView({ behavior: 'smooth', }) }, [selectedIndex]) // Memoize reference so that it doesn't change on every render. const onSelectRef = useUpdatingRef(onSelect) const onSelectItem = useCallback( (item: T, originalIndex: number) => { onSelectRef.current(item, originalIndex) // Close. if (closeOnSelect) { setOpen(false) } }, [closeOnSelect, onSelectRef, setOpen] ) const handleKeyPress = useCallback( (event: KeyboardEvent) => { // If closed, do not process keypresses. if (!open) { return } switch (event.key) { case 'ArrowUp': event.preventDefault() setSelectedIndex((index) => index - 1 < 0 ? filteredData.length - 1 : // Just in case for some reason the index is overflowing. Math.min(index - 1, filteredData.length - 1) ) break case 'ArrowDown': case 'Tab': event.preventDefault() setSelectedIndex( // Just in case for some reason the index is underflowing. (index) => Math.max(index + 1, 0) % filteredData.length ) break case 'Enter': event.preventDefault() // If nothing is selected, click the first item. It's confusing when // the first item is highlighted by default, so we don't show it as // selected, but still select it when pressing enter. const index = selectedIndex === -1 ? 0 : selectedIndex if (index >= 0 && index < filteredData.length) { const { item, originalIndex } = filteredData[index] onSelectItem(item, originalIndex) } break } }, [open, selectedIndex, filteredData, onSelectItem] ) useEffect(() => { document.addEventListener('keydown', handleKeyPress) // Add custom listener if provided. const listener = getKeydownEventListener && getKeydownEventListener(open, setOpen) if (listener) { document.addEventListener('keydown', listener) } // Clean up event listeners on unmount. return () => { document.removeEventListener('keydown', handleKeyPress) // Remove custom listener if provided. if (listener) { document.removeEventListener('keydown', listener) } } }, [getKeydownEventListener, handleKeyPress, open, setOpen]) useEffect(() => { // Auto focus on search bar on open. if (open) { searchBarRef.current?.focus() // Clear filter on close. } else { // Blur search bar in case focused. searchBarRef.current?.blur() // Small delay to let it fade away first. setTimeout(() => setFilter(''), 200) } }, [open, searchBarRef, setFilter]) return ( <> setOpen((o) => !o) }} trigger={trigger} /> } onClose={() => setOpen(false)} smallCloseButton visible={open} >
{filteredData.length > 0 ? ( filteredData.map(({ item, originalIndex }, index) => ( )) ) : ( )}
) }