@viliha/vui-ui 1.11.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT.md CHANGED
@@ -338,6 +338,10 @@ Sorting follows column visibility by default; use `sortable` to decouple them
338
338
 
339
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
340
 
341
+ Loading from a server: pass `loading` to `RecordView` while the fetch is in flight — it shows shimmering skeleton rows (matched to the columns) instead of an empty-state flash; clear it when the data arrives. Don't build your own spinner overlay.
342
+
343
+ Server-side (large tables): set `manual` and RecordView stops filtering/sorting/paginating `data` — it renders it as the current page and reports the query via `onQueryChange({ page, pageSize, sort, search, filters })`. Pair with `rowCount` (totals) and `loading`. In your handler, fetch and set `data` + `rowCount` + `loading`; guard out-of-order responses with a request-id ref and debounce chatty keyword changes. Don't reimplement pagination/sort yourself. Reference: the Data Table demo page (shadcn/ui section) hits a simulated backend.
344
+
341
345
  Dependent/cascading options: make `options` (form) or `filterable.options` (filter) a function — `(draft) => …` in the form, `(values) => …` in the filter — and one field's choices depend on another; RecordView clears the child when the parent change invalidates it. Keep `options` a static array for the bulk "Set {label}" action. If a field has both a function `options` and `renderInput`, guard with `Array.isArray(field.options)` before mapping (renderInput doesn't get the draft to resolve it).
342
346
 
343
347
  ## Filtering
package/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ 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.13.0 — 2026-07-26
11
+
12
+ ### Added
13
+
14
+ - **Server-side ("manual") mode for `RecordView`.** Set `manual` and RecordView
15
+ stops filtering/sorting/paginating `data` — it renders it as the current page
16
+ verbatim and reports the query via `onQueryChange({ page, pageSize, sort,
17
+ search, filters })` so your backend does the work. Pair with `rowCount` (drives
18
+ the footer + page count) and `loading`. Fires once on mount for the initial
19
+ load; per-field filters fire it on the Filter panel's Search/Clear. New exported
20
+ types: `SortState`, `ServerQuery<T>`. Default off — everything stays client-side.
21
+ - **Data Table demo page** in the scaffold (shadcn/ui section) — a full
22
+ server-side table against a simulated backend (pagination, sort, keyword +
23
+ per-field filter, loading shimmer, out-of-order-response guard).
24
+
25
+ ### Changed
26
+
27
+ - The loading skeleton now **shimmers left → right** (a `vui-shimmer` sweep in
28
+ `theme.css`) instead of a pulse — a clearer "records are loading" cue.
29
+
30
+ **For agents:** for a server-backed table use `manual` + `rowCount` +
31
+ `onQueryChange` (fetch → set `data`/`rowCount`/`loading`); guard stale
32
+ responses with a request-id ref. Don't reimplement pagination/sort. Demo:
33
+ the Data Table page. Docs: `/docs/data-table` (Server-side data).
34
+
35
+ ## 1.12.0 — 2026-07-26
36
+
37
+ ### Added
38
+
39
+ - **`RecordView` `loading` prop** — while `true`, the table body renders animated
40
+ skeleton rows (matched to the columns) instead of an empty-state flash, for
41
+ slow server loads (initial fetch or refetch). The toolbar stays usable; clear
42
+ `loading` when the data arrives.
43
+
44
+ **For agents:** wrap your fetch with `loading` (`setLoading(true)` → fetch →
45
+ `finally(() => setLoading(false))`); don't hand-roll a spinner overlay. Demo:
46
+ `markets` simulates a load on first visit. Docs: `/docs/data-table`.
47
+
10
48
  ## 1.11.1 — 2026-07-26
11
49
 
12
50
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.11.1",
3
+ "version": "1.13.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",
@@ -213,6 +213,21 @@ export type FilterValues<T> = Partial<
213
213
  Record<Extract<keyof T, string>, string | string[]>
214
214
  >;
215
215
 
