@viliha/vui-ui 1.9.0 → 1.11.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,10 @@ 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
+
341
+ Dependent/cascading options: make `options` (form) or `filterable.options` (filter) a function — `(draft) => …` in the form, `(values) => …` in the filter — and one field's choices depend on another; RecordView clears the child when the parent change invalidates it. Keep `options` a static array for the bulk "Set {label}" action. If a field has both a function `options` and `renderInput`, guard with `Array.isArray(field.options)` before mapping (renderInput doesn't get the draft to resolve it).
342
+
339
343
  ## Filtering
340
344
 
341
345
  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,52 @@ 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.11.0 — 2026-07-26
11
+
12
+ ### Added
13
+
14
+ - **Dependent (cascading) options in `RecordView`** — a choice field&apos;s
15
+ options can now depend on another field&apos;s current value, in both the
16
+ Add/Edit form and the Filter panel. Opt-in, backward compatible.
17
+ - Form: `RecordField.options` may be a **function of the draft** —
18
+ `((draft) => { value, label }[])` — recomputed as the draft changes.
19
+ - Filter: `FieldFilter.options` may be a **function of the current filter
20
+ values** — `((values) => { value, label }[])`. `FieldFilter` is now generic
21
+ (`FieldFilter<T>`).
22
+ - **Auto-clear:** when the parent changes and the child&apos;s value is no
23
+ longer a valid option, RecordView clears it (form and filter). Static-array
24
+ options are unchanged; the bulk "Set {label}" action only lists static-option
25
+ fields (no single draft to resolve a function against).
26
+
27
+ **For agents:** for a dependent picker (e.g. Country → State) pass a function
28
+ to `options` / `filterable.options`; don&apos;t manually reset the child. If a
29
+ field uses a function `options` *and* `renderInput`, guard with
30
+ `Array.isArray(field.options)` before mapping. Demo: `system/cities`
31
+ (Country → State) in both form and filter. Docs: `/docs/data-table`.
32
+
33
+ ## 1.10.0 — 2026-07-26
34
+
35
+ ### Added
36
+
37
+ - **`Combobox`** (`@viliha/vui-ui/combobox`) — a searchable single-select. Same
38
+ API as `Select` (drop-in) but the popover leads with a type-to-filter input, so
39
+ it scales to long option lists (an FK / country picker). Keyboard: type to
40
+ filter, ↑/↓ to move, Enter to pick, Esc to close.
41
+ - **`RecordView` choice fields can use the Combobox.** In the Add/Edit form set
42
+ `input: "combobox"` on an `options` field for a searchable control (default
43
+ stays `Select`). In the Filter panel, `control: "combobox"` now renders the
44
+ real searchable Combobox (previously it fell back to a plain Select).
45
+ - **`RecordField.renderInput`** — an escape hatch to render **any** Add/Edit
46
+ control (checkbox, radio group, slider, date-range, a custom widget). It
47
+ overrides the default control and receives `{ value, onChange, field, invalid }`;
48
+ the field still owns the label, required mark, and Save validation. The
49
+ Organizations form demonstrates it (Status as a radio group).
50
+
51
+ **For agents:** for a long choice list use `input: "combobox"`; for a control
52
+ the built-ins don't cover use `renderInput` — never hand-build a `<form>`.
53
+ New demo: Organizations Status (radio via `renderInput`); Combobox is also in
54
+ the components gallery. Docs: `/docs/components`, `/docs/data-table`.
55
+
10
56
  ## 1.9.0 — 2026-07-26
11
57
 
12
58
  ### 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.11.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,
@@ -186,16 +187,21 @@ export type FilterControl =
186
187
  * `{ control: "text" }`; pass an object to pick the control and shape it, so
187
188
  * the front end can compose a different filter form per request (Name + Code as
188
189
  * text for one screen, a status dropdown + tag checkboxes for another). */
