import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom"; import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { VirtualList } from "../virtual/VirtualList"; import type { VirtualListApi } from "../virtual/virtualTypes"; import { Icon, type IconProps } from "./Icon"; import { checkIcon, chevronDownIcon } from "./icons"; import styles from "./Select.module.scss"; export type SelectOption = { value: string; label: string; icon?: IconProps["icon"]; iconSrc?: string }; export function Select({ value, options, disabled, ariaLabel, onChange }: { value: string; options: SelectOption[]; disabled?: boolean; ariaLabel: string; onChange: (value: string) => void }) { const button = useRef(null); const menu = useRef(null); const listApi = useRef(null); const menuId = useId(); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 }); const selected = options.find((option) => option.value === value); useLayoutEffect(() => { if (!open || !button.current || !menu.current) return; return autoUpdate(button.current, menu.current, () => void computePosition(button.current!, menu.current!, { placement: "bottom-start", middleware: [offset(5), flip({ padding: 10 }), shift({ padding: 10 }), size({ padding: 10, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(120, availableHeight) })) })], }).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y })))); }, [open]); useEffect(() => { if (open && active >= 0) listApi.current?.scrollToIndex(active); }, [active, open]); useEffect(() => { if (!open) return; const outside = (event: PointerEvent) => { if (!button.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); }, [open]); const move = (step: number) => { if (!options.length) return; setOpen(true); setActive((index) => (index + step + options.length) % options.length); }; const choose = (option: SelectOption) => { onChange(option.value); setOpen(false); button.current?.focus(); }; return <> {open && createPortal(
{ listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(options.length * 30, Math.max(30, position.maxHeight - 8)) }}> {(option, index) => }
, document.body)} ; } export function Combobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string) => void }) { const root = useRef(null); const input = useRef(null); const menu = useRef(null); const listApi = useRef(null); const menuId = useId(); const [open, setOpen] = useState(false); const [active, setActive] = useState(-1); const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 }); const filtered = useMemo(() => { const query = value.trim().toLocaleLowerCase(); return options.filter((option) => !query || option.toLocaleLowerCase().includes(query)); }, [options, value]); useLayoutEffect(() => { if (!open || !root.current || !menu.current) return; return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, { placement: "bottom-start", middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })], }).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y })))); }, [open, filtered.length]); useEffect(() => { if (!open) return; const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); }, [open]); useEffect(() => { if (open && active >= 0) listApi.current?.scrollToIndex(active); }, [active, open]); const openMenu = () => { if (disabled || options.length === 0) return; setOpen(true); const selected = filtered.indexOf(value); setActive(selected >= 0 ? selected : 0); }; const choose = (option: string) => { onChange(option); setOpen(false); input.current?.focus(); }; const move = (step: number) => { if (!open) { openMenu(); return; } if (!filtered.length) return; setActive((index) => (index < 0 ? 0 : (index + step + filtered.length) % filtered.length)); }; return
{ if (options.length) openMenu(); }} onBlur={() => window.setTimeout(() => setOpen(false), 100)} onChange={(event) => { onChange(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); move(1); } if (event.key === "ArrowUp") { event.preventDefault(); move(-1); } if (event.key === "Enter" && open && filtered[active]) { event.preventDefault(); choose(filtered[active]); } if (event.key === "Escape") setOpen(false); }} /> {open && createPortal(
option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; if (active >= 0) api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}> {(option, index) => }
, document.body)}
{append}
; } export function MultiCombobox({ value, options = [], placeholder, disabled, append, onChange }: { value: string[]; options?: string[]; placeholder?: string; disabled?: boolean; append?: ReactNode; onChange: (value: string[]) => void; }) { const root = useRef(null); const input = useRef(null); const menu = useRef(null); const listApi = useRef(null); const menuId = useId(); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const [position, setPosition] = useState({ left: 0, top: 0, width: 0, maxHeight: 280 }); const selected = useMemo(() => new Set(value), [value]); const filtered = useMemo(() => { const normalized = query.trim().toLocaleLowerCase(); return options.filter((option) => !normalized || option.toLocaleLowerCase().includes(normalized)); }, [options, query]); useLayoutEffect(() => { if (!open || !root.current || !menu.current) return; return autoUpdate(root.current, menu.current, () => void computePosition(root.current!, menu.current!, { placement: "bottom-start", middleware: [offset(6), flip({ padding: 12 }), shift({ padding: 12 }), size({ padding: 12, apply: ({ rects, availableHeight }) => setPosition((current) => ({ ...current, width: rects.reference.width, maxHeight: Math.max(160, availableHeight) })) })], }).then(({ x, y }) => setPosition((current) => ({ ...current, left: x, top: y })))); }, [filtered.length, open]); useEffect(() => { if (!open) return; const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node) && !menu.current?.contains(event.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", outside); return () => document.removeEventListener("pointerdown", outside); }, [open]); useEffect(() => { if (open && active >= 0) listApi.current?.scrollToIndex(active); }, [active, open]); const add = (modelId: string) => { const normalized = modelId.trim(); if (normalized && !selected.has(normalized)) onChange([...value, normalized]); setQuery(""); }; const toggle = (modelId: string) => { onChange(selected.has(modelId) ? value.filter((item) => item !== modelId) : [...value, modelId]); setQuery(""); input.current?.focus(); }; const move = (step: number) => { if (!filtered.length) return; setOpen(true); setActive((index) => (index + step + filtered.length) % filtered.length); }; return
{value.length > 0 && {t("已选择 {count} 个", { count: value.length })}} { if (options.length) setOpen(true); }} onBlur={() => add(query)} onChange={(event) => { setQuery(event.target.value); setActive(0); if (options.length) setOpen(true); }} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); move(1); } if (event.key === "ArrowUp") { event.preventDefault(); move(-1); } if (event.key === "Enter") { event.preventDefault(); const normalized = query.trim(); const exact = filtered.find((option) => option.toLocaleLowerCase() === normalized.toLocaleLowerCase()); if (normalized && !exact) add(normalized); else if (open && filtered[active]) toggle(filtered[active]); } if (event.key === "Backspace" && !query && value.length) onChange(value.slice(0, -1)); if (event.key === "Escape") { setOpen(false); input.current?.focus(); } }} />
{open && createPortal(
event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); setOpen(false); input.current?.focus(); } }}> {filtered.length ? option} estimatedItemHeight={30} onReady={(api) => { listApi.current = api; api.scrollToIndex(active); }} style={{ height: Math.min(filtered.length * 30, Math.max(30, position.maxHeight - 8)) }}> {(option, index) => } :
{t("输入模型名称后按 Enter 添加")}
}
, document.body)}
{append}
; }