import * as React from "react"; import { ChevronDownIcon } from "lucide-react"; import { FieldContainer, extractText, type FieldMetaProps } from "./shared"; import { Checkbox as BaseCheckbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; type SelectionMode = "single" | "multiple"; type SelectionValue = Iterable | Set | Array; interface OptionItem { disabled?: boolean; key: string; label: string; } interface ClassNameMap { base?: string; trigger?: string; [key: string]: string | undefined; } export interface SelectProps extends FieldMetaProps { children?: React.ReactNode | ((item: T) => React.ReactNode); className?: string; classNames?: ClassNameMap; disabledKeys?: SelectionValue; isDisabled?: boolean; items?: Iterable; onChange?: (event: React.ChangeEvent) => void; onClick?: (event: React.MouseEvent) => void; onSelectionChange?: (keys: Set) => void; placeholder?: string; selectedKeys?: SelectionValue; selectionMode?: SelectionMode; size?: "sm" | "md" | "lg"; variant?: string; dropdownPlacement?: "bottom" | "top"; } export interface SelectItemProps { children?: React.ReactNode; description?: React.ReactNode; textValue?: string; } export function SelectItem(_props: SelectItemProps) { return null; } SelectItem.displayName = "HeroSelectItem"; function toSet(value?: SelectionValue) { if (!value) { return new Set(); } return new Set(Array.from(value).map((item) => String(item))); } function flattenOptionsFromNode(node: React.ReactNode, options: OptionItem[]) { React.Children.forEach(node, (child, index) => { if (child === null || child === undefined || typeof child === "boolean") { return; } if (Array.isArray(child)) { flattenOptionsFromNode(child, options); return; } if (React.isValidElement(child)) { if (child.type === React.Fragment) { flattenOptionsFromNode(child.props.children, options); return; } if (child.type === SelectItem) { const key = child.key ? String(child.key) : String(index); const props = child.props as SelectItemProps; options.push({ key, label: props.textValue ?? extractText(props.children) ?? key, }); return; } } }); } function getOptions( children: React.ReactNode | ((item: T) => React.ReactNode) | undefined, items: Iterable | undefined, ) { const options: OptionItem[] = []; if (typeof children === "function" && items) { Array.from(items).forEach((item, index) => { const rendered = children(item); if (React.isValidElement(rendered) && rendered.type === SelectItem) { const key = rendered.key ? String(rendered.key) : String(index); const props = rendered.props as SelectItemProps; options.push({ key, label: props.textValue ?? extractText(props.children) ?? key, }); } }); return options; } if (typeof children !== "function") { flattenOptionsFromNode(children, options); } return options; } function sizeClass(size: SelectProps["size"]) { if (size === "sm") { return "h-8 text-xs"; } if (size === "lg") { return "h-10 text-base"; } return "h-9 text-sm"; } function textSizeClass(size: SelectProps["size"]) { if (size === "sm") { return "text-xs"; } if (size === "lg") { return "text-base"; } return "text-sm"; } export function Select({ children, className, classNames, description, disabledKeys, errorMessage, isDisabled, isInvalid, isRequired, items, label, onChange, onClick, onSelectionChange, placeholder, selectedKeys, selectionMode = "single", size, dropdownPlacement = "bottom", }: SelectProps) { const generatedId = React.useId(); const options = React.useMemo( () => getOptions(children, items), [children, items], ); const containerRef = React.useRef(null); const listboxRef = React.useRef(null); const [isExpanded, setIsExpanded] = React.useState(false); const selected = React.useMemo(() => toSet(selectedKeys), [selectedKeys]); const disabled = React.useMemo(() => toSet(disabledKeys), [disabledKeys]); React.useEffect(() => { if (!isExpanded) { return; } const handlePointerDown = (event: MouseEvent | TouchEvent) => { const container = containerRef.current; const listbox = listboxRef.current; if (!container) { return; } const target = event.target; if (!(target instanceof Node)) { return; } if (container.contains(target) || listbox?.contains(target)) { return; } setIsExpanded(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsExpanded(false); } }; document.addEventListener("mousedown", handlePointerDown); document.addEventListener("touchstart", handlePointerDown); document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("mousedown", handlePointerDown); document.removeEventListener("touchstart", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; }, [isExpanded]); React.useEffect(() => { if (isDisabled) { setIsExpanded(false); } }, [isDisabled]); const selectedArray = Array.from(selected); const singleValue = selectedArray[0] ?? ""; const selectedLabels = options .filter((option) => selected.has(option.key)) .map((option) => option.label); const resolvedSelectedValues = selectedLabels.length > 0 ? selectedLabels : selectedArray; const selectedFullText = resolvedSelectedValues.join("、"); const selectedText = selectedArray.length > 0 ? selectedFullText : (placeholder ?? "请选择"); const updateMultipleSelection = (key: string, checked?: boolean) => { if (isDisabled || disabled.has(key)) { return; } const next = new Set(selected); const shouldSelect = typeof checked === "boolean" ? checked : !next.has(key); if (shouldSelect) { next.add(key); } else { next.delete(key); } onSelectionChange?.(next); }; const handleChange = (event: React.ChangeEvent) => { onChange?.(event); if (!onSelectionChange) { return; } if (selectionMode === "multiple") { const values = Array.from(event.target.selectedOptions).map( (option) => option.value, ); onSelectionChange(new Set(values)); return; } if (!event.target.value) { onSelectionChange(new Set()); return; } onSelectionChange(new Set([event.target.value])); }; const renderMultipleListbox = () => { if (!isExpanded) { return null; } const placementClasses = dropdownPlacement === "top" ? "bottom-full mb-1" : "top-full mt-1"; return (
{options.length === 0 ? (
暂无可选项
) : ( options.map((option) => { const optionDisabled = isDisabled || disabled.has(option.key); return (
updateMultipleSelection(option.key, value === true) } />
); }) )}
); }; return ( {selectionMode === "multiple" ? (
{renderMultipleListbox()}
) : ( )}
); }