@exxatdesignux/ui 0.2.8 → 0.2.10

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 (125) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +17 -4
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/AGENTS.md +82 -27
  28. package/template/app/(app)/examples/page.tsx +2 -1
  29. package/template/app/(app)/help/page.tsx +6 -0
  30. package/template/app/(app)/layout.tsx +7 -4
  31. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  32. package/template/app/(app)/question-bank/layout.tsx +46 -0
  33. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  34. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  35. package/template/app/(app)/question-bank/page.tsx +4 -3
  36. package/template/app/globals.css +1 -2
  37. package/template/components/app-sidebar.tsx +51 -13
  38. package/template/components/ask-leo-composer.tsx +173 -45
  39. package/template/components/ask-leo-sidebar.tsx +9 -1
  40. package/template/components/chart-area-interactive.tsx +3 -13
  41. package/template/components/charts-overview.tsx +33 -6
  42. package/template/components/collaboration-access-flow.tsx +144 -0
  43. package/template/components/compliance-page-header.tsx +1 -1
  44. package/template/components/compliance-table.tsx +2 -2
  45. package/template/components/dashboard-tabs.tsx +4 -3
  46. package/template/components/data-list-table-cells.tsx +1 -1
  47. package/template/components/data-list-table.tsx +1 -1
  48. package/template/components/data-table/index.tsx +5 -5
  49. package/template/components/data-table/use-table-state.ts +18 -2
  50. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  51. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts.tsx +62 -227
  53. package/template/components/dedicated-search-recents.tsx +96 -0
  54. package/template/components/dedicated-search-url-composer.tsx +112 -0
  55. package/template/components/getting-started.tsx +1 -1
  56. package/template/components/hub-tree-panel-view.tsx +10 -26
  57. package/template/components/invite-collaborators-drawer.tsx +453 -0
  58. package/template/components/key-metrics.tsx +54 -8
  59. package/template/components/nav-documents.tsx +1 -1
  60. package/template/components/new-placement-form.tsx +3 -3
  61. package/template/components/page-header.tsx +76 -59
  62. package/template/components/placements-board-view.tsx +3 -3
  63. package/template/components/placements-page-header.tsx +1 -1
  64. package/template/components/placements-table-columns.tsx +3 -2
  65. package/template/components/product-switcher.tsx +0 -1
  66. package/template/components/question-bank-board-view.tsx +35 -47
  67. package/template/components/question-bank-client.tsx +293 -81
  68. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  69. package/template/components/question-bank-favorite-button.tsx +46 -0
  70. package/template/components/question-bank-hub-client.tsx +436 -0
  71. package/template/components/question-bank-list-view.tsx +26 -19
  72. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  73. package/template/components/question-bank-os-folder-view.tsx +3 -14
  74. package/template/components/question-bank-page-header.tsx +85 -53
  75. package/template/components/question-bank-panel-activator.tsx +3 -4
  76. package/template/components/question-bank-secondary-nav.tsx +523 -65
  77. package/template/components/question-bank-table.tsx +125 -343
  78. package/template/components/secondary-panel.tsx +130 -63
  79. package/template/components/settings-client.tsx +3 -1
  80. package/template/components/sidebar-shell.tsx +2 -0
  81. package/template/components/sites-all-client.tsx +1 -1
  82. package/template/components/sites-table.tsx +1 -1
  83. package/template/components/system-banner-slot.tsx +2 -1
  84. package/template/components/table-properties/drawer.tsx +3 -3
  85. package/template/components/table-properties/sort-card.tsx +1 -1
  86. package/template/components/team-page-header.tsx +1 -1
  87. package/template/components/team-table.tsx +8 -4
  88. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  89. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  90. package/template/components/templates/discovery-hub-template.tsx +273 -0
  91. package/template/components/templates/list-page.tsx +11 -4
  92. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  93. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  94. package/template/docs/card-vs-rows-pattern.md +36 -0
  95. package/template/docs/collaboration-access-pattern.md +114 -0
  96. package/template/docs/data-views-pattern.md +12 -4
  97. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  98. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  99. package/template/docs/kpi-trend-pattern.md +43 -0
  100. package/template/fontawesome-subset.manifest.json +2 -2
  101. package/template/hooks/use-location-hash.ts +14 -8
  102. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  103. package/template/lib/ask-leo-route-context.ts +24 -0
  104. package/template/lib/collaborator-access.ts +92 -0
  105. package/template/lib/command-menu-config.ts +8 -1
  106. package/template/lib/command-menu-search-data.ts +11 -8
  107. package/template/lib/data-list-display-options.ts +1 -1
  108. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  109. package/template/lib/date-filter.ts +1 -0
  110. package/template/lib/dedicated-search-recents.ts +76 -0
  111. package/template/lib/dedicated-search-url.ts +23 -0
  112. package/template/lib/discovery-hub.ts +15 -0
  113. package/template/lib/list-status-badges.ts +1 -21
  114. package/template/lib/mock/navigation.tsx +4 -2
  115. package/template/lib/mock/placements.ts +9 -9
  116. package/template/lib/mock/question-bank-folders.ts +7 -0
  117. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  118. package/template/lib/mock/question-bank-inspector.ts +1 -2
  119. package/template/lib/mock/question-bank-kpi.ts +38 -26
  120. package/template/lib/mock/question-bank.ts +43 -16
  121. package/template/lib/question-bank-dedicated-search.ts +19 -0
  122. package/template/lib/question-bank-hub-search.ts +90 -0
  123. package/template/lib/question-bank-nav.ts +322 -6
  124. package/template/lib/question-bank-recent-searches.ts +22 -0
  125. package/template/package.json +0 -1