216
+ /** Current sort — the field key and direction, or `null` for unsorted. */
217
+ export type SortState = { key: string; dir: "asc" | "desc" };
218
+
219
+ /** The full query state reported by `onQueryChange` in server (`manual`) mode —
220
+ * everything needed to build a request. `page` is 1-based. */
221
+ export type ServerQuery<T> = {
222
+ page: number;
223
+ pageSize: number;
224
+ sort: SortState | null;
225
+ /** The keyword box. */
226
+ search: string;
227
+ /** Per-field filter values (from `filterable` fields). */
228
+ filters: FilterValues<T>;
229
+ };
230
+
216
231
  export interface RecordField<T> {
217
232
  key: Extract<keyof T, string>;
218
233
  label: string;
@@ -380,6 +395,24 @@ interface RecordViewProps<T extends { id: RowId }> {
380
395
  * or client-side filtering here. In per-field mode the panel does not match
381
396
  * rows itself, so the behavior is entirely yours. */
382
397
  onFilter?: (values: FilterValues<T>) => void;
398
+ /** Show animated skeleton rows instead of the table body while data loads
399
+ * from the server (an initial fetch or a filter/refetch). Set it around your
400
+ * async load; the toolbar stays usable. */
401
+ loading?: boolean;
402
+ /** Server-side mode. When `true`, RecordView does NOT filter, sort, or
403
+ * paginate `data` — it renders `data` as the current page verbatim and reports
404
+ * query state via `onQueryChange`, so your backend does the work. Pair with
405
+ * `rowCount` (for totals), `loading`, and `onQueryChange`. Default `false`
406
+ * (everything client-side). */
407
+ manual?: boolean;
408
+ /** Total row count on the server — drives the pagination footer and page count
409
+ * in `manual` mode (RecordView can't infer it from a single page of `data`). */
410
+ rowCount?: number;
411
+ /** Server mode: called with the full query whenever page, page size, sort, or
412
+ * the keyword changes (and on the per-field Filter Search/Clear). Fetch and
413
+ * update `data` + `rowCount` + `loading` in response. Fires once on mount for
414
+ * the initial load; debounce inside if keyword changes are chatty. */
415
+ onQueryChange?: (query: ServerQuery<T>) => void;
383
416
  }
384
417
 
385
418
  export function RecordView<T extends { id: RowId }>({
@@ -402,6 +435,10 @@ export function RecordView<T extends { id: RowId }>({
402
435
  persistKey,
403
436
  resizableColumns = false,
404
437
  onFilter,
438
+ loading = false,
439
+ manual = false,
440
+ rowCount,
441
+ onQueryChange,
405
442
  }: RecordViewProps<T>) {
406
443
  const { titleLeading } = React.useContext(PageChromeContext);
407
444
  // Surface the page title/icon in the app's global top bar.
@@ -549,6 +586,9 @@ export function RecordView<T extends { id: RowId }>({
549
586
  );
550
587
 
551
588
  const processed = React.useMemo(() => {
589
+ // Server mode: `rows` is already the filtered/sorted current page — render
590
+ // it verbatim (no client-side filter/sort).
591
+ if (manual) return rows;
552
592
  let out = rows;
553
593
  const q = filter.trim().toLowerCase();
554
594
  if (q) {
@@ -577,7 +617,7 @@ export function RecordView<T extends { id: RowId }>({
577
617
  });
578
618
  }
579
619
  return out;
580
- }, [rows, filter, sort, fields, getPrimary]);
620
+ }, [manual, rows, filter, sort, fields, getPrimary]);
581
621
 
582
622
  const activeRow = rows.find((r) => r.id === activeId) ?? null;
583
623
  const deleteTarget =
@@ -586,17 +626,43 @@ export function RecordView<T extends { id: RowId }>({
586
626
  : null;
587
627
 
588
628
  // Pagination (derived; `page` is clamped so it never points past the last page).
589
- const totalPages = Math.max(1, Math.ceil(processed.length / pageSize));
629
+ // Server mode: totals come from `rowCount`, and `data` is already this page —
630
+ // so render it whole (no slice) and size the range to what the server returned.
631
+ const total = manual ? (rowCount ?? processed.length) : processed.length;
632
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
590
633
  const safePage = Math.min(Math.max(1, page), totalPages);
591
- const rangeStart = processed.length === 0 ? 0 : (safePage - 1) * pageSize + 1;
592
- const rangeEnd = Math.min(safePage * pageSize, processed.length);
593
- const paged = processed.slice((safePage - 1) * pageSize, safePage * pageSize);
634
+ const rangeStart = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
635
+ const rangeEnd = manual
636
+ ? total === 0
637
+ ? 0
638
+ : Math.min(rangeStart + processed.length - 1, total)
639
+ : Math.min(safePage * pageSize, total);
640
+ const paged = manual
641
+ ? processed
642
+ : processed.slice((safePage - 1) * pageSize, safePage * pageSize);
594
643
 
595
644
  // Reset to the first page when the filter or page size changes.
596
645
  React.useEffect(() => {
597
646
  setPage(1);
598
647
  }, [filter, pageSize, setPage]);
599
648
 
649
+ // Server mode: report the query so the consumer can fetch. Fires on page,
650
+ // size, sort, and keyword changes (and once on mount for the initial load).
651
+ // Per-field filters emit via the Filter panel's Search/Clear instead, so they
652
+ // apply on demand, not per keystroke. `filterValues` is read fresh here but
653
+ // deliberately left out of the deps for that reason.
654
+ React.useEffect(() => {
655
+ if (!manual) return;
656
+ onQueryChange?.({
657
+ page: safePage,
658
+ pageSize,
659
+ sort,
660
+ search: filter,
661
+ filters: filterValues,
662
+ });
663
+ // eslint-disable-next-line react-hooks/exhaustive-deps
664
+ }, [manual, safePage, pageSize, sort, filter, onQueryChange]);
665
+
600
666
  // Cascading filter options: when the values change, drop any filter value no
601
667
  // longer valid once its options recompute (e.g. changing Region invalidates a
602
668
  // Country filter). Only function-options filters cascade. Strings clear; multi
@@ -1241,13 +1307,33 @@ export function RecordView<T extends { id: RowId }>({
1241
1307
  onClick={() => {
1242
1308
  setFilterValues({});
1243
1309
  onFilter?.({});
1310
+ setPage(1);
1311
+ if (manual)
1312
+ onQueryChange?.({
1313
+ page: 1,
1314
+ pageSize,
1315
+ sort,
1316
+ search: filter,
1317
+ filters: {},
1318
+ });
1244
1319
  }}
1245
1320
  >
1246
1321
  Clear
1247
1322
  </Button>
1248
1323
  <Button
1249
1324
  variant="primary"
1250
- onClick={() => onFilter?.(filterValues)}
1325
+ onClick={() => {
1326
+ onFilter?.(filterValues);
1327
+ setPage(1);
1328
+ if (manual)
1329
+ onQueryChange?.({
1330
+ page: 1,
1331
+ pageSize,
1332
+ sort,
1333
+ search: filter,
1334
+ filters: filterValues,
1335
+ });
1336
+ }}
1251
1337
  >
1252
1338
  <Search className="size-3.5" />
1253
1339
  Search
@@ -1336,7 +1422,7 @@ export function RecordView<T extends { id: RowId }>({
1336
1422
  ))}
1337
1423
  </Dropdown>
1338
1424
  <span className="whitespace-nowrap px-1 tabular-nums">
1339
- {rangeStart}–{rangeEnd} of {processed.length}
1425
+ {rangeStart}–{rangeEnd} of {total}
1340
1426
  </span>
1341
1427
  <button
1342
1428
  type="button"
@@ -1458,7 +1544,31 @@ export function RecordView<T extends { id: RowId }>({
1458
1544
  </TableRow>
1459
1545
  </TableHeader>
1460
1546
  <TableBody>
1461
- {processed.length ? (
1547
+ {loading ? (
1548
+ // Animated skeleton rows while data loads from the server.
1549
+ Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
1550
+ <TableRow key={`skeleton-${i}`} className="hover:bg-transparent">
1551
+ <TableCell style={{ width: CHECKBOX_W }}>
1552
+ <div className="mx-2 size-4 vui-shimmer rounded" />
1553
+ </TableCell>
1554
+ <TableCell style={{ width: colWidths[NAME_COL] }}>
1555
+ <div className="flex items-center gap-2">
1556
+ <div className="size-7 shrink-0 vui-shimmer rounded-full" />
1557
+ <div className="h-3.5 w-32 vui-shimmer rounded" />
1558
+ </div>
1559
+ </TableCell>
1560
+ {visibleFields.map((f) => (
1561
+ <TableCell key={f.key} style={{ width: colWidths[f.key] }}>
1562
+ <div className="h-3.5 w-20 vui-shimmer rounded" />
1563
+ </TableCell>
1564
+ ))}
1565
+ <TableCell aria-hidden="true" className="border-r-0" />
1566
+ <TableCell style={{ width: ACTIONS_W }}>
1567
+ <div className="mx-auto h-4 w-8 vui-shimmer rounded" />
1568
+ </TableCell>
1569
+ </TableRow>
1570
+ ))
1571
+ ) : processed.length ? (
1462
1572
  paged.map((row) => {
1463
1573
  const primary = getPrimary(row);
1464
1574
  return (
package/src/theme.css CHANGED
@@ -273,6 +273,30 @@
273
273
  transform-origin: var(--vui-pop-origin, top);
274
274
  }
275
275
 
276
+ /* Loading skeleton: a highlight band sweeping left → right over a muted base,
277
+ the "records are loading" cue. Used by RecordView's loading skeleton rows.
278
+ (prefers-reduced-motion is respected by the global clamp above.) */
279
+ @keyframes vui-shimmer {
280
+ from {
281
+ background-position: -150% 0;
282
+ }
283
+ to {
284
+ background-position: 150% 0;
285
+ }
286
+ }
287
+ .vui-shimmer {
288
+ background-color: var(--muted);
289
+ background-image: linear-gradient(
290
+ 90deg,
291
+ var(--muted) 0%,
292
+ color-mix(in oklab, var(--foreground) 12%, var(--muted)) 50%,
293
+ var(--muted) 100%
294
+ );
295
+ background-size: 150% 100%;
296
+ background-repeat: no-repeat;
297
+ animation: vui-shimmer 1.5s ease-in-out infinite;
298
+ }
299
+
276
300
  @keyframes vui-slide-out-right {
277
301
  from {
278
302
  transform: translateX(0);
@@ -0,0 +1,15 @@
1
+ import type { Metadata } from "next";
2
+
3
+ export const metadata: Metadata = {
4
+ title: "Data Table",
5
+ description:
6
+ "Server-side data table demo — pagination, sorting, keyword and per-field filtering handled by the backend, with a loading shimmer, built on RecordView.",
7
+ };
8
+
9
+ export default function DataTableLayout({
10
+ children,
11
+ }: {
12
+ children: React.ReactNode;
13
+ }) {
14
+ return children;
15
+ }
@@ -0,0 +1,158 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef, useState } from "react";
4
+ import {
5
+ BackpackIcon as TeamIcon,
6
+ PersonIcon as RoleIcon,
7
+ SewingPinFilledIcon as LocationIcon,
8
+ TableIcon,
9
+ } from "@radix-ui/react-icons";
10
+
11
+ import {
12
+ RecordView,
13
+ type RecordField,
14
+ type ServerQuery,
15
+ } from "@viliha/vui-ui/record-view";
16
+
17
+ // A self-contained demo type + dataset. In a real app this lives in your
18
+ // database; here it stands in for the backend table so pagination is visible.
19
+ type Member = {
20
+ id: number;
21
+ name: string;
22
+ team: string;
23
+ role: string;
24
+ location: string;
25
+ commits: number;
26
+ };
27
+
28
+ const TEAMS = ["Platform", "Growth", "Design", "Data", "Mobile", "Security"];
29
+ const ROLES = ["Engineer", "Manager", "Designer", "Analyst", "Lead"];
30
+ const CITIES = ["Seattle", "Tokyo", "Berlin", "Austin", "Sydney", "London"];
31
+
32
+ const ALL: Member[] = Array.from({ length: 213 }, (_, i) => ({
33
+ id: i + 1,
34
+ name: `Member ${String(i + 1).padStart(3, "0")}`,
35
+ team: TEAMS[i % TEAMS.length]!,
36
+ role: ROLES[i % ROLES.length]!,
37
+ location: CITIES[i % CITIES.length]!,
38
+ commits: (i * 7) % 500,
39
+ }));
40
+
41
+ // Simulated server endpoint: filter + sort + paginate, returning just the page
42
+ // (plus the total). Swap this for a real `fetch` to your API.
43
+ function fetchPage(
44
+ q: ServerQuery<Member>,
45
+ ): Promise<{ rows: Member[]; total: number }> {
46
+ return new Promise((resolve) => {
47
+ setTimeout(() => {
48
+ let out = ALL;
49
+ const search = q.search.trim().toLowerCase();
50
+ if (search) {
51
+ out = out.filter((m) =>
52
+ [m.name, m.team, m.role, m.location].some((v) =>
53
+ v.toLowerCase().includes(search),
54
+ ),
55
+ );
56
+ }
57
+ const team = q.filters.team;
58
+ if (typeof team === "string" && team) {
59
+ out = out.filter((m) => m.team === team);
60
+ }
61
+ if (q.sort) {
62
+ const { key, dir } = q.sort;
63
+ out = [...out].sort((a, b) => {
64
+ const av = a[key as keyof Member];
65
+ const bv = b[key as keyof Member];
66
+ const cmp =
67
+ typeof av === "number" && typeof bv === "number"
68
+ ? av - bv
69
+ : String(av ?? "").localeCompare(String(bv ?? ""));
70
+ return dir === "asc" ? cmp : -cmp;
71
+ });
72
+ }
73
+ const total = out.length;
74
+ const start = (q.page - 1) * q.pageSize;
75
+ resolve({ rows: out.slice(start, start + q.pageSize), total });
76
+ }, 500);
77
+ });
78
+ }
79
+
80
+ const fields: RecordField<Member>[] = [
81
+ { key: "name", label: "Name", editable: true, required: true, group: "General", hideInTable: true, sortable: true },
82
+ {
83
+ key: "team",
84
+ label: "Team",
85
+ icon: TeamIcon,
86
+ editable: true,
87
+ group: "General",
88
+ filterable: {
89
+ control: "select",
90
+ options: TEAMS.map((t) => ({ value: t, label: t })),
91
+ },
92
+ },
93
+ { key: "role", label: "Role", icon: RoleIcon, editable: true, group: "General" },
94
+ { key: "location", label: "Location", icon: LocationIcon, editable: true, group: "General" },
95
+ {
96
+ key: "commits",
97
+ label: "Commits",
98
+ group: "System",
99
+ render: (row) => <span className="tabular-nums">{row.commits}</span>,
100
+ },
101
+ ];
102
+
103
+ function ServerDataTable() {
104
+ // Server-side mode: RecordView reports the query; we fetch the page and feed
105
+ // back `data` + `rowCount` + `loading`. It never filters/sorts/paginates here.
106
+ const [data, setData] = useState<Member[]>([]);
107
+ const [rowCount, setRowCount] = useState(0);
108
+ const [loading, setLoading] = useState(true);
109
+ const reqId = useRef(0);
110
+
111
+ const handleQuery = useCallback((q: ServerQuery<Member>) => {
112
+ const id = ++reqId.current;
113
+ setLoading(true);
114
+ void fetchPage(q).then(({ rows, total }) => {
115
+ if (id !== reqId.current) return; // ignore out-of-order responses
116
+ setData(rows);
117
+ setRowCount(total);
118
+ setLoading(false);
119
+ });
120
+ }, []);
121
+
122
+ return (
123
+ <RecordView
124
+ title="Data Table"
125
+ singular="Member"
126
+ icon={TableIcon}
127
+ manual
128
+ rowCount={rowCount}
129
+ loading={loading}
130
+ onQueryChange={handleQuery}
131
+ fields={fields}
132
+ initialData={data}
133
+ data={data}
134
+ onDataChange={setData}
135
+ getPrimary={(row) => ({
136
+ title: row.name,
137
+ subtitle: `${row.team} · ${row.role}`,
138
+ initials: row.name.slice(0, 2).toUpperCase(),
139
+ })}
140
+ makeEmptyRow={() => ({
141
+ id: Date.now(),
142
+ name: "",
143
+ team: "",
144
+ role: "",
145
+ location: "",
146
+ commits: 0,
147
+ })}
148
+ />
149
+ );
150
+ }
151
+
152
+ export default function DataTablePage() {
153
+ return (
154
+ <main className="h-full">
155
+ <ServerDataTable />
156
+ </main>
157
+ );
158
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useEffect, useState } from "react";
3
4
  import {
4
5
  CubeIcon as Building,
5
6
  GlobeIcon as Compass,
@@ -51,13 +52,28 @@ const fields: RecordField<Market>[] = [
51
52
  ];
52
53
 
53
54
  export function MarketsTable() {
55
+ // Demo: simulate loading records from a server so the skeleton state shows on
56
+ // first visit. In a real app, set `loading` around your fetch/refetch.
57
+ const [data, setData] = useState<Market[]>([]);
58
+ const [loading, setLoading] = useState(true);
59
+ useEffect(() => {
60
+ const t = setTimeout(() => {
61
+ setData(markets);
62
+ setLoading(false);
63
+ }, 900);
64
+ return () => clearTimeout(t);
65
+ }, []);
66
+
54
67
  return (
55
68
  <RecordView
56
69
  title="Markets"
57
70
  singular="Market"
58
71
  icon={MapPin}
72
+ loading={loading}
59
73
  fields={fields}
60
- initialData={markets}
74
+ initialData={data}
75
+ data={data}
76
+ onDataChange={setData}
61
77
  getPrimary={(row) => ({
62
78
  title: row.name,
63
79
  subtitle: row.organization,
@@ -24,6 +24,7 @@ import {
24
24
  RowsIcon as StepsIcon,
25
25
  SewingPinFilledIcon as MapPin,
26
26
  Share2Icon as Network,
27
+ TableIcon,
27
28
  TargetIcon as Target,
28
29
  TextIcon as Languages,
29
30
  TokensIcon as Coins,
@@ -75,6 +76,7 @@ export const NAV: NavSection[] = [
75
76
  title: "shadcn/ui",
76
77
  items: [
77
78
  { label: "Components", href: "/components", icon: Blocks, color: "text-indigo-500" },
79
+ { label: "Data Table", href: "/data-table", icon: TableIcon, color: "text-amber-500" },
78
80
  { label: "Forms", href: "/forms", icon: FormInput, color: "text-teal-500" },
79
81
  { label: "Steps", href: "/steps", icon: StepsIcon, color: "text-violet-500" },
80
82
  { label: "Calendar", href: "/calendar", icon: CalendarIcon, color: "text-rose-500" },
@@ -6,6 +6,7 @@ export const SEGMENT_LABELS: Record<string, string> = {
6
6
  dashboard: "Home",
7
7
  charts: "Charts",
8
8
  components: "Components",
9
+ "data-table": "Data Table",
9
10
  forms: "Forms",
10
11
  steps: "Steps",
11
12
  calendar: "Calendar",
@@ -36,6 +37,7 @@ export const ROUTE_COLORS: Record<string, string> = {
36
37
  "/dashboard": "text-blue-500",
37
38
  "/charts": "text-fuchsia-500",
38
39
  "/components": "text-indigo-500",
40
+ "/data-table": "text-amber-500",
39
41
  "/forms": "text-teal-500",
40
42
  "/steps": "text-violet-500",
41
43
  "/calendar": "text-rose-500",
@@ -65,6 +67,7 @@ export const ROUTE_ACCENT: Record<string, string> = {
65
67
  "/dashboard": "#3b82f6",
66
68
  "/charts": "#d946ef",
67
69
  "/components": "#6366f1",
70
+ "/data-table": "#f59e0b",
68
71
  "/forms": "#14b8a6",
69
72
  "/steps": "#8b5cf6",
70
73
  "/calendar": "#f43f5e",