@viliha/vui-ui 1.7.0 → 1.8.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
@@ -334,6 +334,10 @@ Build forms with VUI and shadcn/ui together, reaching for shadcn Form, React Hoo
334
334
 
335
335
  Never hand-build an HTML table; always use `RecordView`. Configure columns through its field props (`editable`, `required`, `copyable`, `options`, `render`), and let `RecordView` own the rest: sorting, filtering, pagination, bulk actions, and import/export.
336
336
 
337
+ ## Filtering
338
+
339
+ 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.
340
+
337
341
  ## Add / edit form
338
342
 
339
343
  The buffered add/edit form renders in one of two layouts:
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ 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.8.0 — 2026-07-26
11
+
12
+ ### Added
13
+
14
+ - **Per-field filtering in `RecordView`** (opt-in, backward compatible). Mark a
15
+ field `filterable` and the Filter panel switches from the single keyword box to
16
+ a labeled control per field plus **Search / Clear**. The control is dynamic so
17
+ the front end can compose a different filter form per request:
18
+ - `filterable: true` → a text input.
19
+ - `filterable: { control, label, placeholder, options }` → pick the control:
20
+ `"text" | "number" | "date" | "select" | "combobox" | "checkbox"` (unknown or
21
+ omitted → text). `options` falls back to the field's own `options`.
22
+ - New exported types: `FilterControl`, `FieldFilter`, `FilterValues<T>`.
23
+ - The panel **gathers values only** — it does not match rows in per-field mode.
24
+ Wire matching through the new `RecordView` prop `onFilter(values)` (Search and
25
+ Clear both call it), typically a server query or your own client filter. The
26
+ single-keyword box (and its built-in row matching) is unchanged when no field
27
+ is `filterable`.
28
+
29
+ **For agents:** to add per-field filters, only set `filterable` on the relevant
30
+ `RecordField`s and handle `onFilter` — never hand-roll a filter form. Extend
31
+ `FilterControl` when a new control kind is needed. Demo: `system/regions`
32
+ (Name + Code). Docs: `/docs/data-table`.
33
+
10
34
  ## 1.7.0 — 2026-07-25
11
35
 
12
36
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",
@@ -171,6 +171,42 @@ export function usePageTitle(title: string, icon?: IconType) {
171
171
  }, [title]);
172
172
  }
173
173
 
174
+ /** Control kinds the Filter panel can render for a `filterable` field. Omitted
175
+ * or unknown kinds render a text input — extend this union as you add controls
176
+ * (e.g. `"daterange"`, `"multiselect"`). */
177
+ export type FilterControl =
178
+ | "text"
179
+ | "number"
180
+ | "date"
181
+ | "select"
182
+ | "combobox"
183
+ | "checkbox";
184
+
185
+ /** Per-field Filter-panel config. `filterable: true` is shorthand for
186
+ * `{ control: "text" }`; pass an object to pick the control and shape it, so
187
+ * the front end can compose a different filter form per request (Name + Code as
188
+ * text for one screen, a status dropdown + tag checkboxes for another). */
189
+ export interface FieldFilter {
190
+ /** Which control to render. Default `"text"`. */
191
+ control?: FilterControl;
192
+ /** Label above the control. Defaults to the field's `label`. */
193
+ label?: string;
194
+ /** Placeholder for text / number / combobox inputs. */
195
+ 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 }[];
199
+ }
200
+
201
+ /** Values collected by the Filter panel, keyed by field. Single-value controls
202
+ * yield a `string`; multi-select `checkbox` yields a `string[]`. This object is
203
+ * the contract you hand to your own query / refetch via {@link RecordView}'s
204
+ * `onFilter` — in per-field mode the panel gathers values but does not match
205
+ * rows itself, so the search is yours (client-side or server-side). */
206
+ export type FilterValues<T> = Partial<
207
+ Record<Extract<keyof T, string>, string | string[]>
208
+ >;
209
+
174
210
  export interface RecordField<T> {
175
211
  key: Extract<keyof T, string>;
176
212
  label: string;
@@ -199,6 +235,13 @@ export interface RecordField<T> {
199
235
  * textarea). `"number"`/`"date"` render the matching native input; a field
200
236
  * with `options` always renders a `Select` regardless of this. */
201
237
  input?: "text" | "number" | "date";
238
+ /** Expose this field in the Filter panel. When ANY field is filterable, the
239
+ * panel switches from the single keyword box to a labeled control per field
240
+ * plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
241
+ * choose the control (dropdown, checkbox, combobox, number, date …) so the
242
+ * filter form is composed per request. The panel only gathers values — wire
243
+ * matching through RecordView's `onFilter`. Omit to leave the field out. */
244
+ filterable?: boolean | FieldFilter;
202
245
  }
