import { useContext, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; import type { FloatingPlacement } from "@nodedc/ui-core"; import { Dropdown } from "./Dropdown.js"; import { IconButton } from "./Button.js"; import { InspectorSelectPolicyContext } from "./InspectorContext.js"; import { cn } from "./cn.js"; export interface SelectOption { value: T; label: string; description?: string; icon?: ReactNode; disabled?: boolean; action?: { label: string; icon: ReactNode; disabled?: boolean; tone?: "neutral" | "danger"; onAction: () => void }; } export type SelectVariant = "integrated" | "split" | "inline"; export interface SelectProps { value: T; options: Array>; label: string; onChange: (value: T, option: SelectOption) => void; searchable?: boolean; searchPlaceholder?: string; emptyLabel?: string; placement?: FloatingPlacement; minMenuWidth?: number; menuWidth?: number | "anchor"; disabled?: boolean; variant?: SelectVariant; className?: string; triggerClassName?: string; menuClassName?: string; } export function Select({ value, options, label, onChange, searchable = false, searchPlaceholder = "Поиск", emptyLabel = "Ничего не найдено", placement = "bottom-start", minMenuWidth = 180, menuWidth = "anchor", disabled = false, variant = "integrated", className, triggerClassName, menuClassName, }: SelectProps) { const inspectorSplitRequired = useContext(InspectorSelectPolicyContext); const resolvedVariant: SelectVariant = inspectorSplitRequired ? "split" : variant; const [query, setQuery] = useState(""); const triggerButton = useRef(null); const selected = options.find((option) => option.value === value) ?? options[0]; // Row actions are siblings, never nested interactive listbox options. const withActions = options.some((option) => option.action); const popupRole = withActions ? "dialog" : "listbox"; const visibleOptions = useMemo(() => { const normalized = query.trim().toLocaleLowerCase(); if (!normalized) return options; return options.filter((option) => `${option.label} ${option.description ?? ""}`.toLocaleLowerCase().includes(normalized)); }, [options, query]); return ( { const handleClick = (event: MouseEvent) => { triggerButton.current = event.currentTarget; toggle(); }; const handleKeyDown = (event: KeyboardEvent) => { triggerButton.current = event.currentTarget; if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { event.preventDefault(); if (!open) toggle(); } }; if (resolvedVariant === "split") { return (
{selected?.icon ? {selected.icon} : null} {selected?.label ?? "—"}
); } if (resolvedVariant === "inline") { return ( ); } return ( ); }} > {({ close }) => ( <> {searchable ? ( ) : null} {visibleOptions.map((option) => (
{option.action ? { setQuery(""); close(); // A modal must restore focus to a surviving trigger, not this // row action which disappears when the menu closes. triggerButton.current?.focus(); option.action?.onAction(); }} >{option.action.icon} : null}
))} {visibleOptions.length === 0 ?
{emptyLabel}
: null} )}
); }