@viliha/vui-ui 1.8.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 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
+ 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
+ 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
+
337
341
  ## Filtering
338
342
 
339
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,48 @@ 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
+
33
+ ## 1.9.0 — 2026-07-26
34
+
35
+ ### Added
36
+
37
+ - **`sortable` field flag in `RecordView`** — decouples sorting from column
38
+ visibility. By default a field is sortable iff it's a visible column
39
+ (`!hideInTable`), unchanged. Now:
40
+ - `sortable: true` sorts a field with **no column** (e.g. a `hideInTable` name
41
+ shown via `getPrimary`) — it appears in the Sort dropdown.
42
+ - `sortable: false` keeps a **visible column unsortable** (its header stops
43
+ being a sort toggle).
44
+
45
+ Affects both the Sort dropdown and the column-header click. Fully backward
46
+ compatible — omit the flag for today's behavior.
47
+
48
+ **For agents:** to make a non-column field sortable (or lock a column), set
49
+ `sortable` on the `RecordField`; don't rewire the Sort dropdown or headers.
50
+ Demo: `system/regions` sorts by Name (which has no column).
51
+
10
52
  ## 1.8.0 — 2026-07-26
11
53
 
12
54
  ### 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` · `command-palette` (⌘K launcher) · `dialog` ·
253
- `confirm-dialog` · `dropdown-menu` · `input` · `kbd` (key caps + `Shortcut`) ·
254
- `menu` · `required-mark` · `select` · `steps` (multi-step wizard indicator) ·
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.8.0",
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",
@@ -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
+ }
@@ -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,
@@ -226,15 +227,33 @@ export interface RecordField<T> {
226
227
  copyable?: boolean;
227
228
  /** Show in the detail panel only, not as a table column (e.g. first/last name). */
228
229
  hideInTable?: boolean;
230
+ /** Whether this field can be sorted — decoupled from column visibility.
231
+ * Default: sortable iff it's a visible column (`!hideInTable`), the historic
232
+ * behavior. Set `true` to sort a field with no column (e.g. a `hideInTable`
233
+ * name shown via `getPrimary`); set `false` to keep a visible column
234
+ * unsortable. Controls both the Sort dropdown and the column-header toggle. */
235
+ sortable?: boolean;
229
236
  /** Custom, non-editable cell/value renderer. */
230
237
  render?: (row: T) => React.ReactNode;
231
238
  /** If set, the field becomes a choice field: the Add/Edit form renders a
232
239
  * `Select`, and the selection toolbar offers a "Set {label}" bulk action. */
233
240
  options?: { value: string; label: string }[];
234
241
  /** Form control for the Add/Edit panel/page. Default `"text"` (auto-growing
235
- * textarea). `"number"`/`"date"` render the matching native input; a field
236
- * with `options` always renders a `Select` regardless of this. */
237
- input?: "text" | "number" | "date";
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;
238
257
  /** Expose this field in the Filter panel. When ANY field is filterable, the
239
258
  * panel switches from the single keyword box to a labeled control per field
240
259
  * plus Search / Clear. `true` = a text input; pass a {@link FieldFilter} to
@@ -460,6 +479,12 @@ export function RecordView<T extends { id: RowId }>({
460
479
 
461
480
  const tableFields = fields.filter((f) => !f.hideInTable);
462
481
  const visibleFields = tableFields.filter((f) => !hidden.has(f.key));
482
+ // Sorting is decoupled from column visibility: a field is sortable when its
483
+ // `sortable` flag says so, else it falls back to "is a visible column".
484
+ const canSort = (f: RecordField<T>) => f.sortable ?? !f.hideInTable;
485
+ // Fields offered in the Sort dropdown (may include hidden-but-sortable fields
486
+ // and exclude visible-but-unsortable ones).
487
+ const sortFields = fields.filter(canSort);
463
488
  // Fields opted into per-field filtering. Non-empty → the Filter panel renders
464
489
  // a control per field instead of the single keyword box.
465
490
  const filterFields = fields.filter((f) => f.filterable);
@@ -1086,7 +1111,18 @@ export function RecordView<T extends { id: RowId }>({
1086
1111
  <label className="text-xs font-medium text-muted-foreground">
1087
1112
  {label}
1088
1113
  </label>
1089
- {control === "select" || control === "combobox" ? (
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" ? (
1090
1126
  <Select
1091
1127
  value={typeof raw === "string" ? raw : ""}
1092
1128
  onValueChange={setVal}
@@ -1182,7 +1218,7 @@ export function RecordView<T extends { id: RowId }>({
1182
1218
 
1183
1219
  <Dropdown label="Sort" icon={<ArrowUpDown className="size-3.5 text-blue-500" />}>
1184
1220
  <DropdownLabel>Sort by</DropdownLabel>
1185
- {tableFields.map((f) => (
1221
+ {sortFields.map((f) => (
1186
1222
  <DropdownItem
1187
1223
  key={f.key}
1188
1224
  onSelect={() => toggleSort(f.key)}
@@ -1300,32 +1336,44 @@ export function RecordView<T extends { id: RowId }>({
1300
1336
  </TableHead>
1301
1337
  {visibleFields.map((f) => {
1302
1338
  const HeadIcon = f.icon ?? DEFAULT_FIELD_ICON;
1339
+ const sortable = canSort(f);
1340
+ const headInner = (
1341
+ <>
1342
+ <HeadIcon className="size-3.5 shrink-0 text-foreground" />
1343
+ <span className="flex items-center gap-1 whitespace-nowrap">
1344
+ {f.label}
1345
+ {f.required && <RequiredMark />}
1346
+ </span>
1347
+ {sort?.key === f.key &&
1348
+ (sort.dir === "asc" ? (
1349
+ <ArrowUp className="size-3 shrink-0" />
1350
+ ) : (
1351
+ <ArrowDown className="size-3 shrink-0" />
1352
+ ))}
1353
+ </>
1354
+ );
1355
+ const headClass = cn(
1356
+ "flex h-8 w-full items-center gap-1.5 whitespace-nowrap",
1357
+ ALIGN_BOX[alignOf(f.key)],
1358
+ );
1303
1359
  return (
1304
1360
  <TableHead
1305
1361
  key={f.key}
1306
1362
  className="relative w-max"
1307
1363
  style={{ width: colWidths[f.key] }}
1308
1364
  >
1309
- <button
1310
- type="button"
1311
- onClick={() => toggleSort(f.key)}
1312
- className={cn(
1313
- "flex h-8 w-full items-center gap-1.5 whitespace-nowrap hover:text-foreground",
1314
- ALIGN_BOX[alignOf(f.key)],
1315
- )}
1316
- >
1317
- <HeadIcon className="size-3.5 shrink-0 text-foreground" />
1318
- <span className="flex items-center gap-1 whitespace-nowrap">
1319
- {f.label}
1320
- {f.required && <RequiredMark />}
1321
- </span>
1322
- {sort?.key === f.key &&
1323
- (sort.dir === "asc" ? (
1324
- <ArrowUp className="size-3 shrink-0" />
1325
- ) : (
1326
- <ArrowDown className="size-3 shrink-0" />
1327
- ))}
1328
- </button>
1365
+ {sortable ? (
1366
+ <button
1367
+ type="button"
1368
+ onClick={() => toggleSort(f.key)}
1369
+ className={cn(headClass, "hover:text-foreground")}
1370
+ >
1371
+ {headInner}
1372
+ </button>
1373
+ ) : (
1374
+ // Not sortable: a static label, no toggle / hover affordance.
1375
+ <span className={headClass}>{headInner}</span>
1376
+ )}
1329
1377
  {resizeHandle(f.key, f.label)}
1330
1378
  </TableHead>
1331
1379
  );
@@ -1743,15 +1791,34 @@ function RecordDetailPanel<T extends { id: RowId }>({
1743
1791
  {f.render ? (
1744
1792
  <div>{f.render(draft)}</div>
1745
1793
  ) : !readOnly && f.editable ? (
1746
- f.options ? (
1747
- <Select
1748
- value={String(draft[f.key as keyof T] ?? "")}
1749
- onValueChange={(v) => setField(f.key as keyof T, v)}
1750
- options={f.options}
1751
- ariaLabel={f.label}
1752
- placeholder={`Select ${f.label.toLowerCase()}…`}
1753
- className="w-full"
1754
- />
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
+ )
1755
1822
  ) : f.input === "number" || f.input === "date" ? (
1756
1823
  <Input
1757
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>;
@@ -21,6 +21,9 @@ const fields: RecordField<Region>[] = [
21
21
  required: true,
22
22
  group: "General",
23
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,
24
27
  filterable: true,
25
28
  },
26
29
  {