@viliha/vui-ui 1.9.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 +4 -0
- package/CHANGELOG.md +46 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/combobox.tsx +233 -0
- package/src/record-view.tsx +144 -24
- package/template/app/(app)/organizations/organizations-config.tsx +20 -0
- package/template/app/(app)/system/cities/cities-table.tsx +76 -4
package/AGENT.md
CHANGED
|
@@ -336,6 +336,10 @@ Never hand-build an HTML table; always use `RecordView`. Configure columns throu
|
|
|
336
336
|
|
|
337
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
338
|
|
|
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
|
+
|
|
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
|
+
|
|
339
343
|
## Filtering
|
|
340
344
|
|
|
341
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,52 @@ 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
|
+
|
|
33
|
+
## 1.10.0 — 2026-07-26
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`Combobox`** (`@viliha/vui-ui/combobox`) — a searchable single-select. Same
|
|
38
|
+
API as `Select` (drop-in) but the popover leads with a type-to-filter input, so
|
|
39
|
+
it scales to long option lists (an FK / country picker). Keyboard: type to
|
|
40
|
+
filter, ↑/↓ to move, Enter to pick, Esc to close.
|
|
41
|
+
- **`RecordView` choice fields can use the Combobox.** In the Add/Edit form set
|
|
42
|
+
`input: "combobox"` on an `options` field for a searchable control (default
|
|
43
|
+
stays `Select`). In the Filter panel, `control: "combobox"` now renders the
|
|
44
|
+
real searchable Combobox (previously it fell back to a plain Select).
|
|
45
|
+
- **`RecordField.renderInput`** — an escape hatch to render **any** Add/Edit
|
|
46
|
+
control (checkbox, radio group, slider, date-range, a custom widget). It
|
|
47
|
+
overrides the default control and receives `{ value, onChange, field, invalid }`;
|
|
48
|
+
the field still owns the label, required mark, and Save validation. The
|
|
49
|
+
Organizations form demonstrates it (Status as a radio group).
|
|
50
|
+
|
|
51
|
+
**For agents:** for a long choice list use `input: "combobox"`; for a control
|
|
52
|
+
the built-ins don't cover use `renderInput` — never hand-build a `<form>`.
|
|
53
|
+
New demo: Organizations Status (radio via `renderInput`); Combobox is also in
|
|
54
|
+
the components gallery. Docs: `/docs/components`, `/docs/data-table`.
|
|
55
|
+
|
|
10
56
|
## 1.9.0 — 2026-07-26
|
|
11
57
|
|
|
12
58
|
### Added
|
package/README.md
CHANGED
|
@@ -249,9 +249,10 @@ place. If you'd rather keep a verbatim copy, run
|
|
|
249
249
|
## Components
|
|
250
250
|
|
|
251
251
|
`avatar` · `badge` · `breadcrumbs` · `button` · `card` · `chart` (themed Recharts
|
|
252
|
-
wrapper) · `checkbox` · `
|
|
253
|
-
`confirm-dialog` · `dropdown-menu` · `input` · `kbd`
|
|
254
|
-
`menu` · `required-mark` · `select` · `steps`
|
|
252
|
+
wrapper) · `checkbox` · `combobox` (searchable single-select) · `command-palette`
|
|
253
|
+
(⌘K launcher) · `dialog` · `confirm-dialog` · `dropdown-menu` · `input` · `kbd`
|
|
254
|
+
(key caps + `Shortcut`) · `menu` · `required-mark` · `select` · `steps`
|
|
255
|
+
(multi-step wizard indicator) ·
|
|
255
256
|
`table` · `record-view` (the full datatable + `RecordForm`) · plus the `utils`
|
|
256
257
|
(`cn`) helper and the `theme.css` design tokens.
|
|
257
258
|
|
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/combobox.tsx
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import {
|
|
6
|
+
CheckIcon as Check,
|
|
7
|
+
ChevronDownIcon as ChevronDown,
|
|
8
|
+
MagnifyingGlassIcon as Search,
|
|
9
|
+
} from "@radix-ui/react-icons";
|
|
10
|
+
|
|
11
|
+
import { cn } from "./utils";
|
|
12
|
+
import type { SelectOption } from "./select";
|
|
13
|
+
|
|
14
|
+
type Placement = {
|
|
15
|
+
left: number;
|
|
16
|
+
width: number;
|
|
17
|
+
top?: number;
|
|
18
|
+
bottom?: number;
|
|
19
|
+
maxHeight: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Searchable single-select. Same trigger + portal-popover as {@link Select}, but
|
|
24
|
+
* the popover leads with a filter input that type-narrows the options, so it
|
|
25
|
+
* scales to long lists (an FK / country picker) where a plain Select doesn't.
|
|
26
|
+
* Same API as Select (drop-in), plus search/empty placeholders. Keyboard: type
|
|
27
|
+
* to filter, ↑/↓ to move, Enter to pick, Esc to close.
|
|
28
|
+
*/
|
|
29
|
+
export function Combobox({
|
|
30
|
+
value,
|
|
31
|
+
onValueChange,
|
|
32
|
+
options,
|
|
33
|
+
id,
|
|
34
|
+
ariaLabel,
|
|
35
|
+
placeholder = "Select…",
|
|
36
|
+
searchPlaceholder = "Search…",
|
|
37
|
+
emptyText = "No matches",
|
|
38
|
+
className,
|
|
39
|
+
}: {
|
|
40
|
+
value: string;
|
|
41
|
+
onValueChange: (value: string) => void;
|
|
42
|
+
options: SelectOption[];
|
|
43
|
+
id?: string;
|
|
44
|
+
ariaLabel?: string;
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
/** Placeholder for the filter input. */
|
|
47
|
+
searchPlaceholder?: string;
|
|
48
|
+
/** Shown when the query matches nothing. */
|
|
49
|
+
emptyText?: string;
|
|
50
|
+
/** Applied to the root (e.g. width in a flex row). */
|
|
51
|
+
className?: string;
|
|
52
|
+
}) {
|
|
53
|
+
const [open, setOpen] = React.useState(false);
|
|
54
|
+
const [query, setQuery] = React.useState("");
|
|
55
|
+
const [active, setActive] = React.useState(0);
|
|
56
|
+
const [pos, setPos] = React.useState<Placement | null>(null);
|
|
57
|
+
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
58
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
59
|
+
const listRef = React.useRef<HTMLDivElement>(null);
|
|
60
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
61
|
+
|
|
62
|
+
const filtered = React.useMemo(() => {
|
|
63
|
+
const q = query.trim().toLowerCase();
|
|
64
|
+
return q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options;
|
|
65
|
+
}, [options, query]);
|
|
66
|
+
|
|
67
|
+
const place = React.useCallback(() => {
|
|
68
|
+
const el = triggerRef.current;
|
|
69
|
+
if (!el) return;
|
|
70
|
+
const r = el.getBoundingClientRect();
|
|
71
|
+
const below = window.innerHeight - r.bottom;
|
|
72
|
+
const above = r.top;
|
|
73
|
+
const openUp = below < 280 && above > below;
|
|
74
|
+
setPos({
|
|
75
|
+
left: r.left,
|
|
76
|
+
width: r.width,
|
|
77
|
+
top: openUp ? undefined : r.bottom + 4,
|
|
78
|
+
bottom: openUp ? window.innerHeight - r.top + 4 : undefined,
|
|
79
|
+
maxHeight: Math.min(300, (openUp ? above : below) - 8),
|
|
80
|
+
});
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
React.useLayoutEffect(() => {
|
|
84
|
+
if (!open) return;
|
|
85
|
+
place();
|
|
86
|
+
const reflow = () => place();
|
|
87
|
+
window.addEventListener("scroll", reflow, true);
|
|
88
|
+
window.addEventListener("resize", reflow);
|
|
89
|
+
return () => {
|
|
90
|
+
window.removeEventListener("scroll", reflow, true);
|
|
91
|
+
window.removeEventListener("resize", reflow);
|
|
92
|
+
};
|
|
93
|
+
}, [open, place]);
|
|
94
|
+
|
|
95
|
+
// Reset the query + focus the input each time it opens.
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (!open) return;
|
|
98
|
+
setQuery("");
|
|
99
|
+
setActive(0);
|
|
100
|
+
const t = requestAnimationFrame(() => inputRef.current?.focus());
|
|
101
|
+
return () => cancelAnimationFrame(t);
|
|
102
|
+
}, [open]);
|
|
103
|
+
|
|
104
|
+
React.useEffect(() => {
|
|
105
|
+
if (!open) return;
|
|
106
|
+
const onDown = (e: MouseEvent) => {
|
|
107
|
+
const t = e.target as Node;
|
|
108
|
+
if (rootRef.current?.contains(t) || listRef.current?.contains(t)) return;
|
|
109
|
+
setOpen(false);
|
|
110
|
+
};
|
|
111
|
+
document.addEventListener("mousedown", onDown);
|
|
112
|
+
return () => document.removeEventListener("mousedown", onDown);
|
|
113
|
+
}, [open]);
|
|
114
|
+
|
|
115
|
+
// Keep the active index in range as the filtered list shrinks/grows.
|
|
116
|
+
React.useEffect(() => {
|
|
117
|
+
setActive((i) => Math.min(i, Math.max(0, filtered.length - 1)));
|
|
118
|
+
}, [filtered.length]);
|
|
119
|
+
|
|
120
|
+
const selected = options.find((o) => o.value === value);
|
|
121
|
+
const commit = (v: string) => {
|
|
122
|
+
onValueChange(v);
|
|
123
|
+
setOpen(false);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
127
|
+
if (e.key === "ArrowDown") {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
setActive((i) => Math.min(i + 1, filtered.length - 1));
|
|
130
|
+
} else if (e.key === "ArrowUp") {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
setActive((i) => Math.max(i - 1, 0));
|
|
133
|
+
} else if (e.key === "Enter") {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
const o = filtered[active];
|
|
136
|
+
if (o) commit(o.value);
|
|
137
|
+
} else if (e.key === "Escape") {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
setOpen(false);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className={cn("relative", className)} ref={rootRef}>
|
|
145
|
+
<button
|
|
146
|
+
ref={triggerRef}
|
|
147
|
+
type="button"
|
|
148
|
+
id={id}
|
|
149
|
+
aria-haspopup="listbox"
|
|
150
|
+
aria-expanded={open}
|
|
151
|
+
aria-label={ariaLabel}
|
|
152
|
+
onClick={() => setOpen((v) => !v)}
|
|
153
|
+
className={cn(
|
|
154
|
+
"flex h-8 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 transition-colors",
|
|
155
|
+
"hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
<span className={cn("truncate", !selected && "text-muted-foreground")}>
|
|
159
|
+
{selected ? selected.label : placeholder}
|
|
160
|
+
</span>
|
|
161
|
+
<ChevronDown
|
|
162
|
+
className={cn(
|
|
163
|
+
"size-3.5 shrink-0 text-muted-foreground transition-transform",
|
|
164
|
+
open && "rotate-180",
|
|
165
|
+
)}
|
|
166
|
+
aria-hidden="true"
|
|
167
|
+
/>
|
|
168
|
+
</button>
|
|
169
|
+
{open &&
|
|
170
|
+
pos &&
|
|
171
|
+
typeof document !== "undefined" &&
|
|
172
|
+
createPortal(
|
|
173
|
+
<div
|
|
174
|
+
ref={listRef}
|
|
175
|
+
style={{
|
|
176
|
+
position: "fixed",
|
|
177
|
+
left: pos.left,
|
|
178
|
+
width: pos.width,
|
|
179
|
+
top: pos.top,
|
|
180
|
+
bottom: pos.bottom,
|
|
181
|
+
maxHeight: pos.maxHeight,
|
|
182
|
+
}}
|
|
183
|
+
className="vui-pop-in z-[200] flex flex-col overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md"
|
|
184
|
+
>
|
|
185
|
+
<div className="relative border-b border-border p-1.5">
|
|
186
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
187
|
+
<input
|
|
188
|
+
ref={inputRef}
|
|
189
|
+
value={query}
|
|
190
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
191
|
+
onKeyDown={onKeyDown}
|
|
192
|
+
placeholder={searchPlaceholder}
|
|
193
|
+
aria-label={ariaLabel ? `Search ${ariaLabel}` : "Search"}
|
|
194
|
+
className="h-8 w-full rounded-sm bg-transparent pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
<div role="listbox" aria-label={ariaLabel} className="overflow-auto">
|
|
198
|
+
{filtered.length === 0 ? (
|
|
199
|
+
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
200
|
+
{emptyText}
|
|
201
|
+
</div>
|
|
202
|
+
) : (
|
|
203
|
+
filtered.map((o, i) => {
|
|
204
|
+
const isSelected = o.value === value;
|
|
205
|
+
return (
|
|
206
|
+
<button
|
|
207
|
+
key={o.value}
|
|
208
|
+
type="button"
|
|
209
|
+
role="option"
|
|
210
|
+
aria-selected={isSelected}
|
|
211
|
+
onMouseEnter={() => setActive(i)}
|
|
212
|
+
onClick={() => commit(o.value)}
|
|
213
|
+
className={cn(
|
|
214
|
+
"flex w-full items-center justify-between gap-2 border-b border-border px-3 py-2 text-left last:border-b-0",
|
|
215
|
+
i === active && "bg-accent text-accent-foreground",
|
|
216
|
+
isSelected && "bg-accent/60",
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
<span className="truncate">{o.label}</span>
|
|
220
|
+
{isSelected && (
|
|
221
|
+
<Check className="size-3.5 shrink-0 text-[var(--button-primary)]" />
|
|
222
|
+
)}
|
|
223
|
+
</button>
|
|
224
|
+
);
|
|
225
|
+
})
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>,
|
|
229
|
+
document.body,
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
package/src/record-view.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import { Button } from "./button";
|
|
|
38
38
|
import { Checkbox } from "./checkbox";
|
|
39
39
|
import { Input } from "./input";
|
|
40
40
|
import { Select } from "./select";
|
|
41
|
+
import { Combobox } from "./combobox";
|
|
41
42
|
import {
|
|
42
43
|
Table,
|
|
43
44
|
TableBody,
|
|
@@ -186,16 +187,21 @@ export type FilterControl =
|
|
|
186
187
|
* `{ control: "text" }`; pass an object to pick the control and shape it, so
|
|
187
188
|
* the front end can compose a different filter form per request (Name + Code as
|
|
188
189
|
* text for one screen, a status dropdown + tag checkboxes for another). */
|
|
189
|
-
export interface FieldFilter {
|
|
190
|
+
export interface FieldFilter<T = Record<string, unknown>> {
|
|
190
191
|
/** Which control to render. Default `"text"`. */
|
|
191
192
|
control?: FilterControl;
|
|
192
193
|
/** Label above the control. Defaults to the field's `label`. */
|
|
193
194
|
label?: string;
|
|
194
195
|
/** Placeholder for text / number / combobox inputs. */
|
|
195
196
|
placeholder?: string;
|
|
196
|
-
/** Choices for `select` / `combobox` / `checkbox`.
|
|
197
|
-
*
|
|
198
|
-
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 }[]);
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
/** Values collected by the Filter panel, keyed by field. Single-value controls
|
|
@@ -235,19 +241,38 @@ export interface RecordField<T> {
|
|
|
235
241
|
/** Custom, non-editable cell/value renderer. */
|
|
236
242
|
render?: (row: T) => React.ReactNode;
|
|
237
243
|
/** If set, the field becomes a choice field: the Add/Edit form renders a
|
|
238
|
-
* `Select
|
|
239
|
-
|
|
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 }[]);
|
|
240
253
|
/** Form control for the Add/Edit panel/page. Default `"text"` (auto-growing
|
|
241
|
-
* textarea). `"number"`/`"date"` render the matching native input
|
|
242
|
-
* with `options
|
|
243
|
-
|
|
254
|
+
* textarea). `"number"`/`"date"` render the matching native input. For a
|
|
255
|
+
* field with `options`: default renders a `Select`, and `"combobox"` renders
|
|
256
|
+
* a searchable `Combobox` (type-to-filter) — use it for long option lists. */
|
|
257
|
+
input?: "text" | "number" | "date" | "combobox";
|
|
258
|
+
/** Render a custom Add/Edit control — a checkbox, a radio group, a color
|
|
259
|
+
* picker, anything. Overrides the default control entirely (and takes
|
|
260
|
+
* priority over `options`/`input`). You get the current string value and an
|
|
261
|
+
* `onChange` to write it back; the surrounding label, required mark, and Save
|
|
262
|
+
* validation still come from the field. Read-only View uses `render`. */
|
|
263
|
+
renderInput?: (props: {
|
|
264
|
+
value: string;
|
|
265
|
+
onChange: (value: string) => void;
|
|
266
|
+
field: RecordField<T>;
|
|
267
|
+
invalid?: boolean;
|
|
268
|
+
}) => React.ReactNode;
|
|
244
269
|
/** Expose this field in the Filter panel. When ANY field is filterable, the
|
|
245
270
|
* panel switches from the single keyword box to a labeled control per field
|
|
246
271
|
* plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
|
|
247
272
|
* choose the control (dropdown, checkbox, combobox, number, date …) so the
|
|
248
273
|
* filter form is composed per request. The panel only gathers values — wire
|
|
249
274
|
* matching through RecordView's `onFilter`. Omit to leave the field out. */
|
|
250
|
-
filterable?: boolean | FieldFilter
|
|
275
|
+
filterable?: boolean | FieldFilter<T>;
|
|
251
276
|
}
|
|
252
277
|
|
|
253
278
|
/**
|
|
@@ -303,6 +328,15 @@ function clearPersisted(key: string | undefined) {
|
|
|
303
328
|
}
|
|
304
329
|
}
|
|
305
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
|
+
|
|
306
340
|
interface RecordViewProps<T extends { id: RowId }> {
|
|
307
341
|
title: string;
|
|
308
342
|
singular: string;
|
|
@@ -563,6 +597,32 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
563
597
|
setPage(1);
|
|
564
598
|
}, [filter, pageSize, setPage]);
|
|
565
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
|
+
|
|
566
626
|
function startEdit(row: T, key: string) {
|
|
567
627
|
setEditing({ id: row.id, key });
|
|
568
628
|
setDraft(String(row[key as keyof T] ?? ""));
|
|
@@ -805,7 +865,11 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
805
865
|
const allSelected =
|
|
806
866
|
processed.length > 0 && selected.size === processed.length;
|
|
807
867
|
// Choice fields (with `options`) power the "Set …" bulk actions.
|
|
808
|
-
|
|
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
|
+
);
|
|
809
873
|
// Per-column alignment (auto: numbers + short codes center).
|
|
810
874
|
const columnAligns = React.useMemo(
|
|
811
875
|
() => computeColumnAligns(fields, initialData),
|
|
@@ -1059,7 +1123,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1059
1123
|
{bulkFields.map((f) => (
|
|
1060
1124
|
<React.Fragment key={f.key}>
|
|
1061
1125
|
<DropdownLabel>Set {f.label}</DropdownLabel>
|
|
1062
|
-
{f.options
|
|
1126
|
+
{(Array.isArray(f.options) ? f.options : []).map((o) => (
|
|
1063
1127
|
<DropdownItem
|
|
1064
1128
|
key={o.value}
|
|
1065
1129
|
onSelect={() => bulkSetField(f.key, o.value)}
|
|
@@ -1085,11 +1149,18 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1085
1149
|
<DropdownLabel>Filter</DropdownLabel>
|
|
1086
1150
|
<div className="flex max-h-80 w-72 flex-col gap-3 overflow-y-auto p-3">
|
|
1087
1151
|
{filterFields.map((f) => {
|
|
1088
|
-
const cfg: FieldFilter =
|
|
1152
|
+
const cfg: FieldFilter<T> =
|
|
1089
1153
|
typeof f.filterable === "object" ? f.filterable : {};
|
|
1090
1154
|
const control = cfg.control ?? "text";
|
|
1091
1155
|
const label = cfg.label ?? f.label;
|
|
1092
|
-
|
|
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 : []));
|
|
1093
1164
|
const raw = filterValues[f.key];
|
|
1094
1165
|
const setVal = (v: string | string[]) =>
|
|
1095
1166
|
setFilterValues((prev) => ({ ...prev, [f.key]: v }));
|
|
@@ -1098,7 +1169,18 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1098
1169
|
<label className="text-xs font-medium text-muted-foreground">
|
|
1099
1170
|
{label}
|
|
1100
1171
|
</label>
|
|
1101
|
-
{control === "
|
|
1172
|
+
{control === "combobox" ? (
|
|
1173
|
+
<Combobox
|
|
1174
|
+
value={typeof raw === "string" ? raw : ""}
|
|
1175
|
+
onValueChange={setVal}
|
|
1176
|
+
options={opts}
|
|
1177
|
+
ariaLabel={label}
|
|
1178
|
+
placeholder={
|
|
1179
|
+
cfg.placeholder ?? `Any ${label.toLowerCase()}`
|
|
1180
|
+
}
|
|
1181
|
+
className="w-full"
|
|
1182
|
+
/>
|
|
1183
|
+
) : control === "select" ? (
|
|
1102
1184
|
<Select
|
|
1103
1185
|
value={typeof raw === "string" ? raw : ""}
|
|
1104
1186
|
onValueChange={setVal}
|
|
@@ -1680,6 +1762,25 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1680
1762
|
setDraft(row);
|
|
1681
1763
|
}, [row, setDraft]);
|
|
1682
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
|
+
|
|
1683
1784
|
const primary = getPrimary(draft);
|
|
1684
1785
|
const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
|
|
1685
1786
|
|
|
@@ -1767,15 +1868,34 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1767
1868
|
{f.render ? (
|
|
1768
1869
|
<div>{f.render(draft)}</div>
|
|
1769
1870
|
) : !readOnly && f.editable ? (
|
|
1770
|
-
f.
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1871
|
+
f.renderInput ? (
|
|
1872
|
+
// Consumer-supplied control (checkbox, radio, custom widget).
|
|
1873
|
+
f.renderInput({
|
|
1874
|
+
value: String(draft[f.key as keyof T] ?? ""),
|
|
1875
|
+
onChange: (v) => setField(f.key as keyof T, v),
|
|
1876
|
+
field: f,
|
|
1877
|
+
invalid: errors.has(f.key),
|
|
1878
|
+
})
|
|
1879
|
+
) : f.options ? (
|
|
1880
|
+
f.input === "combobox" ? (
|
|
1881
|
+
<Combobox
|
|
1882
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1883
|
+
onValueChange={(v) => setField(f.key as keyof T, v)}
|
|
1884
|
+
options={resolveOptions(f.options, draft)}
|
|
1885
|
+
ariaLabel={f.label}
|
|
1886
|
+
placeholder={`Select ${f.label.toLowerCase()}…`}
|
|
1887
|
+
className="w-full"
|
|
1888
|
+
/>
|
|
1889
|
+
) : (
|
|
1890
|
+
<Select
|
|
1891
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1892
|
+
onValueChange={(v) => setField(f.key as keyof T, v)}
|
|
1893
|
+
options={resolveOptions(f.options, draft)}
|
|
1894
|
+
ariaLabel={f.label}
|
|
1895
|
+
placeholder={`Select ${f.label.toLowerCase()}…`}
|
|
1896
|
+
className="w-full"
|
|
1897
|
+
/>
|
|
1898
|
+
)
|
|
1779
1899
|
) : f.input === "number" || f.input === "date" ? (
|
|
1780
1900
|
<Input
|
|
1781
1901
|
type={f.input}
|
|
@@ -65,6 +65,26 @@ export const fields: RecordField<DemoOrganization>[] = [
|
|
|
65
65
|
{ value: "trial", label: "Trial" },
|
|
66
66
|
{ value: "suspended", label: "Suspended" },
|
|
67
67
|
],
|
|
68
|
+
// `renderInput` drops ANY component into the Add/Edit form — here a radio
|
|
69
|
+
// group instead of the default Select. Swap it for a checkbox, a slider, a
|
|
70
|
+
// color picker… the field still owns the label, required mark and Save
|
|
71
|
+
// validation. (View still shows the Badge via `render`.)
|
|
72
|
+
renderInput: ({ value, onChange, field }) => (
|
|
73
|
+
<div role="radiogroup" aria-label={field.label} className="flex flex-wrap gap-4">
|
|
74
|
+
{(Array.isArray(field.options) ? field.options : []).map((o) => (
|
|
75
|
+
<label key={o.value} className="flex items-center gap-1.5 text-sm">
|
|
76
|
+
<input
|
|
77
|
+
type="radio"
|
|
78
|
+
name={`org-${field.key}`}
|
|
79
|
+
checked={value === o.value}
|
|
80
|
+
onChange={() => onChange(o.value)}
|
|
81
|
+
className="accent-[var(--button-primary)]"
|
|
82
|
+
/>
|
|
83
|
+
{o.label}
|
|
84
|
+
</label>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
),
|
|
68
88
|
render: (row) => {
|
|
69
89
|
const status = statusBadge[row.status];
|
|
70
90
|
return <Badge variant={status.variant}>{status.label}</Badge>;
|
|
@@ -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,
|