@@ -0,0 +1,273 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import Link from "next/link"
5
+ import { useRouter } from "next/navigation"
6
+
7
+ import { useAskLeo } from "@/components/ask-leo-sidebar"
8
+ import { PrimaryPageTemplate, type PrimaryPageTemplateProps } from "@/components/templates/primary-page-template"
9
+ import { Button } from "@/components/ui/button"
10
+ import {
11
+ Command,
12
+ CommandEmpty,
13
+ CommandGroup,
14
+ CommandInput,
15
+ CommandItem,
16
+ CommandList,
17
+ } from "@/components/ui/command"
18
+ import { Shortcut } from "@/components/ui/dropdown-menu"
19
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
20
+ import { Tip } from "@/components/ui/tip"
21
+ import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
22
+ import type { DiscoveryHubSearchGroup, DiscoveryHubSearchItem } from "@/lib/discovery-hub"
23
+
24
+ export interface DiscoveryHubPrimaryAction {
25
+ label: string
26
+ onClick: () => void
27
+ shortcutKeys?: string
28
+ }
29
+
30
+ export interface DiscoveryHubAskLeoPromo {
31
+ title: string
32
+ description: string
33
+ prompts: readonly string[]
34
+ }
35
+
36
+ export interface DiscoveryHubTemplateProps
37
+ extends Pick<PrimaryPageTemplateProps, "siteHeader" | "maxWidthClassName" | "contentClassName" | "bodyClassName"> {
38
+ title: string
39
+ description: string
40
+ inputPlaceholder: string
41
+ inputAriaLabel: string
42
+ emptyMessage: string
43
+ groups: DiscoveryHubSearchGroup[]
44
+ primaryAction: DiscoveryHubPrimaryAction
45
+ askLeoPromo: DiscoveryHubAskLeoPromo
46
+ browseLibraryHref: string
47
+ browseLibraryLabel?: string
48
+ }
49
+
50
+ const DiscoveryHubSearchRow = React.memo(function DiscoveryHubSearchRow({
51
+ item,
52
+ onLink,
53
+ onLeo,
54
+ }: {
55
+ item: DiscoveryHubSearchItem
56
+ onLink: (href: string) => void
57
+ onLeo: (prompt: string) => void
58
+ }) {
59
+ const isLeo = Boolean(item.askLeoPrompt)
60
+ const iconClass = isLeo
61
+ ? "fa-duotone fa-solid fa-star-christmas w-5 shrink-0 text-center text-brand"
62
+ : `${item.icon ?? "fa-light fa-arrow-right"} w-5 shrink-0 text-center`
63
+
64
+ return (
65
+ <CommandItem
66
+ className="mx-2 rounded-lg py-2.5"
67
+ keywords={item.keywords ? [item.keywords] : undefined}
68
+ onSelect={() => {
69
+ if (item.askLeoPrompt) onLeo(item.askLeoPrompt)
70
+ else if (item.href) onLink(item.href)
71
+ }}
72
+ >
73
+ <i className={iconClass} aria-hidden="true" />
74
+ <span>{item.label}</span>
75
+ </CommandItem>
76
+ )
77
+ })
78
+
79
+ /**
80
+ * Discovery hub — centered command-style natural language search, create CTA, and Ask Leo promo.
81
+ * Hubs can pass `groups` from a domain module (e.g. Ask Leo prompt suggestions from `lib/question-bank-hub-search.ts`).
82
+ */
83
+ export function DiscoveryHubTemplate({
84
+ title,
85
+ description,
86
+ inputPlaceholder,
87
+ inputAriaLabel,
88
+ emptyMessage,
89
+ groups,
90
+ primaryAction,
91
+ askLeoPromo,
92
+ browseLibraryHref,
93
+ browseLibraryLabel = "Browse library",
94
+ siteHeader,
95
+ maxWidthClassName = "max-w-3xl",
96
+ contentClassName,
97
+ bodyClassName,
98
+ }: DiscoveryHubTemplateProps) {
99
+ const router = useRouter()
100
+ const { openWithPrompt, toggle } = useAskLeo()
101
+ const mod = useModKeyLabel()
102
+ const alt = useAltKeyLabel()
103
+ const [inputValue, setInputValue] = React.useState("")
104
+ const searchInputRef = React.useRef<HTMLInputElement>(null)
105
+
106
+ const navigate = React.useCallback(
107
+ (href: string) => {
108
+ setInputValue("")
109
+ router.push(href)
110
+ },
111
+ [router],
112
+ )
113
+
114
+ const sendLeoSuggestion = React.useCallback(
115
+ (prompt: string) => {
116
+ setInputValue("")
117
+ openWithPrompt(prompt)
118
+ },
119
+ [openWithPrompt],
120
+ )
121
+
122
+ React.useEffect(() => {
123
+ const id = window.setTimeout(() => searchInputRef.current?.focus(), 0)
124
+ return () => window.clearTimeout(id)
125
+ }, [])
126
+
127
+ const trimmedQuery = inputValue.trim()
128
+ const leoShortcut = `${mod}${alt}K`
129
+ const createShortcut = primaryAction.shortcutKeys ?? `${mod}${alt}N`
130
+
131
+ return (
132
+ <PrimaryPageTemplate
133
+ siteHeader={siteHeader}
134
+ maxWidthClassName={maxWidthClassName}
135
+ contentClassName={contentClassName}
136
+ bodyClassName={bodyClassName}
137
+ >
138
+ <Shortcut keys={createShortcut} onInvoke={primaryAction.onClick} />
139
+ {/* ⌘⌥K (Ask Leo toggle) is bound globally in AskLeoProvider — do not double-bind here. */}
140
+
141
+ <div className="flex min-h-0 flex-1 flex-col px-4 py-8 md:px-6 md:py-10">
142
+ <div className="mx-auto flex w-full min-w-0 flex-1 flex-col gap-8">
143
+ <header className="space-y-2 text-center">
144
+ <h1
145
+ className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl"
146
+ style={{ fontFamily: "var(--font-heading)" }}
147
+ >
148
+ {title}
149
+ </h1>
150
+ <p className="mx-auto max-w-2xl text-sm text-muted-foreground md:text-base">{description}</p>
151
+ </header>
152
+
153
+ <section
154
+ aria-label="Natural language search"
155
+ className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm ring-1 ring-foreground/10"
156
+ >
157
+ <Command
158
+ loop
159
+ label={inputAriaLabel}
160
+ className="rounded-2xl border-0 bg-transparent p-0 shadow-none"
161
+ >
162
+ <div className="flex min-h-14 items-center gap-3 border-b border-border/50 px-4 md:min-h-16 md:px-5">
163
+ <CommandInput
164
+ ref={searchInputRef}
165
+ variant="palette"
166
+ aria-label={inputAriaLabel}
167
+ onValueChange={setInputValue}
168
+ placeholder={inputPlaceholder}
169
+ value={inputValue}
170
+ className="h-14 text-base md:h-16 md:text-lg"
171
+ />
172
+ <span className="hidden shrink-0 text-xs text-muted-foreground sm:inline">
173
+ <KbdGroup>
174
+ <Kbd>{mod}</Kbd>
175
+ <Kbd>K</Kbd>
176
+ </KbdGroup>
177
+ <span className="ms-1.5">global search</span>
178
+ </span>
179
+ </div>
180
+
181
+ <CommandList className="max-h-[min(420px,50vh)] py-2">
182
+ <CommandEmpty>{emptyMessage}</CommandEmpty>
183
+ {groups.map(group => {
184
+ if (group.items.length === 0) return null
185
+ if (group.searchOnly && !trimmedQuery) return null
186
+ return (
187
+ <CommandGroup key={group.id} heading={group.heading}>
188
+ {group.items.map(item => (
189
+ <DiscoveryHubSearchRow
190
+ key={item.id}
191
+ item={item}
192
+ onLeo={sendLeoSuggestion}
193
+ onLink={navigate}
194
+ />
195
+ ))}
196
+ </CommandGroup>
197
+ )
198
+ })}
199
+ </CommandList>
200
+ </Command>
201
+ </section>
202
+
203
+ <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
204
+ <section
205
+ aria-labelledby="discovery-hub-ask-leo"
206
+ className="rounded-2xl border border-brand/20 bg-brand/5 p-4 md:p-5"
207
+ >
208
+ <div className="flex flex-wrap items-start justify-between gap-3">
209
+ <div className="min-w-0 space-y-1">
210
+ <h2 id="discovery-hub-ask-leo" className="text-sm font-semibold text-foreground">
211
+ {askLeoPromo.title}
212
+ </h2>
213
+ <p className="text-sm text-muted-foreground">{askLeoPromo.description}</p>
214
+ </div>
215
+ <Tip
216
+ label={(
217
+ <span className="inline-flex items-center gap-1.5">
218
+ Ask Leo
219
+ <KbdGroup>
220
+ <Kbd>{mod}</Kbd>
221
+ <Kbd>{alt}</Kbd>
222
+ <Kbd>K</Kbd>
223
+ </KbdGroup>
224
+ </span>
225
+ )}
226
+ >
227
+ <Button type="button" variant="outline" size="sm" onClick={toggle}>
228
+ <i className="fa-duotone fa-solid fa-star-christmas text-brand" aria-hidden="true" />
229
+ Ask Leo
230
+ <KbdGroup className="ms-1.5 hidden sm:inline-flex">
231
+ <Kbd variant="bare">{leoShortcut}</Kbd>
232
+ </KbdGroup>
233
+ </Button>
234
+ </Tip>
235
+ </div>
236
+ <ul className="mt-4 flex flex-col gap-2" role="list">
237
+ {askLeoPromo.prompts.map(prompt => (
238
+ <li key={prompt}>
239
+ <Button
240
+ type="button"
241
+ variant="ghost"
242
+ className="h-auto w-full justify-start whitespace-normal px-3 py-2.5 text-left text-sm font-normal"
243
+ onClick={() => sendLeoSuggestion(prompt)}
244
+ >
245
+ <i className="fa-light fa-sparkles me-2 shrink-0 text-brand" aria-hidden="true" />
246
+ <span>{prompt}</span>
247
+ </Button>
248
+ </li>
249
+ ))}
250
+ </ul>
251
+ </section>
252
+
253
+ <div className="flex flex-col gap-2 md:min-w-[14rem]">
254
+ <Button type="button" size="lg" className="w-full" onClick={primaryAction.onClick}>
255
+ <i className="fa-light fa-plus" aria-hidden="true" />
256
+ {primaryAction.label}
257
+ <KbdGroup className="ms-1.5">
258
+ <Kbd variant="bare">{createShortcut}</Kbd>
259
+ </KbdGroup>
260
+ </Button>
261
+ <Button type="button" variant="outline" size="lg" className="w-full" asChild>
262
+ <Link href={browseLibraryHref}>
263
+ <i className="fa-light fa-table-list" aria-hidden="true" />
264
+ {browseLibraryLabel}
265
+ </Link>
266
+ </Button>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </PrimaryPageTemplate>
272
+ )
273
+ }
@@ -124,6 +124,8 @@ export interface ListPageTemplateProps {
124
124
  * `TablePropertiesDrawer` when `onEditView` is omitted.
125
125
  */
126
126
  tablePropertiesRef?: React.RefObject<OpenTablePropertiesHandle | null>
127
+ /** When true, hide the views tab strip (tabs + add view) — e.g. search landing with a single table surface. */
128
+ hideViewsToolbar?: boolean
127
129
  }
