@viliha/vui-ui 1.7.0 → 1.9.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,12 @@ 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
+ 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
+
339
+ ## Filtering
340
+
341
+ 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.
342
+
337
343
  ## Add / edit form
338
344
 
339
345
  The buffered add/edit form renders in one of two layouts:
package/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ 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.9.0 — 2026-07-26
11
+
12
+ ### Added
13
+
14
+ - **`sortable` field flag in `RecordView`** — decouples sorting from column
15
+ visibility. By default a field is sortable iff it's a visible column
16
+ (`!hideInTable`), unchanged. Now:
17
+ - `sortable: true` sorts a field with **no column** (e.g. a `hideInTable` name
18
+ shown via `getPrimary`) — it appears in the Sort dropdown.
19
+ - `sortable: false` keeps a **visible column unsortable** (its header stops
20
+ being a sort toggle).
21
+
22
+ Affects both the Sort dropdown and the column-header click. Fully backward
23
+ compatible — omit the flag for today's behavior.
24
+
25
+ **For agents:** to make a non-column field sortable (or lock a column), set
26
+ `sortable` on the `RecordField`; don't rewire the Sort dropdown or headers.
27
+ Demo: `system/regions` sorts by Name (which has no column).
28
+
29
+ ## 1.8.0 — 2026-07-26
30
+
31
+ ### Added
32
+
33
+ - **Per-field filtering in `RecordView`** (opt-in, backward compatible). Mark a
34
+ field `filterable` and the Filter panel switches from the single keyword box to
35
+ a labeled control per field plus **Search / Clear**. The control is dynamic so
36
+ the front end can compose a different filter form per request:
37
+ - `filterable: true` → a text input.
38
+ - `filterable: { control, label, placeholder, options }` → pick the control:
39
+ `"text" | "number" | "date" | "select" | "combobox" | "checkbox"` (unknown or
40
+ omitted → text). `options` falls back to the field's own `options`.
41
+ - New exported types: `FilterControl`, `FieldFilter`, `FilterValues<T>`.
42
+ - The panel **gathers values only** — it does not match rows in per-field mode.
43
+ Wire matching through the new `RecordView` prop `onFilter(values)` (Search and
44
+ Clear both call it), typically a server query or your own client filter. The
45
+ single-keyword box (and its built-in row matching) is unchanged when no field
46
+ is `filterable`.
47
+
48
+ **For agents:** to add per-field filters, only set `filterable` on the relevant
49
+ `RecordField`s and handle `onFilter` — never hand-roll a filter form. Extend
50
+ `FilterControl` when a new control kind is needed. Demo: `system/regions`
51
+ (Name + Code). Docs: `/docs/data-table`.
52
+
10
53
  ## 1.7.0 — 2026-07-25
11
54
 
12
55
  ### 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.9.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;
@@ -190,6 +226,12 @@ export interface RecordField<T> {
190
226
  copyable?: boolean;
191
227
  /** Show in the detail panel only, not as a table column (e.g. first/last name). */
192
228
  hideInTable?: boolean;
229
+ /** Whether this field can be sorted — decoupled from column visibility.
230
+ * Default: sortable iff it's a visible column (`!hideInTable`), the historic
231
+ * behavior. Set `true` to sort a field with no column (e.g. a `hideInTable`
232
+ * name shown via `getPrimary`); set `false` to keep a visible column
233
+ * unsortable. Controls both the Sort dropdown and the column-header toggle. */
234
+ sortable?: boolean;
193
235
  /** Custom, non-editable cell/value renderer. */
194
236
  render?: (row: T) => React.ReactNode;
195
237
  /** If set, the field becomes a choice field: the Add/Edit form renders a
@@ -199,6 +241,13 @@ export interface RecordField<T> {
199
241
  * textarea). `"number"`/`"date"` render the matching native input; a field
200
242
  * with `options` always renders a `Select` regardless of this. */
201
243
  input?: "text" | "number" | "date";
244
+ /** Expose this field in the Filter panel. When ANY field is filterable, the
245
+ * panel switches from the single keyword box to a labeled control per field
246
+ * plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
247
+ * choose the control (dropdown, checkbox, combobox, number, date …) so the
248
+ * filter form is composed per request. The panel only gathers values — wire
249
+ * matching through RecordView's `onFilter`. Omit to leave the field out. */
250
+ filterable?: boolean | FieldFilter;
202
251
  }
