import { ChangeEvent, ComponentProps, ReactNode, RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { FloatingFocusManager, FloatingList, FloatingPortal, Placement, autoUpdate, flip, offset, size as floatingUiSize, useClick, useDismiss, useFloating, useInteractions, useListNavigation, useMergeRefs, useRole, useTypeahead, } from "@floating-ui/react"; import { SelectSelection, SelectSelectionProps } from "./SelectSelection"; import { SelectContext } from "./useSelectContext"; import type { Option } from "./interface"; import clsx from "clsx"; import { Input, sizeVariant } from "../fields/text/Input"; import { Button } from "../button/Button"; import { Dialog } from "../dialog/Dialog"; import { ThemeColor } from "../../types"; // readonly string[] -> nécessaire uniquement pour @conform-to/react // voir getSelectProps() // src/components/form/form.stories.tsx export type SelectValue = number | string | readonly string[] | null; type SelectChangeEventLike = { target: { type: "select-one"; value: SelectValue; name: string; }; }; export type SelectProps = { variant?: "normal" | "ghost"; showArrow?: boolean; selectionClassName?: string; dialogClassName?: string; width?: number | string; floatingMinWidth?: number; placement?: Placement; options: O[]; color?: ThemeColor; size?: "small" | "medium" | "large" | "custom"; placeholder?: string; getSearchableValue?: (matchReg: RegExp, option: O) => string; searchable?: boolean; selectOptionComponent?: (props: O) => ReactNode; selectSelectionComponent?: (props: SelectSelectionProps) => ReactNode; defaultValue?: SelectValue; value?: SelectValue; onChange?: ((e: SelectChangeEventLike) => void) | null; zIndex?: number; ref?: RefObject; } & Omit, "onChange" | "value" | "defaultValue" | "multiple" | "size">; function defaultGetSearchableValue(matchReg: RegExp, option: Option) { return matchReg.test(option.label.toLowerCase().trim()); } export function SelectLegacy({ id, variant = "normal", disabled = false, showArrow = true, selectionClassName, dialogClassName, width = "100%", floatingMinWidth = 130, placement = "bottom", searchable = false, required = true, defaultValue, name = "", value: controlledValue, onChange = null, placeholder = "Select ...", getSearchableValue, selectSelectionComponent: SelectSelectionCustomComponent, selectOptionComponent: SelectOptionCustomComponent, options = [], zIndex, color = "yellow", size = "medium", ref, onFocus, onBlur, }: SelectProps) { const isControlled = typeof controlledValue !== "undefined"; const divSelectionRef = useRef(null!); const [uncontrolledSelectedIndex, setUncontrolledSelectedIndex] = useState(() => { if (typeof defaultValue === "undefined") { return null; } const pos = options.findIndex((o) => o.value === defaultValue); return pos !== -1 ? pos : null; }); const [search, setSearch] = useState(""); const filteredOptions = useMemo( () => options.filter((option) => { if (!searchable || search.trim() === "") { return true; } const fn = getSearchableValue ?? defaultGetSearchableValue; const matchReg = new RegExp(search.toLowerCase().trim()); return fn(matchReg, option); }), [searchable, search, getSearchableValue, options], ); let selectedIndex: number | null = null; if (isControlled) { if (controlledValue !== null) { const pos = filteredOptions.findIndex((o) => o.value === controlledValue); if (pos !== -1) { selectedIndex = pos; } } } else { selectedIndex = uncontrolledSelectedIndex; } const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(null); const [searchHasFocus, setSearchHasFocus] = useState(false); const SelectSelectionComponent = SelectSelectionCustomComponent ?? SelectSelection; function handleSearchChange(e: ChangeEvent) { setActiveIndex(null); setSearch(e.target.value); } const { refs, floatingStyles, context } = useFloating({ placement, open: isOpen, onOpenChange: setIsOpen, whileElementsMounted: autoUpdate, middleware: [ offset(5), flip({ padding: 10 }), floatingUiSize({ apply({ rects, elements, availableHeight }) { Object.assign(elements.floating.style, { width: `${Math.max(floatingMinWidth, rects.reference.width)}px`, }); const firstChild = elements.floating.firstElementChild as HTMLElement; if (firstChild) { firstChild.style.maxHeight = `${Math.min(availableHeight, 300)}px`; } }, padding: 10, }), ], }); const mergedRef = useMergeRefs([refs.setReference, ref, divSelectionRef]); const listRef = useRef>([]); const labelsRef = useRef>([]); const isTypingRef = useRef(false); const click = useClick(context, { event: "mousedown" }); const dismiss = useDismiss(context); const role = useRole(context, { role: "listbox" }); const listNav = useListNavigation(context, { listRef, activeIndex, focusItemOnHover: searchable ? false : true, selectedIndex, onNavigate: setActiveIndex, loop: true, }); const typeahead = useTypeahead(context, { enabled: !searchHasFocus, listRef: labelsRef, activeIndex, selectedIndex, onMatch: isOpen ? setActiveIndex : undefined, onTypingChange(isTyping) { isTypingRef.current = isTyping; }, }); const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions([ listNav, typeahead, click, dismiss, role, ]); useEffect(() => { if (SelectOptionCustomComponent) { return; } filteredOptions.map((option, i) => { labelsRef.current[i] = option.label; }); }, [search, filteredOptions, SelectOptionCustomComponent]); const handleSelect = useCallback( (index: number | null) => { setIsOpen(false); setSearch(""); const event: SelectChangeEventLike = { target: { type: "select-one", name, value: index === null ? null : filteredOptions[index].value, }, }; onChange?.(event); if (!isControlled) { setUncontrolledSelectedIndex(index); } }, [isControlled, onChange, filteredOptions, name], ); const selectContext = useMemo( () => ({ activeIndex, selectedIndex, getItemProps, handleSelect, }), [activeIndex, selectedIndex, getItemProps, handleSelect], ); const showCancelButton = !required && selectedIndex !== null; return (
void divSelectionRef.current.focus()} readOnly />
{selectedIndex !== null ? ( ) : ( placeholder )} {showCancelButton && ( )} {!showCancelButton && showArrow && ( )}
{isOpen && (
{searchable && (
setSearchHasFocus(true)} onBlur={() => setSearchHasFocus(false)} >
)} {SelectOptionCustomComponent ? ( {filteredOptions.map((option) => ( ))} ) : ( filteredOptions.map((option, index) => { const isActive = activeIndex === index; const isSelected = selectedIndex === index; return ( ); }) )}
)}
); }