128
130
 
129
131
  /** Collision-proof id for a dynamically-added tab. Module-level counters reset
@@ -177,6 +179,7 @@ export function ListPageTemplate({
177
179
  exportTotalRows = 0,
178
180
  onEditView,
179
181
  tablePropertiesRef,
182
+ hideViewsToolbar = false,
180
183
  }: ListPageTemplateProps) {
181
184
  const controlled =
182
185
  tabsProp !== undefined &&
@@ -270,14 +273,14 @@ export function ListPageTemplate({
270
273
 
271
274
  return (
272
275
  <>
273
- {VIEW_TYPES.slice(0, 9).map((v, i) => (
276
+ {!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => (
274
277
  <Shortcut
275
278
  key={v.type}
276
279
  keys={`⌘⇧${i + 1}`}
277
280
  onInvoke={() => addView(v.type)}
278
281
  />
279
282
  ))}
280
- {activeTab && (
283
+ {activeTab && !hideViewsToolbar && (
281
284
  <>
282
285
  <Shortcut keys="F2" onInvoke={() => openRename(activeTab)} />
283
286
  <Shortcut
@@ -299,6 +302,8 @@ export function ListPageTemplate({
299
302
  {showMetrics && metrics}
300
303
 
301
304
  {/* ── Views toolbar (not tablist: settings/close are not tabs — WCAG 1.3.1 / ARIA) ── */}