189
- export interface FieldFilter {
190
+ export interface FieldFilter<T = Record<string, unknown>> {
190
191
  /** Which control to render. Default `"text"`. */
191
192
  control?: FilterControl;
192
193
  /** Label above the control. Defaults to the field's `label`. */
193
194
  label?: string;
194
195
  /** Placeholder for text / number / combobox inputs. */
195
196
  placeholder?: string;
196
- /** Choices for `select` / `combobox` / `checkbox`. Falls back to the field's
197
- * own `options` when omitted. */
198
- options?: { value: string; label: string }[];
197
+ /** Choices for `select` / `combobox` / `checkbox`. A static array, or a
198
+ * function of the current filter values for cascading filters (e.g. Country
199
+ * options derived from the selected Region) — the panel recomputes it on every
200
+ * change and clears a value that's no longer valid. Falls back to the field's
201
+ * own (static) `options` when omitted. */
202
+ options?:
203
+ | { value: string; label: string }[]
204
+ | ((values: FilterValues<T>) => { value: string; label: string }[]);
199
205
  }
200
206
 
201
207
  /** Values collected by the Filter panel, keyed by field. Single-value controls
@@ -235,19 +241,38 @@ export interface RecordField<T> {
235
241
  /** Custom, non-editable cell/value renderer. */
236
242
  render?: (row: T) => React.ReactNode;
237
243
  /** If set, the field becomes a choice field: the Add/Edit form renders a
238
- * `Select`, and the selection toolbar offers a "Set {label}" bulk action. */
239
- options?: { value: string; label: string }[];
244
+ * `Select` (or `Combobox`), and the selection toolbar offers a "Set {label}"
245
+ * bulk action. A static array, or a function of the current draft for
246
+ * dependent/cascading options (e.g. Country choices derived from the selected
247
+ * Region) — the form recomputes it as the draft changes and clears the field
248
+ * when its value is no longer a valid option. (Bulk "Set {label}" only lists
249
+ * static-array option fields, since it has no single draft to resolve against.) */
250
+ options?:
251
+ | { value: string; label: string }[]
252
+ | ((draft: Partial<T>) => { value: string; label: string }[]);
240
253
  /** 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";
254
+ * textarea). `"number"`/`"date"` render the matching native input. For a
255
+ * field with `options`: default renders a `Select`, and `"combobox"` renders
256
+ * a searchable `Combobox` (type-to-filter) use it for long option lists. */
257
+ input?: "text" | "number" | "date" | "combobox";
258
+ /** Render a custom Add/Edit control — a checkbox, a radio group, a color
259
+ * picker, anything. Overrides the default control entirely (and takes
260
+ * priority over `options`/`input`). You get the current string value and an
261
+ * `onChange` to write it back; the surrounding label, required mark, and Save
262
+ * validation still come from the field. Read-only View uses `render`. */
263
+ renderInput?: (props: {
264
+ value: string;
265
+ onChange: (value: string) => void;
266
+ field: RecordField<T>;
267
+ invalid?: boolean;
268
+ }) => React.ReactNode;
244
269
  /** Expose this field in the Filter panel. When ANY field is filterable, the
245
270
  * panel switches from the single keyword box to a labeled control per field
246
271
  * plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
247
272
  * choose the control (dropdown, checkbox, combobox, number, date …) so the
248
273
  * filter form is composed per request. The panel only gathers values — wire
249
274
  * matching through RecordView's `onFilter`. Omit to leave the field out. */
250
- filterable?: boolean | FieldFilter;
275
+ filterable?: boolean | FieldFilter<T>;
251
276
  }
252
277
 
