@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's
|
|
15
|
+
options can now depend on another field'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'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'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.
|
|
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",
|
package/src/record-view.tsx
CHANGED
|
@@ -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`.
|
|
198
|
-
*
|
|
199
|
-
options
|
|
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
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
{
|
|
15
|
-
|
|
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={
|
|
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,
|