@viliha/vui-ui 1.12.0 → 1.13.1

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,7 +338,9 @@ 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 animated 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.
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.
342
344
 
343
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).
344
346
 
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.1 — 2026-07-26
11
+
12
+ ### Fixed
13
+
14
+ - **Keep-alive tabs now truly preserve a page's live state across tab switches**
15
+ (within `NEXT_PUBLIC_MAX_TABS`). The scaffold's `KeepAliveTabs` was overwriting
16
+ a route's cached element with Next's fresh `children` on every re-activation,
17
+ which remounted the page and threw away its state — so a server-backed table
18
+ (e.g. the Data Table demo) refetched every time you returned to its tab. It now
19
+ caches each route's element once and reuses it, so switching away and back keeps
20
+ the loaded data, scroll, and form state with no reload. Query-param routes
21
+ (`/organizations/edit?id=…`) still update via their own `useSearchParams`.
22
+
23
+ ## 1.13.0 — 2026-07-26
24
+
25
+ ### Added
26
+
27
+ - **Server-side ("manual") mode for `RecordView`.** Set `manual` and RecordView
28
+ stops filtering/sorting/paginating `data` — it renders it as the current page
29
+ verbatim and reports the query via `onQueryChange({ page, pageSize, sort,
30
+ search, filters })` so your backend does the work. Pair with `rowCount` (drives
31
+ the footer + page count) and `loading`. Fires once on mount for the initial
32
+ load; per-field filters fire it on the Filter panel's Search/Clear. New exported
33
+ types: `SortState`, `ServerQuery<T>`. Default off — everything stays client-side.
34
+ - **Data Table demo page** in the scaffold (shadcn/ui section) — a full
35
+ server-side table against a simulated backend (pagination, sort, keyword +
36
+ per-field filter, loading shimmer, out-of-order-response guard).
37
+
38
+ ### Changed
39
+
40
+ - The loading skeleton now **shimmers left → right** (a `vui-shimmer` sweep in
41
+ `theme.css`) instead of a pulse — a clearer "records are loading" cue.
42
+
43
+ **For agents:** for a server-backed table use `manual` + `rowCount` +
44
+ `onQueryChange` (fetch → set `data`/`rowCount`/`loading`); guard stale
45
+ responses with a request-id ref. Don't reimplement pagination/sort. Demo:
46
+ the Data Table page. Docs: `/docs/data-table` (Server-side data).
47
+
10
48
  ## 1.12.0 — 2026-07-26
11
49
 
12
50
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viliha/vui-ui",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
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;
@@ -384,6 +399,20 @@ interface RecordViewProps<T extends { id: RowId }> {
384
399
  * from the server (an initial fetch or a filter/refetch). Set it around your
385
400
  * async load; the toolbar stays usable. */
386
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;
387
416
  }
388
417
 
389
418
  export function RecordView<T extends { id: RowId }>({
@@ -407,6 +436,9 @@ export function RecordView<T extends { id: RowId }>({
407
436
  resizableColumns = false,
408
437
  onFilter,
409
438
  loading = false,
439
+ manual = false,
440
+ rowCount,
441
+ onQueryChange,
410
442
  }: RecordViewProps<T>) {
411
443
  const { titleLeading } = React.useContext(PageChromeContext);
412
444
  // Surface the page title/icon in the app's global top bar.
@@ -554,6 +586,9 @@ export function RecordView<T extends { id: RowId }>({
554
586
  );
555
587
 
556
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;
557
592
  let out = rows;
558
593
  const q = filter.trim().toLowerCase();
559
594
  if (q) {
@@ -582,7 +617,7 @@ export function RecordView<T extends { id: RowId }>({
582
617
  });
583
618
  }
584
619
  return out;
585
- }, [rows, filter, sort, fields, getPrimary]);
620
+ }, [manual, rows, filter, sort, fields, getPrimary]);
586
621
 
587
622
  const activeRow = rows.find((r) => r.id === activeId) ?? null;
588
623
  const deleteTarget =
@@ -591,17 +626,43 @@ export function RecordView<T extends { id: RowId }>({
591
626
  : null;
592
627
 
593
628
  // Pagination (derived; `page` is clamped so it never points past the last page).
594
- 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));
595
633
  const safePage = Math.min(Math.max(1, page), totalPages);
596
- const rangeStart = processed.length === 0 ? 0 : (safePage - 1) * pageSize + 1;
597
- const rangeEnd = Math.min(safePage * pageSize, processed.length);
598
- 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);
599
643
 
600
644
  // Reset to the first page when the filter or page size changes.
601
645
  React.useEffect(() => {
602
646
  setPage(1);
603
647
  }, [filter, pageSize, setPage]);
