@lobehub/ui
Version:
Lobe UI is an open-source UI component library for building AIGC web apps
558 lines (555 loc) • 20.6 kB
JavaScript
'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