@flanksource/clicky-ui 0.2.15 → 0.2.16

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.
Files changed (84) hide show
  1. package/dist/components/Combobox.cjs +15 -7
  2. package/dist/components/Combobox.cjs.map +1 -1
  3. package/dist/components/Combobox.d.ts +8 -0
  4. package/dist/components/Combobox.d.ts.map +1 -1
  5. package/dist/components/Combobox.js +15 -7
  6. package/dist/components/Combobox.js.map +1 -1
  7. package/dist/components/DateTimePicker.cjs +3 -0
  8. package/dist/components/DateTimePicker.cjs.map +1 -1
  9. package/dist/components/DateTimePicker.d.ts +6 -2
  10. package/dist/components/DateTimePicker.d.ts.map +1 -1
  11. package/dist/components/DateTimePicker.js +3 -0
  12. package/dist/components/DateTimePicker.js.map +1 -1
  13. package/dist/components/Field.cjs +33 -0
  14. package/dist/components/Field.cjs.map +1 -0
  15. package/dist/components/Field.d.ts +27 -0
  16. package/dist/components/Field.d.ts.map +1 -0
  17. package/dist/components/Field.js +33 -0
  18. package/dist/components/Field.js.map +1 -0
  19. package/dist/components/FilterBar.cjs +41 -14
  20. package/dist/components/FilterBar.cjs.map +1 -1
  21. package/dist/components/FilterBar.d.ts +2 -0
  22. package/dist/components/FilterBar.d.ts.map +1 -1
  23. package/dist/components/FilterBar.js +43 -16
  24. package/dist/components/FilterBar.js.map +1 -1
  25. package/dist/components/MultiSelect.cjs +2 -1
  26. package/dist/components/MultiSelect.cjs.map +1 -1
  27. package/dist/components/MultiSelect.d.ts +5 -1
  28. package/dist/components/MultiSelect.d.ts.map +1 -1
  29. package/dist/components/MultiSelect.js +2 -1
  30. package/dist/components/MultiSelect.js.map +1 -1
  31. package/dist/components/json-schema-form-fields.cjs +15 -8
  32. package/dist/components/json-schema-form-fields.cjs.map +1 -1
  33. package/dist/components/json-schema-form-fields.d.ts.map +1 -1
  34. package/dist/components/json-schema-form-fields.js +15 -8
  35. package/dist/components/json-schema-form-fields.js.map +1 -1
  36. package/dist/components/json-schema-form-types.d.ts +1 -0
  37. package/dist/components/json-schema-form-types.d.ts.map +1 -1
  38. package/dist/components/loading.cjs +4 -4
  39. package/dist/components/loading.cjs.map +1 -1
  40. package/dist/components/loading.d.ts +6 -4
  41. package/dist/components/loading.d.ts.map +1 -1
  42. package/dist/components/loading.js +4 -4
  43. package/dist/components/loading.js.map +1 -1
  44. package/dist/components/use-form.cjs +27 -0
  45. package/dist/components/use-form.cjs.map +1 -0
  46. package/dist/components/use-form.d.ts +30 -0
  47. package/dist/components/use-form.d.ts.map +1 -0
  48. package/dist/components/use-form.js +27 -0
  49. package/dist/components/use-form.js.map +1 -0
  50. package/dist/components.cjs +7 -0
  51. package/dist/components.cjs.map +1 -1
  52. package/dist/components.d.ts +4 -1
  53. package/dist/components.d.ts.map +1 -1
  54. package/dist/components.js +8 -1
  55. package/dist/components.js.map +1 -1
  56. package/dist/data/version-info.cjs +3 -3
  57. package/dist/data/version-info.js +3 -3
  58. package/dist/index.cjs +7 -0
  59. package/dist/index.cjs.map +1 -1
  60. package/dist/index.js +8 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/overlay/Modal.cjs +27 -23
  63. package/dist/overlay/Modal.cjs.map +1 -1
  64. package/dist/overlay/Modal.d.ts +7 -1
  65. package/dist/overlay/Modal.d.ts.map +1 -1
  66. package/dist/overlay/Modal.js +27 -23
  67. package/dist/overlay/Modal.js.map +1 -1
  68. package/dist/rpc/FilterForm.cjs +4 -4
  69. package/dist/rpc/FilterForm.cjs.map +1 -1
  70. package/dist/rpc/FilterForm.js +4 -4
  71. package/dist/rpc/FilterForm.js.map +1 -1
  72. package/dist/rpc/OperationCatalog.cjs +17 -21
  73. package/dist/rpc/OperationCatalog.cjs.map +1 -1
  74. package/dist/rpc/OperationCatalog.d.ts +3 -3
  75. package/dist/rpc/OperationCatalog.d.ts.map +1 -1
  76. package/dist/rpc/OperationCatalog.js +18 -22
  77. package/dist/rpc/OperationCatalog.js.map +1 -1
  78. package/dist/rpc/formMetadata.cjs +5 -3
  79. package/dist/rpc/formMetadata.cjs.map +1 -1
  80. package/dist/rpc/formMetadata.d.ts.map +1 -1
  81. package/dist/rpc/formMetadata.js +5 -3
  82. package/dist/rpc/formMetadata.js.map +1 -1
  83. package/dist/styles.css +1 -1
  84. package/package.json +1 -1
