@viliha/vui-ui 1.1.5 → 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 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` · `table` · `record-view` (the full datatable
129
- + `RecordForm`) · plus the `utils` (`cn`) helper and the `theme.css` design
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.5",
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",
@@ -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] = React.useState("");
273
- const [sort, setSort] = React.useState<{
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] = React.useState(1);
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 [draft, setDraft] = React.useState<T>(row);
1413
- // Reset the buffered form when a different record is opened.
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
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={() => dismiss(onCancel)}>
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={() => dismiss(onCancel)}>
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={() => requestClose(onCancel)}
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={() => requestClose(onCancel)}
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 ref = React.useRef<HTMLDivElement>(null);
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
- if (ref.current && !ref.current.contains(e.target as Node)) {
46
- setOpen(false);
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={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
- <div
89
- role="listbox"
90
- aria-label={ariaLabel}
91
- tabIndex={-1}
92
- className="vui-pop-in absolute z-50 mt-1 max-h-60 w-full min-w-max overflow-auto rounded-md border border-border bg-popover text-popover-foreground shadow-md"
93
- >
94
- {options.map((o) => {
95
- const active = o.value === value;
96
- return (
97
- <button
98
- key={o.value}
99
- type="button"
100
- role="option"
101
- aria-selected={active}
102
- onClick={() => {
103
- onValueChange(o.value);
104
- setOpen(false);
105
- }}
106
- className={cn(
107
- "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",
108
- active && "bg-accent/60",
109
- )}
110
- >
111
- <span className="truncate">{o.label}</span>
112
- {active && (
113
- <Check className="size-3.5 shrink-0 text-[var(--button-primary)]" />
114
- )}
115
- </button>
116
- );
117
- })}
118
- </div>
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
+ }