raft-ui
Version:
React UI components for Raft.
377 lines (376 loc) • 15.6 kB
JavaScript
import { n as useThemeScopeProps, r as useTheme } from "./theme-scope-CUOq9s4c.mjs";
import { Check, ChevronDown, LoaderCircle, Plus, X } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
import { Combobox } from "@base-ui/react/combobox";
//#region src/components/combobox-select/combobox-select.options.ts
function isOptionGroup(option) {
return typeof option === "object" && option !== null && "type" in option && option.type === "group" && "children" in option && Array.isArray(option.children);
}
function normalizeOption(option, labelField, valueField, disabledField) {
const fields = option;
const value = fields[valueField];
const label = fields[labelField];
if (typeof value !== "string" && typeof value !== "number") throw new Error("ComboboxSelect options must have a string or number value.");
return {
value,
label: typeof label === "string" || typeof label === "number" ? String(label) : String(value),
disabled: Boolean(fields[disabledField]),
option
};
}
function optionKey(value) {
return `${typeof value}:${value}`;
}
//#endregion
//#region src/components/combobox-select/combobox-select.tsx
const defaultMessages = {
clear: "Clear selection",
toggle: "Toggle options",
selected: "Selected options",
chipDescription: "Press Backspace or Delete to remove",
inputDescription: "From the start of the input, press Left Arrow to focus selected options",
remove: (label) => `Remove ${label}`
};
const defaultRenderCreateLabel = (query) => `Create “${query}”`;
function recordLabel(record, renderLabel) {
return record.option === void 0 ? record.label : renderLabel?.(record.option) ?? record.label;
}
function renderOptionContent({ record, selected, renderLabel, renderOption, renderCreateLabel }) {
if (record.createQuery !== void 0) return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Plus, { "aria-hidden": true }), /* @__PURE__ */ jsx("span", {
className: "r-combobox-select__text",
children: renderCreateLabel(record.label)
})] });
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
className: "r-combobox-select__text",
children: record.option === void 0 ? record.label : renderOption?.(record.option, { selected }) ?? recordLabel(record, renderLabel)
}), /* @__PURE__ */ jsx(Combobox.ItemIndicator, {
className: "r-combobox-select__indicator",
children: /* @__PURE__ */ jsx(Check, { "aria-hidden": true })
})] });
}
function renderSelectedChip({ record, index, tagLimit, strings, renderLabel, renderTag }) {
const label = recordLabel(record, renderLabel);
const content = record.option === void 0 ? record.label : renderTag?.(record.option) ?? label;
const overflow = index >= tagLimit;
return /* @__PURE__ */ jsxs(Combobox.Chip, {
className: "r-combobox-select__chip",
"data-overflow": overflow || void 0,
"aria-label": record.label,
"aria-description": strings.chipDescription,
children: [/* @__PURE__ */ jsx("span", {
className: "r-combobox-select__text",
children: content
}), /* @__PURE__ */ jsx(Combobox.ChipRemove, {
className: "r-combobox-select__remove",
"aria-label": strings.remove(record.label),
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
})]
}, optionKey(record.value));
}
/** Options-driven composition. The stable compound Combobox API stays unchanged. */
function ComboboxSelect({ options, multiple, value, defaultValue, onValueChange, labelField = "label", valueField = "value", disabledField = "disabled", fallbackOption, filterable = true, filter, remote = false, onSearch, loading = false, create = false, creatable, onCreate, clearable = false, showArrow = true, clearFilterAfterSelect = true, onClear, maxTagCount, size = "md", status, placeholder = "Select an option", renderLabel, renderOption, renderTag, renderCreateLabel = defaultRenderCreateLabel, emptyContent = "No options found", loadingContent = "Loading…", messages, inputProps, portalProps, positionerProps, popupProps, className, style, id: idProp, ref, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, "aria-describedby": ariaDescribedBy, "aria-invalid": ariaInvalid, inputValue, defaultInputValue, onInputValueChange, disabled, readOnly, autoHighlight = true, ...rootProps }) {
const generatedId = useId();
const id = idProp ?? generatedId;
const { theme, resolvedMode } = useTheme();
const scopedPortalProps = useThemeScopeProps(portalProps ?? {});
const strings = {
...defaultMessages,
...messages
};
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? (multiple ? [] : null));
const selectedValue = value === void 0 ? uncontrolledValue : value;
const selectedKeys = Array.isArray(selectedValue) ? selectedValue : selectedValue == null ? [] : [selectedValue];
const [query, setQuery] = useState(String(defaultInputValue ?? ""));
const [createdOptions, setCreatedOptions] = useState([]);
const collator = useMemo(() => new Intl.Collator(void 0, {
sensitivity: "base",
usage: "search"
}), []);
const normalized = useMemo(() => {
const normalize = (option) => normalizeOption(option, labelField, valueField, disabledField);
const groups = [];
options.forEach((option, index) => {
if (isOptionGroup(option)) groups.push({
key: `group:${optionKey(option.key)}`,
label: option.label,
items: option.children.map(normalize)
});
else groups.push({
key: `option:${index}`,
items: [normalize(option)]
});
});
const records = new Map(groups.flatMap((group) => group.items.map((record) => [record.value, record])));
const created = createdOptions.map(normalize).filter((record) => !records.has(record.value));
created.forEach((record) => records.set(record.value, record));
if (created.length) groups.push({
key: "created",
items: created
});
return {
groups,
records
};
}, [
options,
createdOptions,
labelField,
valueField,
disabledField
]);
const [retained, setRetained] = useState(() => /* @__PURE__ */ new Map());
const selected = useMemo(() => {
return selectedKeys.map((key) => normalized.records.get(key) ?? retained.get(key) ?? (fallbackOption ? normalizeOption(fallbackOption(key), labelField, valueField, disabledField) : {
value: key,
label: String(key),
disabled: false
}));
}, [
selectedKeys,
normalized,
retained,
fallbackOption,
labelField,
valueField,
disabledField
]);
useEffect(() => {
setRetained((previous) => {
if (previous.size === selected.length && selected.every((record) => previous.get(record.value) === record)) return previous;
return new Map(selected.map((record) => [record.value, record]));
});
}, [selected]);
const search = String(inputValue ?? query).trim();
const canCreate = (create || creatable) && filterable && !loading && search !== "" && ![...normalized.records.values(), ...selected].some((record) => record.label.trim().toLocaleLowerCase() === search.toLocaleLowerCase());
const candidate = {
value: search,
label: search,
disabled: false,
createQuery: search
};
const items = canCreate ? [...normalized.groups, {
key: "create",
items: [candidate]
}] : normalized.groups;
const css = `r-combobox-select r-combobox-select--${theme} r-combobox-select--${size}${resolvedMode === "dark" ? " r-combobox-select--dark" : ""}`;
const tagLimit = maxTagCount === void 0 ? Infinity : Math.max(0, Math.floor(maxTagCount));
const inputClassName = ["r-combobox-select__input", typeof inputProps?.className === "string" ? inputProps.className : null].filter(Boolean).join(" ");
const controlClassName = [
css,
"r-combobox-select__control",
className
].filter(Boolean).join(" ");
const positionerClassName = ["r-combobox-select__positioner", typeof positionerProps?.className === "string" ? positionerProps.className : null].filter(Boolean).join(" ");
const popupClassName = [
css,
"r-combobox-select__popup",
typeof popupProps?.className === "string" ? popupProps.className : null
].filter(Boolean).join(" ");
const inputRef = ref ?? inputProps?.ref;
const inputLabel = ariaLabel ?? inputProps?.["aria-label"];
const inputLabelledBy = ariaLabelledBy ?? inputProps?.["aria-labelledby"];
const inputDescribedBy = ariaDescribedBy ?? inputProps?.["aria-describedby"];
const inputInvalid = ariaInvalid ?? (status === "error" ? true : inputProps?.["aria-invalid"]);
const inputDescription = multiple && selected.length ? strings.inputDescription : inputProps?.["aria-description"];
const inputPlaceholder = multiple && selected.length ? "" : placeholder;
const inputReadOnly = readOnly || !filterable;
const controlValue = multiple ? selected : selected[0] ?? null;
function handleValueChange(next, details) {
const nextRecords = next === null ? [] : Array.isArray(next) ? next : [next];
const createRecord = nextRecords.find((record) => record.createQuery !== void 0);
let newOption;
let resolved = nextRecords;
if (createRecord) {
const created = onCreate ? onCreate(createRecord.label) : {
[labelField]: createRecord.label,
[valueField]: createRecord.label
};
if (created === null) {
details.cancel();
return;
}
const record = normalizeOption(created, labelField, valueField, disabledField);
if (record.disabled) {
details.cancel();
return;
}
newOption = created;
const existing = normalized.records.get(record.value) ?? retained.get(record.value);
resolved = nextRecords.map((item) => item === createRecord ? existing ?? record : item);
resolved = [...new Map(resolved.map((item) => [item.value, item])).values()];
}
const nextValue = multiple ? resolved.map((record) => record.value) : resolved[0]?.value ?? null;
const nextOptions = multiple ? resolved.map((record) => record.option) : resolved[0]?.option ?? null;
onValueChange?.(nextValue, nextOptions, details);
if (details.isCanceled) return;
if (newOption !== void 0) setCreatedOptions((previous) => [...previous, newOption]);
setRetained(new Map(resolved.map((record) => [record.value, record])));
if (value === void 0) setUncontrolledValue(nextValue);
if (details.reason === "clear-press") onClear?.();
}
function handleInputValueChange(next, details) {
onInputValueChange?.(next, details);
if (details.isCanceled) return;
const shouldKeepQuery = details.reason === "input-change" || details.reason === "item-press" && !clearFilterAfterSelect;
setQuery(shouldKeepQuery ? next : "");
if (details.reason === "input-change" || details.reason === "clear-press") onSearch?.(next);
}
function filterOptions(record, pattern) {
if (record.createQuery !== void 0) return true;
if (remote || !filterable || filter === false) return true;
return record.option !== void 0 && typeof filter === "function" ? filter(pattern, record.option) : collator.compare(record.label.slice(0, pattern.length), pattern) === 0 || record.label.toLocaleLowerCase().includes(pattern.toLocaleLowerCase());
}
function isItemEqualToValue(a, b) {
return a.createQuery === b.createQuery && a.value === b.value;
}
function itemToStringLabel(record) {
return record.label;
}
function itemToStringValue(record) {
return String(record.value);
}
const selectedChips = selected.map((record, index) => renderSelectedChip({
record,
index,
tagLimit,
strings,
renderLabel,
renderTag
}));
const overflowCount = selected.length > tagLimit ? selected.length - tagLimit : 0;
const overflowTitle = selected.slice(tagLimit).map((record) => record.label).join(", ");
const overflow = overflowCount ? /* @__PURE__ */ jsxs("span", {
className: "r-combobox-select__overflow",
title: overflowTitle,
children: ["+", overflowCount]
}) : null;
function renderRecord(record) {
const itemKey = record.createQuery !== void 0 ? "create" : optionKey(record.value);
const content = renderOptionContent({
record,
selected: selected.some((item) => item.value === record.value),
renderLabel,
renderOption,
renderCreateLabel
});
return /* @__PURE__ */ jsx(Combobox.Item, {
value: record,
disabled: record.disabled || loading,
className: "r-combobox-select__option",
children: content
}, itemKey);
}
function renderGroup(group) {
const groupLabel = group.label == null ? null : /* @__PURE__ */ jsx(Combobox.GroupLabel, {
className: "r-combobox-select__group-label",
children: group.label
});
return /* @__PURE__ */ jsxs(Combobox.Group, {
items: group.items,
children: [groupLabel, /* @__PURE__ */ jsx(Combobox.Collection, { children: renderRecord })]
}, group.key);
}
const input = /* @__PURE__ */ jsx(Combobox.Input, {
...inputProps,
ref: inputRef,
id,
"data-slot": "combobox-select-input",
className: inputClassName,
"aria-label": inputLabel,
"aria-labelledby": inputLabelledBy,
"aria-describedby": inputDescribedBy,
"aria-invalid": inputInvalid,
"aria-description": inputDescription,
placeholder: inputPlaceholder,
readOnly: inputReadOnly
});
const controlContent = multiple ? /* @__PURE__ */ jsxs(Combobox.Chips, {
className: "r-combobox-select__chips",
"aria-label": strings.selected,
children: [
selectedChips,
overflow,
input
]
}) : input;
const loadingMessage = loading ? /* @__PURE__ */ jsx("div", {
className: "r-combobox-select__message",
children: loadingContent
}) : null;
const emptyMessage = !loading ? /* @__PURE__ */ jsx("div", {
className: "r-combobox-select__message",
children: emptyContent
}) : null;
const actions = /* @__PURE__ */ jsxs("span", {
className: "r-combobox-select__actions",
children: [
loading ? /* @__PURE__ */ jsx(LoaderCircle, {
className: "r-combobox-select__spinner",
"aria-hidden": true
}) : null,
clearable ? /* @__PURE__ */ jsx(Combobox.Clear, {
className: "r-combobox-select__action",
"aria-label": strings.clear,
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
}) : null,
showArrow ? /* @__PURE__ */ jsx(Combobox.Trigger, {
className: "r-combobox-select__action",
"aria-label": strings.toggle,
children: /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": true })
}) : null
]
});
const valueChange = handleValueChange;
return /* @__PURE__ */ jsxs(Combobox.Root, {
...rootProps,
id,
multiple,
disabled,
readOnly,
items,
value: controlValue,
onValueChange: valueChange,
inputValue,
defaultInputValue,
onInputValueChange: handleInputValueChange,
filter: filterOptions,
itemToStringLabel,
itemToStringValue,
isItemEqualToValue,
autoHighlight,
children: [/* @__PURE__ */ jsxs(Combobox.InputGroup, {
"data-slot": "combobox-select",
"data-status": status,
className: controlClassName,
style,
children: [controlContent, actions]
}), /* @__PURE__ */ jsx(Combobox.Portal, {
...scopedPortalProps,
children: /* @__PURE__ */ jsx(Combobox.Positioner, {
sideOffset: 4,
align: "start",
...positionerProps,
className: positionerClassName,
children: /* @__PURE__ */ jsxs(Combobox.Popup, {
initialFocus: false,
finalFocus: false,
...popupProps,
"data-slot": "combobox-select-popup",
className: popupClassName,
children: [
/* @__PURE__ */ jsx(Combobox.Status, { children: loadingMessage }),
/* @__PURE__ */ jsx(Combobox.List, {
className: "r-combobox-select__list",
"aria-busy": loading,
children: renderGroup
}),
/* @__PURE__ */ jsx(Combobox.Empty, { children: emptyMessage })
]
})
})
})]
});
}
//#endregion
export { ComboboxSelect };