253
278
  /**
@@ -303,6 +328,15 @@ function clearPersisted(key: string | undefined) {
303
328
  }
304
329
  }
305
330
 
331
+ /** Resolve a choice field's form options against the current draft: a static
332
+ * array, or a function of the draft (cascading pickers). */
333
+ function resolveOptions<T>(
334
+ opts: RecordField<T>["options"],
335
+ draft: Partial<T>,
336
+ ): { value: string; label: string }[] {
337
+ return typeof opts === "function" ? opts(draft) : (opts ?? []);
338
+ }
339
+
306
340
  interface RecordViewProps<T extends { id: RowId }> {
307
341
  title: string;
308
342
  singular: string;
@@ -563,6 +597,32 @@ export function RecordView<T extends { id: RowId }>({
563
597
  setPage(1);
564
598
  }, [filter, pageSize, setPage]);
565
599
 
600
+ // Cascading filter options: when the values change, drop any filter value no
601
+ // longer valid once its options recompute (e.g. changing Region invalidates a
602
+ // Country filter). Only function-options filters cascade. Strings clear; multi
603
+ // (checkbox) arrays keep the still-valid entries.
604
+ React.useEffect(() => {
605
+ let changed = false;
606
+ const next: FilterValues<T> = { ...filterValues };
607
+ for (const f of fields) {
608
+ const cfg = typeof f.filterable === "object" ? f.filterable : null;
609
+ if (!cfg || typeof cfg.options !== "function") continue;
610
+ const valid = new Set(cfg.options(filterValues).map((o) => o.value));
611
+ const v = filterValues[f.key];
612
+ if (typeof v === "string" && v && !valid.has(v)) {
613
+ next[f.key] = "";
614
+ changed = true;
615
+ } else if (Array.isArray(v)) {
616
+ const kept = v.filter((x) => valid.has(x));
617
+ if (kept.length !== v.length) {
618
+ next[f.key] = kept;
619
+ changed = true;
620
+ }
621
+ }
622
+ }
623
+ if (changed) setFilterValues(next);
624
+ }, [filterValues, fields, setFilterValues]);
625
+
566
626
  function startEdit(row: T, key: string) {
567
627
  setEditing({ id: row.id, key });
568
628
  setDraft(String(row[key as keyof T] ?? ""));
@@ -805,7 +865,11 @@ export function RecordView<T extends { id: RowId }>({
805
865
  const allSelected =
806
866
  processed.length > 0 && selected.size === processed.length;
807
867
  // Choice fields (with `options`) power the "Set …" bulk actions.
808
- const bulkFields = fields.filter((f) => f.options && f.options.length > 0);
868
+ // Only static-array option fields bulk "Set {label}" has no single draft to
869
+ // resolve a function-options field against.
870
+ const bulkFields = fields.filter(
871
+ (f) => Array.isArray(f.options) && f.options.length > 0,
872
+ );
809
873
  // Per-column alignment (auto: numbers + short codes center).
810
874
  const columnAligns = React.useMemo(
811
875
  () => computeColumnAligns(fields, initialData),
@@ -1059,7 +1123,7 @@ export function RecordView<T extends { id: RowId }>({
1059
1123
  {bulkFields.map((f) => (
1060
1124
  <React.Fragment key={f.key}>
1061
1125
  <DropdownLabel>Set {f.label}</DropdownLabel>
1062
- {f.options?.map((o) => (
1126
+ {(Array.isArray(f.options) ? f.options : []).map((o) => (
1063
1127
  <DropdownItem
1064
1128
  key={o.value}
1065
1129
  onSelect={() => bulkSetField(f.key, o.value)}
@@ -1085,11 +1149,18 @@ export function RecordView<T extends { id: RowId }>({
1085
1149
  <DropdownLabel>Filter</DropdownLabel>
1086
1150
  <div className="flex max-h-80 w-72 flex-col gap-3 overflow-y-auto p-3">
1087
1151
  {filterFields.map((f) => {
1088
- const cfg: FieldFilter =
1152
+ const cfg: FieldFilter<T> =
1089
1153
  typeof f.filterable === "object" ? f.filterable : {};
1090
1154
  const control = cfg.control ?? "text";
1091
1155
  const label = cfg.label ?? f.label;
1092
- const opts = cfg.options ?? f.options ?? [];
1156
+ // Options: cfg's static array or function of the current
1157
+ // filter values (cascading); fall back to the field's static
1158
+ // options (a draft-function can't resolve here).
1159
+ const opts =
1160
+ typeof cfg.options === "function"
1161
+ ? cfg.options(filterValues)
1162
+ : (cfg.options ??
1163
+ (Array.isArray(f.options) ? f.options : []));
1093
1164
  const raw = filterValues[f.key];
1094
1165
  const setVal = (v: string | string[]) =>
1095
1166
  setFilterValues((prev) => ({ ...prev, [f.key]: v }));
@@ -1098,7 +1169,18 @@ export function RecordView<T extends { id: RowId }>({
1098
1169
  <label className="text-xs font-medium text-muted-foreground">
1099
1170
  {label}
1100
1171
  </label>
1101
- {control === "select" || control === "combobox" ? (
1172
+ {control === "combobox" ? (
1173
+ <Combobox
1174
+ value={typeof raw === "string" ? raw : ""}
1175
+ onValueChange={setVal}
1176
+ options={opts}
1177
+ ariaLabel={label}
1178
+ placeholder={
1179
+ cfg.placeholder ?? `Any ${label.toLowerCase()}`
1180
+ }
1181
+ className="w-full"
1182
+ />
1183
+ ) : control === "select" ? (
1102
1184
  <Select
1103
1185
  value={typeof raw === "string" ? raw : ""}
1104
1186
  onValueChange={setVal}
@@ -1680,6 +1762,25 @@ function RecordDetailPanel<T extends { id: RowId }>({
1680
1762
  setDraft(row);
1681
1763
  }, [row, setDraft]);
1682
1764
 
1765
+ // Cascading options: after the draft changes, clear any choice field whose
1766
+ // value is no longer valid once its options recompute (e.g. changing Region
1767
+ // drops a now-invalid Country). Only function-options fields cascade; static
1768
+ // ones never invalidate. Settles in one pass — cleared values are "" and skip.
1769
+ React.useEffect(() => {
1770
+ const stale = fields.filter((f) => {
1771
+ if (typeof f.options !== "function") return false;
1772
+ const v = draft[f.key as keyof T];
1773
+ if (v == null || v === "") return false;
1774
+ return !f.options(draft).some((o) => o.value === String(v));
1775
+ });
1776
+ if (stale.length === 0) return;
1777
+ setDraft((d) => {
1778
+ let next = d;
1779
+ for (const f of stale) next = { ...next, [f.key]: "" };
1780
+ return next;
1781
+ });
1782
+ }, [draft, fields, setDraft]);
1783
+
1683
1784
  const primary = getPrimary(draft);
1684
1785
  const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
1685
1786
 
@@ -1767,15 +1868,34 @@ function RecordDetailPanel<T extends { id: RowId }>({
1767
1868
  {f.render ? (
1768
1869
  <div>{f.render(draft)}</div>
1769
1870
  ) : !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
- />
1871
+ f.renderInput ? (
1872
+ // Consumer-supplied control (checkbox, radio, custom widget).
1873
+ f.renderInput({
1874
+ value: String(draft[f.key as keyof T] ?? ""),
1875
+ onChange: (v) => setField(f.key as keyof T, v),
1876
+ field: f,
1877
+ invalid: errors.has(f.key),
1878
+ })
1879
+ ) : f.options ? (
1880
+ f.input === "combobox" ? (
1881
+ <Combobox
1882
+ value={String(draft[f.key as keyof T] ?? "")}
1883
+ onValueChange={(v) => setField(f.key as keyof T, v)}
1884
+ options={resolveOptions(f.options, draft)}
1885
+ ariaLabel={f.label}
1886
+ placeholder={`Select ${f.label.toLowerCase()}…`}
1887
+ className="w-full"
1888
+ />
1889
+ ) : (
1890
+ <Select
1891
+ value={String(draft[f.key as keyof T] ?? "")}
1892
+ onValueChange={(v) => setField(f.key as keyof T, v)}
1893
+ options={resolveOptions(f.options, draft)}
1894
+ ariaLabel={f.label}
1895
+ placeholder={`Select ${f.label.toLowerCase()}…`}
1896
+ className="w-full"
1897
+ />
1898
+ )
1779
1899
  ) : f.input === "number" || f.input === "date" ? (
1780
1900
  <Input
1781
1901
  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
+ {(Array.isArray(field.options) ? 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>;
@@ -1,28 +1,100 @@
1
1
  "use client";
2
2
 
3
+ import { useMemo, useState } from "react";
3
4
  import {
4
5
  BookmarkIcon as Flag,
5
6
  HomeIcon as Landmark,
6
7
  SewingPinFilledIcon as MapPin,
7
8
  } from "@radix-ui/react-icons";
8
9
 
9
- import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
10
+ import {
11
+ RecordView,
12
+ type FilterValues,
13
+ type RecordField,
14
+ } from "@viliha/vui-ui/record-view";
10
15
  import { cities, type City } from "@/lib/mock-data";
11
16
 
17
+ // Option lists derived from the data.
18
+ const COUNTRIES = [...new Set(cities.map((c) => c.country))]
19
+ .sort()
20
+ .map((c) => ({ value: c, label: c }));
21
+ const statesFor = (country?: string) =>
22
+ [...new Set(cities.filter((c) => c.country === country).map((c) => c.state))]
23
+ .sort()
24
+ .map((s) => ({ value: s, label: s }));
25
+
26
+ // Cascading options: State choices depend on the selected Country. `options` as
27
+ // a function receives the live draft (form) or filter values (filter); when the
28
+ // parent changes, RecordView clears a now-invalid State automatically.
12
29
  const fields: RecordField<City>[] = [
13
30
  { key: "name", label: "Name", editable: true, required: true, group: "General", hideInTable: true },
14
- { key: "state", label: "State", icon: MapPin, editable: true, group: "General" },
15
- { key: "country", label: "Country", icon: Flag, editable: true, group: "General" },
31
+ {
32
+ key: "country",
33
+ label: "Country",
34
+ icon: Flag,
35
+ editable: true,
36
+ group: "General",
37
+ input: "combobox",
38
+ options: COUNTRIES,
39
+ filterable: { control: "select", options: COUNTRIES },
40
+ },
41
+ {
42
+ key: "state",
43
+ label: "State",
44
+ icon: MapPin,
45
+ editable: true,
46
+ group: "General",
47
+ input: "combobox",
48
+ // Form: states for the country in the current draft.
49
+ options: (draft) => statesFor(draft.country),
50
+ filterable: {
51
+ control: "combobox",
52
+ // Filter: states for the country in the current filter values.
53
+ options: (values) =>
54
+ statesFor(typeof values.country === "string" ? values.country : undefined),
55
+ },
56
+ },
16
57
  ];
17
58
 
18
59
  export function CitiesTable() {
60
+ const [all, setAll] = useState<City[]>(cities);
61
+ const [filters, setFilters] = useState<FilterValues<City>>({});
62
+
63
+ // Demo: match the cascading filter values client-side (swap for a server query).
64
+ const rows = useMemo(
65
+ () =>
66
+ all.filter((r) =>
67
+ Object.entries(filters).every(([k, v]) => {
68
+ const needle = String(v ?? "").toLowerCase();
69
+ return !needle || String(r[k as keyof City]).toLowerCase() === needle;
70
+ }),
71
+ ),
72
+ [all, filters],
73
+ );
74
+
75
+ const handleChange = (next: City[]) => {
76
+ setAll((prev) => {
77
+ const nextById = new Map(next.map((r) => [r.id, r] as const));
78
+ const visible = new Set(rows.map((r) => r.id));
79
+ const kept = prev
80
+ .filter((r) => !visible.has(r.id) || nextById.has(r.id))
81
+ .map((r) => nextById.get(r.id) ?? r);
82
+ const added = next.filter((r) => !prev.some((p) => p.id === r.id));
83
+ return [...kept, ...added];
84
+ });
85
+ };
86
+
19
87
  return (
20
88
  <RecordView
21
89
  title="Cities"
22
90
  singular="City"
23
91
  icon={Landmark}
92
+ persistKey="/system/cities"
24
93
  fields={fields}
25
- initialData={cities}
94
+ initialData={rows}
95
+ data={rows}
96
+ onDataChange={handleChange}
97
+ onFilter={setFilters}
26
98
  getPrimary={(row) => ({
27
99
  title: row.name,
28
100
  subtitle: row.country,