@viliha/vui-ui 1.10.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
@@ -338,6 +338,8 @@ Sorting follows column visibility by default; use `sortable` to decouple them
338
338
 
339
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
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
+
341
343
  ## Filtering
342
344
 
343
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,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.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
+
10
33
  ## 1.10.0 — 2026-07-26
11
34
 
12
35
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.10.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",
@@ -187,16 +187,21 @@ export type FilterControl =
187
187
  * `{ control: "text" }`; pass an object to pick the control and shape it, so
188
188
  * the front end can compose a different filter form per request (Name + Code as
189
189
  * text for one screen, a status dropdown + tag checkboxes for another). */
190
- export interface FieldFilter {
190
+ export interface FieldFilter<T = Record<string, unknown>> {
191
191
  /** Which control to render. Default `"text"`. */
192
192
  control?: FilterControl;
193
193
  /** Label above the control. Defaults to the field's `label`. */
194
194
  label?: string;
195
195
  /** Placeholder for text / number / combobox inputs. */
196
196
  placeholder?: string;
197
- /** Choices for `select` / `combobox` / `checkbox`. Falls back to the field's
198
- * own `options` when omitted. */
199
- 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 }[]);
200
205
  }
201
206
 
202
207
  /** Values collected by the Filter panel, keyed by field. Single-value controls
@@ -236,8 +241,15 @@ export interface RecordField<T> {
236
241
  /** Custom, non-editable cell/value renderer. */
237
242
  render?: (row: T) => React.ReactNode;
238
243
  /** If set, the field becomes a choice field: the Add/Edit form renders a
239
- * `Select`, and the selection toolbar offers a "Set {label}" bulk action. */
240
- 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 }[]);
241
253
  /** Form control for the Add/Edit panel/page. Default `"text"` (auto-growing
242
254
  * textarea). `"number"`/`"date"` render the matching native input. For a
243
255
  * field with `options`: default renders a `Select`, and `"combobox"` renders
@@ -260,7 +272,7 @@ export interface RecordField<T> {
260
272
  * choose the control (dropdown, checkbox, combobox, number, date …) so the
261
273
  * filter form is composed per request. The panel only gathers values — wire
262
274
  * matching through RecordView's `onFilter`. Omit to leave the field out. */
263
- filterable?: boolean | FieldFilter;
275
+ filterable?: boolean | FieldFilter<T>;
264
276
  }
265
277
 
266
278
  /**
@@ -316,6 +328,15 @@ function clearPersisted(key: string | undefined) {
316
328
  }
317
329
  }
318
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
+
319
340
  interface RecordViewProps<T extends { id: RowId }> {
320
341
  title: string;
321
342
  singular: string;
@@ -576,6 +597,32 @@ export function RecordView<T extends { id: RowId }>({
576
597
  setPage(1);
577
598
  }, [filter, pageSize, setPage]);
578
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
+
579
626
  function startEdit(row: T, key: string) {
580
627
  setEditing({ id: row.id, key });
581
628
  setDraft(String(row[key as keyof T] ?? ""));
@@ -818,7 +865,11 @@ export function RecordView<T extends { id: RowId }>({
818
865
  const allSelected =
819
866
  processed.length > 0 && selected.size === processed.length;
820
867
  // Choice fields (with `options`) power the "Set …" bulk actions.
821
- 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
+ );
822
873
  // Per-column alignment (auto: numbers + short codes center).
823
874
  const columnAligns = React.useMemo(
824
875
  () => computeColumnAligns(fields, initialData),
@@ -1072,7 +1123,7 @@ export function RecordView<T extends { id: RowId }>({
1072
1123
  {bulkFields.map((f) => (
1073
1124
  <React.Fragment key={f.key}>
1074
1125
  <DropdownLabel>Set {f.label}</DropdownLabel>
1075
- {f.options?.map((o) => (
1126
+ {(Array.isArray(f.options) ? f.options : []).map((o) => (
1076
1127
  <DropdownItem
1077
1128
  key={o.value}
1078
1129
  onSelect={() => bulkSetField(f.key, o.value)}
@@ -1098,11 +1149,18 @@ export function RecordView<T extends { id: RowId }>({
1098
1149
  <DropdownLabel>Filter</DropdownLabel>
1099
1150
  <div className="flex max-h-80 w-72 flex-col gap-3 overflow-y-auto p-3">
1100
1151
  {filterFields.map((f) => {
1101
- const cfg: FieldFilter =
1152
+ const cfg: FieldFilter<T> =
1102
1153
  typeof f.filterable === "object" ? f.filterable : {};
1103
1154
  const control = cfg.control ?? "text";
1104
1155
  const label = cfg.label ?? f.label;
1105
- 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 : []));
1106
1164
  const raw = filterValues[f.key];
1107
1165
  const setVal = (v: string | string[]) =>
1108
1166
  setFilterValues((prev) => ({ ...prev, [f.key]: v }));
@@ -1704,6 +1762,25 @@ function RecordDetailPanel<T extends { id: RowId }>({
1704
1762
  setDraft(row);
1705
1763
  }, [row, setDraft]);
1706
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
+
1707
1784
  const primary = getPrimary(draft);
1708
1785
  const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
1709
1786
 
@@ -1804,7 +1881,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
1804
1881
  <Combobox
1805
1882
  value={String(draft[f.key as keyof T] ?? "")}
1806
1883
  onValueChange={(v) => setField(f.key as keyof T, v)}
1807
- options={f.options}
1884
+ options={resolveOptions(f.options, draft)}
1808
1885
  ariaLabel={f.label}
1809
1886
  placeholder={`Select ${f.label.toLowerCase()}…`}
1810
1887
  className="w-full"
@@ -1813,7 +1890,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
1813
1890
  <Select
1814
1891
  value={String(draft[f.key as keyof T] ?? "")}
1815
1892
  onValueChange={(v) => setField(f.key as keyof T, v)}
1816
- options={f.options}
1893
+ options={resolveOptions(f.options, draft)}
1817
1894
  ariaLabel={f.label}
1818
1895
  placeholder={`Select ${f.label.toLowerCase()}…`}
1819
1896
  className="w-full"
@@ -71,7 +71,7 @@ export const fields: RecordField<DemoOrganization>[] = [
71
71
  // validation. (View still shows the Badge via `render`.)
72
72
  renderInput: ({ value, onChange, field }) => (
73
73
  <div role="radiogroup" aria-label={field.label} className="flex flex-wrap gap-4">
74
- {(field.options ?? []).map((o) => (
74
+ {(Array.isArray(field.options) ? field.options : []).map((o) => (
75
75
  <label key={o.value} className="flex items-center gap-1.5 text-sm">
76
76
  <input
77
77
  type="radio"
@@ -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,