@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 +4 -0
- package/CHANGELOG.md +42 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/combobox.tsx +233 -0
- package/src/record-view.tsx +101 -34
- package/template/app/(app)/organizations/organizations-config.tsx +20 -0
- package/template/app/(app)/system/regions/regions-table.tsx +3 -0
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` · `
|
|
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,
|
|
@@ -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
|
|
236
|
-
* with `options
|
|
237
|
-
|
|
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 === "
|
|
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
|
-
{
|
|
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
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
"
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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.
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
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
|
{
|