UNPKG

raft-ui

Version:

React UI components for Raft.

377 lines (376 loc) 15.6 kB
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 };