@exxatdesignux/ui 0.5.1 → 0.5.3

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.
Files changed (138) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +8 -6
  3. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +2 -1
  4. package/consumer-extras/cursor-rules/exxat-hub-supported-views.mdc +54 -0
  5. package/consumer-extras/cursor-rules/exxat-nav-single-active.mdc +31 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +8 -3
  7. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +15 -5
  8. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +11 -4
  9. package/consumer-extras/handbook/HANDBOOK.md +1 -1
  10. package/consumer-extras/handbook/reference-implementations.md +2 -2
  11. package/consumer-extras/patterns/data-views-pattern.md +6 -0
  12. package/consumer-extras/patterns/hub-supported-views-pattern.md +53 -0
  13. package/dist/components/data-table/filter-text-value-input.js +1 -1
  14. package/dist/components/data-table/filter-text-value-input.js.map +1 -1
  15. package/dist/components/data-table/index.js +16 -12
  16. package/dist/components/data-table/index.js.map +1 -1
  17. package/dist/components/data-table/pagination.js +16 -12
  18. package/dist/components/data-table/pagination.js.map +1 -1
  19. package/dist/components/data-views/data-row-list.js +1 -1
  20. package/dist/components/data-views/data-row-list.js.map +1 -1
  21. package/dist/components/data-views/hub-table.d.ts +8 -4
  22. package/dist/components/data-views/hub-table.js +31 -16
  23. package/dist/components/data-views/hub-table.js.map +1 -1
  24. package/dist/components/data-views/index.d.ts +1 -1
  25. package/dist/components/data-views/index.js +31 -16
  26. package/dist/components/data-views/index.js.map +1 -1
  27. package/dist/components/data-views/list-page-connected-view-body.d.ts +1 -1
  28. package/dist/components/data-views/list-page-connected-view-body.js +1 -0
  29. package/dist/components/data-views/list-page-connected-view-body.js.map +1 -1
  30. package/dist/components/table-properties/column-row.js +1 -1
  31. package/dist/components/table-properties/column-row.js.map +1 -1
  32. package/dist/components/table-properties/drawer-button.js +6 -5
  33. package/dist/components/table-properties/drawer-button.js.map +1 -1
  34. package/dist/components/table-properties/drawer.js +6 -5
  35. package/dist/components/table-properties/drawer.js.map +1 -1
  36. package/dist/components/table-properties/filter-card.js +2 -2
  37. package/dist/components/table-properties/filter-card.js.map +1 -1
  38. package/dist/components/table-properties/index.d.ts +1 -1
  39. package/dist/components/table-properties/index.js +6 -5
  40. package/dist/components/table-properties/index.js.map +1 -1
  41. package/dist/components/table-properties/sort-card.js +1 -1
  42. package/dist/components/table-properties/sort-card.js.map +1 -1
  43. package/dist/components/templates/index.d.ts +1 -1
  44. package/dist/components/templates/index.js +16 -6
  45. package/dist/components/templates/index.js.map +1 -1
  46. package/dist/components/templates/list-page.d.ts +4 -2
  47. package/dist/components/templates/list-page.js +16 -6
  48. package/dist/components/templates/list-page.js.map +1 -1
  49. package/dist/components/ui/banner.d.ts +2 -2
  50. package/dist/components/ui/banner.js +1 -1
  51. package/dist/components/ui/banner.js.map +1 -1
  52. package/dist/components/ui/coach-mark.js +1 -1
  53. package/dist/components/ui/coach-mark.js.map +1 -1
  54. package/dist/components/ui/context-menu.js +1 -1
  55. package/dist/components/ui/context-menu.js.map +1 -1
  56. package/dist/components/ui/date-picker-field.js +1 -1
  57. package/dist/components/ui/date-picker-field.js.map +1 -1
  58. package/dist/components/ui/dropdown-menu.js +2 -2
  59. package/dist/components/ui/dropdown-menu.js.map +1 -1
  60. package/dist/components/ui/export-drawer.js +3 -3
  61. package/dist/components/ui/export-drawer.js.map +1 -1
  62. package/dist/components/ui/hover-card.js +1 -1
  63. package/dist/components/ui/hover-card.js.map +1 -1
  64. package/dist/components/ui/key-metrics.js +6 -6
  65. package/dist/components/ui/key-metrics.js.map +1 -1
  66. package/dist/components/ui/page-header.js +1 -1
  67. package/dist/components/ui/page-header.js.map +1 -1
  68. package/dist/components/ui/popover.js +1 -1
  69. package/dist/components/ui/popover.js.map +1 -1
  70. package/dist/components/ui/select.js +1 -1
  71. package/dist/components/ui/select.js.map +1 -1
  72. package/dist/components/ui/sheet.js +1 -1
  73. package/dist/components/ui/sheet.js.map +1 -1
  74. package/dist/components/ui/sidebar.d.ts +1 -1
  75. package/dist/components/ui/sidebar.js +3 -3
  76. package/dist/components/ui/sidebar.js.map +1 -1
  77. package/dist/components/ui/tip.js +1 -1
  78. package/dist/components/ui/tip.js.map +1 -1
  79. package/dist/components/ui/tooltip.js +1 -1
  80. package/dist/components/ui/tooltip.js.map +1 -1
  81. package/dist/components/ui/view-segmented-control.js +1 -1
  82. package/dist/components/ui/view-segmented-control.js.map +1 -1
  83. package/dist/{data-list-view-registry-CyBoBML4.d.ts → data-list-view-registry-BstmlfQ3.d.ts} +16 -1
  84. package/dist/index.d.ts +2 -1
  85. package/dist/index.js +151 -29
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/data-list-view-registry.d.ts +1 -1
  88. package/dist/lib/data-list-view-registry.js +17 -1
  89. package/dist/lib/data-list-view-registry.js.map +1 -1
  90. package/dist/lib/data-list-view-surface.d.ts +1 -1
  91. package/dist/lib/data-list-view-surface.js +1 -0
  92. package/dist/lib/data-list-view-surface.js.map +1 -1
  93. package/dist/lib/list-page-table-properties.d.ts +1 -1
  94. package/dist/lib/list-page-table-properties.js +1 -0
  95. package/dist/lib/list-page-table-properties.js.map +1 -1
  96. package/dist/lib/nav-active.d.ts +38 -0
  97. package/dist/lib/nav-active.js +104 -0
  98. package/dist/lib/nav-active.js.map +1 -0
  99. package/package.json +1 -1
  100. package/src/components/data-table/index.tsx +25 -17
  101. package/src/components/data-views/data-row-list.tsx +1 -1
  102. package/src/components/data-views/hub-table.tsx +9 -3
  103. package/src/components/templates/list-page.tsx +9 -3
  104. package/src/components/ui/banner.tsx +0 -2
  105. package/src/components/ui/coach-mark.tsx +1 -2
  106. package/src/components/ui/context-menu.tsx +1 -1
  107. package/src/components/ui/dropdown-menu.tsx +2 -2
  108. package/src/components/ui/hover-card.tsx +1 -1
  109. package/src/components/ui/key-metrics.tsx +4 -4
  110. package/src/components/ui/popover.tsx +1 -1
  111. package/src/components/ui/select.tsx +1 -1
  112. package/src/components/ui/sheet.tsx +1 -1
  113. package/src/components/ui/sidebar.tsx +3 -3
  114. package/src/components/ui/tooltip.tsx +1 -1
  115. package/src/index.ts +1 -0
  116. package/src/lib/data-list-view-registry.ts +31 -0
  117. package/src/lib/nav-active.ts +162 -0
  118. package/template/.claude/skills/exxat-ds-skill/SKILL.md +2 -1
  119. package/template/AGENTS.md +16 -1
  120. package/template/components/columns-client.tsx +3 -2
  121. package/template/components/columns-showcase.tsx +22 -18
  122. package/template/components/exxat-product-logo.tsx +1 -1
  123. package/template/components/library-table.tsx +62 -23
  124. package/template/components/new-library-item-form.tsx +0 -7
  125. package/template/components/product-wordmark.tsx +1 -1
  126. package/template/components/sidebar/app-sidebar.tsx +14 -106
  127. package/template/components/sidebar/secondary-nav.tsx +22 -4
  128. package/template/components/tokens-hub-auxiliary-views.tsx +301 -0
  129. package/template/components/tokens-themes-client.tsx +44 -16
  130. package/template/docs/HANDBOOK.md +1 -1
  131. package/template/docs/data-views-pattern.md +6 -0
  132. package/template/docs/glossary.md +2 -1
  133. package/template/docs/hub-supported-views-pattern.md +53 -0
  134. package/template/docs/reference-implementations.md +2 -2
  135. package/template/lib/full-hub-supported-views.ts +8 -0
  136. package/template/lib/library-supported-views.ts +5 -12
  137. package/template/package.json +11 -0
  138. package/tokens/hooks-index.json +2 -2
