@viliha/vui-ui 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT.md CHANGED
@@ -336,6 +336,8 @@ Never hand-build an HTML table; always use `RecordView`. Configure columns throu
336
336
 
337
337
  Sorting follows column visibility by default; use `sortable` to decouple them — `sortable: true` sorts a field with no column (e.g. a `hideInTable` name shown via `getPrimary`), `sortable: false` locks a visible column. Set the flag; don't rewire the Sort dropdown or headers.
338
338
 
339
+ Form controls come from the field: `options` → `Select`, `input: "combobox"` → a searchable `Combobox` (`@viliha/vui-ui/combobox`, use for long lists), `input: "number" | "date"` → native inputs. For anything the built-ins don't cover (checkbox, radio group, slider, custom widget), set `renderInput({ value, onChange, field, invalid })` on the field — it drops any component into the Add/Edit form while the field keeps its label, required mark, and Save validation. Don't hand-build a `<form>`.
340
+
339
341
  ## Filtering
340
342
 
341
343
  The Filter panel is a single keyword box by default (built in, matches all fields). For a labeled control per field, set `filterable` on the relevant fields and never hand-roll a filter form: `filterable: true` (text) or `filterable: { control, label, placeholder, options }` where `control` is `"text" | "number" | "date" | "select" | "combobox" | "checkbox"`. When any field is filterable the panel shows a control per field plus Search / Clear. It only **collects** values — handle `onFilter(values)` on `RecordView` to run a query or client filter (Search and Clear both call it). Types: `FilterControl`, `FieldFilter`, `FilterValues<T>`. Need a control kind that isn't listed? Extend the `FilterControl` union in the component.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ backward-compatible features, **major** for breaking changes.
7
7
 
8
8
  To upgrade, see [Upgrading](./AGENT.md#upgrading) in the agent guide.
9
9
 
10
+ ## 1.10.0 — 2026-07-26
11
+
12
+ ### Added
13
+
14
+ - **`Combobox`** (`@viliha/vui-ui/combobox`) — a searchable single-select. Same
15
+ API as `Select` (drop-in) but the popover leads with a type-to-filter input, so
16
+ it scales to long option lists (an FK / country picker). Keyboard: type to
17
+ filter, ↑/↓ to move, Enter to pick, Esc to close.
18
+ - **`RecordView` choice fields can use the Combobox.** In the Add/Edit form set
19
+ `input: "combobox"` on an `options` field for a searchable control (default
20
+ stays `Select`). In the Filter panel, `control: "combobox"` now renders the
21
+ real searchable Combobox (previously it fell back to a plain Select).
22
+ - **`RecordField.renderInput`** — an escape hatch to render **any** Add/Edit
23
+ control (checkbox, radio group, slider, date-range, a custom widget). It
24
+ overrides the default control and receives `{ value, onChange, field, invalid }`;
25
+ the field still owns the label, required mark, and Save validation. The
26
+ Organizations form demonstrates it (Status as a radio group).
27
+
28
+ **For agents:** for a long choice list use `input: "combobox"`; for a control
29
+ the built-ins don't cover use `renderInput` — never hand-build a `<form>`.
30
+ New demo: Organizations Status (radio via `renderInput`); Combobox is also in
31
+ the components gallery. Docs: `/docs/components`, `/docs/data-table`.
32
+
10
33
  ## 1.9.0 — 2026-07-26
11
34
 
12
35
  ### Added
package/README.md CHANGED
@@ -249,9 +249,10 @@ place. If you'd rather keep a verbatim copy, run
249
249
  ## Components
250
250
 
251
251
  `avatar` · `badge` · `breadcrumbs` · `button` · `card` · `chart` (themed Recharts
252
- wrapper) · `checkbox` · `command-palette` (⌘K launcher) · `dialog` ·
253
- `confirm-dialog` · `dropdown-menu` · `input` · `kbd` (key caps + `Shortcut`) ·
254
- `menu` · `required-mark` · `select` · `steps` (multi-step wizard indicator) ·
252
+ wrapper) · `checkbox` · `combobox` (searchable single-select) · `command-palette`
253
+ (⌘K launcher) · `dialog` · `confirm-dialog` · `dropdown-menu` · `input` · `kbd`
254
+ (key caps + `Shortcut`) · `menu` · `required-mark` · `select` · `steps`
255
+ (multi-step wizard indicator) ·
255
256
  `table` · `record-view` (the full datatable + `RecordForm`) · plus the `utils`
256
257
  (`cn`) helper and the `theme.css` design tokens.
257
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
5
5
  "license": "MIT",
6
6
  "author": "Suman Bonakurthi",
