import { CSSProperties, ReactNode, useCallback, useEffect, useRef, useState, } from 'react' import { createPortal } from 'react-dom' import classNames from 'classnames' import './WaterfallDropdown.scss' export interface IWaterfallOption { id: string label: string description?: string } interface WaterfallDropdownProps { label: string placeholder: string options: IWaterfallOption[] value: string onChange: (id: string) => void ariaLabel?: string icon?: ReactNode } const MENU_MAX_HEIGHT = 280 const MENU_GAP = 4 const ChevronDownIcon = () => ( ) export const CategoryIcon = () => ( ) export const ServiceIcon = () => ( ) export const UnitIcon = () => ( ) export const StaffIcon = () => ( ) export const WaterfallDropdown = ({ label, placeholder, options, value, onChange, ariaLabel, icon, }: WaterfallDropdownProps) => { const [isOpen, setIsOpen] = useState(false) const [menuStyle, setMenuStyle] = useState(null) const wrapperRef = useRef(null) const triggerRef = useRef(null) const menuRef = useRef(null) const selected = options.find((option) => option.id === value) const updateMenuPosition = useCallback(() => { if (!triggerRef.current) { return } const rect = triggerRef.current.getBoundingClientRect() const spaceBelow = window.innerHeight - rect.bottom - MENU_GAP const spaceAbove = rect.top - MENU_GAP const openUpward = spaceBelow < Math.min(MENU_MAX_HEIGHT, 120) && spaceAbove > spaceBelow const maxHeight = Math.max( 80, Math.min( MENU_MAX_HEIGHT, openUpward ? spaceAbove : spaceBelow ) ) setMenuStyle({ position: 'fixed', left: rect.left, width: rect.width, top: openUpward ? undefined : rect.bottom + MENU_GAP, bottom: openUpward ? window.innerHeight - rect.top + MENU_GAP : undefined, maxHeight, zIndex: 100000, }) }, []) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node if ( wrapperRef.current?.contains(target) || menuRef.current?.contains(target) ) { return } setIsOpen(false) } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) useEffect(() => { if (!isOpen) { setMenuStyle(null) return } updateMenuPosition() window.addEventListener('resize', updateMenuPosition) // Capture scroll from nested containers (appearance preview, etc.) window.addEventListener('scroll', updateMenuPosition, true) return () => { window.removeEventListener('resize', updateMenuPosition) window.removeEventListener('scroll', updateMenuPosition, true) } }, [isOpen, updateMenuPosition]) return (
setIsOpen((prev) => !prev)} aria-expanded={isOpen} aria-haspopup="listbox" aria-label={ariaLabel || label} > {icon && ( {icon} )} {selected?.label ?? placeholder}
{isOpen && menuStyle && createPortal(
{options.map((option) => (
{ onChange(option.id) setIsOpen(false) }} > {icon && ( {icon} )} {option.label} {option.description && ( {option.description} )}
))}
, document.body )}
) }