UNPKG

@lobehub/ui

Version:

Lobe UI is an open-source UI component library for building AIGC web apps

558 lines (555 loc) 20.6 kB
'use client'; import Icon_default from "../Icon/Icon.mjs"; import { styles } from "../Menu/sharedStyle.mjs"; import { usePortalContainer } from "../hooks/usePortalContainer.mjs"; import { LOBE_SELECT_CONTAINER_ATTR } from "./constants.mjs"; import { styles as styles$1, triggerVariants } from "./style.mjs"; import { isValidElement, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; import { cx, useThemeMode } from "antd-style"; import { Check, ChevronDown, Loader2, X } from "lucide-react"; import { Select } from "@base-ui/react/select"; import { Virtualizer } from "virtua"; //#region src/LobeSelect/LobeSelect.tsx const isGroupOption = (option) => Boolean(option.options); const getOptionSearchText = (option) => { if (typeof option.label === "string" || typeof option.label === "number") return String(option.label); if (typeof option.value === "string" || typeof option.value === "number") return String(option.value); if (option.title) return option.title; return ""; }; const escapeRegExp = (value) => value.replaceAll(/[$()*+.?[\\\]^{|}]/g, "\\$&"); const splitBySeparators = (value, separators) => { if (!separators || separators.length === 0) return [value]; const pattern = separators.map(escapeRegExp).join("|"); return value.split(new RegExp(pattern, "g")); }; const countVirtualItems = (items) => items.reduce((count, item) => { if (isGroupOption(item)) return count + item.options.length + 1; return count + 1; }, 0); const isValueEmpty = (value) => value === null || value === void 0 || value === ""; const LobeSelect = memo(({ allowClear, autoFocus, className, classNames, defaultOpen, defaultValue, disabled, id, labelRender, listHeight = 512, listItemHeight, loading, mode, name, onChange, onOpenChange, onSelect, open, optionRender, options, placeholder, popupClassName, popupMatchSelectWidth, prefix, readOnly, required, behaviorVariant = "default", selectedIndicatorVariant = "check", shadow, showSearch, size = "middle", style, suffixIcon, suffixIconProps, tokenSeparators, value, variant, virtual }) => { const { isDarkMode } = useThemeMode(); const resolvedVariant = variant ?? (isDarkMode ? "filled" : "outlined"); const isMultiple = mode === "multiple" || mode === "tags"; const isItemAligned = behaviorVariant === "item-aligned"; const [uncontrolledValue, setUncontrolledValue] = useState(() => { if (defaultValue !== void 0) return defaultValue; return isMultiple ? [] : null; }); const normalizeValue = useCallback((nextValue) => { if (isMultiple) { if (Array.isArray(nextValue)) return nextValue; if (nextValue === null || nextValue === void 0) return []; return [nextValue]; } if (Array.isArray(nextValue)) return nextValue[0] ?? null; return nextValue === void 0 ? null : nextValue; }, [isMultiple]); const mergedValue = value !== void 0 ? value : uncontrolledValue; const normalizedValue = useMemo(() => normalizeValue(mergedValue), [mergedValue, normalizeValue]); const valueArray = useMemo(() => isMultiple ? normalizedValue : isValueEmpty(normalizedValue) ? [] : [normalizedValue], [isMultiple, normalizedValue]); const [extraOptions, setExtraOptions] = useState([]); useEffect(() => { if (mode !== "tags" && extraOptions.length > 0) setExtraOptions([]); }, [mode, extraOptions.length]); const { resolvedOptions, optionMap } = useMemo(() => { const baseOptions = options ?? []; const optionValueMap = /* @__PURE__ */ new Map(); const addOption = (item) => { if (!optionValueMap.has(item.value)) optionValueMap.set(item.value, item); }; const walkOptions = (items) => { items.forEach((item) => { if (isGroupOption(item)) item.options.forEach(addOption); else addOption(item); }); }; walkOptions(baseOptions); const filteredExtraOptions = extraOptions.filter((item) => !optionValueMap.has(item.value)); filteredExtraOptions.forEach(addOption); const mergedOptions = [...baseOptions, ...filteredExtraOptions]; const missingValueOptions = valueArray.filter((val) => !optionValueMap.has(val)).map((val) => ({ label: String(val), value: val })); missingValueOptions.forEach(addOption); return { optionMap: optionValueMap, resolvedOptions: missingValueOptions.length ? [...mergedOptions, ...missingValueOptions] : mergedOptions }; }, [ extraOptions, options, valueArray ]); const [uncontrolledOpen, setUncontrolledOpen] = useState(Boolean(defaultOpen)); useEffect(() => { if (open !== void 0) setUncontrolledOpen(open); }, [open]); const mergedOpen = open ?? uncontrolledOpen; const handleOpenChange = useCallback((nextOpen) => { onOpenChange?.(nextOpen); if (open === void 0) setUncontrolledOpen(nextOpen); }, [onOpenChange, open]); const [searchValue, setSearchValue] = useState(""); const shouldShowSearch = Boolean(showSearch || mode === "tags"); useEffect(() => { if (!mergedOpen) setSearchValue(""); }, [mergedOpen]); const getOption = useCallback((optionValue) => { const matched = optionMap.get(optionValue); if (matched) return matched; if (optionValue && typeof optionValue === "object" && "label" in optionValue) return { label: optionValue.label, value: optionValue }; return { label: String(optionValue), value: optionValue }; }, [optionMap]); const previousValueRef = useRef(normalizedValue); useEffect(() => { previousValueRef.current = normalizedValue; }, [normalizedValue]); const handleValueChange = useCallback((nextValue) => { const normalizedNextValue = normalizeValue(nextValue); const previousValue = previousValueRef.current; if (isMultiple) { const prevValues = Array.isArray(previousValue) ? previousValue : []; const nextValues = Array.isArray(normalizedNextValue) ? normalizedNextValue : []; nextValues.filter((val) => !prevValues.some((prev) => Object.is(prev, val))).forEach((val) => { onSelect?.(val, getOption(val)); }); if (value === void 0) setUncontrolledValue(nextValues); onChange?.(nextValues, nextValues.map((val) => getOption(val))); } else { if (!isValueEmpty(normalizedNextValue) && !Object.is(previousValue, normalizedNextValue)) onSelect?.(normalizedNextValue, getOption(normalizedNextValue)); if (value === void 0) setUncontrolledValue(normalizedNextValue); onChange?.(normalizedNextValue, isValueEmpty(normalizedNextValue) ? void 0 : getOption(normalizedNextValue)); } previousValueRef.current = normalizedNextValue; }, [ getOption, isMultiple, normalizeValue, onChange, onSelect, value ]); const appendTagValues = useCallback((rawValues) => { const valuesToAdd = rawValues.map((val) => val.trim()).filter(Boolean); if (!valuesToAdd.length) return; const nextValues = [...valueArray]; const newOptionValues = valuesToAdd.filter((val) => !optionMap.has(val)); if (newOptionValues.length > 0) setExtraOptions((prev) => { const existingValues = new Set(prev.map((item) => item.value)); const merged = [...prev]; newOptionValues.forEach((val) => { if (!existingValues.has(val)) merged.push({ label: val, value: val }); }); return merged; }); valuesToAdd.forEach((val) => { if (!nextValues.some((item) => Object.is(item, val))) nextValues.push(val); }); if (nextValues.length !== valueArray.length) handleValueChange(nextValues); }, [ handleValueChange, optionMap, valueArray ]); const handleSearchChange = useCallback((event) => { const nextValue = event.target.value; if (mode === "tags") { const parts = splitBySeparators(nextValue, tokenSeparators); if (parts.length > 1) { const pending = parts.pop() ?? ""; appendTagValues(parts.filter(Boolean)); setSearchValue(pending); return; } } setSearchValue(nextValue); }, [ appendTagValues, mode, tokenSeparators ]); const handleSearchKeyDown = useCallback((event) => { event.stopPropagation(); if (event.key === "Escape") { handleOpenChange(false); return; } if (mode !== "tags") return; if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); appendTagValues([searchValue]); setSearchValue(""); return; } if (tokenSeparators?.includes(event.key)) { event.preventDefault(); event.stopPropagation(); appendTagValues([searchValue]); setSearchValue(""); } }, [ appendTagValues, handleOpenChange, mode, searchValue, tokenSeparators ]); const filteredOptions = useMemo(() => { if (!shouldShowSearch || !searchValue.trim()) return resolvedOptions; const query = searchValue.trim().toLowerCase(); const filterItems = (items) => { return items.map((item) => { if (isGroupOption(item)) { const groupItems = item.options.filter((option) => getOptionSearchText(option).toLowerCase().includes(query)); if (!groupItems.length) return null; return { ...item, options: groupItems }; } return getOptionSearchText(item).toLowerCase().includes(query) ? item : null; }).filter(Boolean); }; return filterItems(resolvedOptions); }, [ resolvedOptions, searchValue, shouldShowSearch ]); const renderValue = useCallback((currentValue) => { const resolved = normalizeValue(currentValue); const placeholderNode = placeholder === void 0 ? null : /* @__PURE__ */ jsx("span", { className: styles$1.valueText, children: placeholder }); if (isMultiple) { const values = Array.isArray(resolved) ? resolved : []; if (values.length === 0) return placeholderNode; return /* @__PURE__ */ jsx("span", { className: styles$1.tags, children: values.map((val, index) => { const option$1 = getOption(val); const content$1 = labelRender ? labelRender(option$1) : option$1.label ?? String(val); return /* @__PURE__ */ jsx("span", { className: styles$1.tag, children: content$1 }, `${String(val)}-${index}`); }) }); } if (isValueEmpty(resolved)) return placeholderNode; const option = getOption(resolved); const content = labelRender ? labelRender(option) : option.label ?? String(resolved); return /* @__PURE__ */ jsx("span", { className: styles$1.valueText, children: content }); }, [ getOption, isMultiple, labelRender, normalizeValue, placeholder ]); const hasValue = isMultiple ? valueArray.length > 0 : !isValueEmpty(normalizedValue); const showClear = Boolean(allowClear && hasValue && !disabled && !readOnly); const handleClear = useCallback((event) => { event.preventDefault(); event.stopPropagation(); handleValueChange(isMultiple ? [] : null); }, [handleValueChange, isMultiple]); const prefixNode = useMemo(() => { if (prefix === void 0 || prefix === null) return null; if (isValidElement(prefix) || typeof prefix === "string" || typeof prefix === "number") return prefix; return /* @__PURE__ */ jsx(Icon_default, { icon: prefix, size: "small" }); }, [prefix]); const suffixIconNode = useMemo(() => { if (loading) return /* @__PURE__ */ jsx(Icon_default, { icon: Loader2, size: "small", spin: true }); if (suffixIcon === null) return null; if (isValidElement(suffixIcon) || typeof suffixIcon === "string" || typeof suffixIcon === "number") return suffixIcon; return /* @__PURE__ */ jsx(Icon_default, { icon: suffixIcon || ChevronDown, size: "small", ...suffixIconProps, style: { pointerEvents: "none", ...suffixIconProps?.style } }); }, [ loading, suffixIcon, suffixIconProps ]); const popupStyle = useMemo(() => { const maxHeight = isItemAligned ? "80vh" : `${listHeight}px`; const baseStyle = { maxHeight, maxWidth: "var(--available-width)", minWidth: "var(--anchor-width)", ["--lobe-select-popup-max-height"]: maxHeight }; if (popupMatchSelectWidth === void 0 || popupMatchSelectWidth === true) return baseStyle; if (typeof popupMatchSelectWidth === "number") return { ...baseStyle, minWidth: popupMatchSelectWidth, width: popupMatchSelectWidth }; return { ...baseStyle, minWidth: "max-content" }; }, [ isItemAligned, listHeight, popupMatchSelectWidth ]); const triggerClassName = cx(triggerVariants({ shadow, size, variant: resolvedVariant }), className, classNames?.root, classNames?.trigger); const portalContainer = usePortalContainer(LOBE_SELECT_CONTAINER_ATTR); const listRef = useRef(null); const pointerScrollRef = useRef(false); const pointerScrollTimeoutRef = useRef(null); const renderVirtualItem = useCallback((props) => { const { ref, ...rest } = props; return /* @__PURE__ */ jsx("div", { ...rest, ref: (node) => { if (node) node.scrollIntoView = (...args) => { if (!pointerScrollRef.current) HTMLElement.prototype.scrollIntoView.call(node, ...args); }; if (typeof ref === "function") ref(node); else if (ref && "current" in ref) ref.current = node; } }); }, []); const markPointerScroll = useCallback(() => { pointerScrollRef.current = true; if (pointerScrollTimeoutRef.current) clearTimeout(pointerScrollTimeoutRef.current); pointerScrollTimeoutRef.current = setTimeout(() => { pointerScrollRef.current = false; }, 120); }, []); const handleListScroll = useCallback(() => { if (!virtual || !pointerScrollRef.current) return; const listElement = listRef.current; const activeElement = document.activeElement; if (listElement && activeElement && listElement.contains(activeElement)) listElement.focus({ preventScroll: true }); }, [virtual]); useEffect(() => { return () => { if (pointerScrollTimeoutRef.current) clearTimeout(pointerScrollTimeoutRef.current); }; }, []); const virtualListStyle = useMemo(() => { if (!virtual) return void 0; const rowCount = countVirtualItems(filteredOptions); const maxVisibleRows = 6; const estimatedRowHeight = listItemHeight ?? (size === "large" ? 40 : size === "small" ? 28 : 32); return { height: `min(${Math.min(Math.max(rowCount, 1), maxVisibleRows) * estimatedRowHeight + 8}px, var(--lobe-select-available-height, var(--available-height)))` }; }, [ filteredOptions, listItemHeight, size, virtual ]); const keepMountedIndices = useMemo(() => { if (!virtual || valueArray.length === 0) return void 0; const selectedSet = new Set(valueArray); const indices = []; let index = 0; filteredOptions.forEach((item) => { if (isGroupOption(item)) { if (item.options.some((option) => selectedSet.has(option.value))) indices.push(index); index += 1; return; } if (selectedSet.has(item.value)) indices.push(index); index += 1; }); return indices.length ? indices : void 0; }, [ filteredOptions, valueArray, virtual ]); const itemTextClassName = cx(optionRender ? styles.itemContent : styles.label, styles$1.itemText, classNames?.itemText); const isBoldIndicator = selectedIndicatorVariant === "bold"; let optionIndex = 0; const renderOptions = (items) => items.map((item, index) => { if (isGroupOption(item)) return /* @__PURE__ */ jsxs(Select.Group, { className: cx(styles$1.group, classNames?.group), children: [/* @__PURE__ */ jsx(Select.GroupLabel, { className: cx(styles.groupLabel, styles$1.groupLabel, classNames?.groupLabel), children: item.label }), item.options.map((option) => { const currentIndex$1 = optionIndex++; return /* @__PURE__ */ jsxs(Select.Item, { className: cx(styles.item, styles$1.item, isBoldIndicator && styles$1.itemBoldSelected, classNames?.item, classNames?.option, option.className), disabled: option.disabled, label: getOptionSearchText(option), render: virtual ? renderVirtualItem : void 0, style: { minHeight: listItemHeight, ...option.style }, value: option.value, children: [/* @__PURE__ */ jsx(Select.ItemText, { className: itemTextClassName, children: optionRender ? optionRender(option, { index: currentIndex$1 }) : option.label }), !isBoldIndicator && /* @__PURE__ */ jsx(Select.ItemIndicator, { className: cx(styles$1.itemIndicator, classNames?.itemIndicator), children: /* @__PURE__ */ jsx(Icon_default, { icon: Check, size: "small" }) })] }, `${String(option.value)}-${currentIndex$1}`); })] }, `group-${index}`); const currentIndex = optionIndex++; return /* @__PURE__ */ jsxs(Select.Item, { className: cx(styles.item, styles$1.item, isBoldIndicator && styles$1.itemBoldSelected, classNames?.item, classNames?.option, item.className), disabled: item.disabled, label: getOptionSearchText(item), render: virtual ? renderVirtualItem : void 0, style: { minHeight: listItemHeight, ...item.style }, value: item.value, children: [/* @__PURE__ */ jsx(Select.ItemText, { className: itemTextClassName, children: optionRender ? optionRender(item, { index: currentIndex }) : item.label }), !isBoldIndicator && /* @__PURE__ */ jsx(Select.ItemIndicator, { className: cx(styles$1.itemIndicator, classNames?.itemIndicator), children: /* @__PURE__ */ jsx(Icon_default, { icon: Check, size: "small" }) })] }, `${String(item.value)}-${currentIndex}`); }); return /* @__PURE__ */ jsxs(Select.Root, { disabled, id, modal: isItemAligned, multiple: isMultiple, name, onOpenChange: handleOpenChange, onValueChange: handleValueChange, open: mergedOpen, readOnly, required, value: normalizedValue, children: [/* @__PURE__ */ jsxs(Select.Trigger, { autoFocus, className: triggerClassName, disabled, style, children: [ prefixNode !== null && prefixNode !== void 0 && /* @__PURE__ */ jsx("span", { className: cx(styles$1.prefix, classNames?.prefix), children: prefixNode }), /* @__PURE__ */ jsx(Select.Value, { className: cx(styles$1.value, classNames?.value), children: renderValue }), /* @__PURE__ */ jsxs("span", { className: cx(styles$1.suffix, classNames?.suffix), children: [showClear && /* @__PURE__ */ jsx("span", { className: cx(styles$1.clear, classNames?.clear), "data-role": "lobe-select-clear", onClick: handleClear, children: /* @__PURE__ */ jsx(Icon_default, { icon: X, size: "small" }) }), suffixIconNode !== null && suffixIconNode !== void 0 && /* @__PURE__ */ jsx(Select.Icon, { className: cx(styles$1.icon, classNames?.icon), children: suffixIconNode })] }) ] }), /* @__PURE__ */ jsx(Select.Portal, { container: portalContainer, children: /* @__PURE__ */ jsx(Select.Positioner, { align: "start", alignItemWithTrigger: isItemAligned, className: styles$1.positioner, side: "bottom", sideOffset: 6, children: /* @__PURE__ */ jsxs(Select.Popup, { className: cx(styles.popup, styles$1.popup, popupClassName, classNames?.popup, classNames?.dropdown), style: popupStyle, children: [shouldShowSearch && /* @__PURE__ */ jsx("div", { className: cx(styles$1.search, classNames?.search), children: /* @__PURE__ */ jsx("input", { className: styles$1.searchInput, onChange: handleSearchChange, onKeyDown: handleSearchKeyDown, placeholder: typeof placeholder === "string" ? placeholder : void 0, value: searchValue }) }), (() => { const content = filteredOptions.length > 0 ? renderOptions(filteredOptions) : /* @__PURE__ */ jsx("div", { className: cx(styles.item, styles.empty, styles$1.empty, classNames?.empty), children: "No data" }); if (!virtual || filteredOptions.length === 0) return /* @__PURE__ */ jsx(Select.List, { className: cx(styles$1.list, classNames?.list), "data-virtual": virtual || void 0, children: content }); return /* @__PURE__ */ jsx(Select.List, { className: cx(styles$1.list, classNames?.list), "data-virtual": virtual || void 0, onPointerDown: virtual ? markPointerScroll : void 0, onScroll: virtual ? handleListScroll : void 0, onTouchMove: virtual ? markPointerScroll : void 0, onWheel: virtual ? markPointerScroll : void 0, ref: listRef, style: virtualListStyle, tabIndex: virtual ? -1 : void 0, children: /* @__PURE__ */ jsx(Virtualizer, { itemSize: listItemHeight, keepMounted: keepMountedIndices, children: content }) }); })()] }) }) })] }); }); LobeSelect.displayName = "LobeSelect"; var LobeSelect_default = LobeSelect; //#endregion export { LobeSelect_default as default }; //# sourceMappingURL=LobeSelect.mjs.map