@@ -0,0 +1,233 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import {
6
+ CheckIcon as Check,
7
+ ChevronDownIcon as ChevronDown,
8
+ MagnifyingGlassIcon as Search,
9
+ } from "@radix-ui/react-icons";
10
+
11
+ import { cn } from "./utils";
12
+ import type { SelectOption } from "./select";
13
+
14
+ type Placement = {
15
+ left: number;
16
+ width: number;
17
+ top?: number;
18
+ bottom?: number;
19
+ maxHeight: number;
20
+ };
21
+
22
+ /**
23
+ * Searchable single-select. Same trigger + portal-popover as {@link Select}, but
24
+ * the popover leads with a filter input that type-narrows the options, so it
25
+ * scales to long lists (an FK / country picker) where a plain Select doesn't.
26
+ * Same API as Select (drop-in), plus search/empty placeholders. Keyboard: type
27
+ * to filter, ↑/↓ to move, Enter to pick, Esc to close.
28
+ */
29
+ export function Combobox({
30
+ value,
31
+ onValueChange,
32
+ options,
33
+ id,
34
+ ariaLabel,
35
+ placeholder = "Select…",
36
+ searchPlaceholder = "Search…",
37
+ emptyText = "No matches",
38
+ className,
39
+ }: {
40
+ value: string;
41
+ onValueChange: (value: string) => void;
42
+ options: SelectOption[];
43
+ id?: string;
44
+ ariaLabel?: string;
45
+ placeholder?: string;
46
+ /** Placeholder for the filter input. */
47
+ searchPlaceholder?: string;
48
+ /** Shown when the query matches nothing. */
49
+ emptyText?: string;
50
+ /** Applied to the root (e.g. width in a flex row). */
51
+ className?: string;
52
+ }) {
53
+ const [open, setOpen] = React.useState(false);
54
+ const [query, setQuery] = React.useState("");
55
+ const [active, setActive] = React.useState(0);
56
+ const [pos, setPos] = React.useState<Placement | null>(null);
57
+ const rootRef = React.useRef<HTMLDivElement>(null);
58
+ const triggerRef = React.useRef<HTMLButtonElement>(null);
59
+ const listRef = React.useRef<HTMLDivElement>(null);
60
+ const inputRef = React.useRef<HTMLInputElement>(null);
61
+
62
+ const filtered = React.useMemo(() => {
63
+ const q = query.trim().toLowerCase();
64
+ return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
65
+ }, [options, query]);
66
+
67
+ const place = React.useCallback(() => {
68
+ const el = triggerRef.current;
69
+ if (!el) return;
70
+ const r = el.getBoundingClientRect();
71
+ const below = window.innerHeight - r.bottom;
72
+ const above = r.top;
73
+ const openUp = below < 280 && above > below;
74
+ setPos({
75
+ left: r.left,
76
+ width: r.width,
77
+ top: openUp ? undefined : r.bottom + 4,
78
+ bottom: openUp ? window.innerHeight - r.top + 4 : undefined,
79
+ maxHeight: Math.min(300, (openUp ? above : below) - 8),
80
+ });
81
+ }, []);
82
+
83
+ React.useLayoutEffect(() => {
84
+ if (!open) return;
85
+ place();
86
+ const reflow = () => place();
87
+ window.addEventListener("scroll", reflow, true);
88
+ window.addEventListener("resize", reflow);
89
+ return () => {
90
+ window.removeEventListener("scroll", reflow, true);
91
+ window.removeEventListener("resize", reflow);
92
+ };
93
+ }, [open, place]);
94
+
95
+ // Reset the query + focus the input each time it opens.
96
+ React.useEffect(() => {
97
+ if (!open) return;
98
+ setQuery("");
99
+ setActive(0);
100
+ const t = requestAnimationFrame(() => inputRef.current?.focus());
101
+ return () => cancelAnimationFrame(t);
102
+ }, [open]);
103
+
104
+ React.useEffect(() => {
105
+ if (!open) return;
106
+ const onDown = (e: MouseEvent) => {
107
+ const t = e.target as Node;
108
+ if (rootRef.current?.contains(t) || listRef.current?.contains(t)) return;
109
+ setOpen(false);
110
+ };
111
+ document.addEventListener("mousedown", onDown);
112
+ return () => document.removeEventListener("mousedown", onDown);
113
+ }, [open]);
114
+
115
+ // Keep the active index in range as the filtered list shrinks/grows.
116
+ React.useEffect(() => {
117
+ setActive((i) => Math.min(i, Math.max(0, filtered.length - 1)));
118
+ }, [filtered.length]);
119
+
120
+ const selected = options.find((o) => o.value === value);
121
+ const commit = (v: string) => {
122
+ onValueChange(v);
123
+ setOpen(false);
124
+ };
125
+
126
+ const onKeyDown = (e: React.KeyboardEvent) => {
127
+ if (e.key === "ArrowDown") {
128
+ e.preventDefault();
129
+ setActive((i) => Math.min(i + 1, filtered.length - 1));
130
+ } else if (e.key === "ArrowUp") {
131
+ e.preventDefault();
132
+ setActive((i) => Math.max(i - 1, 0));
133
+ } else if (e.key === "Enter") {
134
+ e.preventDefault();
135
+ const o = filtered[active];
136
+ if (o) commit(o.value);
137
+ } else if (e.key === "Escape") {
138
+ e.preventDefault();
139
+ setOpen(false);
140
+ }
141
+ };
142
+
143
+ return (
144
+ <div className={cn("relative", className)} ref={rootRef}>
145
+ <button
146
+ ref={triggerRef}
147
+ type="button"
148
+ id={id}
149
+ aria-haspopup="listbox"
150
+ aria-expanded={open}
151
+ aria-label={ariaLabel}
152
+ onClick={() => setOpen((v) => !v)}
153
+ className={cn(
154
+ "flex h-8 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 transition-colors",
155
+ "hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
156
+ )}
157
+ >
158
+ <span className={cn("truncate", !selected && "text-muted-foreground")}>
159
+ {selected ? selected.label : placeholder}
160
+ </span>
161
+ <ChevronDown
162
+ className={cn(
163
+ "size-3.5 shrink-0 text-muted-foreground transition-transform",
164
+ open && "rotate-180",
165
+ )}
166
+ aria-hidden="true"
167
+ />
168
+ </button>
169
+ {open &&
170
+ pos &&
171
+ typeof document !== "undefined" &&
172
+ createPortal(
173
+ <div
174
+ ref={listRef}
175
+ style={{
176
+ position: "fixed",
177
+ left: pos.left,
178
+ width: pos.width,
179
+ top: pos.top,
180
+ bottom: pos.bottom,
181
+ maxHeight: pos.maxHeight,
182
+ }}
183
+ className="vui-pop-in z-[200] flex flex-col overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md"
184
+ >
185
+ <div className="relative border-b border-border p-1.5">
186
+ <Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
187
+ <input
188
+ ref={inputRef}
189
+ value={query}
190
+ onChange={(e) => setQuery(e.target.value)}
191
+ onKeyDown={onKeyDown}
192
+ placeholder={searchPlaceholder}
193
+ aria-label={ariaLabel ? `Search ${ariaLabel}` : "Search"}
194
+ className="h-8 w-full rounded-sm bg-transparent pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground"
195
+ />
196
+ </div>
197
+ <div role="listbox" aria-label={ariaLabel} className="overflow-auto">
198
+ {filtered.length === 0 ? (
199
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
200
+ {emptyText}
201
+ </div>
202
+ ) : (
203
+ filtered.map((o, i) => {
204
+ const isSelected = o.value === value;
205
+ return (
206
+ <button
207
+ key={o.value}
208
+ type="button"
209
+ role="option"
210
+ aria-selected={isSelected}
211
+ onMouseEnter={() => setActive(i)}
212
+ onClick={() => commit(o.value)}
213
+ className={cn(
214
+ "flex w-full items-center justify-between gap-2 border-b border-border px-3 py-2 text-left last:border-b-0",
215
+ i === active && "bg-accent text-accent-foreground",
216
+ isSelected && "bg-accent/60",
217
+ )}
218
+ >
219
+ <span className="truncate">{o.label}</span>
220
+ {isSelected && (
221
+ <Check className="size-3.5 shrink-0 text-[var(--button-primary)]" />
222
+ )}
223
+ </button>
224
+ );
225
+ })
226
+ )}
227
+ </div>
228
+ </div>,
229
+ document.body,
230
+ )}
231
+ </div>
232
+ );
233
+ }
@@ -38,6 +38,7 @@ import { Button } from "./button";
38
38
  import { Checkbox } from "./checkbox";
