@viliha/vui-ui 1.10.0 → 1.11.1

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,39 @@ 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.1 — 2026-07-26
11
+
12
+ ### Changed
13
+
14
+ - **Sort indicators are now carets, and every sortable column shows one.** A
15
+ sortable column header shows a muted up/down caret (`CaretSort`) by default —
16
+ so sortability is discoverable — and a solid caret for the active direction:
17
+ `CaretUp` = ascending, `CaretDown` = descending (all Radix icons). The Sort
18
+ dropdown matches. Non-sortable columns (`sortable: false`) show no indicator.
19
+
20
+ ## 1.11.0 — 2026-07-26
21
+
22
+ ### Added
23
+
24
+ - **Dependent (cascading) options in `RecordView`** — a choice field&apos;s
25
+ options can now depend on another field&apos;s current value, in both the
26
+ Add/Edit form and the Filter panel. Opt-in, backward compatible.
27
+ - Form: `RecordField.options` may be a **function of the draft** —
28
+ `((draft) => { value, label }[])` — recomputed as the draft changes.
29
+ - Filter: `FieldFilter.options` may be a **function of the current filter
30
+ values** — `((values) => { value, label }[])`. `FieldFilter` is now generic
31
+ (`FieldFilter<T>`).
32
+ - **Auto-clear:** when the parent changes and the child&apos;s value is no
33
+ longer a valid option, RecordView clears it (form and filter). Static-array
34
+ options are unchanged; the bulk "Set {label}" action only lists static-option
35
+ fields (no single draft to resolve a function against).
36
+
37
+ **For agents:** for a dependent picker (e.g. Country → State) pass a function
38
+ to `options` / `filterable.options`; don&apos;t manually reset the child. If a
39
+ field uses a function `options` *and* `renderInput`, guard with
40
+ `Array.isArray(field.options)` before mapping. Demo: `system/cities`
41
+ (Country → State) in both form and filter. Docs: `/docs/data-table`.
42
+
10
43
  ## 1.10.0 — 2026-07-26
11
44
 
12
45
  ### 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.1",
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",
@@ -8,10 +8,10 @@ import {
8
8
  ReaderIcon as Reader,
9
9
  TableIcon as SheetIcon,
10
10
  UploadIcon as Upload,
11
- ArrowDownIcon as ArrowDown,
12
11
  ArrowTopRightIcon as ArrowUpRight,
13
- ArrowUpIcon as ArrowUp,
14
- CaretSortIcon as ArrowUpDown,
12
+ CaretDownIcon as CaretDown,
13
+ CaretSortIcon as CaretSort,
14
+ CaretUpIcon as CaretUp,
15
15
  CheckIcon as Check,
16
16
  ChevronLeftIcon as ChevronLeft,
17
17
  ChevronRightIcon as ChevronRight,
@@ -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 }));
@@ -1216,7 +1274,7 @@ export function RecordView<T extends { id: RowId }>({
1216
1274
  )}
1217
1275
  </Dropdown>
1218
1276
 
1219
- <Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
1277
+ <Dropdown label="Sort" icon={<CaretSort className="size-3.5 text-blue-500" />}>
1220
1278
  <DropdownLabel>Sort by</DropdownLabel>
1221
1279
  {sortFields.map((f) => (
1222
1280
  <DropdownItem
@@ -1225,9 +1283,9 @@ export function RecordView<T extends { id: RowId }>({
1225
1283
  icon={
1226
1284
  sort?.key === f.key ? (
1227
1285
  sort.dir === "asc" ? (
1228
- <ArrowUp className="size-3.5" />
1286
+ <CaretUp className="size-3.5" />
1229
1287
  ) : (
1230
- <ArrowDown className="size-3.5" />
1288
+ <CaretDown className="size-3.5" />
1231
1289
  )
1232
1290
  ) : undefined
1233
1291
  }
@@ -1344,11 +1402,18 @@ export function RecordView<T extends { id: RowId }>({
1344
1402
  {f.label}
1345
1403
  {f.required && <RequiredMark />}
1346
1404
  </span>
1347
- {sort?.key === f.key &&
1348
- (sort.dir === "asc" ? (
1349
- <ArrowUp className="size-3 shrink-0" />
1405
+ {/* Sortable columns always show an indicator: a muted
1406
+ up/down caret by default, a solid caret for the active
1407
+ direction (up = ascending, down = descending). */}
1408
+ {sortable &&
1409
+ (sort?.key === f.key ? (
1410
+ sort.dir === "asc" ? (
1411
+ <CaretUp className="size-3.5 shrink-0" />
1412
+ ) : (
1413
+ <CaretDown className="size-3.5 shrink-0" />
1414
+ )
1350
1415
  ) : (
1351
- <ArrowDown className="size-3 shrink-0" />
1416
+ <CaretSort className="size-3.5 shrink-0 text-muted-foreground/50" />
1352
1417
  ))}
1353
1418
  </>
1354
1419
  );
@@ -1704,6 +1769,25 @@ function RecordDetailPanel<T extends { id: RowId }>({
1704
1769
  setDraft(row);
1705
1770
  }, [row, setDraft]);
1706
1771
 
1772
+ // Cascading options: after the draft changes, clear any choice field whose
1773
+ // value is no longer valid once its options recompute (e.g. changing Region
1774
+ // drops a now-invalid Country). Only function-options fields cascade; static
1775
+ // ones never invalidate. Settles in one pass — cleared values are "" and skip.
1776
+ React.useEffect(() => {
1777
+ const stale = fields.filter((f) => {
1778
+ if (typeof f.options !== "function") return false;
1779
+ const v = draft[f.key as keyof T];
1780
+ if (v == null || v === "") return false;
1781
+ return !f.options(draft).some((o) => o.value === String(v));
1782
+ });
1783
+ if (stale.length === 0) return;
1784
+ setDraft((d) => {
1785
+ let next = d;
1786
+ for (const f of stale) next = { ...next, [f.key]: "" };
1787
+ return next;
1788
+ });
1789
+ }, [draft, fields, setDraft]);
1790
+
1707
1791
  const primary = getPrimary(draft);
1708
1792
  const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
1709
1793
 
@@ -1804,7 +1888,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
1804
1888
  <Combobox
1805
1889
  value={String(draft[f.key as keyof T] ?? "")}
1806
1890
  onValueChange={(v) => setField(f.key as keyof T, v)}
1807
- options={f.options}
1891
+ options={resolveOptions(f.options, draft)}
1808
1892
  ariaLabel={f.label}
1809
1893
  placeholder={`Select ${f.label.toLowerCase()}…`}
1810
1894
  className="w-full"
@@ -1813,7 +1897,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
1813
1897
  <Select
1814
1898
  value={String(draft[f.key as keyof T] ?? "")}
1815
1899
  onValueChange={(v) => setField(f.key as keyof T, v)}
1816
- options={f.options}
1900
+ options={resolveOptions(f.options, draft)}
1817
1901
  ariaLabel={f.label}
1818
1902
  placeholder={`Select ${f.label.toLowerCase()}…`}
1819
1903
  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,