@@ -19,8 +19,8 @@ import {
19
19
  HubTable,
20
20
  type HubTableHandle,
21
21
  type HubTableRenderers,
22
- type HubTableRendererArgs,
23
22
  } from "@/components/data-views"
23
+ import { Skeleton } from "@/components/ui/skeleton"
24
24
  import { LIBRARY_SUPPORTED_VIEWS } from "@/lib/library-supported-views"
25
25
  import { Button } from "@/components/ui/button"
26
26
  import {
@@ -30,7 +30,6 @@ import {
30
30
  DropdownMenuTrigger,
31
31
  } from "@/components/ui/dropdown-menu"
32
32
  import { Tip } from "@/components/ui/tip"
33
- import { Skeleton } from "@/components/ui/skeleton"
34
33
  import {
35
34
  ResizableHandle,
36
35
  ResizablePanel,
@@ -581,6 +580,14 @@ function libraryPanelDetail(row: LibraryItem) {
581
580
 
582
581
  export type LibraryTableHandle = HubTableHandle
583
582
 
583
+ export interface LibraryTableHubLabels {
584
+ hubLabel: string
585
+ lifecycleTabLabel: string
586
+ searchAriaLabel: string
587
+ listAriaLabel?: string
588
+ defaultSort?: { key: string; dir: "asc" | "desc" }
589
+ }
590
+
584
591
  export interface LibraryTableProps {
585
592
  items: LibraryItem[]
586
593
  /** When set, table / board / tree rows are limited to this nav scope (secondary sidebar). */
@@ -596,6 +603,15 @@ export interface LibraryTableProps {
596
603
  folders: LibraryFolder[]
597
604
  onFoldersChange: React.Dispatch<React.SetStateAction<LibraryFolder[]>>
598
605
  onItemsChange: React.Dispatch<React.SetStateAction<LibraryItem[]>>
606
+ /** e.g. Column types showcase — custom `ColumnDef`s while reusing list/board/folder renderers. */
607
+ columnDefs?: ColumnDef<LibraryItem>[]
608
+ /** Override default Library copy when {@link columnDefs} is set. */
609
+ hubLabels?: LibraryTableHubLabels
610
+ pagination?: boolean
611
+ onPaginationChange?: (v: boolean) => void
612
+ paginationInitialPageSize?: number
613
+ paginationPageSizeOptions?: number[]
614
+ showBulkActions?: boolean
599
615
  }
600
616
 
601
617
  export const LibraryTable = React.forwardRef<LibraryTableHandle, LibraryTableProps>(
@@ -611,6 +627,13 @@ export const LibraryTable = React.forwardRef<LibraryTableHandle, LibraryTablePro
611
627
  folders,
612
628
  onFoldersChange,
613
629
  onItemsChange,
630
+ columnDefs,
631
+ hubLabels,
632
+ pagination,
633
+ onPaginationChange,
634
+ paginationInitialPageSize,
635
+ paginationPageSizeOptions,
636
+ showBulkActions = true,
614
637
  },
615
638
  ref,
616
639
  ) {
@@ -628,10 +651,18 @@ export const LibraryTable = React.forwardRef<LibraryTableHandle, LibraryTablePro
628
651
  )
629
652
 
630
653
  const columns = React.useMemo(
631
- () => buildLibraryColumns(tableSourceItems, { onToggleFavorite: toggleFavorite }),
632
- [tableSourceItems, toggleFavorite],
654
+ () =>
655
+ columnDefs ??
656
+ buildLibraryColumns(tableSourceItems, { onToggleFavorite: toggleFavorite }),
657
+ [columnDefs, tableSourceItems, toggleFavorite],
633
658
  )
634
659
 
660
+ const hubLabel = hubLabels?.hubLabel ?? "Library"
661
+ const lifecycleTabLabel = hubLabels?.lifecycleTabLabel ?? "Library"
662
+ const searchAriaLabel = hubLabels?.searchAriaLabel ?? "Search questions"
663
+ const listAriaLabel = hubLabels?.listAriaLabel ?? "Questions"
664
+ const defaultSort = hubLabels?.defaultSort ?? { key: "updatedAt", dir: "desc" as const }
665
+
635
666
  // ─ New-folder / customize-folder modal state (shared by panel + tree-panel) ────
636
667
  const [newFolderOpen, setNewFolderOpen] = React.useState(false)
637
668
  const [newFolderParentId, setNewFolderParentId] = React.useState<string | null>(null)
@@ -750,18 +781,22 @@ export const LibraryTable = React.forwardRef<LibraryTableHandle, LibraryTablePro
750
781
  view={view}
751
782
  onViewChange={onViewChange}
752
783
  supportedViewTypes={LIBRARY_SUPPORTED_VIEWS}
753
- hubLabel="Library"
754
- lifecycleTabLabel="Library"
755
- searchAriaLabel="Search questions"
784
+ hubLabel={hubLabel}
785
+ lifecycleTabLabel={lifecycleTabLabel}
786
+ searchAriaLabel={searchAriaLabel}
756
787
  getRowId={row => row.id}
757
788
  getRowSelectionLabel={row => row.stem}
758
- defaultSort={{ key: "updatedAt", dir: "desc" }}
789
+ defaultSort={defaultSort}
759
790
  emptyState={<p className="text-sm text-muted-foreground">No questions in the bank.</p>}
760
791
  boardGroupByColumnOptions={[...LIBRARY_BOARD_GROUP_OPTIONS]}
761
792
  renderFilterOptionValue={renderFilterOptionValue}
762
793
  syncedSearchFromUrl={searchLanding ? undefined : urlListSearch}
763
- listAriaLabel="Questions"
794
+ listAriaLabel={listAriaLabel}
764
795
  listEmptyState="No questions match your filters."
796
+ pagination={pagination}
797
+ onPaginationChange={onPaginationChange}
798
+ paginationInitialPageSize={paginationInitialPageSize}
799
+ paginationPageSizeOptions={paginationPageSizeOptions}
765
800
  renderListRow={row => (
766
801
  <ListPageBoardCard
767
802
  className={LIBRARY_FAVORITE_HOVER_GROUP}
@@ -784,20 +819,24 @@ export const LibraryTable = React.forwardRef<LibraryTableHandle, LibraryTablePro
784
819
  </div>
785
820
  </ListPageBoardCard>
786
821
  )}
787
- bulkActionsSlot={selected => {
788
- if (selected.size === 0) return null
789
- return (
790
- <>
791
- <span className="sr-only">{selected.size} selected</span>
792
- <Tip label="Export selection (demo)">
793
- <Button size="sm" variant="outline" type="button">
794
- <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
795
- Export
796
- </Button>
797
- </Tip>
798
- </>
799
- )
800
- }}
822
+ bulkActionsSlot={
823
+ showBulkActions
824
+ ? selected => {
825
+ if (selected.size === 0) return null
826
+ return (
827
+ <>
828
+ <span className="sr-only">{selected.size} selected</span>
829
+ <Tip label="Export selection (demo)">
830
+ <Button size="sm" variant="outline" type="button">
831
+ <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
832
+ Export
833
+ </Button>
834
+ </Tip>
835
+ </>
836
+ )
837
+ }
838
+ : undefined
839
+ }
801
840
  renderers={renderers}
802
841
  handleRef={ref}
803
842
  />
@@ -338,13 +338,6 @@ function folderBreadcrumb(folderId: string, folders: LibraryFolder[]): string {
338
338
  return parent ? `${parent.name} / ${f.name}` : f.name
339
339
  }
340
340
 
341
- /** 0–100 percentage of the difficulty meter for the given level. */
342
- function difficultyToPercent(value: "easy" | "medium" | "hard"): number {
343
- if (value === "easy") return 18
344
- if (value === "hard") return 82
345
- return 50
346
- }
347
-
348
341
  /**
349
342
  * Folder-aware difficulty insight — derived deterministically from the
350
343
  * folder id so the same folder always returns the same numbers. In a real
@@ -44,7 +44,7 @@ export interface ProductWordmarkProps {
44
44
  */
45
45
  export function ProductWordmark({
46
46
  config,
47
- variant = "default",
47
+ variant: _variant = "default",
48
48
  className,
49
49
  }: ProductWordmarkProps) {
50
50
  const prefix = config.prefix ?? "Exxat"
@@ -87,115 +87,23 @@ import {
87
87
  type NavSchool,
88
88
  type NavProgram,
89
89
  } from "@/lib/mock/navigation"
90
- /** Path segment of a nav URL (strip `#fragment` for matching). */
91
- function navUrlPath(url: string): string {
92
- if (!url || url === "#") return ""
93
- const i = url.indexOf("#")
94
- return i === -1 ? url : url.slice(0, i)
95
- }
96
-
97
- /** Hash segment from a nav `href` (no `#`). `null` when the URL has no `#`. */
98
- function navUrlFragment(url: string): string | null {
99
- if (!url.includes("#")) return null
100
- return url.slice(url.indexOf("#") + 1)
101
- }
102
-
103
- function normalizedLocationHash(locationHash: string): string {
104
- if (!locationHash) return ""
105
- return locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
106
- }
90
+ import {
91
+ buildNavHashClaims,
92
+ collectNavUrls,
93
+ isNavHrefActive,
94
+ navUrlPath,
95
+ normalizedLocationHash,
96
+ } from "@exxatdesignux/ui/lib/nav-active"
107
97
 
108
- /**
109
- * Path → set of hash fragments claimed by *another* nav item at the same path.
110
- *
111
- * Why: several rows deliberately share a route and disambiguate via `#fragment`.
112
- * Example: `Tokens & themes → /settings#appearance` and `Settings → /settings`
113
- * both render under the same page. Without a registry, when the user is on
114
- * `/settings#appearance` the no-fragment "Settings" row matches by path-equality
115
- * (line below: `pathname === pathOnly`) and lights up too — so *both* rows
116
- * appear active.
117
- *
118
- * The registry is computed once from the static nav config and consulted by
119
- * `isNavActive` to make the no-fragment item defer when a hash-bearing sibling
120
- * claims the current `location.hash`.
121
- */
122
- const NAV_HASH_CLAIMS: ReadonlyMap<string, ReadonlySet<string>> = (() => {
123
- const map = new Map<string, Set<string>>()
124
- const record = (url: string) => {
125
- const p = navUrlPath(url)
126
- const f = navUrlFragment(url)
127
- if (!p || f === null) return
128
- let set = map.get(p)
129
- if (!set) { set = new Set<string>(); map.set(p, set) }
130
- set.add(f)
131
- }
132
- const walk = (items: ReadonlyArray<{ url?: string; children?: ReadonlyArray<{ url?: string; children?: unknown }> }>) => {
133
- for (const it of items) {
134
- if (typeof it.url === "string") record(it.url)
135
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
- if (Array.isArray((it as any).children)) walk((it as any).children)
137
- }
138
- }
139
- walk(NAV_PRIMARY)
140
- walk(NAV_DOCUMENTS)
141
- walk(NAV_QUICK_ACTIONS)
142
- walk(NAV_SECONDARY)
143
- return map
144
- })()
98
+ const ALL_NAV_URLS = collectNavUrls([...NAV_PRIMARY, ...NAV_DOCUMENTS, ...NAV_SECONDARY])
99
+ const NAV_HASH_CLAIMS = buildNavHashClaims(ALL_NAV_URLS)
145
100
 
146
- /** True when another nav item at the same path claims the current location hash. */
147
- function navHasMoreSpecificMatch(pathname: string, locationHash: string): boolean {
148
- const claims = NAV_HASH_CLAIMS.get(pathname)
149
- if (!claims) return false
150
- const h = normalizedLocationHash(locationHash)
151
- if (h === "") return false
152
- return claims.has(h)
153
- }
154
-
155
- /**
156
- * Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
157
- * When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
158
- * in each `href` — those rows use the `frag !== null` branch below.
159
- * For `href` without `#…`, an in-page hash (e.g. QB view tabs) does not clear the match.
160
- */
101
+ /** Single active primary/secondary sidebar row longest matching path wins. */
161
102
  function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
162
- const pathOnly = navUrlPath(url)
163
- const frag = navUrlFragment(url)
164
- const h = normalizedLocationHash(locationHash)
165
-
166
- if (!pathOnly || pathOnly === "#") return false
167
-
168
- if (frag !== null) {
169
- if (pathOnly === "/") return pathname === "/" && h === frag
170
- if (pathOnly === "/library") {
171
- return pathname.startsWith("/library/") && h === frag
172
- }
173
- if (pathOnly.startsWith("/library/")) {
174
- return pathname === pathOnly && h === frag
175
- }
176
- return pathname === pathOnly && h === frag
177
- }
178
-
179
- if (pathOnly === "/") {
180
- if (pathname !== "/" || h !== "") return false
181
- return !navHasMoreSpecificMatch(pathname, locationHash)
182
- }
183
- /**
184
- * Exact path match — ignore `location.hash` when the nav `href` has no `#…` fragment (QB view tabs use hash).
185
- * EXCEPTION: when another nav item at the same path claims the current hash
186
- * (e.g. `Tokens & themes → /settings#appearance` while we're evaluating
187
- * `Settings → /settings`), defer to that more-specific row — otherwise both
188
- * would light up. See `NAV_HASH_CLAIMS`.
189
- */
190
- if (pathname === pathOnly) return !navHasMoreSpecificMatch(pathname, locationHash)
191
- // Design system library — active on hub and detail routes.
192
- if (pathOnly === "/library") {
193
- return pathname.startsWith("/library/")
194
- }
195
- if (pathOnly.startsWith("/library/")) {
196
- return pathname === pathOnly
197
- }
198
- return pathname.startsWith(`${pathOnly}/`)
103
+ return isNavHrefActive(pathname, url, ALL_NAV_URLS, {
104
+ locationHash,
105
+ hashClaimsByPath: NAV_HASH_CLAIMS,
106
+ })
199
107
  }
200
108
 
201
109
  /** Sub-item active — catalog detail routes, hash fragments, or duplicate hub URLs (Rotations). */
@@ -26,6 +26,7 @@
26
26
  import * as React from "react"
27
27
  import { usePathname } from "next/navigation"
28
28
  import { cn } from "@/lib/utils"
29
+ import { resolveActiveNavHref } from "@exxatdesignux/ui/lib/nav-active"
29
30
  import {
30
31
  Tooltip,
31
32
  TooltipContent,
@@ -137,9 +138,16 @@ function RailButton({
137
138
  // NavLink — single item in the content panel
138
139
  // ─────────────────────────────────────────────────────────────────────────────
139
140
 
140
- function NavLink({ link }: { link: SecondaryNavLink }) {
141
+ function NavLink({
142
+ link,
143
+ allLinkHrefs,
144
+ }: {
145
+ link: SecondaryNavLink
146
+ allLinkHrefs: readonly string[]
147
+ }) {
141
148
  const pathname = usePathname()
142
- const isActive = pathname === link.href || pathname.startsWith(link.href + "/")
149
+ const activeHref = resolveActiveNavHref(pathname, allLinkHrefs)
150
+ const isActive = activeHref !== null && activeHref === link.href
143
151
 
144
152
  if (link.isSectionHeader) {
145
153
  return (
@@ -239,9 +247,12 @@ export function SecondaryNavRail({
239
247
 
240
248
  export function SecondaryNavPanel({
241
249
  section,
250
+ allLinkHrefs,
242
251
  className,
243
252
  }: {
244
253
  section: SecondaryNavSection
254
+ /** All navigable hrefs (every section) so only one row is active at a time. */
255
+ allLinkHrefs: readonly string[]
245
256
  className?: string
246
257
  }) {
247
258
  const [query, setQuery] = React.useState("")
@@ -329,7 +340,7 @@ export function SecondaryNavPanel({
329
340
 
330
341
  <ul role="list" className="flex flex-col gap-0.5 px-1.5">
331
342
  {visibleLinks.map(link => (
332
- <NavLink key={link.key} link={link} />
343
+ <NavLink key={link.key} link={link} allLinkHrefs={allLinkHrefs} />
333
344
  ))}
334
345
  {section.searchable && q && visibleLinks.length === 0 && (
335
346
  <li className="px-3 py-2 text-xs text-muted-foreground">No results</li>
@@ -363,6 +374,13 @@ export function SecondaryNav({
363
374
  )
364
375
 
365
376
  const currentSection = sections.find(s => s.key === activeSection) ?? sections[0]
377
+ const allLinkHrefs = React.useMemo(
378
+ () =>
379
+ sections.flatMap(s =>
380
+ s.links.filter(l => !l.isSectionHeader).map(l => l.href),
381
+ ),
382
+ [sections],
383
+ )
366
384
 
367
385
  function handleSectionChange(key: string) {
368
386
  setActiveSection(key)
@@ -388,7 +406,7 @@ export function SecondaryNav({
388
406
  )}
389
407
 
390
408
  {/* Right content panel */}
391
- <SecondaryNavPanel section={currentSection} />
409
+ <SecondaryNavPanel section={currentSection} allLinkHrefs={allLinkHrefs} />
392
410
  </div>
393
411
  )
394
412
  }
@@ -0,0 +1,301 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ FolderGridView,
6
+ ListPageBoardCard,
7
+ ListPageSplitHubChrome,
8
+ type HubTableRendererArgs,
9
+ type HubTableRenderers,
10
+ } from "@/components/data-views"
11
+ import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
12
+ import {
13
+ LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
14
+ LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
15
+ LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
16
+ } from "@/components/data-views/list-page-split-hub-tokens"
17
+ import { KeyMetrics, type MetricInsight, type MetricItem } from "@/components/key-metrics"
18
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
19
+ import { cn } from "@/lib/utils"
20
+ import { categoryPreview, type TokenRecord } from "@/components/tokens-themes-section"
21
+
22
+ export interface TokenHubRow extends Record<string, unknown> {
23
+ id: string
24
+ name: string
25
+ namespace: string
26
+ category: string
27
+ value: string
28
+ deprecated: boolean
29
+ record: TokenRecord
30
+ }
31
+
32
+ export function TokensDashboardBody({
33
+ metrics,
34
+ insight,
35
+ }: {
36
+ metrics: MetricItem[]
37
+ insight: MetricInsight
38
+ }) {
39
+ return (
40
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 lg:px-6">
41
+ <KeyMetrics variant="flat" metrics={metrics} insight={insight} showHeader={false} metricsSingleRow />
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export function TokensFolderBody({ rows }: { rows: TokenHubRow[] }) {
47
+ return (
48
+ <FolderGridView
49
+ rows={rows}
50
+ getRowId={r => r.id}
51
+ ariaLabel="Design tokens"
52
+ constrainWidth
53
+ renderTile={row => (
54
+ <button
55
+ type="button"
56
+ className={cn(
57
+ "flex w-full flex-col items-center gap-2 rounded-lg border border-border bg-card p-4 text-center",
58
+ "transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
59
+ )}
60
+ >
61
+ <div className="flex h-12 w-12 items-center justify-center">{categoryPreview(row.name, row.record)}</div>
62
+ <span className="line-clamp-2 font-mono text-xs text-foreground">{row.name}</span>
63
+ <span className="text-[10px] text-muted-foreground">{row.namespace}</span>
64
+ </button>
65
+ )}
66
+ emptyContent={<p className="text-sm text-muted-foreground">No tokens match your filters.</p>}
67
+ />
68
+ )
69
+ }
70
+
71
+ function TokenDetailPanel({ row }: { row: TokenHubRow }) {
72
+ return (
73
+ <div className="flex min-w-0 flex-col gap-3">
74
+ <div className="flex items-center gap-3">
75
+ <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30">
76
+ {categoryPreview(row.name, row.record)}
77
+ </div>
78
+ <div className="min-w-0">
79
+ <p className="font-mono text-sm font-semibold text-foreground">{row.name}</p>
80
+ <p className="text-xs text-muted-foreground">{row.namespace}</p>
81
+ </div>
82
+ </div>
83
+ <div>
84
+ <span className="text-xs font-medium text-muted-foreground">Value</span>
85
+ <p className="mt-1 font-mono text-xs text-foreground break-all">{row.value}</p>
86
+ </div>
87
+ <div>
88
+ <span className="text-xs font-medium text-muted-foreground">Category</span>
89
+ <p className="mt-1 text-sm text-foreground">{String(row.category)}</p>
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ export function TokensPanelBody({ rows }: { rows: TokenHubRow[] }) {
96
+ const namespaces = React.useMemo(
97
+ () => [...new Set(rows.map(r => r.namespace))].sort((a, b) => a.localeCompare(b)),
98
+ [rows],
99
+ )
100
+ const [activeNamespace, setActiveNamespace] = React.useState<string | null>(namespaces[0] ?? null)
101
+ const [activeId, setActiveId] = React.useState<string | null>(null)
102
+
103
+ React.useEffect(() => {
104
+ if (namespaces.length === 0) {
105
+ setActiveNamespace(null)
106
+ setActiveId(null)
107
+ return
108
+ }
109
+ if (!activeNamespace || !namespaces.includes(activeNamespace)) {
110
+ setActiveNamespace(namespaces[0]!)
111
+ }
112
+ }, [namespaces, activeNamespace])
113
+
114
+ const inNamespace = React.useMemo(
115
+ () => (activeNamespace ? rows.filter(r => r.namespace === activeNamespace) : []),
116
+ [rows, activeNamespace],
117
+ )
118
+
119
+ React.useEffect(() => {
120
+ if (inNamespace.length === 0) {
121
+ setActiveId(null)
122
+ return
123
+ }
124
+ if (!activeId || !inNamespace.some(r => r.id === activeId)) {
125
+ setActiveId(inNamespace[0]!.id)
126
+ }
127
+ }, [inNamespace, activeId])
128
+
129
+ const activeRow = inNamespace.find(r => r.id === activeId) ?? null
130
+
131
+ return (
132
+ <ListPageSplitHubChrome aria-label="Token namespaces and details">
133
+ <ResizablePanelGroup direction="horizontal" className="flex h-full min-h-0 w-full flex-1">
134
+ <ResizablePanel defaultSize={28} minSize={18} className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}>
135
+ <ListPageTreeColumnHeader title="Namespaces" />
136
+ <div className="min-h-0 flex-1 overflow-y-auto p-1">
137
+ {namespaces.map(ns => (
138
+ <button
139
+ key={ns}
140
+ type="button"
141
+ onClick={() => setActiveNamespace(ns)}
142
+ className={cn(
143
+ "flex w-full items-center rounded-md px-3 py-2 text-left text-sm",
144
+ activeNamespace === ns ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:bg-muted/50",
145
+ )}
146
+ >
147
+ {ns}
148
+ </button>
149
+ ))}
150
+ </div>
151
+ </ResizablePanel>
152
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
153
+ <ResizablePanel defaultSize={32} minSize={20} className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}>
154
+ <ListPageTreeColumnHeader title={activeNamespace ?? "Tokens"} />
155
+ <div className="min-h-0 flex-1 overflow-y-auto p-1">
156
+ {inNamespace.map(row => (
157
+ <button
158
+ key={row.id}
159
+ type="button"
160
+ onClick={() => setActiveId(row.id)}
161
+ className={cn(
162
+ "flex w-full flex-col rounded-md px-3 py-2 text-left",
163
+ activeId === row.id ? "bg-muted" : "hover:bg-muted/50",
164
+ )}
165
+ >
166
+ <span className="truncate font-mono text-xs text-foreground">{row.name}</span>
167
+ <span className="truncate text-[10px] text-muted-foreground">{String(row.category)}</span>
168
+ </button>
169
+ ))}
170
+ </div>
171
+ </ResizablePanel>
172
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
173
+ <ResizablePanel defaultSize={40} minSize={24} className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}>
174
+ <ListPageTreeColumnHeader title="Details" className="px-4" />
175
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
176
+ {activeRow ? <TokenDetailPanel row={activeRow} /> : <p className="text-sm text-muted-foreground">Select a token.</p>}
177
+ </div>
178
+ </ResizablePanel>
179
+ </ResizablePanelGroup>
180
+ </ListPageSplitHubChrome>
181
+ )
182
+ }
183
+
184
+ export function TokensTreePanelBody({ rows }: { rows: TokenHubRow[] }) {
185
+ const byNamespace = React.useMemo(() => {
186
+ const map = new Map<string, TokenHubRow[]>()
187
+ for (const row of rows) {
188
+ const list = map.get(row.namespace) ?? []
189
+ list.push(row)
190
+ map.set(row.namespace, list)
191
+ }
192
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b))
193
+ }, [rows])
194
+
195
+ const [openNs, setOpenNs] = React.useState<Set<string>>(() => new Set(byNamespace.map(([ns]) => ns)))
196
+ const [activeId, setActiveId] = React.useState<string | null>(rows[0]?.id ?? null)
197
+ const activeRow = rows.find(r => r.id === activeId) ?? null
198
+
199
+ return (
200
+ <ListPageSplitHubChrome aria-label="Token tree">
201
+ <ResizablePanelGroup direction="horizontal" className="flex h-full min-h-0 w-full flex-1">
202
+ <ResizablePanel defaultSize={40} minSize={24} className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}>
203
+ <ListPageTreeColumnHeader title="All tokens" />
204
+ <div className="min-h-0 flex-1 overflow-y-auto p-2">
205
+ {byNamespace.map(([ns, items]) => {
206
+ const open = openNs.has(ns)
207
+ return (
208
+ <div key={ns} className="mb-1">
209
+ <button
210
+ type="button"
211
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm font-medium hover:bg-muted/50"
212
+ onClick={() =>
213
+ setOpenNs(prev => {
214
+ const next = new Set(prev)
215
+ if (next.has(ns)) next.delete(ns)
216
+ else next.add(ns)
217
+ return next
218
+ })
219
+ }
220
+ >
221
+ <i
222
+ className={cn("fa-light text-xs text-muted-foreground", open ? "fa-chevron-down" : "fa-chevron-right")}
223
+ aria-hidden="true"
224
+ />
225
+ {ns}
226
+ </button>
227
+ {open ? (
228
+ <div className="ms-4 border-s border-border ps-2">
229
+ {items.map(row => (
230
+ <button
231
+ key={row.id}
232
+ type="button"
233
+ onClick={() => setActiveId(row.id)}
234
+ className={cn(
235
+ "flex w-full rounded-md px-2 py-1.5 text-left font-mono text-xs",
236
+ activeId === row.id ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/40",
237
+ )}
238
+ >
239
+ {row.name}
240
+ </button>
241
+ ))}
242
+ </div>
243
+ ) : null}
244
+ </div>
245
+ )
246
+ })}
247
+ </div>
248
+ </ResizablePanel>
249
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
250
+ <ResizablePanel defaultSize={60} minSize={30} className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}>
251
+ <ListPageTreeColumnHeader title="Details" className="px-4" />
252
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
253
+ {activeRow ? <TokenDetailPanel row={activeRow} /> : <p className="text-sm text-muted-foreground">Select a token.</p>}
254
+ </div>
255
+ </ResizablePanel>
256
+ </ResizablePanelGroup>
257
+ </ListPageSplitHubChrome>
258
+ )
259
+ }
260
+
261
+ export function buildTokensHubRenderers(
262
+ metrics: MetricItem[],
263
+ insight: MetricInsight,
264
+ ): Pick<
265
+ HubTableRenderers<TokenHubRow>,
266
+ "dashboard-with-toolbar" | "folder-with-toolbar" | "panel-with-toolbar" | "tree-panel-with-toolbar"
267
+ > {
268
+ return {
269
+ "dashboard-with-toolbar": ({ toolbar }: HubTableRendererArgs<TokenHubRow>) => (
270
+ <div className="flex min-h-0 flex-1 flex-col">
271
+ {toolbar}
272
+ <TokensDashboardBody metrics={metrics} insight={insight} />
273
+ </div>
274
+ ),
275
+ "folder-with-toolbar": ({ state, toolbarShell }) =>
276
+ toolbarShell(<TokensFolderBody rows={state.rows as TokenHubRow[]} />),
277
+ "panel-with-toolbar": ({ state, toolbarShell }) =>
278
+ toolbarShell(<TokensPanelBody rows={state.rows as TokenHubRow[]} />),
279
+ "tree-panel-with-toolbar": ({ state, toolbarShell }) =>
280
+ toolbarShell(<TokensTreePanelBody rows={state.rows as TokenHubRow[]} />),
281
+ }
282
+ }
283
+
284
+ export function renderTokenListRow(row: TokenHubRow) {
285
+ return (
286
+ <ListPageBoardCard layout="row" rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
287
+ <div className="flex min-w-0 items-center gap-3">
288
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-muted/20">
289
+ {categoryPreview(row.name, row.record)}
290
+ </div>
291
+ <div className="min-w-0 space-y-0.5">
292
+ <p className="truncate font-mono text-sm font-semibold text-foreground">{row.name}</p>
293
+ <p className="text-xs text-muted-foreground">
294
+ {row.namespace} · {String(row.category)}
295
+ </p>
296
+ <p className="truncate font-mono text-[10px] text-muted-foreground">{row.value}</p>
297
+ </div>
298
+ </div>
299
+ </ListPageBoardCard>
300
+ )
301
+ }