39
39
  import { Input } from "./input";
40
40
  import { Select } from "./select";
41
+ import { Combobox } from "./combobox";
41
42
  import {
42
43
  Table,
43
44
  TableBody,
@@ -238,9 +239,21 @@ export interface RecordField<T> {
238
239
  * `Select`, and the selection toolbar offers a "Set {label}" bulk action. */
239
240
  options?: { value: string; label: string }[];
240
241
  /** Form control for the Add/Edit panel/page. Default `"text"` (auto-growing
241
- * textarea). `"number"`/`"date"` render the matching native input; a field
242
- * with `options` always renders a `Select` regardless of this. */
243
- input?: "text" | "number" | "date";
242
+ * textarea). `"number"`/`"date"` render the matching native input. For a
243
+ * field with `options`: default renders a `Select`, and `"combobox"` renders
244
+ * a searchable `Combobox` (type-to-filter) use it for long option lists. */
245
+ input?: "text" | "number" | "date" | "combobox";
246
+ /** Render a custom Add/Edit control — a checkbox, a radio group, a color
247
+ * picker, anything. Overrides the default control entirely (and takes
248
+ * priority over `options`/`input`). You get the current string value and an
249
+ * `onChange` to write it back; the surrounding label, required mark, and Save
250
+ * validation still come from the field. Read-only View uses `render`. */
251
+ renderInput?: (props: {
252
+ value: string;
253
+ onChange: (value: string) => void;
254
+ field: RecordField<T>;
255
+ invalid?: boolean;
256
+ }) => React.ReactNode;
244
257
  /** Expose this field in the Filter panel. When ANY field is filterable, the
245
258
  * panel switches from the single keyword box to a labeled control per field
246
259
  * plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
@@ -1098,7 +1111,18 @@ export function RecordView<T extends { id: RowId }>({
1098
1111
  <label className="text-xs font-medium text-muted-foreground">
1099
1112
  {label}
1100
1113
  </label>
1101
- {control === "select" || control === "combobox" ? (
1114
+ {control === "combobox" ? (
1115
+ <Combobox
1116
+ value={typeof raw === "string" ? raw : ""}
1117
+ onValueChange={setVal}
1118
+ options={opts}
1119
+ ariaLabel={label}
1120
+ placeholder={
1121
+ cfg.placeholder ?? `Any ${label.toLowerCase()}`
1122
+ }
1123
+ className="w-full"
1124
+ />
1125
+ ) : control === "select" ? (
1102
1126
  <Select
1103
1127
  value={typeof raw === "string" ? raw : ""}
1104
1128
  onValueChange={setVal}
@@ -1767,15 +1791,34 @@ function RecordDetailPanel<T extends { id: RowId }>({
1767
1791
  {f.render ? (
1768
1792
  <div>{f.render(draft)}</div>
1769
1793
  ) : !readOnly && f.editable ? (
1770
- f.options ? (
1771
- <Select
1772
- value={String(draft[f.key as keyof T] ?? "")}
1773
- onValueChange={(v) => setField(f.key as keyof T, v)}
1774
- options={f.options}
1775
- ariaLabel={f.label}
1776
- placeholder={`Select ${f.label.toLowerCase()}…`}
1777
- className="w-full"
1778
- />
1794
+ f.renderInput ? (
1795
+ // Consumer-supplied control (checkbox, radio, custom widget).
1796
+ f.renderInput({
1797
+ value: String(draft[f.key as keyof T] ?? ""),
1798
+ onChange: (v) => setField(f.key as keyof T, v),
1799
+ field: f,
1800
+ invalid: errors.has(f.key),
1801
+ })
1802
+ ) : f.options ? (
1803
+ f.input === "combobox" ? (
1804
+ <Combobox
1805
+ value={String(draft[f.key as keyof T] ?? "")}
1806
+ onValueChange={(v) => setField(f.key as keyof T, v)}
1807
+ options={f.options}
1808
+ ariaLabel={f.label}
1809
+ placeholder={`Select ${f.label.toLowerCase()}…`}
1810
+ className="w-full"
1811
+ />
1812
+ ) : (
1813
+ <Select
1814
+ value={String(draft[f.key as keyof T] ?? "")}
1815
+ onValueChange={(v) => setField(f.key as keyof T, v)}
1816
+ options={f.options}
1817
+ ariaLabel={f.label}
1818
+ placeholder={`Select ${f.label.toLowerCase()}…`}
1819
+ className="w-full"
1820
+ />
1821
+ )
1779
1822
  ) : f.input === "number" || f.input === "date" ? (
1780
1823
  <Input
1781
1824
  type={f.input}
@@ -65,6 +65,26 @@ export const fields: RecordField<DemoOrganization>[] = [
65
65
  { value: "trial", label: "Trial" },
66
66
  { value: "suspended", label: "Suspended" },
67
67
  ],
68
+ // `renderInput` drops ANY component into the Add/Edit form — here a radio
69
+ // group instead of the default Select. Swap it for a checkbox, a slider, a
70
+ // color picker… the field still owns the label, required mark and Save
71
+ // validation. (View still shows the Badge via `render`.)
72
+ renderInput: ({ value, onChange, field }) => (
73
+ <div role="radiogroup" aria-label={field.label} className="flex flex-wrap gap-4">
74
+ {(field.options ?? []).map((o) => (
75
+ <label key={o.value} className="flex items-center gap-1.5 text-sm">
76
+ <input
77
+ type="radio"
78
+ name={`org-${field.key}`}
79
+ checked={value === o.value}
80
+ onChange={() => onChange(o.value)}
81
+ className="accent-[var(--button-primary)]"
82
+ />
83
+ {o.label}
84
+ </label>
85
+ ))}
86
+ </div>
87
+ ),
68
88
  render: (row) => {
69
89
  const status = statusBadge[row.status];
70
90
  return <Badge variant={status.variant}>{status.label}</Badge>;