@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.
- package/CHANGELOG.md +16 -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/filter-text-value-input.js +1 -1
- package/dist/components/data-table/filter-text-value-input.js.map +1 -1
- package/dist/components/data-table/index.js +16 -12
- package/dist/components/data-table/index.js.map +1 -1
- package/dist/components/data-table/pagination.js +16 -12
- package/dist/components/data-table/pagination.js.map +1 -1
- package/dist/components/data-views/data-row-list.js +1 -1
- package/dist/components/data-views/data-row-list.js.map +1 -1
- package/dist/components/data-views/hub-table.d.ts +8 -4
- package/dist/components/data-views/hub-table.js +31 -16
- 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 +31 -16
- 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/column-row.js +1 -1
- package/dist/components/table-properties/column-row.js.map +1 -1
- package/dist/components/table-properties/drawer-button.js +6 -5
- package/dist/components/table-properties/drawer-button.js.map +1 -1
- package/dist/components/table-properties/drawer.js +6 -5
- package/dist/components/table-properties/drawer.js.map +1 -1
- package/dist/components/table-properties/filter-card.js +2 -2
- package/dist/components/table-properties/filter-card.js.map +1 -1
- package/dist/components/table-properties/index.d.ts +1 -1
- package/dist/components/table-properties/index.js +6 -5
- package/dist/components/table-properties/index.js.map +1 -1
- package/dist/components/table-properties/sort-card.js +1 -1
- package/dist/components/table-properties/sort-card.js.map +1 -1
- package/dist/components/templates/index.d.ts +1 -1
- package/dist/components/templates/index.js +16 -6
- 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 +16 -6
- package/dist/components/templates/list-page.js.map +1 -1
- package/dist/components/ui/banner.d.ts +2 -2
- package/dist/components/ui/banner.js +1 -1
- package/dist/components/ui/banner.js.map +1 -1
- package/dist/components/ui/coach-mark.js +1 -1
- package/dist/components/ui/coach-mark.js.map +1 -1
- package/dist/components/ui/context-menu.js +1 -1
- package/dist/components/ui/context-menu.js.map +1 -1
- package/dist/components/ui/date-picker-field.js +1 -1
- package/dist/components/ui/date-picker-field.js.map +1 -1
- package/dist/components/ui/dropdown-menu.js +2 -2
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/export-drawer.js +3 -3
- package/dist/components/ui/export-drawer.js.map +1 -1
- package/dist/components/ui/hover-card.js +1 -1
- package/dist/components/ui/hover-card.js.map +1 -1
- package/dist/components/ui/key-metrics.js +6 -6
- package/dist/components/ui/key-metrics.js.map +1 -1
- package/dist/components/ui/page-header.js +1 -1
- package/dist/components/ui/page-header.js.map +1 -1
- package/dist/components/ui/popover.js +1 -1
- package/dist/components/ui/popover.js.map +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/select.js.map +1 -1
- package/dist/components/ui/sheet.js +1 -1
- package/dist/components/ui/sheet.js.map +1 -1
- package/dist/components/ui/sidebar.d.ts +1 -1
- package/dist/components/ui/sidebar.js +3 -3
- package/dist/components/ui/sidebar.js.map +1 -1
- package/dist/components/ui/tip.js +1 -1
- package/dist/components/ui/tip.js.map +1 -1
- package/dist/components/ui/tooltip.js +1 -1
- package/dist/components/ui/tooltip.js.map +1 -1
- package/dist/components/ui/view-segmented-control.js +1 -1
- package/dist/components/ui/view-segmented-control.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 +151 -29
- 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/data-row-list.tsx +1 -1
- package/src/components/data-views/hub-table.tsx +9 -3
- package/src/components/templates/list-page.tsx +9 -3
- package/src/components/ui/banner.tsx +0 -2
- package/src/components/ui/coach-mark.tsx +1 -2
- package/src/components/ui/context-menu.tsx +1 -1
- package/src/components/ui/dropdown-menu.tsx +2 -2
- package/src/components/ui/hover-card.tsx +1 -1
- package/src/components/ui/key-metrics.tsx +4 -4
- package/src/components/ui/popover.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/sheet.tsx +1 -1
- package/src/components/ui/sidebar.tsx +3 -3
- package/src/components/ui/tooltip.tsx +1 -1
- 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 +11 -0
- 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
|
-
() =>
|
|
632
|
-
|
|
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=
|
|
754
|
-
lifecycleTabLabel=
|
|
755
|
-
searchAriaLabel=
|
|
784
|
+
hubLabel={hubLabel}
|
|
785
|
+
lifecycleTabLabel={lifecycleTabLabel}
|
|
786
|
+
searchAriaLabel={searchAriaLabel}
|
|
756
787
|
getRowId={row => row.id}
|
|
757
788
|
getRowSelectionLabel={row => row.stem}
|
|
758
|
-
defaultSort={
|
|
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=
|
|
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={
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
@@ -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
|
+
}
|