@viliha/vui-ui 1.1.5 → 1.1.7
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 +7 -3
- package/package.json +1 -1
- package/src/record-view.tsx +93 -14
- 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
|
@@ -97,6 +97,10 @@ The reference app composes these primitives into the conventions documented at
|
|
|
97
97
|
settings, and kanban board.
|
|
98
98
|
- **Command palette** — Quick actions (`⌘K`, navigate pages) and Global search
|
|
99
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).
|
|
100
104
|
- **Breadcrumbs** — the exported `Breadcrumbs` component fed a route-derived
|
|
101
105
|
trail.
|
|
102
106
|
|
|
@@ -125,9 +129,9 @@ verbatim copy? `cp node_modules/@viliha/vui-ui/AGENT.md AGENTS.md`.
|
|
|
125
129
|
`avatar` · `badge` · `breadcrumbs` · `button` · `card` · `chart` (themed Recharts
|
|
126
130
|
wrapper) · `checkbox` · `command-palette` (⌘K launcher) · `dialog` ·
|
|
127
131
|
`confirm-dialog` · `dropdown-menu` · `input` · `kbd` (key caps + `Shortcut`) ·
|
|
128
|
-
`menu` · `required-mark` · `select` · `
|
|
129
|
-
|
|
130
|
-
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.
|
|
131
135
|
|
|
132
136
|
## Theming
|
|
133
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.7",
|
|
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,12 @@ 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;
|
|
282
|
+
/** Allow dragging column edges to resize them. Off by default — columns
|
|
283
|
+
* auto-size, and no resize handle appears on hover. */
|
|
284
|
+
resizableColumns?: boolean;
|
|
231
285
|
}
|
|
232
286
|
|
|
233
287
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -247,6 +301,8 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
247
301
|
onCreate,
|
|
248
302
|
onView,
|
|
249
303
|
onEdit,
|
|
304
|
+
persistKey,
|
|
305
|
+
resizableColumns = false,
|
|
250
306
|
}: RecordViewProps<T>) {
|
|
251
307
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
252
308
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -269,11 +325,14 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
269
325
|
},
|
|
270
326
|
[controlled, data, onDataChange],
|
|
271
327
|
);
|
|
272
|
-
const [filter, setFilter] =
|
|
273
|
-
|
|
328
|
+
const [filter, setFilter] = usePersistentState(
|
|
329
|
+
persistKey ? `${persistKey}::filter` : undefined,
|
|
330
|
+
"",
|
|
331
|
+
);
|
|
332
|
+
const [sort, setSort] = usePersistentState<{
|
|
274
333
|
key: string;
|
|
275
334
|
dir: "asc" | "desc";
|
|
276
|
-
} | null>(null);
|
|
335
|
+
} | null>(persistKey ? `${persistKey}::sort` : undefined, null);
|
|
277
336
|
const [hidden, setHidden] = React.useState<Set<string>>(new Set());
|
|
278
337
|
const [selected, setSelected] = React.useState<Set<RowId>>(new Set());
|
|
279
338
|
const [editing, setEditing] = React.useState<{
|
|
@@ -289,7 +348,10 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
289
348
|
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
|
290
349
|
// Whether the detail panel opened read-only (View) or editable (Edit / Add).
|
|
291
350
|
const [panelReadOnly, setPanelReadOnly] = React.useState(false);
|
|
292
|
-
const [page, setPage] =
|
|
351
|
+
const [page, setPage] = usePersistentState(
|
|
352
|
+
persistKey ? `${persistKey}::page` : undefined,
|
|
353
|
+
1,
|
|
354
|
+
);
|
|
293
355
|
const [pageSize, setPageSize] = React.useState<number>(25);
|
|
294
356
|
const [flashId, setFlashId] = React.useState<RowId | null>(null);
|
|
295
357
|
const [copiedKey, setCopiedKey] = React.useState<string | null>(null);
|
|
@@ -350,7 +412,8 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
350
412
|
0,
|
|
351
413
|
);
|
|
352
414
|
|
|
353
|
-
const resizeHandle = (col: string, label: string) =>
|
|
415
|
+
const resizeHandle = (col: string, label: string) =>
|
|
416
|
+
!resizableColumns ? null : (
|
|
354
417
|
<button
|
|
355
418
|
type="button"
|
|
356
419
|
aria-label={`Resize ${label} column`}
|
|
@@ -418,7 +481,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
418
481
|
// Reset to the first page when the filter or page size changes.
|
|
419
482
|
React.useEffect(() => {
|
|
420
483
|
setPage(1);
|
|
421
|
-
}, [filter, pageSize]);
|
|
484
|
+
}, [filter, pageSize, setPage]);
|
|
422
485
|
|
|
423
486
|
function startEdit(row: T, key: string) {
|
|
424
487
|
setEditing({ id: row.id, key });
|
|
@@ -1390,6 +1453,9 @@ interface DetailPanelProps<T extends { id: RowId }> {
|
|
|
1390
1453
|
onHome?: () => void;
|
|
1391
1454
|
/** Intro text for the documentation panel. */
|
|
1392
1455
|
formDescription?: string;
|
|
1456
|
+
/** Persist the in-progress draft under this key (e.g. the route), so a
|
|
1457
|
+
* half-filled form survives leaving and returning via the open-tabs strip. */
|
|
1458
|
+
persistKey?: string;
|
|
1393
1459
|
}
|
|
1394
1460
|
|
|
1395
1461
|
function RecordDetailPanel<T extends { id: RowId }>({
|
|
@@ -1408,13 +1474,19 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1408
1474
|
title,
|
|
1409
1475
|
onHome,
|
|
1410
1476
|
formDescription,
|
|
1477
|
+
persistKey,
|
|
1411
1478
|
}: DetailPanelProps<T>) {
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1479
|
+
const draftKey = persistKey ? `${persistKey}::draft` : undefined;
|
|
1480
|
+
const [draft, setDraft] = usePersistentState<T>(draftKey, row);
|
|
1481
|
+
// Reset the buffered form only when a *genuinely different* record is opened.
|
|
1482
|
+
// Tracking the last id (not a "first run" flag) is StrictMode-safe: the dev
|
|
1483
|
+
// double-invoke sees the same id and won't wipe a restored / in-progress draft.
|
|
1484
|
+
const lastRowId = React.useRef(row.id);
|
|
1414
1485
|
React.useEffect(() => {
|
|
1486
|
+
if (lastRowId.current === row.id) return;
|
|
1487
|
+
lastRowId.current = row.id;
|
|
1415
1488
|
setDraft(row);
|
|
1416
|
-
|
|
1417
|
-
}, [row.id]);
|
|
1489
|
+
}, [row, setDraft]);
|
|
1418
1490
|
|
|
1419
1491
|
const primary = getPrimary(draft);
|
|
1420
1492
|
const HeaderIcon = TitleIcon ?? DEFAULT_FIELD_ICON;
|
|
@@ -1458,9 +1530,16 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1458
1530
|
setErrors(new Set(missing.map((f) => f.key)));
|
|
1459
1531
|
return;
|
|
1460
1532
|
}
|
|
1533
|
+
clearPersisted(draftKey); // work committed — drop the saved draft
|
|
1461
1534
|
dismiss(() => onSave(draft));
|
|
1462
1535
|
};
|
|
1463
1536
|
|
|
1537
|
+
// Cancel/close discards the draft too, so it doesn't reappear next visit.
|
|
1538
|
+
const handleCancel = () => {
|
|
1539
|
+
clearPersisted(draftKey);
|
|
1540
|
+
dismiss(onCancel);
|
|
1541
|
+
};
|
|
1542
|
+
|
|
1464
1543
|
// Grouped field sections — shared by the slide-over and full-page layouts.
|
|
1465
1544
|
const formBody = GROUP_ORDER.map((group) => {
|
|
1466
1545
|
const groupFields = fields.filter((f) => (f.group ?? "General") === group);
|
|
@@ -1528,7 +1607,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1528
1607
|
<div className="flex shrink-0 items-center justify-end gap-2 border-y border-border bg-muted/40 px-4 py-3">
|
|
1529
1608
|
{readOnly ? (
|
|
1530
1609
|
<>
|
|
1531
|
-
<Button onClick={
|
|
1610
|
+
<Button onClick={handleCancel}>
|
|
1532
1611
|
<X className="size-4" />
|
|
1533
1612
|
Close
|
|
1534
1613
|
</Button>
|
|
@@ -1541,7 +1620,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1541
1620
|
</>
|
|
1542
1621
|
) : (
|
|
1543
1622
|
<>
|
|
1544
|
-
<Button onClick={
|
|
1623
|
+
<Button onClick={handleCancel}>
|
|
1545
1624
|
<X className="size-4" />
|
|
1546
1625
|
Cancel
|
|
1547
1626
|
</Button>
|
|
@@ -1644,7 +1723,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1644
1723
|
"fixed inset-0 z-[55] bg-foreground/25",
|
|
1645
1724
|
closing ? "vui-overlay-out" : "vui-overlay-in",
|
|
1646
1725
|
)}
|
|
1647
|
-
onClick={
|
|
1726
|
+
onClick={handleCancel}
|
|
1648
1727
|
aria-hidden="true"
|
|
1649
1728
|
/>
|
|
1650
1729
|
<aside
|
|
@@ -1677,7 +1756,7 @@ function RecordDetailPanel<T extends { id: RowId }>({
|
|
|
1677
1756
|
<Button
|
|
1678
1757
|
variant="ghost"
|
|
1679
1758
|
size="icon"
|
|
1680
|
-
onClick={
|
|
1759
|
+
onClick={handleCancel}
|
|
1681
1760
|
aria-label="Close"
|
|
1682
1761
|
className="ml-auto"
|
|
1683
1762
|
>
|
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
|
+
}
|