@viliha/vui-ui 1.1.4 → 1.1.6
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 +11 -0
- package/README.md +54 -3
- package/package.json +1 -1
- package/src/record-view.tsx +87 -13
- package/src/select.tsx +97 -39
- package/src/steps.tsx +104 -0
package/AGENT.md
CHANGED
|
@@ -207,6 +207,17 @@ One trail, **derived from the route — never hand-written per page.**
|
|
|
207
207
|
|
|
208
208
|
To reorder or rename, edit the nav config; the trail follows automatically.
|
|
209
209
|
|
|
210
|
+
## Open tabs
|
|
211
|
+
|
|
212
|
+
Enterprise apps often keep several pages open at once. The reference app ships a
|
|
213
|
+
browser-style **tab strip** under the top bar (a reference-app pattern — copy
|
|
214
|
+
`open-tabs.tsx`): an `OpenTabsProvider` tracks opened routes, switching is a
|
|
215
|
+
router push, and the list persists in `sessionStorage`. Tab labels/icons come
|
|
216
|
+
from the same nav config as the sidebar. ⌘/Ctrl-click a nav item opens a
|
|
217
|
+
background tab; expose an `openTab(href, { background })` for custom "open in new
|
|
218
|
+
tab" buttons. Keep it a navigation-tab model (don't try to keep every page
|
|
219
|
+
mounted) unless you specifically need live state preserved across tabs.
|
|
220
|
+
|
|
210
221
|
---
|
|
211
222
|
|
|
212
223
|
# Forms
|
package/README.md
CHANGED
|
@@ -57,6 +57,57 @@ export function Example() {
|
|
|
57
57
|
}
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
### Datatables & forms
|
|
61
|
+
|
|
62
|
+
`RecordView` is a complete admin datatable from a single `fields` array —
|
|
63
|
+
editable cells, sorting, filtering, pagination, row actions, bulk actions,
|
|
64
|
+
CSV / JSON / Excel / PDF import & export, and a buffered **Add / Edit / View**
|
|
65
|
+
form (slide-over or full-page). Required fields render a `*`, alignment and
|
|
66
|
+
colors come from the tokens — you never style a field by hand.
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
import { RecordView, type RecordField } from "@viliha/vui-ui/record-view";
|
|
70
|
+
|
|
71
|
+
const fields: RecordField<Customer>[] = [
|
|
72
|
+
{ key: "name", label: "Name", required: true, hideInTable: true },
|
|
73
|
+
{ key: "email", label: "Email", copyable: true },
|
|
74
|
+
{ key: "country", label: "Country" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
<RecordView
|
|
78
|
+
title="Customers"
|
|
79
|
+
singular="Customer"
|
|
80
|
+
fields={fields}
|
|
81
|
+
initialData={rows}
|
|
82
|
+
getPrimary={(r) => ({ title: r.name, initials: r.name.slice(0, 2) })}
|
|
83
|
+
makeEmptyRow={() => ({ id: Date.now(), name: "", email: "", country: "" })}
|
|
84
|
+
/>;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The same `fields` also drive `RecordForm` — the Add/Edit/View screen — and its
|
|
88
|
+
Info panel (from `formDescription` + per-field `description`). See the
|
|
89
|
+
[Data table docs](https://vui.viliha.com/docs/data-table).
|
|
90
|
+
|
|
91
|
+
## Patterns
|
|
92
|
+
|
|
93
|
+
The reference app composes these primitives into the conventions documented at
|
|
94
|
+
[vui.viliha.com/docs/layout](https://vui.viliha.com/docs/layout):
|
|
95
|
+
|
|
96
|
+
- **Five page types** — data table, record form (Add / Edit / View), dashboard,
|
|
97
|
+
settings, and kanban board.
|
|
98
|
+
- **Command palette** — Quick actions (`⌘K`, navigate pages) and Global search
|
|
99
|
+
(`⌘⌥K`, find records), both built on the exported `CommandPalette`.
|
|
100
|
+
- **Open tabs** — a browser-style strip of opened pages under the top bar
|
|
101
|
+
(⌘-click a nav item for a background tab), persisted across reloads.
|
|
102
|
+
- **Multi-step wizard** — the exported `Steps` indicator + your step state for
|
|
103
|
+
guided flows (see the `/register-business` demo).
|
|
104
|
+
- **Breadcrumbs** — the exported `Breadcrumbs` component fed a route-derived
|
|
105
|
+
trail.
|
|
106
|
+
|
|
107
|
+
Copy them from the
|
|
108
|
+
[backoffice demo](https://github.com/myviliha/vui-starter/tree/main/apps/backoffice)
|
|
109
|
+
and adapt.
|
|
110
|
+
|
|
60
111
|
## Building with an AI agent
|
|
61
112
|
|
|
62
113
|
This package ships an **AI-agent usage guide** at
|
|
@@ -78,9 +129,9 @@ verbatim copy? `cp node_modules/@viliha/vui-ui/AGENT.md AGENTS.md`.
|
|
|
78
129
|
`avatar` · `badge` · `breadcrumbs` · `button` · `card` · `chart` (themed Recharts
|
|
79
130
|
wrapper) · `checkbox` · `command-palette` (⌘K launcher) · `dialog` ·
|
|
80
131
|
`confirm-dialog` · `dropdown-menu` · `input` · `kbd` (key caps + `Shortcut`) ·
|
|
81
|
-
`menu` · `required-mark` · `select` · `
|
|
82
|
-
|
|
83
|
-
tokens.
|
|
132
|
+
`menu` · `required-mark` · `select` · `steps` (multi-step wizard indicator) ·
|
|
133
|
+
`table` · `record-view` (the full datatable + `RecordForm`) · plus the `utils`
|
|
134
|
+
(`cn`) helper and the `theme.css` design tokens.
|
|
84
135
|
|
|
85
136
|
## Theming
|
|
86
137
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
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/record-view.tsx
CHANGED
|
@@ -196,6 +196,54 @@ export interface RecordField<T> {
|
|
|
196
196
|
options?: { value: string; label: string }[];
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* `useState` that mirrors to `sessionStorage` under `key`, so a page's work
|
|
201
|
+
* (table filters/sort/page, a form draft) survives leaving and returning — the
|
|
202
|
+
* per-tab work-preservation behind the open-tabs strip. When `key` is undefined
|
|
203
|
+
* it is a plain `useState`, so this is fully opt-in and backward compatible.
|
|
204
|
+
* Restores on mount (client-only, so exported/SSR markup still matches) and
|
|
205
|
+
* never clobbers stored data with the initial value.
|
|
206
|
+
*/
|
|
207
|
+
function usePersistentState<T>(
|
|
208
|
+
key: string | undefined,
|
|
209
|
+
initial: T,
|
|
210
|
+
): [T, React.Dispatch<React.SetStateAction<T>>] {
|
|
211
|
+
const [state, setState] = React.useState<T>(initial);
|
|
212
|
+
const firstWrite = React.useRef(true);
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
if (!key) return;
|
|
215
|
+
try {
|
|
216
|
+
const raw = sessionStorage.getItem(key);
|
|
217
|
+
if (raw !== null) setState(JSON.parse(raw) as T);
|
|
218
|
+
} catch {
|
|
219
|
+
// ignore malformed storage
|
|
220
|
+
}
|
|
221
|
+
}, [key]);
|
|
222
|
+
React.useEffect(() => {
|
|
223
|
+
if (!key) return;
|
|
224
|
+
if (firstWrite.current) {
|
|
225
|
+
firstWrite.current = false; // don't overwrite stored value with `initial`
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
sessionStorage.setItem(key, JSON.stringify(state));
|
|
230
|
+
} catch {
|
|
231
|
+
// ignore storage failures (private mode, quota)
|
|
232
|
+
}
|
|
233
|
+
}, [key, state]);
|
|
234
|
+
return [state, setState];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Drop a persisted key — e.g. once a form Save/Cancel discards its draft. */
|
|
238
|
+
function clearPersisted(key: string | undefined) {
|
|
239
|
+
if (!key) return;
|
|
240
|
+
try {
|
|
241
|
+
sessionStorage.removeItem(key);
|
|
242
|
+
} catch {
|
|
243
|
+
// ignore
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
199
247
|
interface RecordViewProps<T extends { id: RowId }> {
|
|
200
248
|
title: string;
|
|
201
249
|
singular: string;
|
|
@@ -228,6 +276,9 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
228
276
|
* of opening the built-in overlay form. */
|
|
229
277
|
onView?: (id: RowId) => void;
|
|
230
278
|
onEdit?: (id: RowId) => void;
|
|
279
|
+
/** Persist this view's filter / sort / page under this key (e.g. the route),
|
|
280
|
+
* so the work survives leaving and returning via the open-tabs strip. */
|
|
281
|
+
persistKey?: string;
|
|
231
282
|
}
|
|
232
283
|
|
|
233
284
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -247,6 +298,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
247
298
|
onCreate,
|
|
248
299
|
onView,
|
|
249
300
|
onEdit,
|
|
301
|
+
persistKey,
|
|
250
302
|
}: RecordViewProps<T>) {
|
|
251
303
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
252
304
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -269,11 +321,14 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
269
321
|
},
|
|
270
322
|
[controlled, data, onDataChange],
|
|
271
323
|
);
|
|
272
|
-
const [filter, setFilter] =
|
|
273
|
-
|
|
324
|
+
const [filter, setFilter] = usePersistentState(
|
|
325
|
+
persistKey ? `${persistKey}::filter` : undefined,
|
|
326
|
+
"",
|
|
327
|
+
);
|
|
328
|
+
const [sort, setSort] = usePersistentState<{
|
|
274
329
|
key: string;
|
|
275
330
|
dir: "asc" | "desc";
|
|
276
|
-
} | null>(null);
|
|
331
|
+
} | null>(persistKey ? `${persistKey}::sort` : undefined, null);
|
|
277
332
|
const [hidden, setHidden] = React.useState<Set<string>>(new Set());
|
|
278
333
|
const [selected, setSelected] = React.useState<Set<RowId>>(new Set());
|
|
279
334
|
const [editing, setEditing] = React.useState<{
|
|
@@ -289,7 +344,10 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
289
344
|
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
|
290
345
|
// Whether the detail panel opened read-only (View) or editable (Edit / Add).
|
|
291
346
|
const [panelReadOnly, setPanelReadOnly] = React.useState(false);
|
|
292
|
-
const [page, setPage] =
|
|
347
|
+
const [page, setPage] = usePersistentState(
|
|
348
|
+
persistKey ? `${persistKey}::page` : undefined,
|
|
349
|
+
1,
|
|
350
|
+
);
|
|
293
351
|
const [pageSize, setPageSize] = React.useState<number>(25);
|
|
294
352
|
const [flashId, setFlashId] = React.useState<RowId | null>(null);
|
|
295
353
|
const [copiedKey, setCopiedKey] = React.useState<string | null>(null);
|
|
@@ -418,7 +476,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
418
476
|
// Reset to the first page when the filter or page size changes.
|
|
419
477
|
React.useEffect(() => {
|
|
420
478
|
setPage(1);
|
|
421
|
-
}, [filter, pageSize]);
|
|
479
|
+
}, [filter, pageSize, setPage]);
|
|
422
480
|
|
|
423
481
|
function startEdit(row: T, key: string) {
|
|
424
482
|
setEditing({ id: row.id, key });
|
|
@@ -1390,6 +1448,9 @@ interface DetailPanelProps<T extends { id: RowId }> {
|
|
|
1390
1448
|
onHome?: () => void;
|
|
1391
1449
|
/** Intro text for the documentation panel. */
|
|
1392
1450
|
formDescription?: string;
|
|
1451
|
+
/** Persist the in-progress draft under this key (e.g. the route), so a
|
|
1452
|
+
* half-filled form survives leaving and returning via the open-tabs strip. */
|
|
1453
|
+
persistKey?: string;
|
|
1393
1454
|
}
|
|
1394
1455
|
|
|
1395
1456
|
function RecordDetailPanel<T extends { id: RowId }>({
|
|
@@ -1408,13 +1469,19 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1408
1469
|
title,
|
|
1409
1470
|
onHome,
|
|
1410
1471
|
formDescription,
|
|
1472
|
+
persistKey,
|
|
1411
1473
|
}: DetailPanelProps<T>) {
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1474
|
+
const draftKey = persistKey ? `${persistKey}::draft` : undefined;
|
|
1475
|
+
const [draft, setDraft] = usePersistentState<T>(draftKey, row);
|
|
1476
|
+
// Reset the buffered form only when a *genuinely different* record is opened.
|
|
1477
|
+
// Tracking the last id (not a "first run" flag) is StrictMode-safe: the dev
|
|
1478
|
+
// double-invoke sees the same id and won't wipe a restored / in-progress draft.
|
|
1479
|
+
const lastRowId = React.useRef(row.id);
|
|
1414
1480
|
React.useEffect(() => {
|
|
1481
|
+
if (lastRowId.current === row.id) return;
|
|
1482
|
+
lastRowId.current = row.id;
|
|
1415
1483
|
setDraft(row);
|
|
1416
|
-
|
|
1417
|
-
}, [row.id]);
|
|
1484
|
+
}, [row, setDraft]);
|
|
1418
1485
|
|
|
1419
1486
|
const primary = getPrimary(draft);
|
|
1420
1487
|
const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
|
|
@@ -1458,9 +1525,16 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1458
1525
|
setErrors(new Set(missing.map((f) => f.key)));
|
|
1459
1526
|
return;
|
|
1460
1527
|
}
|
|
1528
|
+
clearPersisted(draftKey); // work committed — drop the saved draft
|
|
1461
1529
|
dismiss(() => onSave(draft));
|
|
1462
1530
|
};
|
|
1463
1531
|
|
|
1532
|
+
// Cancel/close discards the draft too, so it doesn't reappear next visit.
|
|
1533
|
+
const handleCancel = () => {
|
|
1534
|
+
clearPersisted(draftKey);
|
|
1535
|
+
dismiss(onCancel);
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1464
1538
|
// Grouped field sections — shared by the slide-over and full-page layouts.
|
|
1465
1539
|
const formBody = GROUP_ORDER.map((group) => {
|
|
1466
1540
|
const groupFields = fields.filter((f) => (f.group ?? "General") === group);
|
|
@@ -1528,7 +1602,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1528
1602
|
<div className="flex shrink-0 items-center justify-end gap-2 border-y border-border bg-muted/40 px-4 py-3">
|
|
1529
1603
|
{readOnly ? (
|
|
1530
1604
|
<>
|
|
1531
|
-
<Button onClick={
|
|
1605
|
+
<Button onClick={handleCancel}>
|
|
1532
1606
|
<X className="size-4" />
|
|
1533
1607
|
Close
|
|
1534
1608
|
</Button>
|
|
@@ -1541,7 +1615,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1541
1615
|
</>
|
|
1542
1616
|
) : (
|
|
1543
1617
|
<>
|
|
1544
|
-
<Button onClick={
|
|
1618
|
+
<Button onClick={handleCancel}>
|
|
1545
1619
|
<X className="size-4" />
|
|
1546
1620
|
Cancel
|
|
1547
1621
|
</Button>
|
|
@@ -1644,7 +1718,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1644
1718
|
"fixed inset-0 z-[55] bg-foreground/25",
|
|
1645
1719
|
closing ? "vui-overlay-out" : "vui-overlay-in",
|
|
1646
1720
|
)}
|
|
1647
|
-
onClick={
|
|
1721
|
+
onClick={handleCancel}
|
|
1648
1722
|
aria-hidden="true"
|
|
1649
1723
|
/>
|
|
1650
1724
|
<aside
|
|
@@ -1677,7 +1751,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1677
1751
|
<Button
|
|
1678
1752
|
variant="ghost"
|
|
1679
1753
|
size="icon"
|
|
1680
|
-
onClick={
|
|
1754
|
+
onClick={handleCancel}
|
|
1681
1755
|
aria-label="Close"
|
|
1682
1756
|
className="ml-auto"
|
|
1683
1757
|
>
|
package/src/select.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
4
5
|
import {
|
|
5
6
|
CheckIcon as Check,
|
|
6
7
|
ChevronDownIcon as ChevronDown,
|
|
@@ -13,10 +14,20 @@ export interface SelectOption {
|
|
|
13
14
|
label: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
type Placement = {
|
|
18
|
+
left: number;
|
|
19
|
+
width: number;
|
|
20
|
+
top?: number;
|
|
21
|
+
bottom?: number;
|
|
22
|
+
maxHeight: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* Custom single-select styled to match the app (Input-like trigger + popover
|
|
18
27
|
* list), replacing the native `<select>`. Click-to-open, outside-click/Escape
|
|
19
|
-
* to close, checkmark on the active option.
|
|
28
|
+
* to close, checkmark on the active option. The list is rendered in a portal
|
|
29
|
+
* with fixed positioning so it floats above any scrolling/overflow container
|
|
30
|
+
* (forms, dialogs, the tab-kept pages) instead of being clipped.
|
|
20
31
|
*/
|
|
21
32
|
export function Select({
|
|
22
33
|
value,
|
|
@@ -37,14 +48,47 @@ export function Select({
|
|
|
37
48
|
className?: string;
|
|
38
49
|
}) {
|
|
39
50
|
const [open, setOpen] = React.useState(false);
|
|
40
|
-
const
|
|
51
|
+
const [pos, setPos] = React.useState<Placement | null>(null);
|
|
52
|
+
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
53
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
54
|
+
const listRef = React.useRef<HTMLDivElement>(null);
|
|
55
|
+
|
|
56
|
+
const place = React.useCallback(() => {
|
|
57
|
+
const el = triggerRef.current;
|
|
58
|
+
if (!el) return;
|
|
59
|
+
const r = el.getBoundingClientRect();
|
|
60
|
+
const below = window.innerHeight - r.bottom;
|
|
61
|
+
const above = r.top;
|
|
62
|
+
// Flip up when there isn't room below and there's more room above.
|
|
63
|
+
const openUp = below < 240 && above > below;
|
|
64
|
+
setPos({
|
|
65
|
+
left: r.left,
|
|
66
|
+
width: r.width,
|
|
67
|
+
top: openUp ? undefined : r.bottom + 4,
|
|
68
|
+
bottom: openUp ? window.innerHeight - r.top + 4 : undefined,
|
|
69
|
+
maxHeight: Math.min(240, (openUp ? above : below) - 8),
|
|
70
|
+
});
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
React.useLayoutEffect(() => {
|
|
74
|
+
if (!open) return;
|
|
75
|
+
place();
|
|
76
|
+
const reflow = () => place();
|
|
77
|
+
// capture: catch scrolls inside any ancestor container, not just the window
|
|
78
|
+
window.addEventListener("scroll", reflow, true);
|
|
79
|
+
window.addEventListener("resize", reflow);
|
|
80
|
+
return () => {
|
|
81
|
+
window.removeEventListener("scroll", reflow, true);
|
|
82
|
+
window.removeEventListener("resize", reflow);
|
|
83
|
+
};
|
|
84
|
+
}, [open, place]);
|
|
41
85
|
|
|
42
86
|
React.useEffect(() => {
|
|
43
87
|
if (!open) return;
|
|
44
88
|
const onDown = (e: MouseEvent) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
89
|
+
const t = e.target as Node;
|
|
90
|
+
if (rootRef.current?.contains(t) || listRef.current?.contains(t)) return;
|
|
91
|
+
setOpen(false);
|
|
48
92
|
};
|
|
49
93
|
const onKey = (e: KeyboardEvent) => {
|
|
50
94
|
if (e.key === "Escape") setOpen(false);
|
|
@@ -60,8 +104,9 @@ export function Select({
|
|
|
60
104
|
const selected = options.find((o) => o.value === value);
|
|
61
105
|
|
|
62
106
|
return (
|
|
63
|
-
<div className={cn("relative", className)} ref={
|
|
107
|
+
<div className={cn("relative", className)} ref={rootRef}>
|
|
64
108
|
<button
|
|
109
|
+
ref={triggerRef}
|
|
65
110
|
type="button"
|
|
66
111
|
id={id}
|
|
67
112
|
aria-haspopup="listbox"
|
|
@@ -84,39 +129,52 @@ export function Select({
|
|
|
84
129
|
aria-hidden="true"
|
|
85
130
|
/>
|
|
86
131
|
</button>
|
|
87
|
-
{open &&
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
{open &&
|
|
133
|
+
pos &&
|
|
134
|
+
typeof document !== "undefined" &&
|
|
135
|
+
createPortal(
|
|
136
|
+
<div
|
|
137
|
+
ref={listRef}
|
|
138
|
+
role="listbox"
|
|
139
|
+
aria-label={ariaLabel}
|
|
140
|
+
tabIndex={-1}
|
|
141
|
+
style={{
|
|
142
|
+
position: "fixed",
|
|
143
|
+
left: pos.left,
|
|
144
|
+
width: pos.width,
|
|
145
|
+
top: pos.top,
|
|
146
|
+
bottom: pos.bottom,
|
|
147
|
+
maxHeight: pos.maxHeight,
|
|
148
|
+
}}
|
|
149
|
+
className="vui-pop-in z-[200] overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md"
|
|
150
|
+
>
|
|
151
|
+
{options.map((o) => {
|
|
152
|
+
const active = o.value === value;
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
key={o.value}
|
|
156
|
+
type="button"
|
|
157
|
+
role="option"
|
|
158
|
+
aria-selected={active}
|
|
159
|
+
onClick={() => {
|
|
160
|
+
onValueChange(o.value);
|
|
161
|
+
setOpen(false);
|
|
162
|
+
}}
|
|
163
|
+
className={cn(
|
|
164
|
+
"flex w-full items-center justify-between gap-2 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-accent hover:text-accent-foreground",
|
|
165
|
+
active && "bg-accent/60",
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
<span className="truncate">{o.label}</span>
|
|
169
|
+
{active && (
|
|
170
|
+
<Check className="size-3.5 shrink-0 text-[var(--button-primary)]" />
|
|
171
|
+
)}
|
|
172
|
+
</button>
|
|
173
|
+
);
|
|
174
|
+
})}
|
|
175
|
+
</div>,
|
|
176
|
+
document.body,
|
|
177
|
+
)}
|
|
120
178
|
</div>
|
|
121
179
|
);
|
|
122
180
|
}
|
package/src/steps.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { CheckIcon } from "@radix-ui/react-icons";
|
|
3
|
+
|
|
4
|
+
import { cn } from "./utils";
|
|
5
|
+
|
|
6
|
+
export type Step = {
|
|
7
|
+
/** Short title shown under the marker. */
|
|
8
|
+
label: string;
|
|
9
|
+
/** Optional secondary line under the label. */
|
|
10
|
+
description?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A horizontal, numbered step indicator for multi-step forms / wizards.
|
|
15
|
+
* Presentational and controlled: pass the steps and the current index. Completed
|
|
16
|
+
* steps fill with the brand primary and a check; the current step is ringed;
|
|
17
|
+
* upcoming steps are muted. All color comes from theme tokens.
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <Steps
|
|
21
|
+
* current={step}
|
|
22
|
+
* steps={[
|
|
23
|
+
* { label: "Organization", description: "Business details" },
|
|
24
|
+
* { label: "Account", description: "Your credentials" },
|
|
25
|
+
* { label: "Review", description: "Confirm details" },
|
|
26
|
+
* ]}
|
|
27
|
+
* />
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function Steps({
|
|
31
|
+
steps,
|
|
32
|
+
current,
|
|
33
|
+
className,
|
|
34
|
+
}: {
|
|
35
|
+
steps: Step[];
|
|
36
|
+
/** Zero-based index of the active step. */
|
|
37
|
+
current: number;
|
|
38
|
+
className?: string;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<ol
|
|
42
|
+
className={cn("flex items-start", className)}
|
|
43
|
+
aria-label={`Step ${current + 1} of ${steps.length}`}
|
|
44
|
+
>
|
|
45
|
+
{steps.map((step, i) => {
|
|
46
|
+
const state =
|
|
47
|
+
i < current ? "complete" : i === current ? "current" : "upcoming";
|
|
48
|
+
const isLast = i === steps.length - 1;
|
|
49
|
+
return (
|
|
50
|
+
<React.Fragment key={step.label}>
|
|
51
|
+
<li
|
|
52
|
+
className="flex flex-col items-center gap-2 text-center"
|
|
53
|
+
aria-current={state === "current" ? "step" : undefined}
|
|
54
|
+
>
|
|
55
|
+
<span
|
|
56
|
+
className={cn(
|
|
57
|
+
"grid size-8 shrink-0 place-items-center rounded-full border-2 text-sm font-semibold transition-colors",
|
|
58
|
+
state === "complete" &&
|
|
59
|
+
"border-transparent bg-[var(--button-primary)] text-[var(--button-primary-foreground)]",
|
|
60
|
+
state === "current" &&
|
|
61
|
+
"border-[var(--button-primary)] text-[var(--button-primary)]",
|
|
62
|
+
state === "upcoming" &&
|
|
63
|
+
"border-border bg-background text-muted-foreground",
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{state === "complete" ? (
|
|
67
|
+
<CheckIcon className="size-4" />
|
|
68
|
+
) : (
|
|
69
|
+
i + 1
|
|
70
|
+
)}
|
|
71
|
+
</span>
|
|
72
|
+
<span className="flex flex-col gap-0.5">
|
|
73
|
+
<span
|
|
74
|
+
className={cn(
|
|
75
|
+
"text-sm font-medium leading-none",
|
|
76
|
+
state === "upcoming"
|
|
77
|
+
? "text-muted-foreground"
|
|
78
|
+
: "text-foreground",
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{step.label}
|
|
82
|
+
</span>
|
|
83
|
+
{step.description && (
|
|
84
|
+
<span className="text-xs text-muted-foreground">
|
|
85
|
+
{step.description}
|
|
86
|
+
</span>
|
|
87
|
+
)}
|
|
88
|
+
</span>
|
|
89
|
+
</li>
|
|
90
|
+
{!isLast && (
|
|
91
|
+
<span
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
className={cn(
|
|
94
|
+
"mt-4 h-0.5 min-w-6 flex-1 rounded-full transition-colors",
|
|
95
|
+
i < current ? "bg-[var(--button-primary)]" : "bg-border",
|
|
96
|
+
)}
|
|
97
|
+
/>
|
|
98
|
+
)}
|
|
99
|
+
</React.Fragment>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</ol>
|
|
103
|
+
);
|
|
104
|
+
}
|