203
246
 
204
247
  /**
@@ -292,6 +335,11 @@ interface RecordViewProps<T extends { id: RowId }> {
292
335
  /** Allow dragging column edges to resize them. Off by default — columns
293
336
  * auto-size, and no resize handle appears on hover. */
294
337
  resizableColumns?: boolean;
338
+ /** Called from the Filter panel's Search (and Clear) when fields are
339
+ * `filterable`. Receives the collected per-field values; run your own query
340
+ * or client-side filtering here. In per-field mode the panel does not match
341
+ * rows itself, so the behavior is entirely yours. */
342
+ onFilter?: (values: FilterValues<T>) => void;
295
343
  }
296
344
 
297
345
  export function RecordView<T extends { id: RowId }>({
@@ -313,6 +361,7 @@ export function RecordView<T extends { id: RowId }>({
313
361
  onEdit,
314
362
  persistKey,
315
363
  resizableColumns = false,
364
+ onFilter,
316
365
  }: RecordViewProps<T>) {
317
366
  const { titleLeading } = React.useContext(PageChromeContext);
318
367
  // Surface the page title/icon in the app's global top bar.
@@ -339,6 +388,12 @@ export function RecordView<T extends { id: RowId }>({
339
388
  persistKey ? `${persistKey}::filter` : undefined,
340
389
  "",
341
390
  );
391
+ // Per-field Filter-panel values (opt-in via `field.filterable`). Kept apart
392
+ // from the single-keyword `filter`; persisted like the rest of the view.
393
+ const [filterValues, setFilterValues] = usePersistentState<FilterValues<T>>(
394
+ persistKey ? `${persistKey}::filterValues` : undefined,
395
+ {},
396
+ );
342
397
  const [sort, setSort] = usePersistentState<{
343
398
  key: string;
344
399
  dir: "asc" | "desc";
@@ -405,6 +460,9 @@ export function RecordView<T extends { id: RowId }>({
405
460
 
406
461
  const tableFields = fields.filter((f) => !f.hideInTable);
407
462
  const visibleFields = tableFields.filter((f) => !hidden.has(f.key));
463
+ // Fields opted into per-field filtering. Non-empty → the Filter panel renders
464
+ // a control per field instead of the single keyword box.
465
+ const filterFields = fields.filter((f) => f.filterable);
408
466
 
409
467
  // The primary "Name" column renders the record's name field, which is hidden
410
468
  // as a regular column (hideInTable) because it shows here. Mirror its
@@ -1007,19 +1065,119 @@ export function RecordView<T extends { id: RowId }>({
1007
1065
  </Dropdown>
1008
1066
  )}
1009
1067
  <Dropdown label="Filter" icon={<ListFilter className="size-3.5 text-amber-500" />}>
1010
- <DropdownLabel>Filter by keyword</DropdownLabel>
1011
- <div className="p-3">
1012
- <div className="relative">
1013
- <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1014
- <Input
1015
- value={filter}
1016
- onChange={(e) => setFilter(e.target.value)}
1017
- placeholder="Contains…"
1018
- aria-label="Filter"
1019
- className="h-8 pl-9"
1020
- />
1021
- </div>
1022
- </div>
1068
+ {filterFields.length > 0 ? (
1069
+ // Per-field mode: a labeled control per `filterable` field, plus
1070
+ // Search / Clear. The panel only gathers values — matching is the
1071
+ // consumer's job via `onFilter` (see the field's `filterable`).
1072
+ <>
1073
+ <DropdownLabel>Filter</DropdownLabel>
1074
+ <div className="flex max-h-80 w-72 flex-col gap-3 overflow-y-auto p-3">
1075
+ {filterFields.map((f) => {
1076
+ const cfg: FieldFilter =
1077
+ typeof f.filterable === "object" ? f.filterable : {};
1078
+ const control = cfg.control ?? "text";
1079
+ const label = cfg.label ?? f.label;
1080
+ const opts = cfg.options ?? f.options ?? [];
1081
+ const raw = filterValues[f.key];
1082
+ const setVal = (v: string | string[]) =>
1083
+ setFilterValues((prev) => ({ ...prev, [f.key]: v }));
1084
+ return (
1085
+ <div key={f.key} className="flex flex-col gap-1">
1086
+ <label className="text-xs font-medium text-muted-foreground">
1087
+ {label}
1088
+ </label>
1089
+ {control === "select" || control === "combobox" ? (
1090
+ <Select
1091
+ value={typeof raw === "string" ? raw : ""}
1092
+ onValueChange={setVal}
1093
+ options={opts}
1094
+ ariaLabel={label}
1095
+ placeholder={
1096
+ cfg.placeholder ?? `Any ${label.toLowerCase()}`
1097
+ }
1098
+ className="w-full"
1099
+ />
1100
+ ) : control === "checkbox" ? (
1101
+ <div className="flex flex-col gap-1">
1102
+ {opts.map((o) => {
1103
+ const arr = Array.isArray(raw) ? raw : [];
1104
+ const on = arr.includes(o.value);
1105
+ return (
1106
+ <label
1107
+ key={o.value}
1108
+ className="flex items-center gap-2 text-sm"
1109
+ >
1110
+ <input
1111
+ type="checkbox"
1112
+ checked={on}
1113
+ onChange={() =>
1114
+ setVal(
1115
+ on
1116
+ ? arr.filter((v) => v !== o.value)
1117
+ : [...arr, o.value],
1118
+ )
1119
+ }
1120
+ />
1121
+ {o.label}
1122
+ </label>
1123
+ );
1124
+ })}
1125
+ </div>
1126
+ ) : (
1127
+ <Input
1128
+ type={
1129
+ control === "number"
1130
+ ? "number"
1131
+ : control === "date"
1132
+ ? "date"
1133
+ : "text"
1134
+ }
1135
+ value={typeof raw === "string" ? raw : ""}
1136
+ onChange={(e) => setVal(e.target.value)}
1137
+ placeholder={cfg.placeholder ?? "Contains…"}
1138
+ aria-label={label}
1139
+ className="h-8"
1140
+ />
1141
+ )}
1142
+ </div>
1143
+ );
1144
+ })}
1145
+ <div className="flex items-center justify-end gap-2 border-t border-border pt-3">
1146
+ <Button
1147
+ onClick={() => {
1148
+ setFilterValues({});
1149
+ onFilter?.({});
1150
+ }}
1151
+ >
1152
+ Clear
1153
+ </Button>
1154
+ <Button
1155
+ variant="primary"
1156
+ onClick={() => onFilter?.(filterValues)}
1157
+ >
1158
+ <Search className="size-3.5" />
1159
+ Search
1160
+ </Button>
1161
+ </div>
1162
+ </div>
1163
+ </>
1164
+ ) : (
1165
+ <>
1166
+ <DropdownLabel>Filter by keyword</DropdownLabel>
1167
+ <div className="p-3">
1168
+ <div className="relative">
1169
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1170
+ <Input
1171
+ value={filter}
1172
+ onChange={(e) => setFilter(e.target.value)}
1173
+ placeholder="Contains…"
1174
+ aria-label="Filter"
1175
+ className="h-8 pl-9"
1176
+ />
1177
+ </div>
1178
+ </div>
1179
+ </>
1180
+ )}
1023
1181
  </Dropdown>
1024
1182
 
1025
1183
  <Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
@@ -1,26 +1,83 @@
1
1
  "use client";
2
2
 
3
- import {
4
- CodeIcon as Hash,
5
- GlobeIcon as Globe,
6
- } from "@radix-ui/react-icons";
3
+ import { useMemo, useState } from "react";
4
+ import { CodeIcon as Hash, GlobeIcon as Globe } from "@radix-ui/react-icons";
7
5
 
8
- import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
6
+ import {
7
+ RecordView,
8
+ type FilterValues,
9
+ type RecordField,
10
+ } from "@viliha/vui-ui/record-view";
9
11
  import { regions, type Region } from "@/lib/mock-data";
10
12
 
13
+ // `filterable` opts a field into the Filter panel. With any field filterable the
14
+ // panel shows a labeled control per field + Search/Clear instead of the single
15
+ // keyword box. `true` = a text input; pass a config to pick the control.
11
16
  const fields: RecordField<Region>[] = [
12
- { key: "name", label: "Name", editable: true, required: true, group: "General", hideInTable: true },
13
- { key: "code", label: "Code", icon: Hash, editable: true, group: "General" },
17
+ {
18
+ key: "name",
19
+ label: "Name",
20
+ editable: true,
21
+ required: true,
22
+ group: "General",
23
+ hideInTable: true,
24
+ filterable: true,
25
+ },
26
+ {
27
+ key: "code",
28
+ label: "Code",
29
+ icon: Hash,
30
+ editable: true,
31
+ group: "General",
32
+ filterable: { control: "text", placeholder: "e.g. APAC" },
33
+ },
14
34
  ];
15
35
 
16
36
  export function RegionsTable() {
37
+ const [all, setAll] = useState<Region[]>(regions);
38
+ const [filters, setFilters] = useState<FilterValues<Region>>({});
39
+
40
+ // Demo: match the per-field values client-side. The component does NOT filter
41
+ // in per-field mode — `onFilter` hands you the values; swap this for a server
42
+ // query in a real app. ponytail: naive contains-match, fine for demo data.
43
+ const rows = useMemo(
44
+ () =>
45
+ all.filter((r) =>
46
+ Object.entries(filters).every(([k, v]) => {
47
+ const needle = String(v ?? "").toLowerCase();
48
+ return (
49
+ !needle ||
50
+ String(r[k as keyof Region]).toLowerCase().includes(needle)
51
+ );
52
+ }),
53
+ ),
54
+ [all, filters],
55
+ );
56
+
57
+ // Reconcile edits/adds/deletes made on the filtered view back into `all`.
58
+ const handleChange = (next: Region[]) => {
59
+ setAll((prev) => {
60
+ const nextById = new Map(next.map((r) => [r.id, r] as const));
61
+ const visible = new Set(rows.map((r) => r.id));
62
+ const kept = prev
63
+ .filter((r) => !visible.has(r.id) || nextById.has(r.id)) // drop deleted
64
+ .map((r) => nextById.get(r.id) ?? r); // apply edits
65
+ const added = next.filter((r) => !prev.some((p) => p.id === r.id));
66
+ return [...kept, ...added];
67
+ });
68
+ };
69
+
17
70
  return (
18
71
  <RecordView
19
72
  title="Regions"
20
73
  singular="Region"
21
74
  icon={Globe}
75
+ persistKey="/system/regions"
22
76
  fields={fields}
23
- initialData={regions}
77
+ initialData={rows}
78
+ data={rows}
79
+ onDataChange={handleChange}
80
+ onFilter={setFilters}
24
81
  getPrimary={(row) => ({
25
82
  title: row.name,
26
83
  subtitle: row.code,