@viliha/vui-ui 1.9.0 → 1.10.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 +2 -0
- package/CHANGELOG.md +23 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/combobox.tsx +233 -0
- package/src/record-view.tsx +56 -13
- package/template/app/(app)/organizations/organizations-config.tsx +20 -0
package/AGENT.md
CHANGED
|
@@ -336,6 +336,8 @@ 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
|
+
|
|
339
341
|
## Filtering
|
|
340
342
|
|
|
341
343
|
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.10.0 — 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`Combobox`** (`@viliha/vui-ui/combobox`) — a searchable single-select. Same
|
|
15
|
+
API as `Select` (drop-in) but the popover leads with a type-to-filter input, so
|
|
16
|
+
it scales to long option lists (an FK / country picker). Keyboard: type to
|
|
17
|
+
filter, ↑/↓ to move, Enter to pick, Esc to close.
|
|
18
|
+
- **`RecordView` choice fields can use the Combobox.** In the Add/Edit form set
|
|
19
|
+
`input: "combobox"` on an `options` field for a searchable control (default
|
|
20
|
+
stays `Select`). In the Filter panel, `control: "combobox"` now renders the
|
|
21
|
+
real searchable Combobox (previously it fell back to a plain Select).
|
|
22
|
+
- **`RecordField.renderInput`** — an escape hatch to render **any** Add/Edit
|
|
23
|
+
control (checkbox, radio group, slider, date-range, a custom widget). It
|
|
24
|
+
overrides the default control and receives `{ value, onChange, field, invalid }`;
|
|
25
|
+
the field still owns the label, required mark, and Save validation. The
|
|
26
|
+
Organizations form demonstrates it (Status as a radio group).
|
|
27
|
+
|
|
28
|
+
**For agents:** for a long choice list use `input: "combobox"`; for a control
|
|
29
|
+
the built-ins don't cover use `renderInput` — never hand-build a `<form>`.
|
|
30
|
+
New demo: Organizations Status (radio via `renderInput`); Combobox is also in
|
|
31
|
+
the components gallery. Docs: `/docs/components`, `/docs/data-table`.
|
|
32
|
+
|
|
10
33
|
## 1.9.0 — 2026-07-26
|
|
11
34
|
|
|
12
35
|
### 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.10.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,
|
|
@@ -238,9 +239,21 @@ export interface RecordField<T> {
|
|
|
238
239
|
* `Select`, and the selection toolbar offers a "Set {label}" bulk action. */
|
|
239
240
|
options?: { value: string; label: string }[];
|
|
240
241
|
/** 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
|
-
|
|
242
|
+
* textarea). `"number"`/`"date"` render the matching native input. For a
|
|
243
|
+
* field with `options`: default renders a `Select`, and `"combobox"` renders
|
|
244
|
+
* a searchable `Combobox` (type-to-filter) — use it for long option lists. */
|
|
245
|
+
input?: "text" | "number" | "date" | "combobox";
|
|
246
|
+
/** Render a custom Add/Edit control — a checkbox, a radio group, a color
|
|
247
|
+
* picker, anything. Overrides the default control entirely (and takes
|
|
248
|
+
* priority over `options`/`input`). You get the current string value and an
|
|
249
|
+
* `onChange` to write it back; the surrounding label, required mark, and Save
|
|
250
|
+
* validation still come from the field. Read-only View uses `render`. */
|
|
251
|
+
renderInput?: (props: {
|
|
252
|
+
value: string;
|
|
253
|
+
onChange: (value: string) => void;
|
|
254
|
+
field: RecordField<T>;
|
|
255
|
+
invalid?: boolean;
|
|
256
|
+
}) => React.ReactNode;
|
|
244
257
|
/** Expose this field in the Filter panel. When ANY field is filterable, the
|
|
245
258
|
* panel switches from the single keyword box to a labeled control per field
|
|
246
259
|
* plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
|
|
@@ -1098,7 +1111,18 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1098
1111
|
<label className="text-xs font-medium text-muted-foreground">
|
|
1099
1112
|
{label}
|
|
1100
1113
|
</label>
|
|
1101
|
-
{control === "
|
|
1114
|
+
{control === "combobox" ? (
|
|
1115
|
+
<Combobox
|
|
1116
|
+
value={typeof raw === "string" ? raw : ""}
|
|
1117
|
+
onValueChange={setVal}
|
|
1118
|
+
options={opts}
|
|
1119
|
+
ariaLabel={label}
|
|
1120
|
+
placeholder={
|
|
1121
|
+
cfg.placeholder ?? `Any ${label.toLowerCase()}`
|
|
1122
|
+
}
|
|
1123
|
+
className="w-full"
|
|
1124
|
+
/>
|
|
1125
|
+
) : control === "select" ? (
|
|
1102
1126
|
<Select
|
|
1103
1127
|
value={typeof raw === "string" ? raw : ""}
|
|
1104
1128
|
onValueChange={setVal}
|
|
@@ -1767,15 +1791,34 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1767
1791
|
{f.render ? (
|
|
1768
1792
|
<div>{f.render(draft)}</div>
|
|
1769
1793
|
) : !readOnly && f.editable ? (
|
|
1770
|
-
f.
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1794
|
+
f.renderInput ? (
|
|
1795
|
+
// Consumer-supplied control (checkbox, radio, custom widget).
|
|
1796
|
+
f.renderInput({
|
|
1797
|
+
value: String(draft[f.key as keyof T] ?? ""),
|
|
1798
|
+
onChange: (v) => setField(f.key as keyof T, v),
|
|
1799
|
+
field: f,
|
|
1800
|
+
invalid: errors.has(f.key),
|
|
1801
|
+
})
|
|
1802
|
+
) : f.options ? (
|
|
1803
|
+
f.input === "combobox" ? (
|
|
1804
|
+
<Combobox
|
|
1805
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1806
|
+
onValueChange={(v) => setField(f.key as keyof T, v)}
|
|
1807
|
+
options={f.options}
|
|
1808
|
+
ariaLabel={f.label}
|
|
1809
|
+
placeholder={`Select ${f.label.toLowerCase()}…`}
|
|
1810
|
+
className="w-full"
|
|
1811
|
+
/>
|
|
1812
|
+
) : (
|
|
1813
|
+
<Select
|
|
1814
|
+
value={String(draft[f.key as keyof T] ?? "")}
|
|
1815
|
+
onValueChange={(v) => setField(f.key as keyof T, v)}
|
|
1816
|
+
options={f.options}
|
|
1817
|
+
ariaLabel={f.label}
|
|
1818
|
+
placeholder={`Select ${f.label.toLowerCase()}…`}
|
|
1819
|
+
className="w-full"
|
|
1820
|
+
/>
|
|
1821
|
+
)
|
|
1779
1822
|
) : f.input === "number" || f.input === "date" ? (
|
|
1780
1823
|
<Input
|
|
1781
1824
|
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
|
+
{(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>;
|