@exxatdesignux/ui 0.5.2 → 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.
- package/CHANGELOG.md +9 -0
- package/consumer-extras/cursor-rules/exxat-data-tables.mdc +8 -6
- package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +2 -1
- package/consumer-extras/cursor-rules/exxat-hub-supported-views.mdc +54 -0
- package/consumer-extras/cursor-rules/exxat-nav-single-active.mdc +31 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +8 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +15 -5
- package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +11 -4
- package/consumer-extras/handbook/HANDBOOK.md +1 -1
- package/consumer-extras/handbook/reference-implementations.md +2 -2
- package/consumer-extras/patterns/data-views-pattern.md +6 -0
- package/consumer-extras/patterns/hub-supported-views-pattern.md +53 -0
- package/dist/components/data-table/index.js +13 -9
- package/dist/components/data-table/index.js.map +1 -1
- package/dist/components/data-table/pagination.js +13 -9
- package/dist/components/data-table/pagination.js.map +1 -1
- package/dist/components/data-views/hub-table.d.ts +8 -4
- package/dist/components/data-views/hub-table.js +25 -10
- package/dist/components/data-views/hub-table.js.map +1 -1
- package/dist/components/data-views/index.d.ts +1 -1
- package/dist/components/data-views/index.js +25 -10
- package/dist/components/data-views/index.js.map +1 -1
- package/dist/components/data-views/list-page-connected-view-body.d.ts +1 -1
- package/dist/components/data-views/list-page-connected-view-body.js +1 -0
- package/dist/components/data-views/list-page-connected-view-body.js.map +1 -1
- package/dist/components/table-properties/drawer-button.js +1 -0
- package/dist/components/table-properties/drawer-button.js.map +1 -1
- package/dist/components/table-properties/drawer.js +1 -0
- package/dist/components/table-properties/drawer.js.map +1 -1
- package/dist/components/table-properties/index.d.ts +1 -1
- package/dist/components/table-properties/index.js +1 -0
- package/dist/components/table-properties/index.js.map +1 -1
- package/dist/components/templates/index.d.ts +1 -1
- package/dist/components/templates/index.js +12 -2
- package/dist/components/templates/index.js.map +1 -1
- package/dist/components/templates/list-page.d.ts +4 -2
- package/dist/components/templates/list-page.js +12 -2
- package/dist/components/templates/list-page.js.map +1 -1
- package/dist/{data-list-view-registry-CyBoBML4.d.ts → data-list-view-registry-BstmlfQ3.d.ts} +16 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +135 -13
- package/dist/index.js.map +1 -1
- package/dist/lib/data-list-view-registry.d.ts +1 -1
- package/dist/lib/data-list-view-registry.js +17 -1
- package/dist/lib/data-list-view-registry.js.map +1 -1
- package/dist/lib/data-list-view-surface.d.ts +1 -1
- package/dist/lib/data-list-view-surface.js +1 -0
- package/dist/lib/data-list-view-surface.js.map +1 -1
- package/dist/lib/list-page-table-properties.d.ts +1 -1
- package/dist/lib/list-page-table-properties.js +1 -0
- package/dist/lib/list-page-table-properties.js.map +1 -1
- package/dist/lib/nav-active.d.ts +38 -0
- package/dist/lib/nav-active.js +104 -0
- package/dist/lib/nav-active.js.map +1 -0
- package/package.json +1 -1
- package/src/components/data-table/index.tsx +25 -17
- package/src/components/data-views/hub-table.tsx +9 -3
- package/src/components/templates/list-page.tsx +9 -3
- package/src/index.ts +1 -0
- package/src/lib/data-list-view-registry.ts +31 -0
- package/src/lib/nav-active.ts +162 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +2 -1
- package/template/AGENTS.md +16 -1
- package/template/components/columns-client.tsx +3 -2
- package/template/components/columns-showcase.tsx +22 -18
- package/template/components/exxat-product-logo.tsx +1 -1
- package/template/components/library-table.tsx +62 -23
- package/template/components/new-library-item-form.tsx +0 -7
- package/template/components/product-wordmark.tsx +1 -1
- package/template/components/sidebar/app-sidebar.tsx +14 -106
- package/template/components/sidebar/secondary-nav.tsx +22 -4
- package/template/components/tokens-hub-auxiliary-views.tsx +301 -0
- package/template/components/tokens-themes-client.tsx +44 -16
- package/template/docs/HANDBOOK.md +1 -1
- package/template/docs/data-views-pattern.md +6 -0
- package/template/docs/glossary.md +2 -1
- package/template/docs/hub-supported-views-pattern.md +53 -0
- package/template/docs/reference-implementations.md +2 -2
- package/template/lib/full-hub-supported-views.ts +8 -0
- package/template/lib/library-supported-views.ts +5 -12
- package/template/package.json +1 -0
- package/tokens/hooks-index.json +2 -2
|
@@ -87,115 +87,23 @@ import {
|
|
|
87
87
|
type NavSchool,
|
|
88
88
|
type NavProgram,
|
|
89
89
|
} from "@/lib/mock/navigation"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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({
|
|
141
|
+
function NavLink({
|
|
142
|
+
link,
|
|
143
|
+
allLinkHrefs,
|
|
144
|
+
}: {
|
|
145
|
+
link: SecondaryNavLink
|
|
146
|
+
allLinkHrefs: readonly string[]
|
|
147
|
+
}) {
|
|
141
148
|
const pathname = usePathname()
|
|
142
|
-
const
|
|
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
|
+
}
|
|
@@ -34,11 +34,18 @@ import {
|
|
|
34
34
|
} from "@/components/key-metrics"
|
|
35
35
|
import {
|
|
36
36
|
HubTable,
|
|
37
|
+
ListPageBoardCard,
|
|
37
38
|
ListPageTemplate,
|
|
38
39
|
type ViewTab,
|
|
39
40
|
} from "@/components/data-views"
|
|
41
|
+
import type { ListPageBoardColumnDef } from "@/components/data-views/list-page-board-template"
|
|
40
42
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
41
|
-
import
|
|
43
|
+
import { FULL_HUB_SUPPORTED_VIEWS } from "@/lib/data-list-view-registry"
|
|
44
|
+
import {
|
|
45
|
+
buildTokensHubRenderers,
|
|
46
|
+
renderTokenListRow,
|
|
47
|
+
type TokenHubRow,
|
|
48
|
+
} from "@/components/tokens-hub-auxiliary-views"
|
|
42
49
|
import { Button } from "@/components/ui/button"
|
|
43
50
|
import { Tip } from "@/components/ui/tip"
|
|
44
51
|
import { Badge } from "@/components/ui/badge"
|
|
@@ -51,7 +58,6 @@ import {
|
|
|
51
58
|
categoryPreview,
|
|
52
59
|
primaryValueText,
|
|
53
60
|
type TokenCategory,
|
|
54
|
-
type TokenRecord,
|
|
55
61
|
} from "@/components/tokens-themes-section"
|
|
56
62
|
import {
|
|
57
63
|
readTokensCategory,
|
|
@@ -60,16 +66,7 @@ import {
|
|
|
60
66
|
} from "@/components/tokens-secondary-nav"
|
|
61
67
|
|
|
62
68
|
/** Row shape consumed by `DataTable` — flat fields make built-in search work out of the box. */
|
|
63
|
-
|
|
64
|
-
id: string // == name, unique
|
|
65
|
-
name: string // var(--…)
|
|
66
|
-
namespace: string
|
|
67
|
-
category: TokenCategory | string
|
|
68
|
-
value: string
|
|
69
|
-
deprecated: boolean
|
|
70
|
-
/** The original token index entry — used by the Preview cell renderer. */
|
|
71
|
-
record: TokenRecord
|
|
72
|
-
}
|
|
69
|
+
type TokenRow = TokenHubRow & { category: TokenCategory | string }
|
|
73
70
|
|
|
74
71
|
/** Build all token rows once at module load (token index is static at runtime). */
|
|
75
72
|
const TOKEN_ROWS: TokenRow[] = (() => {
|
|
@@ -124,8 +121,8 @@ const TOKENS_VIEW_TABS: ViewTab[] = [
|
|
|
124
121
|
},
|
|
125
122
|
]
|
|
126
123
|
|
|
127
|
-
/**
|
|
128
|
-
const TOKENS_SUPPORTED_VIEWS
|
|
124
|
+
/** Same seven views as Library / Column types — each has a renderer on `HubTable` below. */
|
|
125
|
+
const TOKENS_SUPPORTED_VIEWS = FULL_HUB_SUPPORTED_VIEWS
|
|
129
126
|
|
|
130
127
|
/**
|
|
131
128
|
* Canonical KPI shape (matches `placement-kpi.ts` precedent):
|
|
@@ -324,6 +321,19 @@ export function TokensThemesClient() {
|
|
|
324
321
|
|
|
325
322
|
const getTabCount = React.useCallback(() => rows.length, [rows.length])
|
|
326
323
|
|
|
324
|
+
const tokenBoardGroups = React.useMemo((): ListPageBoardColumnDef<TokenRow>[] => {
|
|
325
|
+
return CATEGORY_TABS.map(tab => ({
|
|
326
|
+
id: String(tab.id),
|
|
327
|
+
label: tab.label,
|
|
328
|
+
filter: (row: TokenRow) => tab.matches(String(row.category)),
|
|
329
|
+
}))
|
|
330
|
+
}, [])
|
|
331
|
+
|
|
332
|
+
const tokenHubRenderers = React.useMemo(
|
|
333
|
+
() => buildTokensHubRenderers(metrics, insight),
|
|
334
|
+
[metrics, insight],
|
|
335
|
+
)
|
|
336
|
+
|
|
327
337
|
const columns: ColumnDef<TokenRow>[] = React.useMemo(() => [
|
|
328
338
|
{
|
|
329
339
|
key: "preview",
|
|
@@ -425,7 +435,7 @@ export function TokensThemesClient() {
|
|
|
425
435
|
onTabsChange={setTabs}
|
|
426
436
|
activeTabId={activeTabId}
|
|
427
437
|
onActiveTabChange={setActiveTabId}
|
|
428
|
-
supportedViewTypes={
|
|
438
|
+
supportedViewTypes={TOKENS_SUPPORTED_VIEWS}
|
|
429
439
|
getTabCount={getTabCount}
|
|
430
440
|
header={
|
|
431
441
|
<PageHeader
|
|
@@ -467,7 +477,25 @@ export function TokensThemesClient() {
|
|
|
467
477
|
No tokens match your filters.
|
|
468
478
|
</p>
|
|
469
479
|
}
|
|
470
|
-
|
|
480
|
+
listAriaLabel="Tokens"
|
|
481
|
+
listEmptyState="No tokens match your filters."
|
|
482
|
+
renderListRow={renderTokenListRow}
|
|
483
|
+
renderBoardCard={row => (
|
|
484
|
+
<ListPageBoardCard layout="stack">
|
|
485
|
+
<div className="flex items-center gap-2">
|
|
486
|
+
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded border border-border bg-muted/20">
|
|
487
|
+
{categoryPreview(row.name, row.record)}
|
|
488
|
+
</div>
|
|
489
|
+
<div className="min-w-0">
|
|
490
|
+
<p className="truncate font-mono text-xs font-semibold text-foreground">{row.name}</p>
|
|
491
|
+
<p className="text-[10px] text-muted-foreground">{row.namespace}</p>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
</ListPageBoardCard>
|
|
495
|
+
)}
|
|
496
|
+
boardGroups={tokenBoardGroups}
|
|
497
|
+
boardEmptyColumnLabel="No tokens"
|
|
498
|
+
renderers={tokenHubRenderers}
|
|
471
499
|
/>
|
|
472
500
|
)}
|
|
473
501
|
/>
|
|
@@ -33,7 +33,7 @@ This is the **happy path** for the most common task: "I have an entity (records,
|
|
|
33
33
|
| 5 | Compose the page client with `PrimaryPageTemplate` → `ListPageTemplate` (KPIs in `metrics`, view tabs in `defaultTabs`, the `HubTable` in `renderContent`). | `apps/web/components/<entity>-client.tsx` | `exxat-list-page-connected-views.mdc` |
|
|
34
34
|
| 6 | Add to nav (`lib/mock/navigation.tsx`). If the hub needs scoped sub-navigation (e.g. categories), declare `secondaryPanel: "<id>"` and register the panel. | `apps/web/lib/mock/navigation.tsx`, `apps/web/components/sidebar/secondary-panel.tsx` | `exxat-primary-nav-secondary-panel.mdc` |
|
|
35
35
|
|
|
36
|
-
**Reference pages to copy:** `apps/web/components/
|
|
36
|
+
**Reference pages to copy:** `apps/web/components/library-table.tsx` + `library-client.tsx` (canonical seven-view hub), `apps/web/components/columns-showcase.tsx` (custom columns + same Add view via `LibraryTable`), `apps/web/components/tokens-themes-client.tsx` + `tokens-hub-auxiliary-views.tsx`. See **`hub-supported-views-pattern.md`** before changing Add view.
|
|
37
37
|
|
|
38
38
|
> **Stop signs.** If you find yourself building a parallel table stack, a second metrics strip, a custom filter row, or pasting raw `<DataTable>` into `renderContent` — **stop and re-read** `.cursor/rules/exxat-reuse-before-custom.mdc`.
|
|
39
39
|
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
This document describes how list pages combine **views**, **toolbar** behavior, **filters**, **properties**, and **persistence** in this codebase.
|
|
8
8
|
|
|
9
|
+
## Add view parity (seven views)
|
|
10
|
+
|
|
11
|
+
**Binding:** `.cursor/rules/exxat-hub-supported-views.mdc`. **Detail:** `docs/hub-supported-views-pattern.md`.
|
|
12
|
+
|
|
13
|
+
Every list hub **should** use **`FULL_HUB_SUPPORTED_VIEWS`** (table, list, board, dashboard, folder, panel, tree-panel) on both **`ListPageTemplate`** and **`HubTable`**, with a working renderer per view. **Library** (`library-table.tsx`) is the reference. Do not ship table-only or four-view allowlists on showcase/catalog hubs unless product documents an exception.
|
|
14
|
+
|
|
9
15
|
## Reuse existing components (required)
|
|
10
16
|
|
|
11
17
|
**Prefer composing what already exists** over rebuilding one-off tabs, search, filters, or property panels. The **Placements** flow is the reference implementation; new list/table/board pages should wire the same building blocks with new data and column definitions.
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
| **Secondary panel** | A scoped navigation rail (e.g. "Library → All / Mine / Tree", "Tokens & themes → Colors / Radius / Motion / …") that sits between the main sidebar and the page. Opening one collapses the main sidebar; closing one restores the previous sidebar state. | `exxat-primary-nav-secondary-panel.mdc` |
|
|
47
47
|
| **Site header** | The top bar on a primary route (org/product switcher + breadcrumbs + actions). Owned by `PrimaryPageTemplate`. | `apps/web/components/templates/primary-page-template.tsx` |
|
|
48
48
|
| **Skill** | A `.cursor/skills/<name>/SKILL.md` (mirrored in `.claude/skills/`) workflow + checklist for a recurring agent task. Use a skill when the same checklist would be repeated across many sessions. | `apps/web/AGENTS.md` |
|
|
49
|
-
| **`supportedViewTypes`** | The allowlist of `DataListViewType` values a hub implements.
|
|
49
|
+
| **`supportedViewTypes`** | The allowlist of `DataListViewType` values a hub implements. Default **`FULL_HUB_SUPPORTED_VIEWS`** (seven views, same Add view as Library). Must match on **`ListPageTemplate`** + **`HubTable`**; every entry needs a renderer. | `packages/ui/src/lib/data-list-view-registry.ts`, `docs/hub-supported-views-pattern.md` |
|
|
50
|
+
| **`FULL_HUB_SUPPORTED_VIEWS`** | table · list · board · dashboard · folder · panel · tree-panel — canonical list-hub allowlist. | `data-list-view-registry.ts` |
|
|
50
51
|
| **Trend polarity** | `MetricItem.trendPolarity` says whether "up" is good (`higher_is_better`, default), bad (`lower_is_better`), or value-neutral (`informational`). The arrow's tint follows the polarity, not the sign. | `exxat-kpi-trends.mdc` |
|
|
51
52
|
| **`useTableState`** | The state hook that owns rows, filters, search, sort, pagination, group-by, and column visibility. Always one instance per hub. | `exxat-centralized-list-dataset.mdc` |
|
|
52
53
|
| **View tab** | A tab on `ListPageTemplate` representing one view of the same dataset (table, list, board, dashboard, folder, panel, tree, …). Each tab carries a `viewType` and the `renderContent` callback receives it. | `exxat-list-page-connected-views.mdc` |
|