diff --git a/src/components/select-modal.tsx b/src/components/select-modal.tsx new file mode 100644 index 0000000..9125140 --- /dev/null +++ b/src/components/select-modal.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalProps } from '@nextui-org/modal'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { Button, ButtonProps, Input } from '@nextui-org/react'; +import { SearchIcon } from '@nextui-org/shared-icons'; +import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized'; +import { useDebounceCallback } from 'usehooks-ts'; +import { CellMeasurerChildProps } from 'react-virtualized/dist/es/CellMeasurer'; + + + +export type SelectModalProps = { + isOpen: boolean, + onSelected: (item: D | null | undefined) => void, + selectedItem: D | null | undefined, + modalSize?: ModalProps['size'], + displayMode: T, + rowSize: number, + items: D[], + renderItem: (item: D) => (ReactNode | ((props: Pick) => ReactNode)), + gap?: number +} & (T extends 'grid' ? { + colSize: number +} : { + colSize?: never +}); + +const SelectModal = ({ gap, selectedItem, renderItem, displayMode, items, isOpen, onSelected, modalSize, colSize, rowSize }: SelectModalProps) => { + const measurementCache = useMemo(() => { + return new CellMeasurerCache({ + defaultHeight: rowSize, + fixedWidth: true, + minHeight: Math.ceil(rowSize / 3), + keyMapper: () => window.innerWidth + }); + }, [rowSize]); + + const listRef = useRef(null); + const [selected, setSelected] = useState(selectedItem); + const [filteredItems, setFilteredItems] = useState(items); + const [gridRowCount, setGridRowCount] = useState(0); + + useEffect(() => { + // reset filtered and displayed selected item on open + if (isOpen) { + setSelected(selectedItem); + setFilteredItems(items); + } + }, [isOpen]); + + const filter = useDebounceCallback((query: string) => { + const lowerQuery = query.toLowerCase(); + setFilteredItems(items.filter(({ name }) => name?.toLowerCase().includes(lowerQuery))); + }, 100); + + const recompute = useDebounceCallback(() => { + listRef.current?.recomputeRowHeights(); + }, 150); + + useEffect(() => { + if (!isOpen) return; + + let prevWidth = -1; + const cb = () => { + if (prevWidth !== window.innerWidth) { + prevWidth = window.innerWidth; + recompute(); + } + }; + window.addEventListener('resize', cb); + + return () => { + window.removeEventListener('resize', cb); + }; + }, [isOpen, recompute]); + + const containerStyle = { pointerEvents: 'auto' } as const; + + const renderedContent = useMemo(() => { + if (!isOpen) return null; + + if (displayMode === 'list') { + const buttonStyle = { height: `calc(100% - ${gap ?? 0}px)` }; + + return (
+ + {({ height, width }) => ( { + const child = renderItem(filteredItems[index]); + + return ( + {({ measure, registerChild }) => { + return (
+ +
) + }} +
); + }} + />)} +
+
); + } + + const buttonStyle = { maxWidth: `${colSize}px`, + aspectRatio: `${colSize}/${rowSize}`, + height: `calc(100% - ${gap ?? 0}px)` }; + const rowStyle = { gap: `${gap ?? 0}px` }; + + return ( + {(({ width }) => { + const itemsPerRow = Math.max(1, Math.floor(width / colSize!)); + const rowCount = Math.ceil(filteredItems.length / itemsPerRow); + setGridRowCount(rowCount); + + return (
+ + {({ height }) => ( ( + {({ measure, registerChild }) => { + const children = filteredItems.slice(index * itemsPerRow, (index + 1) * itemsPerRow) + .map((item, i) => { + const res = renderItem(item); + + return () + }); + + return
+ {children} +
; + }} +
)} />)} +
+
); + })} +
) + }, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gridRowCount, gap]); + + return ( { + onSelected(selected); + }} isOpen={isOpen} + className={`!rounded-2xl !max-h-[90dvh] sm:!max-h-[85dvh] ${modalSize === 'full' ? 'md:max-w-[90dvw]' : ''}`}> + + {onModalClose => <> + Select Item + + } isClearable onValueChange={filter} + onClear={() => setFilteredItems(items)} /> + {renderedContent} + + + + + + } + + ) +}; + +export type SelectModalButtonProps = Omit & + Pick, 'modalSize' | 'displayMode' | 'colSize' | 'rowSize' | 'items' | 'renderItem' | 'selectedItem' | 'onSelected' | 'gap'> +export const SelectModalButton = (props: SelectModalButtonProps) => { + const [isOpen, setOpen] = useState(false); + const { gap, onSelected, selectedItem, renderItem, items, colSize, rowSize, displayMode, modalSize } = props; + + return (<> + { + setOpen(false); + onSelected(item); + }} /> +