305
+ {!hideViewsToolbar && (
306
+ <>
302
307
  {/* Outer: horizontal scroll only. Inner: vertical padding so ring-offset focus rings are not clipped
303
308
  (`overflow-x-auto` forces overflow-y to clip in a single box). */}
304
309
  <div className="mt-3 shrink-0 overflow-x-auto px-4 lg:px-6">
@@ -350,7 +355,7 @@ export function ListPageTemplate({
350
355
  </button>
351
356
  </DropdownMenuTrigger>
352
357
  </Tip>
353
- <DropdownMenuContent align="start" className="w-56">
358
+ <DropdownMenuContent align="start">
354
359
  <DropdownMenuLabel className="text-xs text-muted-foreground">
355
360
  View: {VIEW_TYPES.find(v => v.type === tab.viewType)?.label}
356
361
  </DropdownMenuLabel>
@@ -471,7 +476,7 @@ export function ListPageTemplate({
471
476
  Add view
472
477
  </Button>
473
478
  </DropdownMenuTrigger>
474
- <DropdownMenuContent align="start" className="w-40">
479
+ <DropdownMenuContent align="start">
475
480
  <DropdownMenuLabel className="text-xs">Add a view</DropdownMenuLabel>
476
481
  <DropdownMenuSeparator />
477
482
  {VIEW_TYPES.map((v, i) => (
@@ -488,6 +493,8 @@ export function ListPageTemplate({
488
493
  </DropdownMenu>
489
494
  </div>
490
495
  </div>
496
+ </>
497
+ )}
491
498
 
492
499
  {/* ── Content — keyed by tab so each view tab owns its height (no stale min-height). ── */}
493
500
  {activeTab ? (
@@ -0,0 +1,57 @@
1
+ import * as React from "react"
2
+
3
+ import { cn } from "@/lib/utils"
4
+
5
+ export interface NestedSecondaryPanelShellProps {
6
+ /** When false, the shell is visually hidden (no width). */
7
+ open: boolean
8
+ /** Icon-only rail (same width token as primary `Sidebar` collapsible icon mode). */
9
+ compact: boolean
10
+ children: React.ReactNode
11
+ /** Forwarded to the outer `<nav>`. */
12
+ "aria-label"?: string
13
+ className?: string
14
+ }
15
+
16
+ /**
17
+ * Shared chrome for a nested hub rail — full width vs icon rail, aligned with primary sidebar tokens.
18
+ * Domain panels render their header + nav inside `children`.
19
+ */
20
+ export function NestedSecondaryPanelShell({
21
+ open,
22
+ compact,
23
+ children,
24
+ "aria-label": ariaLabel = "Secondary navigation",
25
+ className,
26
+ }: NestedSecondaryPanelShellProps) {
27
+ return (
28
+ <nav
29
+ aria-label={ariaLabel}
30
+ data-state={open ? "open" : "closed"}
31
+ data-layout={open ? (compact ? "icon" : "expanded") : "closed"}
32
+ className={cn(
33
+ "flex flex-col overflow-hidden",
34
+ "transition-[width,margin,opacity] duration-200 ease-linear",
35
+ open
36
+ ? cn(
37
+ "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative md:sticky md:top-2",
38
+ compact
39
+ ? "w-12 min-w-12 max-w-12 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]"
40
+ : "w-64 min-w-64 max-w-64 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]",
41
+ )
42
+ : "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
43
+ className,
44
+ )}
45
+ style={open ? { backgroundColor: "var(--secondary-panel-bg, #FAFBFF)" } : undefined}
46
+ >
47
+ <div
48
+ className={cn(
49
+ "flex flex-1 flex-col overflow-y-auto overflow-x-hidden",
50
+ open ? "min-w-0" : "hidden min-w-0 w-0 p-0",
51
+ )}
52
+ >
53
+ {children}
54
+ </div>
55
+ </nav>
56
+ )
57
+ }
@@ -0,0 +1,54 @@
1
+ import * as React from "react"
2
+
3
+ import { PrimaryPageTemplate, type PrimaryPageTemplateProps } from "@/components/templates/primary-page-template"
4
+ import { useAutoPanel } from "@/components/secondary-panel"
5
+
6
+ export interface SecondaryPanelHubActivatorProps {
7
+ panelId: string
8
+ }
9
+
10
+ /** Opens the nested secondary panel while the activator stays mounted (e.g. a route-segment layout). */
11
+ export function SecondaryPanelHubActivator({ panelId }: SecondaryPanelHubActivatorProps) {
12
+ useAutoPanel(panelId)
13
+ return null
14
+ }
15
+
16
+ export interface SecondaryPanelHubTemplateProps
17
+ extends Omit<PrimaryPageTemplateProps, "beforeSiteHeader"> {
18
+ /** Bridges hub state into the secondary nav (folder tree, access sheet, …). */
19
+ bridges?: React.ReactNode
20
+ /** Extra chrome before `SiteHeader`. */
21
+ beforeSiteHeader?: React.ReactNode
22
+ }
23
+
24
+ /**
25
+ * Primary hub shell with optional bridges + `PrimaryPageTemplate` body.
26
+ * Mount `useAutoPanel` / `SecondaryPanelHubActivator` on a parent layout that stays mounted across
27
+ * hub child routes (see `app/(app)/question-bank/layout.tsx`) so the panel does not close between navigations.
28
+ *
29
+ * Pair with `useSecondaryPanelHubNav` and hub-specific `lib/*-nav.ts` helpers for URL scope.
30
+ */
31
+ export function SecondaryPanelHubTemplate({
32
+ bridges,
33
+ beforeSiteHeader,
34
+ siteHeader,
35
+ children,
36
+ maxWidthClassName,
37
+ contentClassName,
38
+ bodyClassName,
39
+ }: SecondaryPanelHubTemplateProps) {
40
+ return (
41
+ <>
42
+ {bridges}
43
+ <PrimaryPageTemplate
44
+ beforeSiteHeader={beforeSiteHeader}
45
+ siteHeader={siteHeader}
46
+ maxWidthClassName={maxWidthClassName}
47
+ contentClassName={contentClassName}
48
+ bodyClassName={bodyClassName}
49
+ >
50
+ {children}
51
+ </PrimaryPageTemplate>
52
+ </>
53
+ )
54
+ }
@@ -0,0 +1,36 @@
1
+ # Cards vs table rows vs list rows
2
+
3
+ > **Related:** **`AGENTS.md` §4–§5**, **`.cursor/rules/exxat-data-tables.mdc`**, **`.cursor/rules/exxat-board-cards.mdc`**, **`docs/data-views-pattern.md`**.
4
+
5
+ ## When to use **rows** (table or dense list)
6
+
7
+ - **Many similar records** (roughly **10+**) where users **scan**, **sort**, **filter**, and **compare columns**.
8
+ - **Same fields** across entities — columns align; export and **TablePropertiesDrawer** apply.
9
+ - **Primary hub** pattern — **`DataTable`** + **`ListPageTemplate`** per **`exxat-data-tables`**.
10
+
11
+ **Use:** `DataTable`, shared toolbar, row actions, pinned columns.
12
+
13
+ ## When to use **cards** (tile / board / marketing)
14
+
15
+ - **Lower cardinality** or **visual-first** browsing — folders, dashboards, “pick a template”, hero metrics beside a chart.
16
+ - **Heterogeneous content** — each card has a different layout (title, badge, avatar, two-line body) where a rigid grid of columns would fight the design.
17
+ - **Kanban** — **`ListPageBoardCard`** + column model; still fed by **`tableState.rows`**.
18
+
19
+ **Use:** `ListPageBoardCard`, `ChartCard`, icon grids under **`ListPageViewFrame`**, dashboard chart tiles.
20
+
21
+ ## When to use **list rows** (not full DataTable)
22
+
23
+ - **Medium density** — fewer columns than a grid; reading order is **vertical** (timeline, activity, simple roster without heavy operators).
24
+ - Still wire **search** when the list is the main surface and item count grows.
25
+
26
+ **Prefer** graduating to **`DataTable`** once the hub needs filters, column hide, density, or export parity with other list hubs.
27
+
28
+ ## Anti-patterns
29
+
30
+ - **Cards for 50+ homogeneous records** when the product expects sort/filter/compare — that belongs in **`DataTable`**.
31
+ - **A second bespoke “table”** alongside **`DataTable`** for the same dataset — extend the table stack instead (**`exxat-centralized-list-dataset`**).
32
+ - **Raw `<table>`** for product data lists — forbidden (**`exxat-data-tables`**).
33
+
34
+ ## See also
35
+
36
+ - **`.cursor/rules/exxat-card-vs-list-rows.mdc`**, **`.cursor/skills/exxat-card-vs-list-rows/SKILL.md`**
@@ -0,0 +1,114 @@
1
+ # Collaboration & access pattern
2
+
3
+ Shared UI for **who can access a hub** (face stack in the header) and **inviting people** (floating sheet). **Reference:** Question bank — `QuestionBankPageHeader`, `QuestionBankClient`, `InviteCollaboratorsDrawer`.
4
+
5
+ ## When to use
6
+
7
+ - A list hub or library is **shared** across people (not a private directory).
8
+ - Users need to see **who has access**, **invite by email**, and assign **library access** (Owner / Editor / Commenter / Viewer).
9
+ - The hub already uses **`ListPageTemplate`** + **`PageHeader`** (or an entity header built on it).
10
+
11
+ **Do not** use this for org-wide **role** administration (Faculty, Program coordinator, Director) as the only story — those are **directory role tags** on people, not library access.
12
+
13
+ ## Vocabulary
14
+
15
+ | Concept | Meaning | Source |
16
+ |--------|---------|--------|
17
+ | **Library access** | What someone can do **in this hub** (Owner, Editor, Commenter, Viewer) | `lib/collaborator-access.ts` |
18
+ | **Directory roles** | Org/job tags on a person (Faculty, Program coordinator, Director) | `PageHeaderCollaborator.roles` |
19
+ | **Face rail** | Overlapping avatars in the header when the roster is non-empty | `PageHeader` `variant="collaboration"` |
20
+ | **Empty roster CTA** | Outline **Add collaborator** in the header when `collaborators` is empty | `PageHeader` + `COLLABORATION_HEADER_ADD_LABEL` |
21
+ | **Invite sheet** | Floating right **`Sheet`** for roster + invite form | `InviteCollaboratorsDrawer` |
22
+ | **Hub wiring** | Roster state + invite sheet in one render-prop shell | `CollaborationAccessFlow` |
23
+
24
+ ## Header (`PageHeader`)
25
+
26
+ - Set **`variant="collaboration"`**.
27
+ - Pass **`collaborators`** (`PageHeaderCollaborator[]`) and optional **`accessInfo`**.
28
+ - **Non-empty roster** — overlapping **face rail** only; each face and **`+N`** open the invite sheet via **`onCollaboratorsOpen`**.
29
+ - **Empty roster** — outline **`Add collaborator`** (`addCollaboratorLabel`, default **`COLLABORATION_HEADER_ADD_LABEL`**) opens the same sheet.
30
+ - **Invite** also lives under **⋯ More** on the entity page header (first item when `variant="collaboration"`).
31
+
32
+ ```tsx
33
+ <CollaborationAccessFlow
34
+ initialCollaborators={QUESTION_BANK_HEADER_COLLABORATORS}
35
+ resourceLabel={hubHeader.title}
36
+ >
37
+ {({ collaborators, openInvite }) => (
38
+ <QuestionBankPageHeader
39
+ variant="collaboration"
40
+ title={hubHeader.title}
41
+ questionCount={count}
42
+ collaborators={collaborators}
43
+ onAddCollaborator={openInvite}
44
+ onCollaboratorsOpen={openInvite}
45
+ onExport={() => setExportOpen(true)}
46
+ showMetrics={showMetrics}
47
+ onToggleMetrics={() => setShowMetrics(v => !v)}
48
+ />
49
+ )}
50
+ </CollaborationAccessFlow>
51
+ ```
52
+
53
+ ## Hub client state
54
+
55
+ - Prefer **`CollaborationAccessFlow`** — owns **`collaborators`**, **`openInvite`**, and **`InviteCollaboratorsDrawer`**; pass **`openInvite`** to **`onAddCollaborator`** / **`onCollaboratorsOpen`**.
56
+ - Without the flow: **`collaborators`** — `useState` seeded from `lib/mock/<entity>-header-collaborators.ts` (or API later); **`inviteOpen`** — boolean; mount **`InviteCollaboratorsDrawer`** beside **`ListPageTemplate`**.
57
+ - On invite success, append to **`collaborators`** so the **face rail** and sheet roster stay aligned.
58
+ - **Change access** — roster menu updates **`collaborators`** via **`onCollaboratorAccessChange`** ( **`CollaborationAccessFlow`** default).
59
+ - **Remove access** — confirm dialog then **`onCollaboratorRemove`**; blocked for the only **Owner**.
60
+
61
+ ## `PageHeaderCollaborator`
62
+
63
+ | Field | Use |
64
+ |-------|-----|
65
+ | `id`, `name`, `imageUrl`, `initials` | Face rail + roster row |
66
+ | `email` | Roster (below name); invite form |
67
+ | `access` | Library access badge (Owner … Viewer) |
68
+ | `roles` | Optional **outline** chips (Faculty, Program coordinator, Director) |
69
+
70
+ ## Invite sheet (`InviteCollaboratorsDrawer`)
71
+
72
+ Mirror **`ExportDrawer`** chrome: floating **`Sheet`**, no overlay, **`showCloseButton={false}`**, footer **Cancel** / **Send invite** with inline **`Kbd`** (**Esc** / **⏎**), **`Shortcut`** for Enter on the open surface.
73
+
74
+ **Invite field:** one bordered row — email input + **access** menu on the right.
75
+
76
+ - Use **`Select`** with **`SelectGroup`** for access (invite row in **`InputGroupAddon`**, roster row standalone); **`position="popper"`** inside the sheet; **no** toast on success (**§6.5**).
77
+
78
+ **People with access:** one **`rounded-lg border`** list with **`divide-y`** — **not** one card per person.
79
+
80
+ Row order:
81
+
82
+ 1. **Name** (`text-sm font-medium`)
83
+ 2. **Email** (`text-xs text-muted-foreground`)
84
+ 3. **Role tags** (`Badge variant="outline"`) when `roles` is set
85
+ 4. Trailing **library access** **`Select`** when the hub wires **`onCollaboratorAccessChange`**; **Remove access** (trash) opens a confirm **`Dialog`** when **`onCollaboratorRemove`** is set. The sole **Owner** cannot be removed or demoted until another owner exists.
86
+
87
+ ## Library access constants
88
+
89
+ - Types and invite options: **`lib/collaborator-access.ts`**
90
+ - **`INVITE_COLLABORATOR_ACCESS_OPTIONS`** — Editor / Commenter / Viewer (no Owner on invite)
91
+ - Customize option **descriptions** per hub; keep **values** stable for forms/API
92
+
93
+ ## File map
94
+
95
+ | Piece | Path |
96
+ |-------|------|
97
+ | Access types | `lib/collaborator-access.ts` |
98
+ | Hub flow | `components/collaboration-access-flow.tsx` |
99
+ | Collaborator type | `components/page-header.tsx` (`PageHeaderCollaborator`) |
100
+ | Invite sheet | `components/invite-collaborators-drawer.tsx` |
101
+ | Entity header | `components/question-bank-page-header.tsx` |
102
+ | Hub wiring | `components/question-bank-client.tsx` |
103
+ | Demo roster | `lib/mock/question-bank-header-collaborators.ts` |
104
+
105
+ ## Checklist (new hub)
106
+
107
+ - [ ] `PageHeader` / entity header uses **`variant="collaboration"`** when the product is shared.
108
+ - [ ] **Empty roster** shows **Add collaborator**; **non-empty** shows face rail; both open the invite sheet.
109
+ - [ ] **Invite people** under **⋯ More**; **`CollaborationAccessFlow`** (or equivalent) owns roster + sheet.
110
+ - [ ] Roster: single bordered list; **name → email → role tags**; access badge on the right.
111
+ - [ ] Invite row: email + access menu; **`FormDescription`** for format; **no** toast.
112
+ - [ ] Labels from **`collaborator-access.ts`**; mock/API rows extend **`PageHeaderCollaborator`** once.
113
+
114
+ **Handbook:** `AGENTS.md` §4.7 · **Rule:** `.cursor/rules/exxat-collaboration-access.mdc` · **Skill:** `.cursor/skills/exxat-collaboration-access/SKILL.md`
@@ -45,7 +45,7 @@ Non-table view branches (e.g. **folder** icon grid, **panel** finder, OS-style f
45
45
  ## Mock data and connected views
46
46
 
47
47
  1. **Put entity rows in `lib/mock/<entity>.ts`** — Export a typed array (e.g. `TeamMember[]`, `Placement[]`) and reuse it from the page client and from KPI helpers.
48
- 2. **KPI / summary helpers** — Add `lib/mock/<entity>-kpi.ts` (or next to the mock file) with pure functions **`entityKpiMetrics(rows: T[])`** and **`entityKpiInsight(rows: T[])`** returning `MetricItem[]` and `MetricInsight` from `@/components/key-metrics`. Drive **both** the template **`metrics`** slot and the **dashboard view** from the **same helpers**, passing **`tableState.rows`** in the table component so filters/search apply everywhere.
48
+ 2. **KPI / summary helpers** — Add `lib/mock/<entity>-kpi.ts` (or next to the mock file) with pure functions **`entityKpiMetrics(rows: T[])`** and **`entityKpiInsight(rows: T[])`** returning `MetricItem[]` and `MetricInsight` from `@/components/key-metrics`. Set **`MetricItem.trendPolarity`** when an increase is **not** favorable (see **`docs/kpi-trend-pattern.md`**). Drive **both** the template **`metrics`** slot and the **dashboard view** from the **same helpers**, passing **`tableState.rows`** in the table component so filters/search apply everywhere.
49
49
  3. **Single table component** — One component (e.g. `TeamTable`) receives **`members`** (full mock) + **`view`**. It calls **`useTableState(members, columns, …)`** once. Branch on `view`:
50
50
  - **`table`** → `DataTable` with that state.
51
51
  - **`list` / `board`** → `DataTableToolbar` + list/board UI with **`tableState.rows`**.
@@ -72,6 +72,12 @@ Non-table view branches (e.g. **folder** icon grid, **panel** finder, OS-style f
72
72
 
73
73
  **Import:** `@/components/table-properties` re-exports the drawer and types.
74
74
 
75
+ ## Dropdown menus (view settings, row actions)
76
+
77
+ **`DropdownMenuContent`** in **`@exxatdesignux/ui`** merges a **default surface** (**`DROPDOWN_MENU_CONTENT_SURFACE_CLASS`**: **`min-w-52 w-max max-w-[min(24rem,calc(100vw-2rem))]`**) so **ListPageTemplate** view settings / Add view, **`DataTable`** filter and column menus, and hub **row ⋯** menus size to their labels (including shortcut hints) without fixed **`w-40` / `w-48`** rails. Sizing is **pure CSS** — do **not** add **`ResizeObserver`** or per-open measurement for standard menus.
78
+
79
+ **Override** when product intent needs a fixed width (e.g. pagination **page size** **`w-20`**, **`NavUser`** trigger-width + **`min-w-60`**, school switcher **`!w-max min-w-72 …`**).
80
+
75
81
  ## Board UI reuse
76
82
 
77
83
  **Handbook:** **`AGENTS.md` §4.4** — board card shell, badge row, shared status maps, and MUST/MUST NOT. **Cursor:** **`.cursor/rules/exxat-board-cards.mdc`**, skill **`.cursor/skills/exxat-board-cards/SKILL.md`**.
@@ -138,9 +144,11 @@ Reference: `components/placements-page-header.tsx`, `components/team-page-header
138
144
 
139
145
  **When to use a new page (route):** The flow is **primary**, **long-form**, **multi-step**, or should have its **own URL** / bookmark / history **without** the parent page behind it — e.g. full create/edit, wizards, or detail that *is* the task.
140
146
 
141
- **Rule of thumb:** **Context + quick** → **drawer**; **otherwise** → **new page**.
147
+ **Rule of thumb:** **Context + quick** → **drawer**; **blocking short choice** → **dialog**; **otherwise** → **new page**.
148
+
149
+ **Modal vs side panel (same route):** When the overlay stays on the same URL, prefer **`docs/drawer-vs-dialog-pattern.md`** and **`.cursor/rules/exxat-drawer-vs-dialog.mdc`** — drawers keep the hub visible; dialogs trap focus for confirms.
142
150
 
143
- Canonical rules: **`AGENTS.md` §6.4**, root **`.cursor/rules/exxat-page-vs-drawer.mdc`**.
151
+ Canonical rules: **`AGENTS.md` §6.4**, root **`.cursor/rules/exxat-page-vs-drawer.mdc`**, **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**.
144
152
 
145
153
  ---
146
154
 
@@ -160,7 +168,7 @@ When a route is a **primary** destination in nav (main hub for an entity) **and*
160
168
  - [ ] **>10 items** → search, filter, sort, properties (per surface type above).
161
169
  - [ ] **Has data to export** → **More** menu with **Export** + shared `ExportDrawer` pattern.
162
170
  - [ ] **Primary + large / main hub** → `ListPageTemplate`-style shell where applicable.
163
- - [ ] **Page vs drawer (§6.4)** — Quick actions with parent **context** → drawer/sheet; primary or long flows → **new route**.
171
+ - [ ] **Page vs drawer vs dialog (§6.4)** — Quick with parent **context** → drawer/sheet; **blocking** confirm → **dialog**; primary or long flows → **new route** (`docs/drawer-vs-dialog-pattern.md`).
164
172
  - [ ] **Primary button** → `Button` default variant (`size="lg"` for parity with Placements), not outline.
165
173
  - [ ] **Dashboard view tab** → `KeyMetrics` + shared KPI helpers from **`tableState.rows`**; no duplicate one-off metric cards.
166
174
  - [ ] **Data view charts** → `ChartFigure` + `chart-keyboard-selection`; layout persistence via **`data-view-dashboard-storage`** (see `AGENTS.md` §4.3).