@@ -15,6 +15,7 @@ function Combobox(props) {
15
15
  options,
16
16
  placeholder,
17
17
  label,
18
+ ariaLabel: ariaLabelProp,
18
19
  disabled,
19
20
  required,
20
21
  invalid,
@@ -25,7 +26,8 @@ function Combobox(props) {
25
26
  loading,
26
27
  onSearch,
27
28
  onKeyDown: onKeyDownProp,
28
- suffix
29
+ suffix,
30
+ prefix
29
31
  } = props;
30
32
  const multiple = props.multiple === true;
31
33
  const selectedValues = react.useMemo(
@@ -71,15 +73,19 @@ function Combobox(props) {
71
73
  }, [filtered.length]);
72
74
  react.useEffect(() => {
73
75
  if (!open) return;
74
- const onPointerDown = (e) => {
76
+ const onAway = (e) => {
75
77
  var _a, _b;
76
78
  const target = e.target;
77
79
  if (!((_a = rootRef.current) == null ? void 0 : _a.contains(target)) && !((_b = listRef.current) == null ? void 0 : _b.contains(target))) {
78
80
  commitAndClose();
79
81
  }
80
82
  };
81
- document.addEventListener("mousedown", onPointerDown);
82
- return () => document.removeEventListener("mousedown", onPointerDown);
83
+ document.addEventListener("mousedown", onAway);
84
+ document.addEventListener("focusin", onAway);
85
+ return () => {
86
+ document.removeEventListener("mousedown", onAway);
87
+ document.removeEventListener("focusin", onAway);
88
+ };
83
89
  });
84
90
  react.useLayoutEffect(() => {
85
91
  if (!open) {
@@ -188,7 +194,7 @@ function Combobox(props) {
188
194
  }
189
195
  }
190
196
  const listId = id ? `${id}-listbox` : void 0;
191
- const ariaLabel = typeof label === "string" ? label : void 0;
197
+ const ariaLabel = ariaLabelProp ?? (typeof label === "string" ? label : void 0);
192
198
  const showClear = !required && !loading && !disabled && selectedValues.length > 0;
193
199
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: rootRef, className: utils.cn("relative", className), children: [
194
200
  /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: anchorRef, "data-jsf-control": true, className: "relative flex items-center", children: [
@@ -218,6 +224,7 @@ function Combobox(props) {
218
224
  className: utils.cn(
219
225
  "w-full rounded-md border border-input bg-background text-foreground",
220
226
  size ? jsonSchemaFormSize.inputSizeClass[size] : "h-control-h px-control-px text-sm",
227
+ prefix && "pl-8",
221
228
  suffix ? showClear ? "pr-[5.5rem]" : "pr-14" : showClear ? "pr-14" : "pr-8",
222
229
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
223
230
  "disabled:cursor-not-allowed disabled:opacity-50",
@@ -226,6 +233,7 @@ function Combobox(props) {
226
233
  style: label != null ? labelPadding(label) : void 0
227
234
  }
228
235
  ),
236
+ prefix && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute inset-y-0 left-1.5 flex items-center", children: prefix }),
229
237
  suffix && /* @__PURE__ */ jsxRuntime.jsx("div", { className: utils.cn("absolute flex h-full items-center", showClear ? "right-[3.75rem]" : "right-7"), children: suffix }),
230
238
  showClear && /* @__PURE__ */ jsxRuntime.jsx(
231
239
  "button",
@@ -338,8 +346,8 @@ function Combobox(props) {
338
346
  ] });
339
347
  }
340
348
  function labelPadding(label) {
341
- const length = typeof label === "string" ? label.length : 6;
342
- return { paddingLeft: `${Math.min(length * 0.62 + 0.75, 9)}rem` };
349
+ if (typeof label !== "string") return { paddingLeft: "1.75rem" };
350
+ return { paddingLeft: `${Math.min(label.length * 0.62 + 0.75, 9)}rem` };
343
351
  }
344
352
  function withSelectedOptions(options, selectedValues) {
345
353
  const present = new Set(options.map((o) => o.value));
@@ -1 +1 @@
1
- {"version":3,"file":"Combobox.cjs","sources":["../../src/components/Combobox.tsx"],"sourcesContent":["import {\n Fragment,\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\";\nimport { inputSizeClass, type FormSize } from \"./json-schema-form-size\";\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 * Optional section label. A non-interactive header renders above the first\n * option of each group (options are grouped by contiguous `group` value in\n * the order provided). Options without a `group` render no header.\n */\n group?: string;\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 * Marks the control as invalid: renders a destructive border and sets\n * aria-invalid. Purely presentational — the consumer decides what invalid\n * means (e.g. a value absent from the options in a strict picker).\n */\n invalid?: 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 /**\n * Overrides the input's height/padding/text size with an explicit size token\n * (xs–xl). When unset, the input uses the global density tokens.\n */\n size?: FormSize;\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 /** Trailing in-field adornment, rendered left of the clear/chevron controls. */\n suffix?: ReactNode;\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 invalid,\n allowCustomValue = true,\n id,\n size,\n className,\n loading,\n onSearch,\n onKeyDown: onKeyDownProp,\n suffix,\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 // Query option rows by role rather than child index — interleaved group\n // header rows are direct children too, so children[index] would be offset.\n const item = list.querySelectorAll<HTMLElement>('[role=\"option\"]')[index];\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} data-jsf-control 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 aria-invalid={invalid || undefined}\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 \"w-full rounded-md border border-input bg-background text-foreground\",\n size ? inputSizeClass[size] : \"h-control-h px-control-px text-sm\",\n suffix ? (showClear ? \"pr-[5.5rem]\" : \"pr-14\") : 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 invalid && \"border-destructive focus-visible:ring-destructive\",\n )}\n style={label != null ? labelPadding(label) : undefined}\n />\n {suffix && (\n <div className={cn(\"absolute flex h-full items-center\", showClear ? \"right-[3.75rem]\" : \"right-7\")}>\n {suffix}\n </div>\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 // A header renders above the first option of each group. Derived\n // from the surviving (filtered) options, so an empty group emits no\n // header. The option keeps `i` (its index in `filtered`) for\n // id/highlight/select — headers are role=\"presentation\", never\n // counted in the highlight/scroll index.\n const showHeader = opt.group != null && opt.group !== filtered[i - 1]?.group;\n return (\n <Fragment key={opt.value}>\n {showHeader && (\n <div\n role=\"presentation\"\n className=\"select-none px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground\"\n >\n {opt.group}\n </div>\n )}\n <div\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 </Fragment>\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","inputSizeClass","Icon","UiClose","UiChevronDown","createPortal","Fragment","UiCheck","LabelIcon"],"mappings":"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"}
1
+ {"version":3,"file":"Combobox.cjs","sources":["../../src/components/Combobox.tsx"],"sourcesContent":["import {\n Fragment,\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\";\nimport { inputSizeClass, type FormSize } from \"./json-schema-form-size\";\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 * Optional section label. A non-interactive header renders above the first\n * option of each group (options are grouped by contiguous `group` value in\n * the order provided). Options without a `group` render no header.\n */\n group?: string;\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 /**\n * Accessible name for the input. Use when `label` is a non-string node (e.g.\n * an icon) so the control still has a text name for screen readers; defaults\n * to `label` when it is a string.\n */\n ariaLabel?: string;\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 * Marks the control as invalid: renders a destructive border and sets\n * aria-invalid. Purely presentational — the consumer decides what invalid\n * means (e.g. a value absent from the options in a strict picker).\n */\n invalid?: 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 /**\n * Overrides the input's height/padding/text size with an explicit size token\n * (xs–xl). When unset, the input uses the global density tokens.\n */\n size?: FormSize;\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 /** Trailing in-field adornment, rendered left of the clear/chevron controls. */\n suffix?: ReactNode;\n /** Leading in-field adornment, rendered at the left edge of the input. */\n prefix?: ReactNode;\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 ariaLabel: ariaLabelProp,\n disabled,\n required,\n invalid,\n allowCustomValue = true,\n id,\n size,\n className,\n loading,\n onSearch,\n onKeyDown: onKeyDownProp,\n suffix,\n prefix,\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 // Dismiss when the pointer goes down, OR focus moves, anywhere outside the\n // control and its portaled listbox — so clicking elsewhere in a dialog (the\n // backdrop never intercepts a document-level listener) or tabbing to another\n // field both close the menu. The listbox is portaled out of rootRef, so a\n // click inside it would otherwise read as \"outside\" and close before select.\n const onAway = (e: Event) => {\n const target = e.target as Node;\n if (!rootRef.current?.contains(target) && !listRef.current?.contains(target)) {\n commitAndClose();\n }\n };\n document.addEventListener(\"mousedown\", onAway);\n document.addEventListener(\"focusin\", onAway);\n return () => {\n document.removeEventListener(\"mousedown\", onAway);\n document.removeEventListener(\"focusin\", onAway);\n };\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 // Query option rows by role rather than child index — interleaved group\n // header rows are direct children too, so children[index] would be offset.\n const item = list.querySelectorAll<HTMLElement>('[role=\"option\"]')[index];\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 = ariaLabelProp ?? (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} data-jsf-control 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 aria-invalid={invalid || undefined}\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 \"w-full rounded-md border border-input bg-background text-foreground\",\n size ? inputSizeClass[size] : \"h-control-h px-control-px text-sm\",\n prefix && \"pl-8\",\n suffix ? (showClear ? \"pr-[5.5rem]\" : \"pr-14\") : 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 invalid && \"border-destructive focus-visible:ring-destructive\",\n )}\n style={label != null ? labelPadding(label) : undefined}\n />\n {prefix && (\n <div className=\"absolute inset-y-0 left-1.5 flex items-center\">{prefix}</div>\n )}\n {suffix && (\n <div className={cn(\"absolute flex h-full items-center\", showClear ? \"right-[3.75rem]\" : \"right-7\")}>\n {suffix}\n </div>\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 // A header renders above the first option of each group. Derived\n // from the surviving (filtered) options, so an empty group emits no\n // header. The option keeps `i` (its index in `filtered`) for\n // id/highlight/select — headers are role=\"presentation\", never\n // counted in the highlight/scroll index.\n const showHeader = opt.group != null && opt.group !== filtered[i - 1]?.group;\n return (\n <Fragment key={opt.value}>\n {showHeader && (\n <div\n role=\"presentation\"\n className=\"select-none px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground\"\n >\n {opt.group}\n </div>\n )}\n <div\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 </Fragment>\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. Node labels (an icon from comboboxLabelProps) are\n// compact, so reserve a fixed icon-sized inset; text labels scale with their\n// character count at roughly 0.6rem per character plus padding.\nfunction labelPadding(label: ReactNode) {\n if (typeof label !== \"string\") return { paddingLeft: \"1.75rem\" };\n return { paddingLeft: `${Math.min(label.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","inputSizeClass","Icon","UiClose","UiChevronDown","createPortal","Fragment","UiCheck","LabelIcon"],"mappings":"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"}
@@ -21,6 +21,12 @@ type ComboboxBaseProps = {
21
21
  placeholder?: string;
22
22
  /** Optional label rendered inline inside the control, before the input. */
23
23
  label?: ReactNode;
24
+ /**
25
+ * Accessible name for the input. Use when `label` is a non-string node (e.g.
26
+ * an icon) so the control still has a text name for screen readers; defaults
27
+ * to `label` when it is a string.
28
+ */
29
+ ariaLabel?: string;
24
30
  /** Disables the input. */
25
31
  disabled?: boolean;
26
32
  /** When true, the value cannot be cleared (the clear button is hidden). */
@@ -61,6 +67,8 @@ type ComboboxBaseProps = {
61
67
  onKeyDown?: (e: KeyboardEvent<HTMLInputElement>) => void;
62
68
  /** Trailing in-field adornment, rendered left of the clear/chevron controls. */
63
69
  suffix?: ReactNode;
70
+ /** Leading in-field adornment, rendered at the left edge of the input. */
71
+ prefix?: ReactNode;
64
72
  };
65
73
  export type ComboboxSingleProps = ComboboxBaseProps & {
66
74
  multiple?: false;
@@ -1 +1 @@
1
- {"version":3,"file":"Combobox.d.ts","sourceRoot":"","sources":["../../src/components/Combobox.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,aAAa,EAClB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAGf,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAEnE,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAMxE,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;IACrB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,qCAAqC;IACrC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,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;IACzD,gFAAgF;IAChF,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,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,2CAsY5C"}
1
+ {"version":3,"file":"Combobox.d.ts","sourceRoot":"","sources":["../../src/components/Combobox.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,aAAa,EAClB,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAGf,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAEnE,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAMxE,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;IACrB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,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;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0BAA0B;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,qCAAqC;IACrC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;OAGG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,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;IACzD,gFAAgF;IAChF,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,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,2CAmZ5C"}
@@ -13,6 +13,7 @@ function Combobox(props) {
13
13
  options,
14
14
  placeholder,
15
15
  label,
16
+ ariaLabel: ariaLabelProp,
16
17
  disabled,
17
18
  required,
18
19
  invalid,
@@ -23,7 +24,8 @@ function Combobox(props) {
23
24
  loading,
24
25
  onSearch,
25
26
  onKeyDown: onKeyDownProp,
26
- suffix
27
+ suffix,
28
+ prefix
27
29
  } = props;
28
30
  const multiple = props.multiple === true;
29
31
  const selectedValues = useMemo(
@@ -69,15 +71,19 @@ function Combobox(props) {
69
71
  }, [filtered.length]);
70
72
  useEffect(() => {
71
73
  if (!open) return;
72
- const onPointerDown = (e) => {
74
+ const onAway = (e) => {
73
75
  var _a, _b;
74
76
  const target = e.target;
75
77
  if (!((_a = rootRef.current) == null ? void 0 : _a.contains(target)) && !((_b = listRef.current) == null ? void 0 : _b.contains(target))) {
76
78
  commitAndClose();
77
79
  }
78
80
  };
79
- document.addEventListener("mousedown", onPointerDown);
80
- return () => document.removeEventListener("mousedown", onPointerDown);
81
+ document.addEventListener("mousedown", onAway);
82
+ document.addEventListener("focusin", onAway);
83
+ return () => {
84
+ document.removeEventListener("mousedown", onAway);
85
+ document.removeEventListener("focusin", onAway);
86
+ };
81
87
  });
82
88
  useLayoutEffect(() => {
83
89
  if (!open) {
@@ -186,7 +192,7 @@ function Combobox(props) {
186
192
  }
187
193
  }
188
194
  const listId = id ? `${id}-listbox` : void 0;
189
- const ariaLabel = typeof label === "string" ? label : void 0;
195
+ const ariaLabel = ariaLabelProp ?? (typeof label === "string" ? label : void 0);
190
196
  const showClear = !required && !loading && !disabled && selectedValues.length > 0;
191
197
  return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("relative", className), children: [
192
198
  /* @__PURE__ */ jsxs("div", { ref: anchorRef, "data-jsf-control": true, className: "relative flex items-center", children: [
@@ -216,6 +222,7 @@ function Combobox(props) {
216
222
  className: cn(
217
223
  "w-full rounded-md border border-input bg-background text-foreground",
218
224
  size ? inputSizeClass[size] : "h-control-h px-control-px text-sm",
225
+ prefix && "pl-8",
219
226
  suffix ? showClear ? "pr-[5.5rem]" : "pr-14" : showClear ? "pr-14" : "pr-8",
220
227
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
221
228
  "disabled:cursor-not-allowed disabled:opacity-50",
@@ -224,6 +231,7 @@ function Combobox(props) {
224
231
  style: label != null ? labelPadding(label) : void 0
225
232
  }
226
233
  ),
234
+ prefix && /* @__PURE__ */ jsx("div", { className: "absolute inset-y-0 left-1.5 flex items-center", children: prefix }),
227
235
  suffix && /* @__PURE__ */ jsx("div", { className: cn("absolute flex h-full items-center", showClear ? "right-[3.75rem]" : "right-7"), children: suffix }),
228
236
  showClear && /* @__PURE__ */ jsx(
229
237
  "button",
@@ -336,8 +344,8 @@ function Combobox(props) {
336
344
  ] });
337
345
  }
338
346
  function labelPadding(label) {
339
- const length = typeof label === "string" ? label.length : 6;
340
- return { paddingLeft: `${Math.min(length * 0.62 + 0.75, 9)}rem` };
347
+ if (typeof label !== "string") return { paddingLeft: "1.75rem" };
348
+ return { paddingLeft: `${Math.min(label.length * 0.62 + 0.75, 9)}rem` };
341
349
  }
342
350
  function withSelectedOptions(options, selectedValues) {
343
351
  const present = new Set(options.map((o) => o.value));
@@ -1 +1 @@
1
- {"version":3,"file":"Combobox.js","sources":["../../src/components/Combobox.tsx"],"sourcesContent":["import {\n Fragment,\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\";\nimport { inputSizeClass, type FormSize } from \"./json-schema-form-size\";\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 * Optional section label. A non-interactive header renders above the first\n * option of each group (options are grouped by contiguous `group` value in\n * the order provided). Options without a `group` render no header.\n */\n group?: string;\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 * Marks the control as invalid: renders a destructive border and sets\n * aria-invalid. Purely presentational — the consumer decides what invalid\n * means (e.g. a value absent from the options in a strict picker).\n */\n invalid?: 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 /**\n * Overrides the input's height/padding/text size with an explicit size token\n * (xs–xl). When unset, the input uses the global density tokens.\n */\n size?: FormSize;\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 /** Trailing in-field adornment, rendered left of the clear/chevron controls. */\n suffix?: ReactNode;\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 invalid,\n allowCustomValue = true,\n id,\n size,\n className,\n loading,\n onSearch,\n onKeyDown: onKeyDownProp,\n suffix,\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 // Query option rows by role rather than child index — interleaved group\n // header rows are direct children too, so children[index] would be offset.\n const item = list.querySelectorAll<HTMLElement>('[role=\"option\"]')[index];\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} data-jsf-control 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 aria-invalid={invalid || undefined}\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 \"w-full rounded-md border border-input bg-background text-foreground\",\n size ? inputSizeClass[size] : \"h-control-h px-control-px text-sm\",\n suffix ? (showClear ? \"pr-[5.5rem]\" : \"pr-14\") : 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 invalid && \"border-destructive focus-visible:ring-destructive\",\n )}\n style={label != null ? labelPadding(label) : undefined}\n />\n {suffix && (\n <div className={cn(\"absolute flex h-full items-center\", showClear ? \"right-[3.75rem]\" : \"right-7\")}>\n {suffix}\n </div>\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 // A header renders above the first option of each group. Derived\n // from the surviving (filtered) options, so an empty group emits no\n // header. The option keeps `i` (its index in `filtered`) for\n // id/highlight/select — headers are role=\"presentation\", never\n // counted in the highlight/scroll index.\n const showHeader = opt.group != null && opt.group !== filtered[i - 1]?.group;\n return (\n <Fragment key={opt.value}>\n {showHeader && (\n <div\n role=\"presentation\"\n className=\"select-none px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground\"\n >\n {opt.group}\n </div>\n )}\n <div\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 </Fragment>\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":[],"mappings":"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"}
1
+ {"version":3,"file":"Combobox.js","sources":["../../src/components/Combobox.tsx"],"sourcesContent":["import {\n Fragment,\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\";\nimport { inputSizeClass, type FormSize } from \"./json-schema-form-size\";\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 * Optional section label. A non-interactive header renders above the first\n * option of each group (options are grouped by contiguous `group` value in\n * the order provided). Options without a `group` render no header.\n */\n group?: string;\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 /**\n * Accessible name for the input. Use when `label` is a non-string node (e.g.\n * an icon) so the control still has a text name for screen readers; defaults\n * to `label` when it is a string.\n */\n ariaLabel?: string;\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 * Marks the control as invalid: renders a destructive border and sets\n * aria-invalid. Purely presentational — the consumer decides what invalid\n * means (e.g. a value absent from the options in a strict picker).\n */\n invalid?: 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 /**\n * Overrides the input's height/padding/text size with an explicit size token\n * (xs–xl). When unset, the input uses the global density tokens.\n */\n size?: FormSize;\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 /** Trailing in-field adornment, rendered left of the clear/chevron controls. */\n suffix?: ReactNode;\n /** Leading in-field adornment, rendered at the left edge of the input. */\n prefix?: ReactNode;\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 ariaLabel: ariaLabelProp,\n disabled,\n required,\n invalid,\n allowCustomValue = true,\n id,\n size,\n className,\n loading,\n onSearch,\n onKeyDown: onKeyDownProp,\n suffix,\n prefix,\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 // Dismiss when the pointer goes down, OR focus moves, anywhere outside the\n // control and its portaled listbox — so clicking elsewhere in a dialog (the\n // backdrop never intercepts a document-level listener) or tabbing to another\n // field both close the menu. The listbox is portaled out of rootRef, so a\n // click inside it would otherwise read as \"outside\" and close before select.\n const onAway = (e: Event) => {\n const target = e.target as Node;\n if (!rootRef.current?.contains(target) && !listRef.current?.contains(target)) {\n commitAndClose();\n }\n };\n document.addEventListener(\"mousedown\", onAway);\n document.addEventListener(\"focusin\", onAway);\n return () => {\n document.removeEventListener(\"mousedown\", onAway);\n document.removeEventListener(\"focusin\", onAway);\n };\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 // Query option rows by role rather than child index — interleaved group\n // header rows are direct children too, so children[index] would be offset.\n const item = list.querySelectorAll<HTMLElement>('[role=\"option\"]')[index];\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 = ariaLabelProp ?? (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} data-jsf-control 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 aria-invalid={invalid || undefined}\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 \"w-full rounded-md border border-input bg-background text-foreground\",\n size ? inputSizeClass[size] : \"h-control-h px-control-px text-sm\",\n prefix && \"pl-8\",\n suffix ? (showClear ? \"pr-[5.5rem]\" : \"pr-14\") : 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 invalid && \"border-destructive focus-visible:ring-destructive\",\n )}\n style={label != null ? labelPadding(label) : undefined}\n />\n {prefix && (\n <div className=\"absolute inset-y-0 left-1.5 flex items-center\">{prefix}</div>\n )}\n {suffix && (\n <div className={cn(\"absolute flex h-full items-center\", showClear ? \"right-[3.75rem]\" : \"right-7\")}>\n {suffix}\n </div>\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 // A header renders above the first option of each group. Derived\n // from the surviving (filtered) options, so an empty group emits no\n // header. The option keeps `i` (its index in `filtered`) for\n // id/highlight/select — headers are role=\"presentation\", never\n // counted in the highlight/scroll index.\n const showHeader = opt.group != null && opt.group !== filtered[i - 1]?.group;\n return (\n <Fragment key={opt.value}>\n {showHeader && (\n <div\n role=\"presentation\"\n className=\"select-none px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground\"\n >\n {opt.group}\n </div>\n )}\n <div\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 </Fragment>\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. Node labels (an icon from comboboxLabelProps) are\n// compact, so reserve a fixed icon-sized inset; text labels scale with their\n// character count at roughly 0.6rem per character plus padding.\nfunction labelPadding(label: ReactNode) {\n if (typeof label !== \"string\") return { paddingLeft: \"1.75rem\" };\n return { paddingLeft: `${Math.min(label.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":[],"mappings":"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"}
@@ -14,6 +14,7 @@ const DateTimePicker = react.forwardRef(
14
14
  buttonClassName,
15
15
  openButtonLabel = "Open time picker",
16
16
  suffix,
17
+ prefix,
17
18
  ...props
18
19
  }, ref) => {
19
20
  const visibleRef = react.useRef(null);
@@ -27,6 +28,7 @@ const DateTimePicker = react.forwardRef(
27
28
  }
28
29
  }
29
30
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { "data-jsf-control": true, className: utils.cn("relative", className), children: [
31
+ prefix && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "absolute inset-y-0 left-1.5 flex items-center", children: prefix }),
30
32
  /* @__PURE__ */ jsxRuntime.jsx(
31
33
  "input",
32
34
  {
@@ -36,6 +38,7 @@ const DateTimePicker = react.forwardRef(
36
38
  value,
37
39
  className: utils.cn(
38
40
  "h-8 w-full rounded-md border border-input bg-background px-2 pr-8 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
41
+ prefix && "pl-8",
39
42
  inputClassName
40
43
  ),
41
44
  onChange: (event) => onChange == null ? void 0 : onChange(event.target.value)
@@ -1 +1 @@
1
- {"version":3,"file":"DateTimePicker.cjs","sources":["../../src/components/DateTimePicker.tsx"],"sourcesContent":["import { forwardRef, useRef, type InputHTMLAttributes, type ReactNode } from \"react\";\nimport { Icon } from \"../data/Icon\";\nimport { UiCalendar } from \"../icons\";\nimport { cn } from \"../lib/utils\";\n\nexport type DateTimePickerProps = Omit<\n InputHTMLAttributes<HTMLInputElement>,\n \"type\" | \"value\" | \"onChange\"\n> & {\n value?: string;\n onChange?: (value: string) => void;\n className?: string;\n inputClassName?: string;\n buttonClassName?: string;\n openButtonLabel?: string;\n /** Trailing in-field adornment, rendered left of the calendar button. */\n suffix?: ReactNode;\n};\n\nexport const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>(\n (\n {\n value = \"\",\n onChange,\n className,\n inputClassName,\n buttonClassName,\n openButtonLabel = \"Open time picker\",\n suffix,\n ...props\n },\n ref,\n ) => {\n const visibleRef = useRef<HTMLInputElement | null>(null);\n const pickerRef = useRef<HTMLInputElement | null>(null);\n\n function assignRef(node: HTMLInputElement | null) {\n visibleRef.current = node;\n if (typeof ref === \"function\") {\n ref(node);\n } else if (ref) {\n ref.current = node;\n }\n }\n\n return (\n <div data-jsf-control className={cn(\"relative\", className)}>\n <input\n {...props}\n ref={assignRef}\n type=\"text\"\n value={value}\n className={cn(\n \"h-8 w-full rounded-md border border-input bg-background px-2 pr-8 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n inputClassName,\n )}\n onChange={(event) => onChange?.(event.target.value)}\n />\n <input\n ref={pickerRef}\n type=\"datetime-local\"\n tabIndex={-1}\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute left-0 top-0 h-0 w-0 opacity-0\"\n value={toDateTimeLocalValue(value)}\n onChange={(event) => onChange?.(event.target.value)}\n />\n {suffix && (\n <div className=\"absolute inset-y-0 right-7 flex items-center\">{suffix}</div>\n )}\n <button\n type=\"button\"\n aria-label={openButtonLabel}\n disabled={props.disabled}\n className={cn(\n \"absolute inset-y-0 right-1 inline-flex items-center text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n buttonClassName,\n )}\n onClick={() => {\n visibleRef.current?.focus();\n pickerRef.current?.showPicker?.();\n }}\n >\n <Icon icon={UiCalendar} className=\"text-sm\" />\n </button>\n </div>\n );\n },\n);\n\nDateTimePicker.displayName = \"DateTimePicker\";\n\nfunction toDateTimeLocalValue(value: string) {\n const trimmed = value.trim();\n if (!trimmed) return \"\";\n if (trimmed === \"now\" || trimmed.startsWith(\"now\")) return \"\";\n\n const withTime = /^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed) ? `${trimmed}T00:00` : trimmed;\n\n return /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}/.test(withTime) ? withTime.slice(0, 16) : \"\";\n}\n"],"names":["forwardRef","useRef","jsxs","cn","jsx","Icon","UiCalendar"],"mappings":";;;;;;;AAmBO,MAAM,iBAAiBA,MAAAA;AAAAA,EAC5B,CACE;AAAA,IACE,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,kBAAkB;AAAA,IAClB;AAAA,IACA,GAAG;AAAA,EAAA,GAEL,QACG;AACH,UAAM,aAAaC,MAAAA,OAAgC,IAAI;AACvD,UAAM,YAAYA,MAAAA,OAAgC,IAAI;AAEtD,aAAS,UAAU,MAA+B;AAChD,iBAAW,UAAU;AACrB,UAAI,OAAO,QAAQ,YAAY;AAC7B,YAAI,IAAI;AAAA,MACV,WAAW,KAAK;AACd,YAAI,UAAU;AAAA,MAChB;AAAA,IACF;AAEA,WACEC,gCAAC,SAAI,oBAAgB,MAAC,WAAWC,MAAAA,GAAG,YAAY,SAAS,GACvD,UAAA;AAAA,MAAAC,2BAAAA;AAAAA,QAAC;AAAA,QAAA;AAAA,UACE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAK;AAAA,UACL;AAAA,UACA,WAAWD,MAAAA;AAAAA,YACT;AAAA,YACA;AAAA,UAAA;AAAA,UAEF,UAAU,CAAC,UAAU,qCAAW,MAAM,OAAO;AAAA,QAAK;AAAA,MAAA;AAAA,MAEpDC,2BAAAA;AAAAA,QAAC;AAAA,QAAA;AAAA,UACC,KAAK;AAAA,UACL,MAAK;AAAA,UACL,UAAU;AAAA,UACV,eAAY;AAAA,UACZ,WAAU;AAAA,UACV,OAAO,qBAAqB,KAAK;AAAA,UACjC,UAAU,CAAC,UAAU,qCAAW,MAAM,OAAO;AAAA,QAAK;AAAA,MAAA;AAAA,MAEnD,UACCA,2BAAAA,IAAC,OAAA,EAAI,WAAU,gDAAgD,UAAA,QAAO;AAAA,MAExEA,2BAAAA;AAAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAK;AAAA,UACL,cAAY;AAAA,UACZ,UAAU,MAAM;AAAA,UAChB,WAAWD,MAAAA;AAAAA,YACT;AAAA,YACA;AAAA,UAAA;AAAA,UAEF,SAAS,MAAM;;AACb,6BAAW,YAAX,mBAAoB;AACpB,kCAAU,YAAV,mBAAmB,eAAnB;AAAA,UACF;AAAA,UAEA,UAAAC,2BAAAA,IAACC,WAAA,EAAK,MAAMC,WAAAA,YAAY,WAAU,UAAA,CAAU;AAAA,QAAA;AAAA,MAAA;AAAA,IAC9C,GACF;AAAA,EAEJ;AACF;AAEA,eAAe,cAAc;AAE7B,SAAS,qBAAqB,OAAe;AAC3C,QAAM,UAAU,MAAM,KAAA;AACtB,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,YAAY,SAAS,QAAQ,WAAW,KAAK,EAAG,QAAO;AAE3D,QAAM,WAAW,sBAAsB,KAAK,OAAO,IAAI,GAAG,OAAO,WAAW;AAE5E,SAAO,iCAAiC,KAAK,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,IAAI;AACnF;;"}
1
+ {"version":3,"file":"DateTimePicker.cjs","sources":["../../src/components/DateTimePicker.tsx"],"sourcesContent":["import { forwardRef, useRef, type InputHTMLAttributes, type ReactNode } from \"react\";\nimport { Icon } from \"../data/Icon\";\nimport { UiCalendar } from \"../icons\";\nimport { cn } from \"../lib/utils\";\n\nexport type DateTimePickerProps = Omit<\n InputHTMLAttributes<HTMLInputElement>,\n // `prefix` is a global HTML attribute typed as string; omit it so our\n // ReactNode adornment prop below is not intersected down to `string & ReactNode`.\n \"type\" | \"value\" | \"onChange\" | \"prefix\"\n> & {\n value?: string;\n onChange?: (value: string) => void;\n className?: string;\n inputClassName?: string;\n buttonClassName?: string;\n openButtonLabel?: string;\n /** Trailing in-field adornment, rendered left of the calendar button. */\n suffix?: ReactNode;\n /** Leading in-field adornment, rendered at the left edge of the input. */\n prefix?: ReactNode;\n};\n\nexport const DateTimePicker = forwardRef<HTMLInputElement, DateTimePickerProps>(\n (\n {\n value = \"\",\n onChange,\n className,\n inputClassName,\n buttonClassName,\n openButtonLabel = \"Open time picker\",\n suffix,\n prefix,\n ...props\n },\n ref,\n ) => {\n const visibleRef = useRef<HTMLInputElement | null>(null);\n const pickerRef = useRef<HTMLInputElement | null>(null);\n\n function assignRef(node: HTMLInputElement | null) {\n visibleRef.current = node;\n if (typeof ref === \"function\") {\n ref(node);\n } else if (ref) {\n ref.current = node;\n }\n }\n\n return (\n <div data-jsf-control className={cn(\"relative\", className)}>\n {prefix && <div className=\"absolute inset-y-0 left-1.5 flex items-center\">{prefix}</div>}\n <input\n {...props}\n ref={assignRef}\n type=\"text\"\n value={value}\n className={cn(\n \"h-8 w-full rounded-md border border-input bg-background px-2 pr-8 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n prefix && \"pl-8\",\n inputClassName,\n )}\n onChange={(event) => onChange?.(event.target.value)}\n />\n <input\n ref={pickerRef}\n type=\"datetime-local\"\n tabIndex={-1}\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute left-0 top-0 h-0 w-0 opacity-0\"\n value={toDateTimeLocalValue(value)}\n onChange={(event) => onChange?.(event.target.value)}\n />\n {suffix && (\n <div className=\"absolute inset-y-0 right-7 flex items-center\">{suffix}</div>\n )}\n <button\n type=\"button\"\n aria-label={openButtonLabel}\n disabled={props.disabled}\n className={cn(\n \"absolute inset-y-0 right-1 inline-flex items-center text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50\",\n buttonClassName,\n )}\n onClick={() => {\n visibleRef.current?.focus();\n pickerRef.current?.showPicker?.();\n }}\n >\n <Icon icon={UiCalendar} className=\"text-sm\" />\n </button>\n </div>\n );\n },\n);\n\nDateTimePicker.displayName = \"DateTimePicker\";\n\nfunction toDateTimeLocalValue(value: string) {\n const trimmed = value.trim();\n if (!trimmed) return \"\";\n if (trimmed === \"now\" || trimmed.startsWith(\"now\")) return \"\";\n\n const withTime = /^\\d{4}-\\d{2}-\\d{2}$/.test(trimmed) ? `${trimmed}T00:00` : trimmed;\n\n return /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}/.test(withTime) ? withTime.slice(0, 16) : \"\";\n}\n"],"names":["forwardRef","useRef","jsxs","cn","jsx","Icon","UiCalendar"],"mappings":";;;;;;;AAuBO,MAAM,iBAAiBA,MAAAA;AAAAA,EAC5B,CACE;AAAA,IACE,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,kBAAkB;AAAA,IAClB;AAAA,IACA;AAAA,IACA,GAAG;AAAA,EAAA,GAEL,QACG;AACH,UAAM,aAAaC,MAAAA,OAAgC,IAAI;AACvD,UAAM,YAAYA,MAAAA,OAAgC,IAAI;AAEtD,aAAS,UAAU,MAA+B;AAChD,iBAAW,UAAU;AACrB,UAAI,OAAO,QAAQ,YAAY;AAC7B,YAAI,IAAI;AAAA,MACV,WAAW,KAAK;AACd,YAAI,UAAU;AAAA,MAChB;AAAA,IACF;AAEA,WACEC,gCAAC,SAAI,oBAAgB,MAAC,WAAWC,MAAAA,GAAG,YAAY,SAAS,GACtD,UAAA;AAAA,MAAA,UAAUC,2BAAAA,IAAC,OAAA,EAAI,WAAU,iDAAiD,UAAA,QAAO;AAAA,MAClFA,2BAAAA;AAAAA,QAAC;AAAA,QAAA;AAAA,UACE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAK;AAAA,UACL;AAAA,UACA,WAAWD,MAAAA;AAAAA,YACT;AAAA,YACA,UAAU;AAAA,YACV;AAAA,UAAA;AAAA,UAEF,UAAU,CAAC,UAAU,qCAAW,MAAM,OAAO;AAAA,QAAK;AAAA,MAAA;AAAA,MAEpDC,2BAAAA;AAAAA,QAAC;AAAA,QAAA;AAAA,UACC,KAAK;AAAA,UACL,MAAK;AAAA,UACL,UAAU;AAAA,UACV,eAAY;AAAA,UACZ,WAAU;AAAA,UACV,OAAO,qBAAqB,KAAK;AAAA,UACjC,UAAU,CAAC,UAAU,qCAAW,MAAM,OAAO;AAAA,QAAK;AAAA,MAAA;AAAA,MAEnD,UACCA,2BAAAA,IAAC,OAAA,EAAI,WAAU,gDAAgD,UAAA,QAAO;AAAA,MAExEA,2BAAAA;AAAAA,QAAC;AAAA,QAAA;AAAA,UACC,MAAK;AAAA,UACL,cAAY;AAAA,UACZ,UAAU,MAAM;AAAA,UAChB,WAAWD,MAAAA;AAAAA,YACT;AAAA,YACA;AAAA,UAAA;AAAA,UAEF,SAAS,MAAM;;AACb,6BAAW,YAAX,mBAAoB;AACpB,kCAAU,YAAV,mBAAmB,eAAnB;AAAA,UACF;AAAA,UAEA,UAAAC,2BAAAA,IAACC,WAAA,EAAK,MAAMC,WAAAA,YAAY,WAAU,UAAA,CAAU;AAAA,QAAA;AAAA,MAAA;AAAA,IAC9C,GACF;AAAA,EAEJ;AACF;AAEA,eAAe,cAAc;AAE7B,SAAS,qBAAqB,OAAe;AAC3C,QAAM,UAAU,MAAM,KAAA;AACtB,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI,YAAY,SAAS,QAAQ,WAAW,KAAK,EAAG,QAAO;AAE3D,QAAM,WAAW,sBAAsB,KAAK,OAAO,IAAI,GAAG,OAAO,WAAW;AAE5E,SAAO,iCAAiC,KAAK,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,IAAI;AACnF;;"}
@@ -1,5 +1,5 @@
1
1
  import { InputHTMLAttributes, ReactNode } from 'react';
2
- export type DateTimePickerProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & {
2
+ export type DateTimePickerProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "prefix"> & {
3
3
  value?: string;
4
4
  onChange?: (value: string) => void;
5
5
  className?: string;
@@ -8,8 +8,10 @@ export type DateTimePickerProps = Omit<InputHTMLAttributes<HTMLInputElement>, "t
8
8
  openButtonLabel?: string;
9
9
  /** Trailing in-field adornment, rendered left of the calendar button. */
10
10
  suffix?: ReactNode;
11
+ /** Leading in-field adornment, rendered at the left edge of the input. */
12
+ prefix?: ReactNode;
11
13
  };
12
- export declare const DateTimePicker: import('react').ForwardRefExoticComponent<Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "type" | "onChange"> & {
14
+ export declare const DateTimePicker: import('react').ForwardRefExoticComponent<Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "type" | "onChange" | "prefix"> & {
13
15
  value?: string;
14
16
  onChange?: (value: string) => void;
15
17
  className?: string;
@@ -18,5 +20,7 @@ export declare const DateTimePicker: import('react').ForwardRefExoticComponent<O
18
20
  openButtonLabel?: string;
19
21
  /** Trailing in-field adornment, rendered left of the calendar button. */
20
22
  suffix?: ReactNode;
23
+ /** Leading in-field adornment, rendered at the left edge of the input. */
24
+ prefix?: ReactNode;
21
25
  } & import('react').RefAttributes<HTMLInputElement>>;
22
26
  //# sourceMappingURL=DateTimePicker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"DateTimePicker.d.ts","sourceRoot":"","sources":["../../src/components/DateTimePicker.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,mBAAmB,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAKrF,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,mBAAmB,CAAC,gBAAgB,CAAC,EACrC,MAAM,GAAG,OAAO,GAAG,UAAU,CAC9B,GAAG;IACF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,CAAC;AAEF,eAAO,MAAM,cAAc;YAVjB,MAAM;eACH,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI;gBACtB,MAAM;qBACD,MAAM;sBACL,MAAM;sBACN,MAAM;IACxB,yEAAyE;aAChE,SAAS;oDAwEnB,CAAC"}
1
+ {"version":3,"file":"DateTimePicker.d.ts","sourceRoot":"","sources":["../../src/components/DateTimePicker.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,mBAAmB,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAKrF,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,mBAAmB,CAAC,gBAAgB,CAAC,EAGrC,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CACzC,GAAG;IACF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,SAAS,CAAC;CACpB,CAAC;AAEF,eAAO,MAAM,cAAc;YAZjB,MAAM;eACH,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI;gBACtB,MAAM;qBACD,MAAM;sBACL,MAAM;sBACN,MAAM;IACxB,yEAAyE;aAChE,SAAS;IAClB,0EAA0E;aACjE,SAAS;oDA2EnB,CAAC"}
@@ -12,6 +12,7 @@ const DateTimePicker = forwardRef(
12
12
  buttonClassName,
13
13
  openButtonLabel = "Open time picker",
14
14
  suffix,
15
+ prefix,
15
16
  ...props
16
17
  }, ref) => {
17
18
  const visibleRef = useRef(null);
@@ -25,6 +26,7 @@ const DateTimePicker = forwardRef(
25
26
  }
26
27
  }
27
28
  return /* @__PURE__ */ jsxs("div", { "data-jsf-control": true, className: cn("relative", className), children: [
29
+ prefix && /* @__PURE__ */ jsx("div", { className: "absolute inset-y-0 left-1.5 flex items-center", children: prefix }),
28
30
  /* @__PURE__ */ jsx(
29
31
  "input",
30
32
  {
@@ -34,6 +36,7 @@ const DateTimePicker = forwardRef(
34
36
  value,
35
37
  className: cn(
36
38
  "h-8 w-full rounded-md border border-input bg-background px-2 pr-8 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
39
+ prefix && "pl-8",
37
40
  inputClassName
38
41
  ),
39
42
  onChange: (event) => onChange == null ? void 0 : onChange(event.target.value)