@flanksource/clicky-ui 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Combobox.cjs +89 -49
- package/dist/components/Combobox.cjs.map +1 -1
- package/dist/components/Combobox.d.ts +3 -0
- package/dist/components/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox.js +91 -51
- package/dist/components/Combobox.js.map +1 -1
- package/dist/components/FilterBar.cjs +42 -44
- package/dist/components/FilterBar.cjs.map +1 -1
- package/dist/components/FilterBar.d.ts +19 -0
- package/dist/components/FilterBar.d.ts.map +1 -1
- package/dist/components/FilterBar.js +43 -45
- package/dist/components/FilterBar.js.map +1 -1
- package/dist/components/JsonSchemaForm.cjs +35 -0
- package/dist/components/JsonSchemaForm.cjs.map +1 -0
- package/dist/components/JsonSchemaForm.d.ts +3 -0
- package/dist/components/JsonSchemaForm.d.ts.map +1 -0
- package/dist/components/JsonSchemaForm.js +35 -0
- package/dist/components/JsonSchemaForm.js.map +1 -0
- package/dist/components/json-schema-form-array.cjs +169 -0
- package/dist/components/json-schema-form-array.cjs.map +1 -0
- package/dist/components/json-schema-form-array.d.ts +8 -0
- package/dist/components/json-schema-form-array.d.ts.map +1 -0
- package/dist/components/json-schema-form-array.js +169 -0
- package/dist/components/json-schema-form-array.js.map +1 -0
- package/dist/components/json-schema-form-fields.cjs +365 -0
- package/dist/components/json-schema-form-fields.cjs.map +1 -0
- package/dist/components/json-schema-form-fields.d.ts +19 -0
- package/dist/components/json-schema-form-fields.d.ts.map +1 -0
- package/dist/components/json-schema-form-fields.js +365 -0
- package/dist/components/json-schema-form-fields.js.map +1 -0
- package/dist/components/json-schema-form-render.cjs +154 -0
- package/dist/components/json-schema-form-render.cjs.map +1 -0
- package/dist/components/json-schema-form-render.d.ts +40 -0
- package/dist/components/json-schema-form-render.d.ts.map +1 -0
- package/dist/components/json-schema-form-render.js +154 -0
- package/dist/components/json-schema-form-render.js.map +1 -0
- package/dist/components/json-schema-form-resolve.cjs +121 -0
- package/dist/components/json-schema-form-resolve.cjs.map +1 -0
- package/dist/components/json-schema-form-resolve.d.ts +19 -0
- package/dist/components/json-schema-form-resolve.d.ts.map +1 -0
- package/dist/components/json-schema-form-resolve.js +121 -0
- package/dist/components/json-schema-form-resolve.js.map +1 -0
- package/dist/components/json-schema-form-types.d.ts +108 -0
- package/dist/components/json-schema-form-types.d.ts.map +1 -0
- package/dist/components.cjs +7 -0
- package/dist/components.cjs.map +1 -1
- package/dist/components.d.ts +3 -0
- package/dist/components.d.ts.map +1 -1
- package/dist/components.js +8 -1
- package/dist/components.js.map +1 -1
- package/dist/data/Clicky.cjs +1 -1
- package/dist/data/Clicky.cjs.map +1 -1
- package/dist/data/Clicky.js +1 -1
- package/dist/data/Clicky.js.map +1 -1
- package/dist/data/DataTable.cjs +16 -15
- package/dist/data/DataTable.cjs.map +1 -1
- package/dist/data/DataTable.d.ts +2 -1
- package/dist/data/DataTable.d.ts.map +1 -1
- package/dist/data/DataTable.js +16 -15
- package/dist/data/DataTable.js.map +1 -1
- package/dist/data/Icon.cjs +11 -0
- package/dist/data/Icon.cjs.map +1 -1
- package/dist/data/Icon.d.ts +12 -1
- package/dist/data/Icon.d.ts.map +1 -1
- package/dist/data/Icon.js +12 -1
- package/dist/data/Icon.js.map +1 -1
- package/dist/data/LogDetails.cjs +277 -0
- package/dist/data/LogDetails.cjs.map +1 -0
- package/dist/data/LogDetails.d.ts +5 -0
- package/dist/data/LogDetails.d.ts.map +1 -0
- package/dist/data/LogDetails.js +277 -0
- package/dist/data/LogDetails.js.map +1 -0
- package/dist/data/LogsTable.cjs +6 -202
- package/dist/data/LogsTable.cjs.map +1 -1
- package/dist/data/LogsTable.d.ts.map +1 -1
- package/dist/data/LogsTable.js +4 -200
- package/dist/data/LogsTable.js.map +1 -1
- package/dist/data/Properties.cjs +2 -2
- package/dist/data/Properties.cjs.map +1 -1
- package/dist/data/Properties.js +2 -2
- package/dist/data/Properties.js.map +1 -1
- package/dist/data/cells/TagList.cjs +19 -3
- package/dist/data/cells/TagList.cjs.map +1 -1
- package/dist/data/cells/TagList.d.ts +11 -1
- package/dist/data/cells/TagList.d.ts.map +1 -1
- package/dist/data/cells/TagList.js +19 -3
- package/dist/data/cells/TagList.js.map +1 -1
- package/dist/data/cells/Timestamp.cjs +17 -2
- package/dist/data/cells/Timestamp.cjs.map +1 -1
- package/dist/data/cells/Timestamp.d.ts +2 -0
- package/dist/data/cells/Timestamp.d.ts.map +1 -1
- package/dist/data/cells/Timestamp.js +17 -2
- package/dist/data/cells/Timestamp.js.map +1 -1
- package/dist/data/log-utils.cjs +7 -0
- package/dist/data/log-utils.cjs.map +1 -0
- package/dist/data/log-utils.d.ts +2 -0
- package/dist/data/log-utils.d.ts.map +1 -0
- package/dist/data/log-utils.js +7 -0
- package/dist/data/log-utils.js.map +1 -0
- package/dist/data.cjs +3 -0
- package/dist/data.cjs.map +1 -1
- package/dist/data.d.ts +2 -2
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +5 -2
- package/dist/index.cjs +13 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/dist/rpc/EntityExplorerApp.cjs +3 -2
- package/dist/rpc/EntityExplorerApp.cjs.map +1 -1
- package/dist/rpc/EntityExplorerApp.js +3 -2
- package/dist/rpc/EntityExplorerApp.js.map +1 -1
- package/dist/rpc/OperationActionDialog.cjs +1 -1
- package/dist/rpc/OperationActionDialog.cjs.map +1 -1
- package/dist/rpc/OperationActionDialog.js +1 -1
- package/dist/rpc/OperationActionDialog.js.map +1 -1
- package/dist/rpc/OperationCommandPage.cjs +21 -14
- package/dist/rpc/OperationCommandPage.cjs.map +1 -1
- package/dist/rpc/OperationCommandPage.d.ts.map +1 -1
- package/dist/rpc/OperationCommandPage.js +21 -14
- package/dist/rpc/OperationCommandPage.js.map +1 -1
- package/dist/rpc/apiClient.cjs +316 -0
- package/dist/rpc/apiClient.cjs.map +1 -0
- package/dist/rpc/apiClient.d.ts +44 -0
- package/dist/rpc/apiClient.d.ts.map +1 -0
- package/dist/rpc/apiClient.js +316 -0
- package/dist/rpc/apiClient.js.map +1 -0
- package/dist/rpc/useOperations.cjs.map +1 -1
- package/dist/rpc/useOperations.d.ts +2 -1
- package/dist/rpc/useOperations.d.ts.map +1 -1
- package/dist/rpc/useOperations.js.map +1 -1
- package/dist/rpc.cjs +3 -0
- package/dist/rpc.cjs.map +1 -1
- package/dist/rpc.d.ts +1 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +3 -0
- package/dist/rpc.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const jsxRuntime = require("react/jsx-runtime");
|
|
4
4
|
const react = require("react");
|
|
5
|
+
const reactDom = require("react-dom");
|
|
5
6
|
const utils = require("../lib/utils.cjs");
|
|
6
7
|
const Icon = require("../data/Icon.cjs");
|
|
7
8
|
const UiClose = require("../icons/components/UiClose.cjs");
|
|
8
9
|
const UiChevronDown = require("../icons/components/UiChevronDown.cjs");
|
|
9
10
|
const UiCheck = require("../icons/components/UiCheck.cjs");
|
|
11
|
+
const MENU_MAX_WIDTH_PX = 400;
|
|
10
12
|
function Combobox(props) {
|
|
11
13
|
const {
|
|
12
14
|
options,
|
|
@@ -27,9 +29,11 @@ function Combobox(props) {
|
|
|
27
29
|
[multiple, props.value]
|
|
28
30
|
);
|
|
29
31
|
const rootRef = react.useRef(null);
|
|
32
|
+
const anchorRef = react.useRef(null);
|
|
30
33
|
const inputRef = react.useRef(null);
|
|
31
34
|
const listRef = react.useRef(null);
|
|
32
35
|
const [open, setOpen] = react.useState(false);
|
|
36
|
+
const [menuPos, setMenuPos] = react.useState(null);
|
|
33
37
|
const [query, setQuery] = react.useState("");
|
|
34
38
|
const [highlighted, setHighlighted] = react.useState(-1);
|
|
35
39
|
const isSelected = (optValue) => selectedValues.includes(optValue);
|
|
@@ -64,14 +68,36 @@ function Combobox(props) {
|
|
|
64
68
|
react.useEffect(() => {
|
|
65
69
|
if (!open) return;
|
|
66
70
|
const onPointerDown = (e) => {
|
|
67
|
-
var _a;
|
|
68
|
-
|
|
71
|
+
var _a, _b;
|
|
72
|
+
const target = e.target;
|
|
73
|
+
if (!((_a = rootRef.current) == null ? void 0 : _a.contains(target)) && !((_b = listRef.current) == null ? void 0 : _b.contains(target))) {
|
|
69
74
|
commitAndClose();
|
|
70
75
|
}
|
|
71
76
|
};
|
|
72
77
|
document.addEventListener("mousedown", onPointerDown);
|
|
73
78
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
74
79
|
});
|
|
80
|
+
react.useLayoutEffect(() => {
|
|
81
|
+
if (!open) {
|
|
82
|
+
setMenuPos(null);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const update = () => {
|
|
86
|
+
const anchor = anchorRef.current;
|
|
87
|
+
if (!anchor) return;
|
|
88
|
+
const rect = anchor.getBoundingClientRect();
|
|
89
|
+
const viewportCap = window.innerWidth - rect.left - 8;
|
|
90
|
+
const maxWidth = Math.max(rect.width, Math.min(MENU_MAX_WIDTH_PX, viewportCap));
|
|
91
|
+
setMenuPos({ top: rect.bottom + 4, left: rect.left, width: rect.width, maxWidth });
|
|
92
|
+
};
|
|
93
|
+
update();
|
|
94
|
+
window.addEventListener("scroll", update, true);
|
|
95
|
+
window.addEventListener("resize", update);
|
|
96
|
+
return () => {
|
|
97
|
+
window.removeEventListener("scroll", update, true);
|
|
98
|
+
window.removeEventListener("resize", update);
|
|
99
|
+
};
|
|
100
|
+
}, [open]);
|
|
75
101
|
function emit(next) {
|
|
76
102
|
if (multiple) {
|
|
77
103
|
props.onChange(next);
|
|
@@ -161,7 +187,7 @@ function Combobox(props) {
|
|
|
161
187
|
const ariaLabel = typeof label === "string" ? label : void 0;
|
|
162
188
|
const showClear = !required && !loading && !disabled && selectedValues.length > 0;
|
|
163
189
|
return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: rootRef, className: utils.cn("relative", className), children: [
|
|
164
|
-
/* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative flex items-center", children: [
|
|
190
|
+
/* @__PURE__ */ jsxRuntime.jsxs("div", { ref: anchorRef, className: "relative flex items-center", children: [
|
|
165
191
|
label != null && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "pointer-events-none absolute left-2 z-10 whitespace-nowrap font-medium uppercase tracking-wide text-muted-foreground text-[10px]", children: label }),
|
|
166
192
|
/* @__PURE__ */ jsxRuntime.jsx(
|
|
167
193
|
"input",
|
|
@@ -228,53 +254,67 @@ function Combobox(props) {
|
|
|
228
254
|
}
|
|
229
255
|
)
|
|
230
256
|
] }),
|
|
231
|
-
open &&
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
i
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
257
|
+
open && menuPos && typeof document !== "undefined" && reactDom.createPortal(
|
|
258
|
+
/* @__PURE__ */ jsxRuntime.jsxs(
|
|
259
|
+
"div",
|
|
260
|
+
{
|
|
261
|
+
id: listId,
|
|
262
|
+
ref: listRef,
|
|
263
|
+
role: "listbox",
|
|
264
|
+
"aria-multiselectable": multiple || void 0,
|
|
265
|
+
style: {
|
|
266
|
+
position: "fixed",
|
|
267
|
+
top: menuPos.top,
|
|
268
|
+
left: menuPos.left,
|
|
269
|
+
// Grow to fit the widest option, but never narrower than the input
|
|
270
|
+
// nor wider than 400px (beyond which option labels truncate). The
|
|
271
|
+
// cap is also bounded by the viewport so the menu can't overflow.
|
|
272
|
+
minWidth: menuPos.width,
|
|
273
|
+
maxWidth: menuPos.maxWidth
|
|
274
|
+
},
|
|
275
|
+
className: "z-[9999] max-h-64 w-max overflow-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/5",
|
|
276
|
+
children: [
|
|
277
|
+
loading && filtered.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-2 py-4 text-center text-sm text-muted-foreground", children: "Loading…" }),
|
|
278
|
+
!loading && filtered.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-2 py-4 text-center text-sm text-muted-foreground", children: "No results" }),
|
|
279
|
+
filtered.map((opt, i) => {
|
|
280
|
+
const selected = isSelected(opt.value);
|
|
281
|
+
return /* @__PURE__ */ jsxRuntime.jsxs(
|
|
282
|
+
"div",
|
|
283
|
+
{
|
|
284
|
+
id: listId ? `${listId}-${i}` : void 0,
|
|
285
|
+
role: "option",
|
|
286
|
+
"aria-selected": selected,
|
|
287
|
+
"aria-disabled": opt.disabled,
|
|
288
|
+
onMouseDown: (e) => {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
if (!opt.disabled) selectOption(opt);
|
|
291
|
+
},
|
|
292
|
+
onMouseEnter: () => setHighlighted(i),
|
|
293
|
+
className: utils.cn(
|
|
294
|
+
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
|
|
295
|
+
i === highlighted && "bg-accent",
|
|
296
|
+
selected && "font-medium",
|
|
297
|
+
opt.disabled && "cursor-not-allowed opacity-50"
|
|
269
298
|
),
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
299
|
+
children: [
|
|
300
|
+
/* @__PURE__ */ jsxRuntime.jsx(
|
|
301
|
+
Icon.Icon,
|
|
302
|
+
{
|
|
303
|
+
icon: UiCheck.UiCheck,
|
|
304
|
+
className: utils.cn("text-xs shrink-0", selected ? "opacity-100" : "opacity-0")
|
|
305
|
+
}
|
|
306
|
+
),
|
|
307
|
+
/* @__PURE__ */ jsxRuntime.jsx(Icon.LabelIcon, { icon: opt.icon, className: "text-sm text-muted-foreground" }),
|
|
308
|
+
/* @__PURE__ */ jsxRuntime.jsx("span", { className: "min-w-0 truncate", children: opt.label })
|
|
309
|
+
]
|
|
310
|
+
},
|
|
311
|
+
opt.value
|
|
312
|
+
);
|
|
313
|
+
})
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
),
|
|
317
|
+
document.body
|
|
278
318
|
)
|
|
279
319
|
] });
|
|
280
320
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Combobox.cjs","sources":["../../src/components/Combobox.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\nimport { cn } from \"../lib/utils\";\nimport { Icon } from \"../data/Icon\";\nimport { UiChevronDown, UiClose, UiCheck } from \"../icons\";\n\nexport type ComboboxOption = {\n value: string;\n label: string;\n disabled?: boolean;\n};\n\ntype ComboboxBaseProps = {\n /** Available options, rendered in the order provided. */\n options: ComboboxOption[];\n /** Ghost text when no value is set. */\n placeholder?: string;\n /** Optional label rendered inline inside the control, before the input. */\n label?: ReactNode;\n /** Disables the input. */\n disabled?: boolean;\n /** When true, the value cannot be cleared (the clear button is hidden). */\n required?: boolean;\n /**\n * When false, the value is restricted to the provided options — typed text\n * that does not match an option is discarded instead of committed. Defaults\n * to true (freeform entry allowed).\n */\n allowCustomValue?: boolean;\n /** HTML id for the input element. */\n id?: string;\n /** Classes applied to the root wrapper. */\n className?: string;\n /** Shows a loading indicator when options are being fetched. */\n loading?: boolean;\n /**\n * Optional async search invoked (debounced ~250ms) as the user types. The\n * consumer fetches and feeds matching options back via the `options` prop\n * (setting `loading` while in flight). When set, the component renders the\n * provided `options` as-is instead of filtering them client-side — the\n * server has already filtered. When absent, typing filters `options`\n * client-side as before.\n */\n onSearch?: (query: string) => void;\n /** Called when a key is pressed in the input. */\n onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;\n};\n\nexport type ComboboxSingleProps = ComboboxBaseProps & {\n multiple?: false;\n /** Controlled selected value. */\n value: string;\n /** Called when the selected value changes (from list or freeform input). */\n onChange: (value: string) => void;\n};\n\nexport type ComboboxMultiProps = ComboboxBaseProps & {\n multiple: true;\n /** Controlled selected values. */\n value: string[];\n /** Called with the complete next value array after each toggle. */\n onChange: (value: string[]) => void;\n};\n\nexport type ComboboxProps = ComboboxSingleProps | ComboboxMultiProps;\n\nexport function Combobox(props: ComboboxProps) {\n const {\n options,\n placeholder,\n label,\n disabled,\n required,\n allowCustomValue = true,\n id,\n className,\n loading,\n onSearch,\n onKeyDown: onKeyDownProp,\n } = props;\n const multiple = props.multiple === true;\n const selectedValues = useMemo<string[]>(\n () => (multiple ? props.value : props.value ? [props.value] : []),\n [multiple, props.value],\n );\n\n const rootRef = useRef<HTMLDivElement>(null);\n const inputRef = useRef<HTMLInputElement>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const [open, setOpen] = useState(false);\n // `query` is the type-ahead filter text, kept separate from the committed\n // value. It is empty unless the user is actively typing, so opening the\n // dropdown shows every option rather than only the selected one.\n const [query, setQuery] = useState(\"\");\n const [highlighted, setHighlighted] = useState(-1);\n\n const isSelected = (optValue: string) => selectedValues.includes(optValue);\n\n // Closed-state input text. Single: the selected option's label (or its raw\n // value as a fallback). Multi: a summary mirroring MultiSelect.\n const closedLabel = useMemo(() => {\n if (multiple) {\n const labels = options\n .filter((o) => selectedValues.includes(o.value))\n .map((o) => o.label);\n if (labels.length === 0) return \"\";\n if (labels.length <= 2) return labels.join(\", \");\n return `${labels.length} selected`;\n }\n const single = selectedValues[0] ?? \"\";\n return options.find((o) => o.value === single)?.label ?? single;\n }, [multiple, options, selectedValues]);\n\n // While open the input mirrors the query; while closed it shows the\n // committed selection's label/summary.\n const displayValue = open ? query : closedLabel;\n\n const filtered = useMemo(() => {\n // With onSearch the server already filtered. The matches replace the list,\n // but any already-selected value not in the matches is pinned so the user's\n // selection stays visible and toggleable. Selected values carry no label\n // object here, so synthesize {value, label: value} (the typeahead's value is\n // its own human label).\n if (onSearch) return withSelectedOptions(options, selectedValues);\n const q = query.toLowerCase().trim();\n if (!q) return options;\n return options.filter(\n (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),\n );\n }, [onSearch, options, query, selectedValues]);\n\n // Debounced server-side search: fires onSearch ~250ms after the query settles\n // while the menu is open. The empty-query call lets the consumer reset its\n // result set. Only active when onSearch is provided.\n useEffect(() => {\n if (!onSearch || !open) return;\n const handle = setTimeout(() => onSearch(query.trim()), 250);\n return () => clearTimeout(handle);\n }, [onSearch, open, query]);\n\n useEffect(() => {\n setHighlighted(-1);\n }, [filtered.length]);\n\n useEffect(() => {\n if (!open) return;\n const onPointerDown = (e: MouseEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) {\n commitAndClose();\n }\n };\n document.addEventListener(\"mousedown\", onPointerDown);\n return () => document.removeEventListener(\"mousedown\", onPointerDown);\n });\n\n function emit(next: string[]) {\n if (multiple) {\n props.onChange(next);\n } else {\n props.onChange(next[0] ?? \"\");\n }\n }\n\n function openMenu() {\n setQuery(\"\");\n setHighlighted(-1);\n setOpen(true);\n }\n\n function commitAndClose() {\n // Freeform commit (single, allowCustomValue only): a non-empty query that\n // doesn't match an option becomes the value. An empty query keeps the\n // existing selection. Multi and strict modes never commit freeform text.\n const trimmed = query.trim();\n if (!multiple && allowCustomValue && trimmed && trimmed !== selectedValues[0]) {\n emit([trimmed]);\n }\n setQuery(\"\");\n setOpen(false);\n setHighlighted(-1);\n }\n\n function selectOption(opt: ComboboxOption) {\n if (multiple) {\n const next = isSelected(opt.value)\n ? selectedValues.filter((v) => v !== opt.value)\n : [...selectedValues, opt.value];\n emit(next);\n setQuery(\"\");\n setHighlighted(-1);\n inputRef.current?.focus();\n return;\n }\n emit([opt.value]);\n setQuery(\"\");\n setOpen(false);\n setHighlighted(-1);\n inputRef.current?.focus();\n }\n\n function clear() {\n if (selectedValues.length > 0) emit([]);\n setQuery(\"\");\n setHighlighted(-1);\n inputRef.current?.focus();\n }\n\n function scrollToHighlighted(index: number) {\n const list = listRef.current;\n if (!list) return;\n const item = list.children[index] as HTMLElement | undefined;\n item?.scrollIntoView({ block: \"nearest\" });\n }\n\n function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n if (!open) {\n openMenu();\n return;\n }\n const next = highlighted < filtered.length - 1 ? highlighted + 1 : 0;\n setHighlighted(next);\n scrollToHighlighted(next);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n if (!open) return;\n const next = highlighted > 0 ? highlighted - 1 : filtered.length - 1;\n setHighlighted(next);\n scrollToHighlighted(next);\n } else if (e.key === \"Enter\") {\n if (open && highlighted >= 0 && filtered[highlighted] && !filtered[highlighted].disabled) {\n e.preventDefault();\n selectOption(filtered[highlighted]);\n return;\n }\n commitAndClose();\n onKeyDownProp?.(e);\n } else if (e.key === \"Escape\") {\n if (open) {\n e.preventDefault();\n setQuery(\"\");\n setOpen(false);\n setHighlighted(-1);\n }\n } else {\n onKeyDownProp?.(e);\n }\n }\n\n const listId = id ? `${id}-listbox` : undefined;\n const ariaLabel = typeof label === \"string\" ? label : undefined;\n const showClear = !required && !loading && !disabled && selectedValues.length > 0;\n\n return (\n <div ref={rootRef} className={cn(\"relative\", className)}>\n <div className=\"relative flex items-center\">\n {label != null && (\n <span className=\"pointer-events-none absolute left-2 z-10 whitespace-nowrap font-medium uppercase tracking-wide text-muted-foreground text-[10px]\">\n {label}\n </span>\n )}\n <input\n ref={inputRef}\n id={id}\n type=\"text\"\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listId}\n aria-activedescendant={highlighted >= 0 ? `${listId}-${highlighted}` : undefined}\n aria-label={ariaLabel}\n autoComplete=\"off\"\n disabled={disabled}\n placeholder={placeholder}\n value={displayValue}\n onChange={(e) => {\n setQuery(e.target.value);\n if (!open) setOpen(true);\n }}\n onFocus={openMenu}\n onKeyDown={onKeyDown}\n className={cn(\n \"h-control-h w-full rounded-md border border-input bg-background px-control-px text-sm text-foreground\",\n showClear ? \"pr-14\" : \"pr-8\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n )}\n style={label != null ? labelPadding(label) : undefined}\n />\n {showClear && (\n <button\n type=\"button\"\n tabIndex={-1}\n aria-label=\"Clear\"\n onClick={clear}\n className=\"absolute right-7 flex h-full items-center px-1 text-muted-foreground hover:text-foreground\"\n >\n <Icon icon={UiClose} className=\"text-xs\" />\n </button>\n )}\n {loading ? (\n <span className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2\">\n <svg className=\"h-3.5 w-3.5 animate-spin text-muted-foreground\" viewBox=\"0 0 16 16\" fill=\"none\">\n <circle cx=\"8\" cy=\"8\" r=\"6.5\" stroke=\"currentColor\" strokeWidth=\"2\" opacity=\"0.25\" />\n <path d=\"M14.5 8a6.5 6.5 0 0 0-6.5-6.5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n </svg>\n </span>\n ) : (\n <button\n type=\"button\"\n tabIndex={-1}\n disabled={disabled}\n aria-label=\"Toggle options\"\n onClick={() => {\n if (open) {\n commitAndClose();\n } else {\n openMenu();\n inputRef.current?.focus();\n }\n }}\n className=\"pointer-events-auto absolute right-0 flex h-full items-center px-2 text-muted-foreground\"\n >\n <Icon icon={UiChevronDown} className=\"text-xs\" />\n </button>\n )}\n </div>\n {open && (\n <div\n id={listId}\n ref={listRef}\n role=\"listbox\"\n aria-multiselectable={multiple || undefined}\n className=\"absolute left-0 top-[calc(100%+0.25rem)] z-50 w-full min-w-[10rem] max-h-64 overflow-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/5\"\n >\n {loading && filtered.length === 0 && (\n <div className=\"px-2 py-4 text-center text-sm text-muted-foreground\">Loading…</div>\n )}\n {!loading && filtered.length === 0 && (\n <div className=\"px-2 py-4 text-center text-sm text-muted-foreground\">No results</div>\n )}\n {filtered.map((opt, i) => {\n const selected = isSelected(opt.value);\n return (\n <div\n key={opt.value}\n id={listId ? `${listId}-${i}` : undefined}\n role=\"option\"\n aria-selected={selected}\n aria-disabled={opt.disabled}\n onMouseDown={(e) => {\n e.preventDefault();\n if (!opt.disabled) selectOption(opt);\n }}\n onMouseEnter={() => setHighlighted(i)}\n className={cn(\n \"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm\",\n i === highlighted && \"bg-accent\",\n selected && \"font-medium\",\n opt.disabled && \"cursor-not-allowed opacity-50\",\n )}\n >\n <Icon\n icon={UiCheck}\n className={cn(\"text-xs shrink-0\", selected ? \"opacity-100\" : \"opacity-0\")}\n />\n <span className=\"min-w-0 truncate\">{opt.label}</span>\n </div>\n );\n })}\n </div>\n )}\n </div>\n );\n}\n\n// Reserve room on the left of the input for the inline label so typed text\n// does not overlap it. Roughly 0.6rem per character plus padding.\nfunction labelPadding(label: ReactNode) {\n const length = typeof label === \"string\" ? label.length : 6;\n return { paddingLeft: `${Math.min(length * 0.62 + 0.75, 9)}rem` };\n}\n\n// withSelectedOptions returns the option list plus any selected value that isn't\n// already present, synthesized as {value, label: value}. Used by the server\n// (onSearch) typeahead so an already-selected value missing from the current\n// matches still renders as a checked row. Selected-but-absent values come first\n// so they don't get scrolled past by a long match list.\nfunction withSelectedOptions(\n options: ComboboxOption[],\n selectedValues: string[],\n): ComboboxOption[] {\n const present = new Set(options.map((o) => o.value));\n const pinned = selectedValues\n .filter((v) => !present.has(v))\n .map((v) => ({ value: v, label: v }));\n return pinned.length === 0 ? options : [...pinned, ...options];\n}\n"],"names":["useMemo","useRef","useState","useEffect","jsxs","cn","jsx","Icon","UiClose","UiChevronDown","UiCheck"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"Combobox.cjs","sources":["../../src/components/Combobox.tsx"],"sourcesContent":["import {\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type KeyboardEvent,\n type ReactNode,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"../lib/utils\";\nimport { Icon, LabelIcon, type LabelIconSpec } from \"../data/Icon\";\nimport { UiChevronDown, UiClose, UiCheck } from \"../icons\";\n\n// Upper bound the open dropdown grows to in order to show full option labels\n// before they truncate. The menu's minimum is always the input's width.\nconst MENU_MAX_WIDTH_PX = 400;\n\nexport type ComboboxOption = {\n value: string;\n label: string;\n disabled?: boolean;\n /** Leading glyph for the option: a runtime icon name or a rendered node. */\n icon?: LabelIconSpec;\n};\n\ntype ComboboxBaseProps = {\n /** Available options, rendered in the order provided. */\n options: ComboboxOption[];\n /** Ghost text when no value is set. */\n placeholder?: string;\n /** Optional label rendered inline inside the control, before the input. */\n label?: ReactNode;\n /** Disables the input. */\n disabled?: boolean;\n /** When true, the value cannot be cleared (the clear button is hidden). */\n required?: boolean;\n /**\n * When false, the value is restricted to the provided options — typed text\n * that does not match an option is discarded instead of committed. Defaults\n * to true (freeform entry allowed).\n */\n allowCustomValue?: boolean;\n /** HTML id for the input element. */\n id?: string;\n /** Classes applied to the root wrapper. */\n className?: string;\n /** Shows a loading indicator when options are being fetched. */\n loading?: boolean;\n /**\n * Optional async search invoked (debounced ~250ms) as the user types. The\n * consumer fetches and feeds matching options back via the `options` prop\n * (setting `loading` while in flight). When set, the component renders the\n * provided `options` as-is instead of filtering them client-side — the\n * server has already filtered. When absent, typing filters `options`\n * client-side as before.\n */\n onSearch?: (query: string) => void;\n /** Called when a key is pressed in the input. */\n onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;\n};\n\nexport type ComboboxSingleProps = ComboboxBaseProps & {\n multiple?: false;\n /** Controlled selected value. */\n value: string;\n /** Called when the selected value changes (from list or freeform input). */\n onChange: (value: string) => void;\n};\n\nexport type ComboboxMultiProps = ComboboxBaseProps & {\n multiple: true;\n /** Controlled selected values. */\n value: string[];\n /** Called with the complete next value array after each toggle. */\n onChange: (value: string[]) => void;\n};\n\nexport type ComboboxProps = ComboboxSingleProps | ComboboxMultiProps;\n\nexport function Combobox(props: ComboboxProps) {\n const {\n options,\n placeholder,\n label,\n disabled,\n required,\n allowCustomValue = true,\n id,\n className,\n loading,\n onSearch,\n onKeyDown: onKeyDownProp,\n } = props;\n const multiple = props.multiple === true;\n const selectedValues = useMemo<string[]>(\n () => (multiple ? props.value : props.value ? [props.value] : []),\n [multiple, props.value],\n );\n\n const rootRef = useRef<HTMLDivElement>(null);\n const anchorRef = useRef<HTMLDivElement>(null);\n const inputRef = useRef<HTMLInputElement>(null);\n const listRef = useRef<HTMLDivElement>(null);\n const [open, setOpen] = useState(false);\n // Fixed-position coordinates for the portaled listbox, measured from the\n // input row. `width` is the input width (the menu's minimum); `maxWidth` caps\n // content growth at 400px, further bounded by the viewport. Null until first\n // measured so we never render at (0,0).\n const [menuPos, setMenuPos] = useState<{\n top: number;\n left: number;\n width: number;\n maxWidth: number;\n } | null>(null);\n // `query` is the type-ahead filter text, kept separate from the committed\n // value. It is empty unless the user is actively typing, so opening the\n // dropdown shows every option rather than only the selected one.\n const [query, setQuery] = useState(\"\");\n const [highlighted, setHighlighted] = useState(-1);\n\n const isSelected = (optValue: string) => selectedValues.includes(optValue);\n\n // Closed-state input text. Single: the selected option's label (or its raw\n // value as a fallback). Multi: a summary mirroring MultiSelect.\n const closedLabel = useMemo(() => {\n if (multiple) {\n const labels = options\n .filter((o) => selectedValues.includes(o.value))\n .map((o) => o.label);\n if (labels.length === 0) return \"\";\n if (labels.length <= 2) return labels.join(\", \");\n return `${labels.length} selected`;\n }\n const single = selectedValues[0] ?? \"\";\n return options.find((o) => o.value === single)?.label ?? single;\n }, [multiple, options, selectedValues]);\n\n // While open the input mirrors the query; while closed it shows the\n // committed selection's label/summary.\n const displayValue = open ? query : closedLabel;\n\n const filtered = useMemo(() => {\n // With onSearch the server already filtered. The matches replace the list,\n // but any already-selected value not in the matches is pinned so the user's\n // selection stays visible and toggleable. Selected values carry no label\n // object here, so synthesize {value, label: value} (the typeahead's value is\n // its own human label).\n if (onSearch) return withSelectedOptions(options, selectedValues);\n const q = query.toLowerCase().trim();\n if (!q) return options;\n return options.filter(\n (o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),\n );\n }, [onSearch, options, query, selectedValues]);\n\n // Debounced server-side search: fires onSearch ~250ms after the query settles\n // while the menu is open. The empty-query call lets the consumer reset its\n // result set. Only active when onSearch is provided.\n useEffect(() => {\n if (!onSearch || !open) return;\n const handle = setTimeout(() => onSearch(query.trim()), 250);\n return () => clearTimeout(handle);\n }, [onSearch, open, query]);\n\n useEffect(() => {\n setHighlighted(-1);\n }, [filtered.length]);\n\n useEffect(() => {\n if (!open) return;\n const onPointerDown = (e: MouseEvent) => {\n const target = e.target as Node;\n // The listbox is portaled out of rootRef, so a click inside it would\n // otherwise read as \"outside\" and close the menu before selection.\n if (!rootRef.current?.contains(target) && !listRef.current?.contains(target)) {\n commitAndClose();\n }\n };\n document.addEventListener(\"mousedown\", onPointerDown);\n return () => document.removeEventListener(\"mousedown\", onPointerDown);\n });\n\n // Position the portaled listbox with fixed coordinates measured from the\n // input row, so it escapes any overflow-hidden / scroll ancestor (e.g. a\n // Modal body) that would otherwise clip it. Mirrors HoverCard's approach.\n useLayoutEffect(() => {\n if (!open) {\n setMenuPos(null);\n return;\n }\n const update = () => {\n const anchor = anchorRef.current;\n if (!anchor) return;\n const rect = anchor.getBoundingClientRect();\n // The menu may grow rightward up to 400px, but not past the viewport edge\n // (leaving an 8px gutter). Never let the cap fall below the input width.\n const viewportCap = window.innerWidth - rect.left - 8;\n const maxWidth = Math.max(rect.width, Math.min(MENU_MAX_WIDTH_PX, viewportCap));\n setMenuPos({ top: rect.bottom + 4, left: rect.left, width: rect.width, maxWidth });\n };\n update();\n window.addEventListener(\"scroll\", update, true);\n window.addEventListener(\"resize\", update);\n return () => {\n window.removeEventListener(\"scroll\", update, true);\n window.removeEventListener(\"resize\", update);\n };\n }, [open]);\n\n function emit(next: string[]) {\n if (multiple) {\n props.onChange(next);\n } else {\n props.onChange(next[0] ?? \"\");\n }\n }\n\n function openMenu() {\n setQuery(\"\");\n setHighlighted(-1);\n setOpen(true);\n }\n\n function commitAndClose() {\n // Freeform commit (single, allowCustomValue only): a non-empty query that\n // doesn't match an option becomes the value. An empty query keeps the\n // existing selection. Multi and strict modes never commit freeform text.\n const trimmed = query.trim();\n if (!multiple && allowCustomValue && trimmed && trimmed !== selectedValues[0]) {\n emit([trimmed]);\n }\n setQuery(\"\");\n setOpen(false);\n setHighlighted(-1);\n }\n\n function selectOption(opt: ComboboxOption) {\n if (multiple) {\n const next = isSelected(opt.value)\n ? selectedValues.filter((v) => v !== opt.value)\n : [...selectedValues, opt.value];\n emit(next);\n setQuery(\"\");\n setHighlighted(-1);\n inputRef.current?.focus();\n return;\n }\n emit([opt.value]);\n setQuery(\"\");\n setOpen(false);\n setHighlighted(-1);\n inputRef.current?.focus();\n }\n\n function clear() {\n if (selectedValues.length > 0) emit([]);\n setQuery(\"\");\n setHighlighted(-1);\n inputRef.current?.focus();\n }\n\n function scrollToHighlighted(index: number) {\n const list = listRef.current;\n if (!list) return;\n const item = list.children[index] as HTMLElement | undefined;\n item?.scrollIntoView({ block: \"nearest\" });\n }\n\n function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n if (!open) {\n openMenu();\n return;\n }\n const next = highlighted < filtered.length - 1 ? highlighted + 1 : 0;\n setHighlighted(next);\n scrollToHighlighted(next);\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n if (!open) return;\n const next = highlighted > 0 ? highlighted - 1 : filtered.length - 1;\n setHighlighted(next);\n scrollToHighlighted(next);\n } else if (e.key === \"Enter\") {\n if (open && highlighted >= 0 && filtered[highlighted] && !filtered[highlighted].disabled) {\n e.preventDefault();\n selectOption(filtered[highlighted]);\n return;\n }\n commitAndClose();\n onKeyDownProp?.(e);\n } else if (e.key === \"Escape\") {\n if (open) {\n e.preventDefault();\n setQuery(\"\");\n setOpen(false);\n setHighlighted(-1);\n }\n } else {\n onKeyDownProp?.(e);\n }\n }\n\n const listId = id ? `${id}-listbox` : undefined;\n const ariaLabel = typeof label === \"string\" ? label : undefined;\n const showClear = !required && !loading && !disabled && selectedValues.length > 0;\n\n return (\n <div ref={rootRef} className={cn(\"relative\", className)}>\n <div ref={anchorRef} className=\"relative flex items-center\">\n {label != null && (\n <span className=\"pointer-events-none absolute left-2 z-10 whitespace-nowrap font-medium uppercase tracking-wide text-muted-foreground text-[10px]\">\n {label}\n </span>\n )}\n <input\n ref={inputRef}\n id={id}\n type=\"text\"\n role=\"combobox\"\n aria-expanded={open}\n aria-controls={listId}\n aria-activedescendant={highlighted >= 0 ? `${listId}-${highlighted}` : undefined}\n aria-label={ariaLabel}\n autoComplete=\"off\"\n disabled={disabled}\n placeholder={placeholder}\n value={displayValue}\n onChange={(e) => {\n setQuery(e.target.value);\n if (!open) setOpen(true);\n }}\n onFocus={openMenu}\n onKeyDown={onKeyDown}\n className={cn(\n \"h-control-h w-full rounded-md border border-input bg-background px-control-px text-sm text-foreground\",\n showClear ? \"pr-14\" : \"pr-8\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n )}\n style={label != null ? labelPadding(label) : undefined}\n />\n {showClear && (\n <button\n type=\"button\"\n tabIndex={-1}\n aria-label=\"Clear\"\n onClick={clear}\n className=\"absolute right-7 flex h-full items-center px-1 text-muted-foreground hover:text-foreground\"\n >\n <Icon icon={UiClose} className=\"text-xs\" />\n </button>\n )}\n {loading ? (\n <span className=\"pointer-events-none absolute right-2 top-1/2 -translate-y-1/2\">\n <svg className=\"h-3.5 w-3.5 animate-spin text-muted-foreground\" viewBox=\"0 0 16 16\" fill=\"none\">\n <circle cx=\"8\" cy=\"8\" r=\"6.5\" stroke=\"currentColor\" strokeWidth=\"2\" opacity=\"0.25\" />\n <path d=\"M14.5 8a6.5 6.5 0 0 0-6.5-6.5\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n </svg>\n </span>\n ) : (\n <button\n type=\"button\"\n tabIndex={-1}\n disabled={disabled}\n aria-label=\"Toggle options\"\n onClick={() => {\n if (open) {\n commitAndClose();\n } else {\n openMenu();\n inputRef.current?.focus();\n }\n }}\n className=\"pointer-events-auto absolute right-0 flex h-full items-center px-2 text-muted-foreground\"\n >\n <Icon icon={UiChevronDown} className=\"text-xs\" />\n </button>\n )}\n </div>\n {open && menuPos && typeof document !== \"undefined\" &&\n createPortal(\n <div\n id={listId}\n ref={listRef}\n role=\"listbox\"\n aria-multiselectable={multiple || undefined}\n style={{\n position: \"fixed\",\n top: menuPos.top,\n left: menuPos.left,\n // Grow to fit the widest option, but never narrower than the input\n // nor wider than 400px (beyond which option labels truncate). The\n // cap is also bounded by the viewport so the menu can't overflow.\n minWidth: menuPos.width,\n maxWidth: menuPos.maxWidth,\n }}\n className=\"z-[9999] max-h-64 w-max overflow-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/5\"\n >\n {loading && filtered.length === 0 && (\n <div className=\"px-2 py-4 text-center text-sm text-muted-foreground\">Loading…</div>\n )}\n {!loading && filtered.length === 0 && (\n <div className=\"px-2 py-4 text-center text-sm text-muted-foreground\">No results</div>\n )}\n {filtered.map((opt, i) => {\n const selected = isSelected(opt.value);\n return (\n <div\n key={opt.value}\n id={listId ? `${listId}-${i}` : undefined}\n role=\"option\"\n aria-selected={selected}\n aria-disabled={opt.disabled}\n onMouseDown={(e) => {\n e.preventDefault();\n if (!opt.disabled) selectOption(opt);\n }}\n onMouseEnter={() => setHighlighted(i)}\n className={cn(\n \"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm\",\n i === highlighted && \"bg-accent\",\n selected && \"font-medium\",\n opt.disabled && \"cursor-not-allowed opacity-50\",\n )}\n >\n <Icon\n icon={UiCheck}\n className={cn(\"text-xs shrink-0\", selected ? \"opacity-100\" : \"opacity-0\")}\n />\n <LabelIcon icon={opt.icon} className=\"text-sm text-muted-foreground\" />\n <span className=\"min-w-0 truncate\">{opt.label}</span>\n </div>\n );\n })}\n </div>,\n document.body,\n )}\n </div>\n );\n}\n\n// Reserve room on the left of the input for the inline label so typed text\n// does not overlap it. Roughly 0.6rem per character plus padding.\nfunction labelPadding(label: ReactNode) {\n const length = typeof label === \"string\" ? label.length : 6;\n return { paddingLeft: `${Math.min(length * 0.62 + 0.75, 9)}rem` };\n}\n\n// withSelectedOptions returns the option list plus any selected value that isn't\n// already present, synthesized as {value, label: value}. Used by the server\n// (onSearch) typeahead so an already-selected value missing from the current\n// matches still renders as a checked row. Selected-but-absent values come first\n// so they don't get scrolled past by a long match list.\nfunction withSelectedOptions(\n options: ComboboxOption[],\n selectedValues: string[],\n): ComboboxOption[] {\n const present = new Set(options.map((o) => o.value));\n const pinned = selectedValues\n .filter((v) => !present.has(v))\n .map((v) => ({ value: v, label: v }));\n return pinned.length === 0 ? options : [...pinned, ...options];\n}\n"],"names":["useMemo","useRef","useState","useEffect","useLayoutEffect","jsxs","cn","jsx","Icon","UiClose","UiChevronDown","createPortal","UiCheck","LabelIcon"],"mappings":"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"}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { KeyboardEvent, ReactNode } from 'react';
|
|
2
|
+
import { LabelIconSpec } from '../data/Icon';
|
|
2
3
|
export type ComboboxOption = {
|
|
3
4
|
value: string;
|
|
4
5
|
label: string;
|
|
5
6
|
disabled?: boolean;
|
|
7
|
+
/** Leading glyph for the option: a runtime icon name or a rendered node. */
|
|
8
|
+
icon?: LabelIconSpec;
|
|
6
9
|
};
|
|
7
10
|
type ComboboxBaseProps = {
|
|
8
11
|
/** Available options, rendered in the order provided. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Combobox.d.ts","sourceRoot":"","sources":["../../src/components/Combobox.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"Combobox.d.ts","sourceRoot":"","sources":["../../src/components/Combobox.tsx"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,aAAa,EAClB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAGf,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAOnE,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,4EAA4E;IAC5E,IAAI,CAAC,EAAE,aAAa,CAAC;CACtB,CAAC;AAEF,KAAK,iBAAiB,GAAG;IACvB,yDAAyD;IACzD,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,uCAAuC;IACvC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,0BAA0B;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,qCAAqC;IACrC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gEAAgE;IAChE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,iDAAiD;IACjD,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,aAAa,CAAC,gBAAgB,CAAC,KAAK,IAAI,CAAC;CAC1D,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,iBAAiB,GAAG;IACpD,QAAQ,CAAC,EAAE,KAAK,CAAC;IACjB,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG,iBAAiB,GAAG;IACnD,QAAQ,EAAE,IAAI,CAAC;IACf,kCAAkC;IAClC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,mEAAmE;IACnE,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;CACrC,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,mBAAmB,GAAG,kBAAkB,CAAC;AAErE,wBAAgB,QAAQ,CAAC,KAAK,EAAE,aAAa,2CA0W5C"}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useRef, useState, useEffect } from "react";
|
|
2
|
+
import { useMemo, useRef, useState, useEffect, useLayoutEffect } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
3
4
|
import { cn } from "../lib/utils.js";
|
|
4
|
-
import { Icon } from "../data/Icon.js";
|
|
5
|
+
import { Icon, LabelIcon } from "../data/Icon.js";
|
|
5
6
|
import { UiClose } from "../icons/components/UiClose.js";
|
|
6
7
|
import { UiChevronDown } from "../icons/components/UiChevronDown.js";
|
|
7
8
|
import { UiCheck } from "../icons/components/UiCheck.js";
|
|
9
|
+
const MENU_MAX_WIDTH_PX = 400;
|
|
8
10
|
function Combobox(props) {
|
|
9
11
|
const {
|
|
10
12
|
options,
|
|
@@ -25,9 +27,11 @@ function Combobox(props) {
|
|
|
25
27
|
[multiple, props.value]
|
|
26
28
|
);
|
|
27
29
|
const rootRef = useRef(null);
|
|
30
|
+
const anchorRef = useRef(null);
|
|
28
31
|
const inputRef = useRef(null);
|
|
29
32
|
const listRef = useRef(null);
|
|
30
33
|
const [open, setOpen] = useState(false);
|
|
34
|
+
const [menuPos, setMenuPos] = useState(null);
|
|
31
35
|
const [query, setQuery] = useState("");
|
|
32
36
|
const [highlighted, setHighlighted] = useState(-1);
|
|
33
37
|
const isSelected = (optValue) => selectedValues.includes(optValue);
|
|
@@ -62,14 +66,36 @@ function Combobox(props) {
|
|
|
62
66
|
useEffect(() => {
|
|
63
67
|
if (!open) return;
|
|
64
68
|
const onPointerDown = (e) => {
|
|
65
|
-
var _a;
|
|
66
|
-
|
|
69
|
+
var _a, _b;
|
|
70
|
+
const target = e.target;
|
|
71
|
+
if (!((_a = rootRef.current) == null ? void 0 : _a.contains(target)) && !((_b = listRef.current) == null ? void 0 : _b.contains(target))) {
|
|
67
72
|
commitAndClose();
|
|
68
73
|
}
|
|
69
74
|
};
|
|
70
75
|
document.addEventListener("mousedown", onPointerDown);
|
|
71
76
|
return () => document.removeEventListener("mousedown", onPointerDown);
|
|
72
77
|
});
|
|
78
|
+
useLayoutEffect(() => {
|
|
79
|
+
if (!open) {
|
|
80
|
+
setMenuPos(null);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const update = () => {
|
|
84
|
+
const anchor = anchorRef.current;
|
|
85
|
+
if (!anchor) return;
|
|
86
|
+
const rect = anchor.getBoundingClientRect();
|
|
87
|
+
const viewportCap = window.innerWidth - rect.left - 8;
|
|
88
|
+
const maxWidth = Math.max(rect.width, Math.min(MENU_MAX_WIDTH_PX, viewportCap));
|
|
89
|
+
setMenuPos({ top: rect.bottom + 4, left: rect.left, width: rect.width, maxWidth });
|
|
90
|
+
};
|
|
91
|
+
update();
|
|
92
|
+
window.addEventListener("scroll", update, true);
|
|
93
|
+
window.addEventListener("resize", update);
|
|
94
|
+
return () => {
|
|
95
|
+
window.removeEventListener("scroll", update, true);
|
|
96
|
+
window.removeEventListener("resize", update);
|
|
97
|
+
};
|
|
98
|
+
}, [open]);
|
|
73
99
|
function emit(next) {
|
|
74
100
|
if (multiple) {
|
|
75
101
|
props.onChange(next);
|
|
@@ -159,7 +185,7 @@ function Combobox(props) {
|
|
|
159
185
|
const ariaLabel = typeof label === "string" ? label : void 0;
|
|
160
186
|
const showClear = !required && !loading && !disabled && selectedValues.length > 0;
|
|
161
187
|
return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("relative", className), children: [
|
|
162
|
-
/* @__PURE__ */ jsxs("div", { className: "relative flex items-center", children: [
|
|
188
|
+
/* @__PURE__ */ jsxs("div", { ref: anchorRef, className: "relative flex items-center", children: [
|
|
163
189
|
label != null && /* @__PURE__ */ jsx("span", { className: "pointer-events-none absolute left-2 z-10 whitespace-nowrap font-medium uppercase tracking-wide text-muted-foreground text-[10px]", children: label }),
|
|
164
190
|
/* @__PURE__ */ jsx(
|
|
165
191
|
"input",
|
|
@@ -226,53 +252,67 @@ function Combobox(props) {
|
|
|
226
252
|
}
|
|
227
253
|
)
|
|
228
254
|
] }),
|
|
229
|
-
open &&
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
i
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
255
|
+
open && menuPos && typeof document !== "undefined" && createPortal(
|
|
256
|
+
/* @__PURE__ */ jsxs(
|
|
257
|
+
"div",
|
|
258
|
+
{
|
|
259
|
+
id: listId,
|
|
260
|
+
ref: listRef,
|
|
261
|
+
role: "listbox",
|
|
262
|
+
"aria-multiselectable": multiple || void 0,
|
|
263
|
+
style: {
|
|
264
|
+
position: "fixed",
|
|
265
|
+
top: menuPos.top,
|
|
266
|
+
left: menuPos.left,
|
|
267
|
+
// Grow to fit the widest option, but never narrower than the input
|
|
268
|
+
// nor wider than 400px (beyond which option labels truncate). The
|
|
269
|
+
// cap is also bounded by the viewport so the menu can't overflow.
|
|
270
|
+
minWidth: menuPos.width,
|
|
271
|
+
maxWidth: menuPos.maxWidth
|
|
272
|
+
},
|
|
273
|
+
className: "z-[9999] max-h-64 w-max overflow-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg shadow-black/5",
|
|
274
|
+
children: [
|
|
275
|
+
loading && filtered.length === 0 && /* @__PURE__ */ jsx("div", { className: "px-2 py-4 text-center text-sm text-muted-foreground", children: "Loading…" }),
|
|
276
|
+
!loading && filtered.length === 0 && /* @__PURE__ */ jsx("div", { className: "px-2 py-4 text-center text-sm text-muted-foreground", children: "No results" }),
|
|
277
|
+
filtered.map((opt, i) => {
|
|
278
|
+
const selected = isSelected(opt.value);
|
|
279
|
+
return /* @__PURE__ */ jsxs(
|
|
280
|
+
"div",
|
|
281
|
+
{
|
|
282
|
+
id: listId ? `${listId}-${i}` : void 0,
|
|
283
|
+
role: "option",
|
|
284
|
+
"aria-selected": selected,
|
|
285
|
+
"aria-disabled": opt.disabled,
|
|
286
|
+
onMouseDown: (e) => {
|
|
287
|
+
e.preventDefault();
|
|
288
|
+
if (!opt.disabled) selectOption(opt);
|
|
289
|
+
},
|
|
290
|
+
onMouseEnter: () => setHighlighted(i),
|
|
291
|
+
className: cn(
|
|
292
|
+
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
|
|
293
|
+
i === highlighted && "bg-accent",
|
|
294
|
+
selected && "font-medium",
|
|
295
|
+
opt.disabled && "cursor-not-allowed opacity-50"
|
|
267
296
|
),
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
297
|
+
children: [
|
|
298
|
+
/* @__PURE__ */ jsx(
|
|
299
|
+
Icon,
|
|
300
|
+
{
|
|
301
|
+
icon: UiCheck,
|
|
302
|
+
className: cn("text-xs shrink-0", selected ? "opacity-100" : "opacity-0")
|
|
303
|
+
}
|
|
304
|
+
),
|
|
305
|
+
/* @__PURE__ */ jsx(LabelIcon, { icon: opt.icon, className: "text-sm text-muted-foreground" }),
|
|
306
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 truncate", children: opt.label })
|
|
307
|
+
]
|
|
308
|
+
},
|
|
309
|
+
opt.value
|
|
310
|
+
);
|
|
311
|
+
})
|
|
312
|
+
]
|
|
313
|
+
}
|
|
314
|
+
),
|
|
315
|
+
document.body
|
|
276
316
|
)
|
|
277
317
|
] });
|
|
278
318
|
}
|