203
252
 
204
253
  /**
@@ -292,6 +341,11 @@ interface RecordViewProps<T extends { id: RowId }> {
292
341
  /** Allow dragging column edges to resize them. Off by default — columns
293
342
  * auto-size, and no resize handle appears on hover. */
294
343
  resizableColumns?: boolean;
344
+ /** Called from the Filter panel's Search (and Clear) when fields are
345
+ * `filterable`. Receives the collected per-field values; run your own query
346
+ * or client-side filtering here. In per-field mode the panel does not match
347
+ * rows itself, so the behavior is entirely yours. */
348
+ onFilter?: (values: FilterValues<T>) => void;
295
349
  }
296
350
 
297
351
  export function RecordView<T extends { id: RowId }>({
@@ -313,6 +367,7 @@ export function RecordView<T extends { id: RowId }>({
313
367
  onEdit,
314
368
  persistKey,
315
369
  resizableColumns = false,
370
+ onFilter,
316
371
  }: RecordViewProps<T>) {
317
372
  const { titleLeading } = React.useContext(PageChromeContext);
318
373
  // Surface the page title/icon in the app's global top bar.
@@ -339,6 +394,12 @@ export function RecordView<T extends { id: RowId }>({
339
394
  persistKey ? `${persistKey}::filter` : undefined,
340
395
  "",
341
396
  );
397
+ // Per-field Filter-panel values (opt-in via `field.filterable`). Kept apart
398
+ // from the single-keyword `filter`; persisted like the rest of the view.
399
+ const [filterValues, setFilterValues] = usePersistentState<FilterValues<T>>(
400
+ persistKey ? `${persistKey}::filterValues` : undefined,
401
+ {},
402
+ );
342
403
  const [sort, setSort] = usePersistentState<{
343
404
  key: string;
344
405
  dir: "asc" | "desc";
@@ -405,6 +466,15 @@ export function RecordView<T extends { id: RowId }>({
405
466
 
406
467
  const tableFields = fields.filter((f) => !f.hideInTable);
407
468
  const visibleFields = tableFields.filter((f) => !hidden.has(f.key));
469
+ // Sorting is decoupled from column visibility: a field is sortable when its
470
+ // `sortable` flag says so, else it falls back to "is a visible column".
471
+ const canSort = (f: RecordField<T>) => f.sortable ?? !f.hideInTable;
472
+ // Fields offered in the Sort dropdown (may include hidden-but-sortable fields
473
+ // and exclude visible-but-unsortable ones).
474
+ const sortFields = fields.filter(canSort);
475
+ // Fields opted into per-field filtering. Non-empty → the Filter panel renders
476
+ // a control per field instead of the single keyword box.
477
+ const filterFields = fields.filter((f) => f.filterable);
408
478
 
409
479
  // The primary "Name" column renders the record's name field, which is hidden
410
480
  // as a regular column (hideInTable) because it shows here. Mirror its
@@ -1007,24 +1077,124 @@ export function RecordView<T extends { id: RowId }>({
1007
1077
  </Dropdown>
1008
1078
  )}
1009
1079
  <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>
1080
+ {filterFields.length > 0 ? (
1081
+ // Per-field mode: a labeled control per `filterable` field, plus
1082
+ // Search / Clear. The panel only gathers values — matching is the
1083
+ // consumer's job via `onFilter` (see the field's `filterable`).
1084
+ <>
1085
+ <DropdownLabel>Filter</DropdownLabel>
1086
+ <div className="flex max-h-80 w-72 flex-col gap-3 overflow-y-auto p-3">
1087
+ {filterFields.map((f) => {
1088
+ const cfg: FieldFilter =
1089
+ typeof f.filterable === "object" ? f.filterable : {};
1090
+ const control = cfg.control ?? "text";
1091
+ const label = cfg.label ?? f.label;
1092
+ const opts = cfg.options ?? f.options ?? [];
1093
+ const raw = filterValues[f.key];
1094
+ const setVal = (v: string | string[]) =>
1095
+ setFilterValues((prev) => ({ ...prev, [f.key]: v }));
1096
+ return (
1097
+ <div key={f.key} className="flex flex-col gap-1">
1098
+ <label className="text-xs font-medium text-muted-foreground">
1099
+ {label}
1100
+ </label>
1101
+ {control === "select" || control === "combobox" ? (
1102
+ <Select
1103
+ value={typeof raw === "string" ? raw : ""}
1104
+ onValueChange={setVal}
1105
+ options={opts}
1106
+ ariaLabel={label}
1107
+ placeholder={
1108
+ cfg.placeholder ?? `Any ${label.toLowerCase()}`
1109
+ }
1110
+ className="w-full"
1111
+ />
1112
+ ) : control === "checkbox" ? (
1113
+ <div className="flex flex-col gap-1">
1114
+ {opts.map((o) => {
1115
+ const arr = Array.isArray(raw) ? raw : [];
1116
+ const on = arr.includes(o.value);
1117
+ return (
1118
+ <label
1119
+ key={o.value}
1120
+ className="flex items-center gap-2 text-sm"
1121
+ >
1122
+ <input
1123
+ type="checkbox"
1124
+ checked={on}
1125
+ onChange={() =>
1126
+ setVal(
1127
+ on
1128
+ ? arr.filter((v) => v !== o.value)
1129
+ : [...arr, o.value],
1130
+ )
1131
+ }
1132
+ />
1133
+ {o.label}
1134
+ </label>
1135
+ );
1136
+ })}
1137
+ </div>
1138
+ ) : (
1139
+ <Input
1140
+ type={
1141
+ control === "number"
1142
+ ? "number"
1143
+ : control === "date"
1144
+ ? "date"
1145
+ : "text"
1146
+ }
1147
+ value={typeof raw === "string" ? raw : ""}
1148
+ onChange={(e) => setVal(e.target.value)}
1149
+ placeholder={cfg.placeholder ?? "Contains…"}
1150
+ aria-label={label}
1151
+ className="h-8"
1152
+ />
1153
+ )}
1154
+ </div>
1155
+ );
1156
+ })}
1157
+ <div className="flex items-center justify-end gap-2 border-t border-border pt-3">
1158
+ <Button
1159
+ onClick={() => {
1160
+ setFilterValues({});
1161
+ onFilter?.({});
1162
+ }}
1163
+ >
1164
+ Clear
1165
+ </Button>
1166
+ <Button
1167
+ variant="primary"
1168
+ onClick={() => onFilter?.(filterValues)}
1169
+ >
1170
+ <Search className="size-3.5" />
1171
+ Search
1172
+ </Button>
1173
+ </div>
1174
+ </div>
1175
+ </>
1176
+ ) : (
1177
+ <>
1178
+ <DropdownLabel>Filter by keyword</DropdownLabel>
1179
+ <div className="p-3">
1180
+ <div className="relative">
1181
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1182
+ <Input
1183
+ value={filter}
1184
+ onChange={(e) => setFilter(e.target.value)}
1185
+ placeholder="Contains…"
1186
+ aria-label="Filter"
1187
+ className="h-8 pl-9"
1188
+ />
1189
+ </div>
1190
+ </div>
1191
+ </>
1192
+ )}
1023
1193
  </Dropdown>
1024
1194
 
1025
1195
  <Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
1026
1196
  <DropdownLabel>Sort by</DropdownLabel>
1027
- {tableFields.map((f) => (
1197
+ {sortFields.map((f) => (
1028
1198
  <DropdownItem
1029
1199
  key={f.key}
1030
1200
  onSelect={() => toggleSort(f.key)}
@@ -1142,32 +1312,44 @@ export function RecordView<T extends { id: RowId }>({
1142
1312
  </TableHead>
1143
1313
  {visibleFields.map((f) => {
1144
1314
  const HeadIcon = f.icon ?? DEFAULT_FIELD_ICON;
1315
+ const sortable = canSort(f);
1316
+ const headInner = (
1317
+ <>
1318
+ <HeadIcon className="size-3.5 shrink-0 text-foreground" />
1319
+ <span className="flex items-center gap-1 whitespace-nowrap">
1320
+ {f.label}
1321
+ {f.required && <RequiredMark />}
1322
+ </span>
1323
+ {sort?.key === f.key &&
1324
+ (sort.dir === "asc" ? (
1325
+ <ArrowUp className="size-3 shrink-0" />
1326
+ ) : (
1327
+ <ArrowDown className="size-3 shrink-0" />
1328
+ ))}
1329
+ </>
1330
+ );
1331
+ const headClass = cn(
1332
+ "flex h-8 w-full items-center gap-1.5 whitespace-nowrap",
1333
+ ALIGN_BOX[alignOf(f.key)],
1334
+ );
1145
1335
  return (
1146
1336
  <TableHead
1147
1337
  key={f.key}
1148
1338
  className="relative w-max"
1149
1339
  style={{ width: colWidths[f.key] }}
1150
1340
  >
1151
- <button
1152
- type="button"
1153
- onClick={() => toggleSort(f.key)}
1154
- className={cn(
1155
- "flex h-8 w-full items-center gap-1.5 whitespace-nowrap hover:text-foreground",
1156
- ALIGN_BOX[alignOf(f.key)],
1157
- )}
1158
- >
1159
- <HeadIcon className="size-3.5 shrink-0 text-foreground" />
1160
- <span className="flex items-center gap-1 whitespace-nowrap">
1161
- {f.label}
1162
- {f.required && <RequiredMark />}
1163
- </span>
1164
- {sort?.key === f.key &&
1165
- (sort.dir === "asc" ? (
1166
- <ArrowUp className="size-3 shrink-0" />
1167
- ) : (
1168
- <ArrowDown className="size-3 shrink-0" />
1169
- ))}
1170
- </button>
1341
+ {sortable ? (
1342
+ <button
1343
+ type="button"
1344
+ onClick={() => toggleSort(f.key)}
1345
+ className={cn(headClass, "hover:text-foreground")}
1346
+ >
1347
+ {headInner}
1348
+ </button>
1349
+ ) : (
1350
+ // Not sortable: a static label, no toggle / hover affordance.
1351
+ <span className={headClass}>{headInner}</span>
1352
+ )}
1171
1353
  {resizeHandle(f.key, f.label)}
1172
1354
  </TableHead>
1173
1355
  );
@@ -1,26 +1,86 @@
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
+ // No Name column (shown in the identity cell via getPrimary), but still
25
+ // sortable from the Sort dropdown — `sortable` decouples it from visibility.
26
+ sortable: true,
27
+ filterable: true,
28
+ },
29
+ {
30
+ key: "code",
31
+ label: "Code",
32
+ icon: Hash,
33
+ editable: true,
34
+ group: "General",
35
+ filterable: { control: "text", placeholder: "e.g. APAC" },
36
+ },
14
37
  ];
15
38
 
16
39
  export function RegionsTable() {
40
+ const [all, setAll] = useState<Region[]>(regions);
41
+ const [filters, setFilters] = useState<FilterValues<Region>>({});
42
+
43
+ // Demo: match the per-field values client-side. The component does NOT filter
44
+ // in per-field mode — `onFilter` hands you the values; swap this for a server
45
+ // query in a real app. ponytail: naive contains-match, fine for demo data.
46
+ const rows = useMemo(
47
+ () =>
48
+ all.filter((r) =>
49
+ Object.entries(filters).every(([k, v]) => {
50
+ const needle = String(v ?? "").toLowerCase();
51
+ return (
52
+ !needle ||
53
+ String(r[k as keyof Region]).toLowerCase().includes(needle)
54
+ );
55
+ }),
56
+ ),
57
+ [all, filters],
58
+ );
59
+
60
+ // Reconcile edits/adds/deletes made on the filtered view back into `all`.
61
+ const handleChange = (next: Region[]) => {
62
+ setAll((prev) => {
63
+ const nextById = new Map(next.map((r) => [r.id, r] as const));
64
+ const visible = new Set(rows.map((r) => r.id));
65
+ const kept = prev
66
+ .filter((r) => !visible.has(r.id) || nextById.has(r.id)) // drop deleted
67
+ .map((r) => nextById.get(r.id) ?? r); // apply edits
68
+ const added = next.filter((r) => !prev.some((p) => p.id === r.id));
69
+ return [...kept, ...added];
70
+ });
71
+ };
72
+
17
73
  return (
18
74
  <RecordView
19
75
  title="Regions"
20
76
  singular="Region"
21
77
  icon={Globe}
78
+ persistKey="/system/regions"
22
79
  fields={fields}
23
- initialData={regions}
80
+ initialData={rows}
81
+ data={rows}
82
+ onDataChange={handleChange}
83
+ onFilter={setFilters}
24
84
  getPrimary={(row) => ({
25
85
  title: row.name,
26
86
  subtitle: row.code,