@exxatdesignux/ui 0.5.2 → 0.5.4

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 (104) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/consumer-extras/cursor-rules/exxat-accessibility.mdc +1 -1
  4. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +8 -6
  5. package/consumer-extras/cursor-rules/exxat-drawer-vs-dialog.mdc +4 -4
  6. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +6 -1
  7. package/consumer-extras/cursor-rules/exxat-hub-supported-views.mdc +54 -0
  8. package/consumer-extras/cursor-rules/exxat-nav-single-active.mdc +31 -0
  9. package/consumer-extras/cursor-rules/exxat-no-vaul.mdc +25 -0
  10. package/consumer-extras/cursor-rules/exxat-page-header-actions.mdc +31 -0
  11. package/consumer-extras/cursor-rules/exxat-table-row-preview.mdc +24 -0
  12. package/consumer-extras/cursor-rules/exxat-tabs-chrome.mdc +31 -0
  13. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +5 -5
  14. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +10 -5
  15. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +1 -1
  16. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +15 -5
  17. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +14 -5
  18. package/consumer-extras/handbook/HANDBOOK.md +1 -1
  19. package/consumer-extras/handbook/reference-implementations.md +2 -2
  20. package/consumer-extras/patterns/consumer-upgrade-checklist.md +14 -1
  21. package/consumer-extras/patterns/data-views-pattern.md +6 -0
  22. package/consumer-extras/patterns/drawer-vs-dialog-pattern.md +50 -0
  23. package/consumer-extras/patterns/hub-supported-views-pattern.md +53 -0
  24. package/dist/components/data-table/index.js +13 -9
  25. package/dist/components/data-table/index.js.map +1 -1
  26. package/dist/components/data-table/pagination.js +13 -9
  27. package/dist/components/data-table/pagination.js.map +1 -1
  28. package/dist/components/data-views/hub-table.d.ts +8 -4
  29. package/dist/components/data-views/hub-table.js +25 -10
  30. package/dist/components/data-views/hub-table.js.map +1 -1
  31. package/dist/components/data-views/index.d.ts +1 -1
  32. package/dist/components/data-views/index.js +25 -10
  33. package/dist/components/data-views/index.js.map +1 -1
  34. package/dist/components/data-views/list-page-connected-view-body.d.ts +1 -1
  35. package/dist/components/data-views/list-page-connected-view-body.js +1 -0
  36. package/dist/components/data-views/list-page-connected-view-body.js.map +1 -1
  37. package/dist/components/table-properties/drawer-button.js +1 -0
  38. package/dist/components/table-properties/drawer-button.js.map +1 -1
  39. package/dist/components/table-properties/drawer.js +1 -0
  40. package/dist/components/table-properties/drawer.js.map +1 -1
  41. package/dist/components/table-properties/index.d.ts +1 -1
  42. package/dist/components/table-properties/index.js +1 -0
  43. package/dist/components/table-properties/index.js.map +1 -1
  44. package/dist/components/templates/index.d.ts +1 -1
  45. package/dist/components/templates/index.js +12 -2
  46. package/dist/components/templates/index.js.map +1 -1
  47. package/dist/components/templates/list-page.d.ts +4 -2
  48. package/dist/components/templates/list-page.js +12 -2
  49. package/dist/components/templates/list-page.js.map +1 -1
  50. package/dist/{data-list-view-registry-CyBoBML4.d.ts → data-list-view-registry-BstmlfQ3.d.ts} +16 -1
  51. package/dist/index.d.ts +2 -3
  52. package/dist/index.js +135 -126
  53. package/dist/index.js.map +1 -1
  54. package/dist/lib/data-list-view-registry.d.ts +1 -1
  55. package/dist/lib/data-list-view-registry.js +17 -1
  56. package/dist/lib/data-list-view-registry.js.map +1 -1
  57. package/dist/lib/data-list-view-surface.d.ts +1 -1
  58. package/dist/lib/data-list-view-surface.js +1 -0
  59. package/dist/lib/data-list-view-surface.js.map +1 -1
  60. package/dist/lib/list-page-table-properties.d.ts +1 -1
  61. package/dist/lib/list-page-table-properties.js +1 -0
  62. package/dist/lib/list-page-table-properties.js.map +1 -1
  63. package/dist/lib/nav-active.d.ts +38 -0
  64. package/dist/lib/nav-active.js +104 -0
  65. package/dist/lib/nav-active.js.map +1 -0
  66. package/package.json +1 -2
  67. package/src/components/data-table/index.tsx +25 -17
  68. package/src/components/data-views/hub-table.tsx +9 -3
  69. package/src/components/templates/list-page.tsx +9 -3
  70. package/src/index.ts +1 -1
  71. package/src/lib/data-list-view-registry.ts +31 -0
  72. package/src/lib/nav-active.ts +162 -0
  73. package/template/.claude/skills/exxat-ds-skill/SKILL.md +2 -1
  74. package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
  75. package/template/AGENTS.md +18 -3
  76. package/template/components/columns-client.tsx +3 -2
  77. package/template/components/columns-showcase.tsx +22 -18
  78. package/template/components/exxat-product-logo.tsx +1 -1
  79. package/template/components/library-table.tsx +62 -23
  80. package/template/components/new-library-item-form.tsx +0 -7
  81. package/template/components/product-wordmark.tsx +1 -1
  82. package/template/components/sidebar/app-sidebar.tsx +14 -106
  83. package/template/components/sidebar/secondary-nav.tsx +22 -4
  84. package/template/components/site-header.tsx +1 -1
  85. package/template/components/tokens-hub-auxiliary-views.tsx +301 -0
  86. package/template/components/tokens-themes-client.tsx +44 -16
  87. package/template/docs/HANDBOOK.md +2 -2
  88. package/template/docs/component-selection-guide.md +1 -1
  89. package/template/docs/consumer-upgrade-checklist.md +51 -0
  90. package/template/docs/data-views-pattern.md +6 -0
  91. package/template/docs/drawer-vs-dialog-pattern.md +8 -8
  92. package/template/docs/glossary.md +2 -1
  93. package/template/docs/hub-supported-views-pattern.md +53 -0
  94. package/template/docs/reference-implementations.md +2 -2
  95. package/template/lib/full-hub-supported-views.ts +8 -0
  96. package/template/lib/library-supported-views.ts +5 -12
  97. package/template/lib/motion-ui.ts +2 -2
  98. package/template/package.json +1 -1
  99. package/tokens/hooks-index.json +2 -2
  100. package/dist/components/ui/drawer.d.ts +0 -16
  101. package/dist/components/ui/drawer.js +0 -125
  102. package/dist/components/ui/drawer.js.map +0 -1
  103. package/src/components/ui/drawer.tsx +0 -134
  104. package/template/components/ui/drawer.tsx +0 -1
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export {
46
46
  export * from "./lib/list-page-table-properties"
47
47
  export * from "./lib/data-list-view"
48
48
  export * from "./lib/data-list-view-registry"
49
+ export * from "./lib/nav-active"
49
50
  export * from "./lib/data-list-view-surface"
50
51
  export * from "./lib/data-list-display-options"
51
52
 
@@ -68,7 +69,6 @@ export * from "./components/ui/context-menu"
68
69
  export * from "./components/ui/date-picker-field"
69
70
  export * from "./components/ui/dialog"
70
71
  export * from "./components/ui/drag-handle-grip"
71
- export * from "./components/ui/drawer"
72
72
  export * from "./components/ui/dropdown-menu"
73
73
  export * from "./components/ui/export-drawer"
74
74
  export * from "./components/ui/hover-card"
@@ -6,6 +6,7 @@
6
6
  * `getDataListViewRenderKind` + `ListPageConnectedViewBody` (never a dashboard fallback).
7
7
  *
8
8
  * @see `docs/data-views-pattern.md` — "View registry and connected bodies"
9
+ * @see `.cursor/rules/exxat-hub-supported-views.mdc` — default hubs use **`FULL_HUB_SUPPORTED_VIEWS`** (seven views)
9
10
  */
10
11
 
11
12
  import {
@@ -47,6 +48,36 @@ const BY_VALUE = new Map<DataListViewType, DataListViewDefinition>(
47
48
 
48
49
  export const DATA_LIST_VIEW_REGISTRY: readonly DataListViewDefinition[] = DEFINITIONS
49
50
 
51
+ /**
52
+ * Default view allowlist for **primary list hubs** (Placements / Team / Students-style pages).
53
+ * Pair with `ListPageTemplate` + `HubTable` when the hub implements table, list, board,
54
+ * and dashboard renderers. Omit `supportedViewTypes` on both components to use this default.
55
+ */
56
+ export const PRIMARY_HUB_SUPPORTED_VIEWS = [
57
+ "table",
58
+ "list",
59
+ "board",
60
+ "dashboard",
61
+ ] as const satisfies readonly DataListViewType[]
62
+
63
+ /**
64
+ * Default allowlist for **list-page hubs** in this design system (Library / Column types /
65
+ * Tokens, etc.). Matches the All questions hub: table, list, board, dashboard, folder,
66
+ * panel, and tree-panel. Pair with renderers for each kind on `HubTable` + `ListPageTemplate`.
67
+ */
68
+ export const FULL_HUB_SUPPORTED_VIEWS = [
69
+ "table",
70
+ "list",
71
+ "board",
72
+ "dashboard",
73
+ "folder",
74
+ "panel",
75
+ "tree-panel",
76
+ ] as const satisfies readonly DataListViewType[]
77
+
78
+ /** Every registered view type (includes folder, panel, calendar, tree-panel). */
79
+ export const ALL_DATA_LIST_VIEW_TYPES = DATA_LIST_VIEW_REGISTRY.map(d => d.value)
80
+
50
81
  export function dataListViewDefinition(view: DataListViewType): DataListViewDefinition {
51
82
  const def = BY_VALUE.get(view)
52
83
  if (!def) {
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Sidebar / secondary-nav active-state helpers.
3
+ *
4
+ * Only one nav target should be "selected" at a time. Prefix matching
5
+ * (`/dashboard` active on `/dashboard/students`) is correct only when no
6
+ * more-specific nav href also matches the current pathname.
7
+ */
8
+
9
+ export function normalizePathname(pathname: string): string {
10
+ if (pathname.length > 1 && pathname.endsWith("/")) return pathname.slice(0, -1)
11
+ return pathname
12
+ }
13
+
14
+ /** Path segment before `#` (if any). */
15
+ export function navUrlPath(url: string): string {
16
+ const hash = url.indexOf("#")
17
+ return hash >= 0 ? url.slice(0, hash) : url
18
+ }
19
+
20
+ /** Hash fragment after `#`, or `null` when the href has no fragment. */
21
+ export function navUrlFragment(url: string): string | null {
22
+ const i = url.indexOf("#")
23
+ return i >= 0 ? url.slice(i + 1) : null
24
+ }
25
+
26
+ export function normalizedLocationHash(locationHash: string): string {
27
+ if (!locationHash) return ""
28
+ return locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
29
+ }
30
+
31
+ /** Path → hash fragments claimed by *another* nav item at the same path. */
32
+ export function buildNavHashClaims(
33
+ urls: readonly string[],
34
+ ): ReadonlyMap<string, ReadonlySet<string>> {
35
+ const map = new Map<string, Set<string>>()
36
+ for (const url of urls) {
37
+ const p = navUrlPath(url)
38
+ const f = navUrlFragment(url)
39
+ if (!p || f === null) continue
40
+ let set = map.get(p)
41
+ if (!set) {
42
+ set = new Set<string>()
43
+ map.set(p, set)
44
+ }
45
+ set.add(f)
46
+ }
47
+ return map
48
+ }
49
+
50
+ function navHasMoreSpecificHashMatch(
51
+ pathname: string,
52
+ locationHash: string,
53
+ hashClaimsByPath: ReadonlyMap<string, ReadonlySet<string>>,
54
+ ): boolean {
55
+ const claims = hashClaimsByPath.get(pathname)
56
+ if (!claims) return false
57
+ const h = normalizedLocationHash(locationHash)
58
+ if (h === "") return false
59
+ return claims.has(h)
60
+ }
61
+
62
+ function pathnameMatchesNavPath(
63
+ pathname: string,
64
+ pathOnly: string,
65
+ locationHash: string,
66
+ hashClaimsByPath: ReadonlyMap<string, ReadonlySet<string>>,
67
+ ): boolean {
68
+ const norm = normalizePathname(pathname)
69
+ const h = normalizedLocationHash(locationHash)
70
+
71
+ if (pathOnly === "/") {
72
+ if (norm !== "/" || h !== "") return false
73
+ return !navHasMoreSpecificHashMatch("/", locationHash, hashClaimsByPath)
74
+ }
75
+
76
+ if (norm === pathOnly) {
77
+ return !navHasMoreSpecificHashMatch(pathOnly, locationHash, hashClaimsByPath)
78
+ }
79
+
80
+ if (pathOnly === "/library") {
81
+ return norm.startsWith("/library/")
82
+ }
83
+ if (pathOnly.startsWith("/library/")) {
84
+ return norm === pathOnly
85
+ }
86
+
87
+ return norm.startsWith(`${pathOnly}/`)
88
+ }
89
+
90
+ /**
91
+ * Longest matching nav path for `pathname` among `candidateUrls` (path + prefix rules).
92
+ * Returns the winning href string, or `null`.
93
+ */
94
+ export function resolveActiveNavHref(
95
+ pathname: string,
96
+ candidateUrls: readonly string[],
97
+ options?: {
98
+ locationHash?: string
99
+ hashClaimsByPath?: ReadonlyMap<string, ReadonlySet<string>>
100
+ },
101
+ ): string | null {
102
+ const norm = normalizePathname(pathname)
103
+ const hashClaims = options?.hashClaimsByPath ?? buildNavHashClaims(candidateUrls)
104
+ const locationHash = options?.locationHash ?? ""
105
+
106
+ let bestHref: string | null = null
107
+ let bestLen = -1
108
+
109
+ for (const url of candidateUrls) {
110
+ const pathOnly = navUrlPath(url)
111
+ const frag = navUrlFragment(url)
112
+ if (!pathOnly || pathOnly === "#") continue
113
+
114
+ let matches = false
115
+ if (frag !== null) {
116
+ const h = normalizedLocationHash(locationHash)
117
+ if (pathOnly === "/") matches = norm === "/" && h === frag
118
+ else if (pathOnly === "/library") matches = norm.startsWith("/library/") && h === frag
119
+ else if (pathOnly.startsWith("/library/")) matches = norm === pathOnly && h === frag
120
+ else matches = norm === pathOnly && h === frag
121
+ } else {
122
+ matches = pathnameMatchesNavPath(norm, pathOnly, locationHash, hashClaims)
123
+ }
124
+
125
+ if (matches && pathOnly.length > bestLen) {
126
+ bestHref = url
127
+ bestLen = pathOnly.length
128
+ }
129
+ }
130
+
131
+ return bestHref
132
+ }
133
+
134
+ /** True when `url` is the single best match for `pathname` among all nav hrefs. */
135
+ export function isNavHrefActive(
136
+ pathname: string,
137
+ url: string,
138
+ allNavUrls: readonly string[],
139
+ options?: {
140
+ locationHash?: string
141
+ hashClaimsByPath?: ReadonlyMap<string, ReadonlySet<string>>
142
+ },
143
+ ): boolean {
144
+ const active = resolveActiveNavHref(pathname, allNavUrls, options)
145
+ if (!active) return false
146
+ return navUrlPath(active) === navUrlPath(url) && navUrlFragment(active) === navUrlFragment(url)
147
+ }
148
+
149
+ /** Collect every `url` from a nav tree (primary rows + children). */
150
+ export function collectNavUrls(
151
+ items: ReadonlyArray<{ url?: string; children?: ReadonlyArray<{ url?: string; children?: unknown }> }>,
152
+ ): string[] {
153
+ const out: string[] = []
154
+ const walk = (list: ReadonlyArray<{ url?: string; children?: ReadonlyArray<{ url?: string; children?: unknown }> }>) => {
155
+ for (const it of list) {
156
+ if (typeof it.url === "string" && it.url.length > 0 && it.url !== "#") out.push(it.url)
157
+ if (Array.isArray(it.children)) walk(it.children)
158
+ }
159
+ }
160
+ walk(items)
161
+ return out
162
+ }
@@ -112,7 +112,8 @@ ListPageTemplate
112
112
  ```
113
113
 
114
114
  **Reference implementations:**
115
- - `components/columns-showcase.tsx` + `components/columns-client.tsx` — smallest single-view hub (start here)
115
+ - `components/library-table.tsx` + `library-client.tsx` — canonical seven-view hub (start here)
116
+ - `components/columns-showcase.tsx` — cell catalog via `LibraryTable` (custom `columnDefs`, same Add view as Library)
116
117
  - `components/tokens-themes-client.tsx` + `components/tokens-secondary-nav.tsx` — hub with secondary panel + URL-driven scope + built-in pagination chrome
117
118
  - `components/library-table.tsx` + `components/library-hub-client.tsx` — full multi-view hub (table, board, dashboard)
118
119
 
@@ -17,7 +17,7 @@ alwaysApply: true
17
17
  4. **Touch targets (2.5.8):** **≥ 24×24 CSS px** or **24px** spacing — **`min-h-6 min-w-6` / `size-6`** for icon-only; avoid **`size-4`** as sole target.
18
18
  5. **Contrast:** normal text **≥ 4.5:1**; UI / focus **≥ 3:1** where required; muted on tinted surfaces use correct surface tokens.
19
19
  6. **Minimum text size:** visible product copy **≥ 11px** — **`text-xs`** or larger (**`AGENTS.md` §8.3**, **`app/globals.css`** `--text-xs`).
20
- 7. **Dialogs / sheets / drawers:** must have a **Title** (`DialogTitle` / `SheetTitle` / `DrawerTitle`); **`sr-only`** if hidden.
20
+ 7. **Dialogs / sheets:** must have a **Title** (`DialogTitle` / `SheetTitle`); **`sr-only`** if hidden.
21
21
  8. **Format hints persistent, not placeholders (SC 3.3.2, 1.3.1).** Fields with required formats — **date, time, phone, currency, GPA, IDs, URLs, unit-bearing numbers** — MUST render the format via **`FormDescription`** (or equivalent `aria-describedby` helper text). Placeholders disappear on focus and **MUST NOT** be the sole carrier. Prefer picker primitives (e.g. `DatePickerField`) over free-text where available.
22
22
  9. **Every icon that communicates information MUST have a text alternative** — not just icon-only buttons. Three cases (SC 1.1.1, 3.3.2, 2.4.6):
23
23
 
@@ -81,7 +81,7 @@ If two documents conflict, prefer the **more specific** rule for the file type,
81
81
 
82
82
  **MUST:** If the main surface is a **`DataTable`** (or equivalent data grid), wrap it in **`ListPageTemplate`** so the **views toolbar** exists (tabs, add view, per-tab settings). Do **not** place `DataTable` only under `PageHeader` without the tab shell.
83
83
 
84
- **Reference implementations:** `components/library-hub-client.tsx` (full hub), `components/columns-showcase.tsx` (single-view catalog), `components/tokens-themes-client.tsx` (hub + secondary panel).
84
+ **Reference implementations:** `components/library-client.tsx` + `components/library-table.tsx` (canonical seven-view hub), `components/columns-showcase.tsx` (cell catalog via `LibraryTable`), `components/tokens-themes-client.tsx` + `components/tokens-hub-auxiliary-views.tsx`.
85
85
 
86
86
  **Rationale:** Consistent navigation, saved views, per-tab view type (table / list / board / dashboard), export at template level.
87
87
 
@@ -99,6 +99,20 @@ If two documents conflict, prefer the **more specific** rule for the file type,
99
99
 
100
100
  **Details:** `docs/data-views-pattern.md` (mock data, connected views, dashboard view).
101
101
 
102
+ ### 4.1.1 Add view parity (`supportedViewTypes`)
103
+
104
+ **MUST:** Every **`ListPageTemplate`** hub that mounts **`HubTable`** uses the same **seven** Add view options as Library (All questions) unless a narrower list is **documented** in `lib/<entity>-supported-views.ts`:
105
+
106
+ - Registry constant: **`FULL_HUB_SUPPORTED_VIEWS`** (`@/lib/data-list-view-registry` or `@/lib/full-hub-supported-views.ts`).
107
+ - Pass the **same** allowlist to **`ListPageTemplate`** and **`HubTable`** (or omit on both for the default).
108
+ - Implement a **real renderer** for each allowed view (list = **`ListPageBoardCard`** via `renderListRow` — copy **`library-table.tsx`**).
109
+ - **`LibraryItem`** catalogs (Column types): use **`LibraryTable`** with `columnDefs` + `folders` — do not trim to four views or placeholder list rows.
110
+ - **Tokens:** **`tokens-hub-auxiliary-views.tsx`** + **`FULL_HUB_SUPPORTED_VIEWS`**.
111
+
112
+ **MUST NOT:** `supportedViewTypes={["table"]}`, bare two-line `renderListRow`, or `PRIMARY_HUB_SUPPORTED_VIEWS` without documented product exception.
113
+
114
+ **Binding rule:** `.cursor/rules/exxat-hub-supported-views.mdc`. **Pattern doc:** `docs/hub-supported-views-pattern.md`.
115
+
102
116
  ### 4.2 `TablePropertiesDrawer` and the active view
103
117
 
104
118
  **MUST:** Any page that uses **`ListPageTemplate`** with **`tab.viewType`** (table / list / board / dashboard) and renders **`TablePropertiesDrawer`** **MUST** pass:
@@ -352,9 +366,9 @@ Follow root **`.cursor/rules/exxat-kbd-shortcuts.mdc`**. Summary:
352
366
  - **UI components** (borders, focus rings where required): **≥ 3:1**.
353
367
  - **Muted text on tinted surfaces** (e.g. sidebar): use tokens mixed against the **correct surface** (e.g. **`--sidebar`** / `--sidebar-section-label-foreground`), not only `--background`.
354
368
 
355
- ### 8.4 Overlays (Dialog / Sheet / Drawer)
369
+ ### 8.4 Overlays (Dialog / Sheet)
356
370
 
357
- **MUST:** Provide an accessible **title** — `DialogTitle` / `SheetTitle` / `DrawerTitle`; use **`className="sr-only"`** when the title is visually hidden (align with shadcn patterns in this repo).
371
+ **MUST:** Provide an accessible **title** — `DialogTitle` / `SheetTitle`; use **`className="sr-only"`** when the title is visually hidden (align with shadcn patterns in this repo). Product side panels use **`Sheet`** only (Export, Properties, invite — not a separate Vaul drawer primitive).
358
372
 
359
373
  ### 8.5 Verification
360
374
 
@@ -634,6 +648,7 @@ Copy and complete when implementing or reviewing:
634
648
  - [ ] **Primary hub + large data:** Same composition as `PlacementsClient` / `TeamClient` (template + metrics when applicable).
635
649
  - [ ] **All view tabs:** List/board/dashboard use **`tableState.rows`**; dashboard view uses **`KeyMetrics`** + shared KPI helpers — no “not wired” placeholders or duplicate metric cards.
636
650
  - [ ] **Properties drawer:** **`TablePropertiesDrawer`** receives **`currentView`** and **`onViewChange`** from **`renderContent`** / **`updateTab`** + **`dataListViewIcon`** (§4.2) — not table-only copy on Board/List/Dashboard.
651
+ - [ ] **Add view parity:** **`FULL_HUB_SUPPORTED_VIEWS`** on **`ListPageTemplate`** + **`HubTable`** (in sync); every allowed view has a renderer; list uses **`ListPageBoardCard`** — **`.cursor/rules/exxat-hub-supported-views.mdc`**, **`docs/hub-supported-views-pattern.md`**.
637
652
  - [ ] **Data view dashboard (Placements / Team / Compliance):** Charts use **`ChartFigure`** + **`ChartDataTable`**; **Edit layout** on toolbar; **`activeBar` / `activeShape`** keyboard styling from **`lib/chart-keyboard-selection`** — not opacity-only **`Cell`** hacks (§4.3).
638
653
  - [ ] **Dashboard layout persistence:** **`lib/data-view-dashboard-storage`** (or **`saveDashboardLayout`** / **`loadDashboardLayout`** on Placements); **`mergeDashboardLayout`** on load — no new ad-hoc storage keys for the same layout (§4.3).
639
654
  - [ ] **⌘K palette (§7.1):** If adding or changing **`dataGroups`**, map rows in **`lib/command-menu-search-data.ts`** (not `command-menu.tsx`); use **`searchOnly`** on bulky groups; keep **`docs/command-menu-pattern.md`** aligned.
@@ -7,7 +7,7 @@
7
7
  * (`PrimaryPageTemplate` + `ListPageTemplate`):
8
8
  * - `header` : `PageHeader` with title + one-line subtitle describing the demo.
9
9
  * - `metrics` : `KeyMetrics` `variant="flat"` — patterns, pinned, sortable, demo rows.
10
- * - tabs : single `table` view tab (one demo table — no list / board variants).
10
+ * - tabs : default `table` tab; Add view offers list / board / dashboard (same as Library).
11
11
  * - `renderContent` : the `<ColumnsShowcase />` DataTable surface.
12
12
  *
13
13
  * Cell patterns are exercised inside `columns-showcase.tsx` so the rendered
@@ -33,6 +33,7 @@ import {
33
33
  COLUMNS_SHOWCASE_PATTERN_COUNT,
34
34
  COLUMNS_SHOWCASE_PINNED_COUNT,
35
35
  COLUMNS_SHOWCASE_SORTABLE_COUNT,
36
+ COLUMNS_SUPPORTED_VIEWS,
36
37
  } from "@/components/columns-showcase"
37
38
 
38
39
  const COLUMNS_DEFAULT_TABS: ViewTab[] = [
@@ -127,8 +128,8 @@ export function ColumnsClient() {
127
128
  onTabsChange={setTabs}
128
129
  activeTabId={activeTabId}
129
130
  onActiveTabChange={setActiveTabId}
130
- supportedViewTypes={["table"]}
131
131
  getTabCount={getTabCount}
132
+ supportedViewTypes={COLUMNS_SUPPORTED_VIEWS}
132
133
  header={
133
134
  <PageHeader
134
135
  title="Column types"
@@ -48,7 +48,6 @@
48
48
 
49
49
  import * as React from "react"
50
50
  import {
51
- HubTable,
52
51
  AttachmentCountCell,
53
52
  BooleanToggleCell,
54
53
  CurrencyCell,
@@ -66,6 +65,9 @@ import {
66
65
  type RowActionDef,
67
66
  } from "@/components/data-views"
68
67
  import type { DataListViewType } from "@/lib/data-list-view"
68
+ import { FULL_HUB_SUPPORTED_VIEWS } from "@/lib/data-list-view-registry"
69
+ import { LibraryTable } from "@/components/library-table"
70
+ import { DEFAULT_LIBRARY_FOLDERS, type LibraryFolder } from "@/lib/mock/library-folders"
69
71
  import { AvatarInitials } from "@/components/ui/avatar"
70
72
  import { cn } from "@/lib/utils"
71
73
  import {
@@ -477,7 +479,8 @@ export const COLUMNS_SHOWCASE_PATTERN_COUNT = 18
477
479
  export const COLUMNS_SHOWCASE_PINNED_COUNT = 3 // select + name + actions
478
480
  export const COLUMNS_SHOWCASE_SORTABLE_COUNT = 11 // name, owner, type, level, rating, progress, cost, count, files, lastActivityAt, updatedAt
479
481
 
480
- const COLUMNS_SUPPORTED_VIEWS: readonly DataListViewType[] = ["table"] as const
482
+ /** Same seven views as Library / All questions (Add view + Properties). */
483
+ export const COLUMNS_SUPPORTED_VIEWS = FULL_HUB_SUPPORTED_VIEWS
481
484
 
482
485
  export interface ColumnsShowcaseProps {
483
486
  /** Active view from `ListPageTemplate.renderContent`. */
@@ -493,6 +496,9 @@ export interface ColumnsShowcaseProps {
493
496
  */
494
497
  export function ColumnsShowcase({ view, onViewChange }: ColumnsShowcaseProps) {
495
498
  const [rows, setRows] = React.useState<LibraryItem[]>(() => buildRows())
499
+ const [folders, setFolders] = React.useState<LibraryFolder[]>(() =>
500
+ DEFAULT_LIBRARY_FOLDERS.map(f => ({ ...f })),
501
+ )
496
502
  const [pagination, setPagination] = React.useState(false)
497
503
 
498
504
  const toggleFavorite = React.useCallback((row: LibraryItem) => {
@@ -514,28 +520,26 @@ export function ColumnsShowcase({ view, onViewChange }: ColumnsShowcaseProps) {
514
520
  const columns = useColumns(toggleFavorite, togglePublished)
515
521
 
516
522
  return (
517
- <HubTable<LibraryItem>
518
- rows={rows}
519
- columns={columns}
523
+ <LibraryTable
524
+ items={rows}
525
+ onItemsChange={setRows}
526
+ folders={folders}
527
+ onFoldersChange={setFolders}
520
528
  view={view}
521
529
  onViewChange={onViewChange}
522
- supportedViewTypes={COLUMNS_SUPPORTED_VIEWS}
523
- hubLabel="Column types"
524
- lifecycleTabLabel="Column types"
525
- searchAriaLabel="Search columns showcase"
526
- getRowId={(r) => r.id}
527
- getRowSelectionLabel={(r) => r.stem}
528
- defaultSort={{ key: "stem", dir: "asc" }}
530
+ columnDefs={columns}
531
+ hubLabels={{
532
+ hubLabel: "Column types",
533
+ lifecycleTabLabel: "Column types",
534
+ searchAriaLabel: "Search columns showcase",
535
+ listAriaLabel: "Column types",
536
+ defaultSort: { key: "stem", dir: "asc" },
537
+ }}
529
538
  pagination={pagination}
530
539
  onPaginationChange={setPagination}
531
540
  paginationInitialPageSize={5}
532
541
  paginationPageSizeOptions={[5, 10, 25]}
533
- emptyState={
534
- <p className="text-sm text-muted-foreground">
535
- No rows match your filters.
536
- </p>
537
- }
538
- renderers={{}}
542
+ showBulkActions={false}
539
543
  />
540
544
  )
541
545
  }
@@ -186,7 +186,7 @@ function ExxatLogoBase({
186
186
  export function ExxatProductLogo({
187
187
  product,
188
188
  className,
189
- variant = "default",
189
+ variant: _variant = "default",
190
190
  }: ExxatProductLogoProps) {
191
191
  const customProductBrand = useAppStore(s => s.customProductBrand)
192
192
  const productBrandColors = useAppStore(s => s.productBrandColors)
@@ -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"