604
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
+
605
666
  // Cascading filter options: when the values change, drop any filter value no
606
667
  // longer valid once its options recompute (e.g. changing Region invalidates a
607
668
  // Country filter). Only function-options filters cascade. Strings clear; multi
@@ -1246,13 +1307,33 @@ export function RecordView<T extends { id: RowId }>({
1246
1307
  onClick={() => {
1247
1308
  setFilterValues({});
1248
1309
  onFilter?.({});
1310
+ setPage(1);
1311
+ if (manual)
1312
+ onQueryChange?.({
1313
+ page: 1,
1314
+ pageSize,
1315
+ sort,
1316
+ search: filter,
1317
+ filters: {},
1318
+ });
1249
1319
  }}
1250
1320
  >
1251
1321
  Clear
1252
1322
  </Button>
1253
1323
  <Button
1254
1324
  variant="primary"
1255
- 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
+ }}
1256
1337
  >
1257
1338
  <Search className="size-3.5" />
1258
1339
  Search
@@ -1341,7 +1422,7 @@ export function RecordView<T extends { id: RowId }>({
1341
1422
  ))}
1342
1423
  </Dropdown>
1343
1424
  <span className="whitespace-nowrap px-1 tabular-nums">
1344
- {rangeStart}–{rangeEnd} of {processed.length}
1425
+ {rangeStart}–{rangeEnd} of {total}
1345
1426
  </span>
1346
1427
  <button
1347
1428
  type="button"
@@ -1468,22 +1549,22 @@ export function RecordView<T extends { id: RowId }>({
1468
1549
  Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
1469
1550
  <TableRow key={`skeleton-${i}`} className="hover:bg-transparent">
1470
1551
  <TableCell style={{ width: CHECKBOX_W }}>
1471
- <div className="mx-2 size-4 animate-pulse rounded bg-muted" />
1552
+ <div className="mx-2 size-4 vui-shimmer rounded" />
1472
1553
  </TableCell>
1473
1554
  <TableCell style={{ width: colWidths[NAME_COL] }}>
1474
1555
  <div className="flex items-center gap-2">
1475
- <div className="size-7 shrink-0 animate-pulse rounded-full bg-muted" />
1476
- <div className="h-3.5 w-32 animate-pulse rounded bg-muted" />
1556
+ <div className="size-7 shrink-0 vui-shimmer rounded-full" />
1557
+ <div className="h-3.5 w-32 vui-shimmer rounded" />
1477
1558
  </div>
1478
1559
  </TableCell>
1479
1560
  {visibleFields.map((f) => (
1480
1561
  <TableCell key={f.key} style={{ width: colWidths[f.key] }}>
1481
- <div className="h-3.5 w-20 animate-pulse rounded bg-muted" />
1562
+ <div className="h-3.5 w-20 vui-shimmer rounded" />
1482
1563
  </TableCell>
1483
1564
  ))}
1484
1565
  <TableCell aria-hidden="true" className="border-r-0" />
1485
1566
  <TableCell style={{ width: ACTIONS_W }}>
1486
- <div className="mx-auto h-4 w-8 animate-pulse rounded bg-muted" />
1567
+ <div className="mx-auto h-4 w-8 vui-shimmer rounded" />
1487
1568
  </TableCell>
1488
1569
  </TableRow>
1489
1570
  ))
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
+ }
@@ -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" },
@@ -503,11 +503,14 @@ export function KeepAliveTabs({ children }: { children: React.ReactNode }) {
503
503
  const active = tabKey(usePathname());
504
504
  const { tabs } = useOpenTabs();
505
505
  const cache = React.useRef(new Map<string, React.ReactNode>());
506
- // Refresh the active route's element (so re-opening an /edit tab with a
507
- // different record shows the right data); inactive tabs keep their cached
508
- // element and stay mounted. In-progress form work survives via RecordForm's
509
- // sessionStorage draft (see persistKey), which is StrictMode-safe.
510
- cache.current.set(active, children);
506
+ // Cache each route's element ONCE, on first activation, and reuse it — never
507
+ // overwrite on re-activation. Reusing the same element reference is what lets
508
+ // React keep the mounted instance (and its loaded data / scroll / form state)
509
+ // across tab switches; overwriting with Next's fresh `children` would remount
510
+ // the subtree and reload everything. Routes that vary by query param (e.g.
511
+ // /organizations/edit?id=…) share one tab and update themselves via
512
+ // `useSearchParams`, so they don't need a refresh here.
513
+ if (!cache.current.has(active)) cache.current.set(active, children);
511
514
  // Drop entries for tabs that have been closed.
512
515
  const open = new Set(tabs.map((t) => t.href));
513
516
  for (const key of [...cache.current.keys()]) {
@@ -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",