@exxatdesignux/ui 0.1.0 → 0.2.7

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 (155) hide show
  1. package/bin/cli.mjs +176 -0
  2. package/bin/init.mjs +15 -1
  3. package/bin/sync-extras.mjs +65 -0
  4. package/consumer-extras/README.md +21 -0
  5. package/consumer-extras/cursor-skills/exxat-accessibility/SKILL.md +282 -0
  6. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +68 -0
  7. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +99 -0
  8. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +713 -0
  9. package/consumer-extras/cursor-skills/exxat-fontawesome-icons/SKILL.md +31 -0
  10. package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +36 -0
  11. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +27 -0
  12. package/consumer-extras/patterns/command-menu-pattern.md +45 -0
  13. package/consumer-extras/patterns/data-views-pattern.md +167 -0
  14. package/package.json +7 -3
  15. package/src/components/ui/sidebar.tsx +7 -2
  16. package/template/.agents/skills/shadcn/SKILL.md +242 -0
  17. package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
  18. package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
  19. package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
  20. package/template/.agents/skills/shadcn/cli.md +257 -0
  21. package/template/.agents/skills/shadcn/customization.md +202 -0
  22. package/template/.agents/skills/shadcn/evals/evals.json +47 -0
  23. package/template/.agents/skills/shadcn/mcp.md +94 -0
  24. package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
  25. package/template/.agents/skills/shadcn/rules/composition.md +195 -0
  26. package/template/.agents/skills/shadcn/rules/forms.md +192 -0
  27. package/template/.agents/skills/shadcn/rules/icons.md +101 -0
  28. package/template/.agents/skills/shadcn/rules/styling.md +162 -0
  29. package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
  30. package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
  31. package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
  32. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
  33. package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
  34. package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
  35. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
  36. package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
  37. package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
  38. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
  39. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
  40. package/template/AGENTS.md +52 -11
  41. package/template/app/(app)/dashboard/page.tsx +1 -1
  42. package/template/app/(app)/data-list/[id]/page.tsx +24 -8
  43. package/template/app/(app)/data-list/new/page.tsx +7 -4
  44. package/template/app/(app)/data-list/page.tsx +1 -1
  45. package/template/app/(app)/examples/page.tsx +41 -0
  46. package/template/app/(app)/question-bank/page.tsx +3 -3
  47. package/template/app/globals.css +1 -1
  48. package/template/components/app-sidebar.tsx +52 -35
  49. package/template/components/compliance-table.tsx +79 -0
  50. package/template/components/data-list-client.tsx +36 -25
  51. package/template/components/data-list-table.tsx +797 -10
  52. package/template/components/data-views/finder-panel-view.tsx +405 -0
  53. package/template/components/data-views/folder-grid-view.tsx +86 -0
  54. package/template/components/data-views/index.ts +59 -0
  55. package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  56. package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  57. package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
  58. package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
  59. package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  60. package/template/components/data-views/list-page-view-frame.tsx +53 -0
  61. package/template/components/data-views/os-folder-glyph.tsx +121 -0
  62. package/template/components/folder-details-shell.tsx +230 -0
  63. package/template/components/hub-tree-panel-view.tsx +672 -0
  64. package/template/components/list-hub-status-badge.tsx +17 -3
  65. package/template/components/page-header.tsx +149 -7
  66. package/template/components/placements-page-header.tsx +14 -8
  67. package/template/components/placements-table-columns.tsx +8 -8
  68. package/template/components/question-bank-client.tsx +157 -39
  69. package/template/components/question-bank-new-folder-sheet.tsx +248 -0
  70. package/template/components/question-bank-os-folder-view.tsx +648 -0
  71. package/template/components/question-bank-page-header.tsx +31 -2
  72. package/template/components/question-bank-panel-activator.tsx +9 -0
  73. package/template/components/question-bank-secondary-nav.tsx +226 -0
  74. package/template/components/question-bank-table.tsx +707 -22
  75. package/template/components/secondary-panel.tsx +41 -107
  76. package/template/components/sites-table.tsx +66 -0
  77. package/template/components/team-client.tsx +7 -0
  78. package/template/components/team-table.tsx +156 -1
  79. package/template/components/templates/list-page.tsx +2 -2
  80. package/template/components/ui/avatar.tsx +1 -1
  81. package/template/components/ui/badge.tsx +1 -1
  82. package/template/components/ui/banner.tsx +1 -1
  83. package/template/components/ui/breadcrumb.tsx +1 -1
  84. package/template/components/ui/button.tsx +1 -1
  85. package/template/components/ui/calendar.tsx +1 -1
  86. package/template/components/ui/card.tsx +1 -1
  87. package/template/components/ui/chart.tsx +1 -1
  88. package/template/components/ui/checkbox.tsx +1 -1
  89. package/template/components/ui/coach-mark.tsx +1 -1
  90. package/template/components/ui/collapsible.tsx +1 -1
  91. package/template/components/ui/command.tsx +1 -1
  92. package/template/components/ui/date-picker-field.tsx +1 -1
  93. package/template/components/ui/dialog.tsx +1 -1
  94. package/template/components/ui/drag-handle-grip.tsx +1 -1
  95. package/template/components/ui/drawer.tsx +1 -1
  96. package/template/components/ui/dropdown-menu.tsx +1 -1
  97. package/template/components/ui/field.tsx +1 -1
  98. package/template/components/ui/form.tsx +1 -1
  99. package/template/components/ui/input-group.tsx +1 -1
  100. package/template/components/ui/input-mask.tsx +1 -1
  101. package/template/components/ui/input.tsx +1 -1
  102. package/template/components/ui/kbd.tsx +1 -1
  103. package/template/components/ui/label.tsx +1 -1
  104. package/template/components/ui/payment-card-fields.tsx +1 -1
  105. package/template/components/ui/popover.tsx +1 -1
  106. package/template/components/ui/radio-group.tsx +1 -1
  107. package/template/components/ui/resizable.tsx +68 -0
  108. package/template/components/ui/select.tsx +1 -1
  109. package/template/components/ui/selection-tile-grid.tsx +1 -1
  110. package/template/components/ui/separator.tsx +1 -1
  111. package/template/components/ui/sheet.tsx +1 -1
  112. package/template/components/ui/sidebar.tsx +1 -1
  113. package/template/components/ui/skeleton.tsx +1 -1
  114. package/template/components/ui/sonner.tsx +1 -1
  115. package/template/components/ui/status-badge.tsx +1 -1
  116. package/template/components/ui/table.tsx +1 -1
  117. package/template/components/ui/tabs.tsx +1 -1
  118. package/template/components/ui/textarea.tsx +1 -1
  119. package/template/components/ui/tip.tsx +1 -1
  120. package/template/components/ui/toggle-group.tsx +1 -1
  121. package/template/components/ui/toggle-switch.tsx +1 -1
  122. package/template/components/ui/toggle.tsx +1 -1
  123. package/template/components/ui/tooltip.tsx +1 -1
  124. package/template/components/ui/view-segmented-control.tsx +1 -1
  125. package/template/docs/data-views-pattern.md +7 -0
  126. package/template/hooks/use-app-theme.ts +1 -1
  127. package/template/hooks/use-coach-mark.ts +1 -1
  128. package/template/hooks/use-location-hash.ts +15 -0
  129. package/template/hooks/use-mobile.ts +1 -1
  130. package/template/hooks/use-mod-key-label.ts +1 -1
  131. package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
  132. package/template/lib/ask-leo-route-context.ts +25 -57
  133. package/template/lib/coach-mark-registry.ts +13 -13
  134. package/template/lib/command-menu-config.ts +28 -23
  135. package/template/lib/command-menu-search-data.ts +10 -9
  136. package/template/lib/data-list-view-surface.ts +12 -1
  137. package/template/lib/data-list-view.ts +6 -3
  138. package/template/lib/date-filter.ts +1 -1
  139. package/template/lib/mock/dashboard.ts +11 -11
  140. package/template/lib/mock/navigation.tsx +22 -63
  141. package/template/lib/mock/placements-kpi.ts +19 -19
  142. package/template/lib/mock/question-bank-folders.ts +167 -0
  143. package/template/lib/mock/question-bank-header-collaborators.ts +14 -0
  144. package/template/lib/mock/question-bank-inspector.ts +109 -0
  145. package/template/lib/mock/question-bank-kpi.ts +1 -1
  146. package/template/lib/mock/question-bank.ts +80 -0
  147. package/template/lib/question-bank-nav.ts +91 -0
  148. package/template/lib/utils.ts +1 -1
  149. package/template/next.config.mjs +8 -0
  150. package/template/package.json +1 -0
  151. package/template/public/folders/icons8-folder-windows-11.svg +1 -0
  152. package/template/app/(app)/compliance/page.tsx +0 -10
  153. package/template/app/(app)/rotations/page.tsx +0 -15
  154. package/template/app/(app)/sites/all/page.tsx +0 -13
  155. package/template/app/(app)/team/page.tsx +0 -10
@@ -17,14 +17,21 @@ export const LIST_HUB_STATUS_BADGE_TABLE_SHELL =
17
17
  export const LIST_HUB_STATUS_BADGE_BOARD_SHELL =
18
18
  "inline-flex h-6 items-center gap-1 border-0 px-2 py-1 text-xs font-medium leading-none shadow-none"
19
19
 
20
+ /**
21
+ * Inspector / split-pane detail headers — uniform 24px chip height next to status badges.
22
+ * Reuse on sibling `Badge`s so rows align with `ListHubStatusBadge` `surface="detail"`.
23
+ */
24
+ export const LIST_HUB_INSPECTOR_CHIP_SHELL =
25
+ "inline-flex h-6 min-h-6 shrink-0 items-center gap-1.5 px-2 py-0 text-xs font-medium leading-none"
26
+
20
27
  export interface ListHubStatusBadgeProps {
21
28
  label: string
22
29
  /** Tails from `*_STATUS_BADGE_CLASS` in `@/lib/list-status-badges` */
23
30
  tintClassName: string
24
31
  /** Font Awesome icon class suffix, e.g. `fa-circle-check` (paired with `fa-light` here). */
25
32
  icon: string
26
- /** `table` — DataTable cells and list rows; `board` — `ListPageBoardCardBadgeRow`. */
27
- surface?: "table" | "board"
33
+ /** `table` — grid cells; `board` kanban cards; `detail` — hub inspector / tree detail column. */
34
+ surface?: "table" | "board" | "detail"
28
35
  className?: string
29
36
  }
30
37
 
@@ -35,11 +42,18 @@ export function ListHubStatusBadge({
35
42
  surface = "table",
36
43
  className,
37
44
  }: ListHubStatusBadgeProps) {
45
+ const shell =
46
+ surface === "board"
47
+ ? LIST_HUB_STATUS_BADGE_BOARD_SHELL
48
+ : surface === "detail"
49
+ ? LIST_HUB_INSPECTOR_CHIP_SHELL
50
+ : LIST_HUB_STATUS_BADGE_TABLE_SHELL
51
+
38
52
  return (
39
53
  <Badge
40
54
  variant="outline"
41
55
  className={cn(
42
- surface === "board" ? LIST_HUB_STATUS_BADGE_BOARD_SHELL : LIST_HUB_STATUS_BADGE_TABLE_SHELL,
56
+ shell,
43
57
  tintClassName,
44
58
  className,
45
59
  )}
@@ -1,22 +1,60 @@
1
+ "use client"
2
+
1
3
  /**
2
4
  * PageHeader — Full-width content area header
3
5
  *
4
6
  * Sits at the top of a page's main content, BELOW the breadcrumb/topbar.
5
7
  * Uses Ivy Presto (Adobe Fonts) for the title via font-heading CSS variable.
6
8
  *
9
+ * **Variant `collaboration`** — optional access line + stacked collaborator faces
10
+ * and an invite control ahead of the primary `actions` slot (Question bank pattern).
11
+ *
7
12
  * WCAG 2.1 AA:
8
13
  * ✓ <h1> landmark — one per page (WCAG 1.3.1)
9
14
  * ✓ Sufficient colour contrast ≥ 4.5:1 on title + subtitle (SC 1.4.3)
15
+ * ✓ Face stack: `role="group"` + aggregate `aria-label`; each face has a `Tooltip` name
10
16
  */
11
17
 
12
18
  import * as React from "react"
13
19
  import { cn } from "@/lib/utils"
20
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
21
+ import { Button } from "@/components/ui/button"
22
+ import { Tip } from "@/components/ui/tip"
23
+ import {
24
+ Tooltip,
25
+ TooltipContent,
26
+ TooltipTrigger,
27
+ } from "@/components/ui/tooltip"
28
+
29
+ export type PageHeaderVariant = "default" | "collaboration"
30
+
31
+ export interface PageHeaderCollaborator {
32
+ id: string
33
+ name: string
34
+ imageUrl?: string | null
35
+ initials?: string
36
+ }
14
37
 
15
38
  export interface PageHeaderProps {
16
39
  /** Primary page title — rendered as <h1> in Ivy Presto serif */
17
40
  title: string
18
- /** Short descriptor or date shown below the title */
41
+ /** Short descriptor or date shown below the title (and below `accessInfo` when set) */
19
42
  subtitle?: string
43
+ /** Layout preset — `collaboration` enables access line + face stack + invite ahead of `actions`. */
44
+ variant?: PageHeaderVariant
45
+ /**
46
+ * Role / access copy or badges — rendered between the title and subtitle when
47
+ * `variant="collaboration"` (e.g. lock icon + “Editors can modify”).
48
+ */
49
+ accessInfo?: React.ReactNode
50
+ /** People with access — shown as an overlapping face stack when `variant="collaboration"`. */
51
+ collaborators?: PageHeaderCollaborator[]
52
+ /** Max faces before a `+N` chip — default 4 */
53
+ collaboratorDisplayLimit?: number
54
+ /** Outline control beside the stack — e.g. open share / invite dialog */
55
+ onAddCollaborator?: () => void
56
+ /** Accessible name for the invite control — default “Invite people” */
57
+ addCollaboratorLabel?: string
20
58
  /** Optional slot for right-aligned actions (buttons, selectors, etc.) */
21
59
  actions?: React.ReactNode
22
60
  /** Extra className for the outer wrapper */
@@ -25,32 +63,136 @@ export interface PageHeaderProps {
25
63
  showTitleBlock?: boolean
26
64
  }
27
65
 
28
- export function PageHeader({ title, subtitle, actions, className, showTitleBlock = true }: PageHeaderProps) {
66
+ function PageHeaderCollaborationFaces({
67
+ people,
68
+ limit,
69
+ addLabel,
70
+ onAdd,
71
+ }: {
72
+ people: PageHeaderCollaborator[]
73
+ limit: number
74
+ addLabel: string
75
+ onAdd?: () => void
76
+ }) {
77
+ const visible = people.slice(0, limit)
78
+ const overflow = Math.max(0, people.length - visible.length)
79
+ const names = people.map(p => p.name).join(", ")
80
+
81
+ return (
82
+ <div
83
+ role="group"
84
+ aria-label={names ? `People with access: ${names}` : "People with access"}
85
+ className="flex shrink-0 items-center gap-2 sm:gap-2.5"
86
+ >
87
+ {visible.length > 0 && (
88
+ <div className="flex -space-x-2 ps-0.5">
89
+ {visible.map((c, index) => (
90
+ <Tooltip key={c.id}>
91
+ <TooltipTrigger asChild>
92
+ <Avatar
93
+ size="sm"
94
+ shape="circle"
95
+ className="relative ring-2 ring-background"
96
+ style={{ zIndex: 10 + index }}
97
+ >
98
+ {c.imageUrl ? (
99
+ <AvatarImage src={c.imageUrl} alt="" referrerPolicy="no-referrer" />
100
+ ) : null}
101
+ <AvatarFallback className="text-xs font-semibold">
102
+ {(c.initials ?? c.name.slice(0, 2)).toUpperCase()}
103
+ </AvatarFallback>
104
+ </Avatar>
105
+ </TooltipTrigger>
106
+ <TooltipContent side="bottom">{c.name}</TooltipContent>
107
+ </Tooltip>
108
+ ))}
109
+ {overflow > 0 && (
110
+ <div
111
+ className="relative z-30 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium tabular-nums text-muted-foreground ring-2 ring-background sm:size-7"
112
+ aria-label={`${overflow} more people with access`}
113
+ >
114
+ +{overflow}
115
+ </div>
116
+ )}
117
+ </div>
118
+ )}
119
+ {onAdd ? (
120
+ <Tip side="bottom" label={addLabel}>
121
+ <Button
122
+ type="button"
123
+ variant="outline"
124
+ size="icon"
125
+ className="size-8 min-h-8 min-w-8 shrink-0 rounded-full border-dashed"
126
+ aria-label={addLabel}
127
+ onClick={onAdd}
128
+ >
129
+ <i className="fa-light fa-user-plus text-sm" aria-hidden="true" />
130
+ </Button>
131
+ </Tip>
132
+ ) : null}
133
+ </div>
134
+ )
135
+ }
136
+
137
+ export function PageHeader({
138
+ title,
139
+ subtitle,
140
+ variant = "default",
141
+ accessInfo,
142
+ collaborators,
143
+ collaboratorDisplayLimit = 4,
144
+ onAddCollaborator,
145
+ addCollaboratorLabel = "Invite people",
146
+ actions,
147
+ className,
148
+ showTitleBlock = true,
149
+ }: PageHeaderProps) {
150
+ const isCollaboration = variant === "collaboration"
151
+ const showAccess = Boolean(isCollaboration && accessInfo)
152
+ const showFaceRail =
153
+ isCollaboration &&
154
+ ((collaborators && collaborators.length > 0) || Boolean(onAddCollaborator))
155
+ const showActionsColumn = Boolean(actions) || showFaceRail
156
+
29
157
  return (
30
158
  <div
31
159
  className={cn(
32
160
  "flex flex-col gap-1 px-4 pt-2 pb-4 lg:px-6",
33
161
  "sm:flex-row sm:items-end sm:gap-4",
34
162
  showTitleBlock ? "sm:justify-between" : "sm:justify-end",
35
- className
163
+ className,
36
164
  )}
37
165
  >
38
166
  {/* Title block — hidden visually when showTitleBlock is false; keep h1 for a11y */}
39
- <div className={cn("flex flex-col gap-0.5", !showTitleBlock && "sr-only")}>
167
+ <div className={cn("flex min-w-0 flex-col gap-0.5", !showTitleBlock && "sr-only")}>
40
168
  <h1
41
169
  className="text-2xl font-semibold tracking-tight leading-tight text-foreground"
42
170
  style={{ fontFamily: "var(--font-heading)" }}
43
171
  >
44
172
  {title}
45
173
  </h1>
174
+ {showAccess && (
175
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs leading-snug text-muted-foreground">
176
+ {accessInfo}
177
+ </div>
178
+ )}
46
179
  {subtitle && (
47
180
  <p className="text-sm text-muted-foreground leading-none">{subtitle}</p>
48
181
  )}
49
182
  </div>
50
183
 
51
- {/* Right-side actions — e.g. date picker, CTA buttons */}
52
- {actions && (
53
- <div className="flex items-center gap-2 shrink-0">{actions}</div>
184
+ {showActionsColumn && (
185
+ <div className="flex flex-wrap items-center gap-2 sm:gap-3 shrink-0 sm:ms-auto sm:justify-end">
186
+ {showFaceRail ? (
187
+ <PageHeaderCollaborationFaces
188
+ people={collaborators ?? []}
189
+ limit={collaboratorDisplayLimit}
190
+ addLabel={addCollaboratorLabel}
191
+ onAdd={onAddCollaborator}
192
+ />
193
+ ) : null}
194
+ {actions}
195
+ </div>
54
196
  )}
55
197
  </div>
56
198
  )
@@ -17,23 +17,29 @@ import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
17
17
  import { isEditableTarget } from "@/lib/editable-target"
18
18
 
19
19
  export interface PlacementsPageHeaderProps {
20
+ /** Main heading in the page header */
21
+ title?: string
22
+ /** Primary button label */
23
+ primaryCtaLabel?: string
20
24
  /** Shown under the page title */
21
25
  subtitle?: string
22
26
  onNewPlacement: () => void
23
27
  onExport: () => void
24
28
  showMetrics: boolean
25
29
  onToggleMetrics: () => void
26
- /** When false, “Placements” + subtitle are hidden visually (Display options). */
30
+ /** When false, title + subtitle are hidden visually (Display options). */
27
31
  showTitleBlock?: boolean
28
32
  className?: string
29
33
  }
30
34
 
31
35
  /**
32
- * Placements list shell header — title, primary CTA, overflow menu (export, metrics).
33
- * Reusable for any placements route that needs the same chrome.
36
+ * List hub shell header — title, primary CTA, overflow menu (export, metrics).
37
+ * Reusable for any route that needs the same chrome.
34
38
  */
35
39
  export function PlacementsPageHeader({
36
- subtitle = "24 records · Last updated now",
40
+ title = "Sample records",
41
+ primaryCtaLabel = "New row",
42
+ subtitle = "24 demo rows · Last updated now",
37
43
  onNewPlacement,
38
44
  onExport,
39
45
  showMetrics,
@@ -76,17 +82,17 @@ export function PlacementsPageHeader({
76
82
  <Shortcut keys="⌘⇧E" onInvoke={onExport} />
77
83
  <Shortcut keys="⌘⌥H" onInvoke={onToggleMetrics} />
78
84
  <PageHeader
79
- title="Placements"
85
+ title={title}
80
86
  subtitle={subtitle}
81
87
  className={className}
82
88
  showTitleBlock={showTitleBlock}
83
89
  actions={
84
- <div className="flex items-center gap-2" role="group" aria-label="Placement actions">
90
+ <div className="flex items-center gap-2" role="group" aria-label="Primary list actions">
85
91
  <Tip
86
92
  side="bottom"
87
93
  label={
88
94
  <>
89
- <span>New placement</span>
95
+ <span>{primaryCtaLabel}</span>
90
96
  <KbdGroup>
91
97
  <Kbd>{mod}</Kbd>
92
98
  <Kbd>{alt}</Kbd>
@@ -97,7 +103,7 @@ export function PlacementsPageHeader({
97
103
  >
98
104
  <Button size="lg" onClick={onNewPlacement}>
99
105
  <i className="fa-light fa-plus" aria-hidden="true" />
100
- New placement
106
+ {primaryCtaLabel}
101
107
  </Button>
102
108
  </Tip>
103
109
  <DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
@@ -621,19 +621,19 @@ export function getPlacementColumnsForLifecycle(tab: PlacementLifecycleTabId): C
621
621
  export function emptyCopyForPlacementLifecycleTab(tab: PlacementLifecycleTabId): string {
622
622
  switch (tab) {
623
623
  case "upcoming":
624
- return "No upcoming placements match your filters."
624
+ return "No rows in this segment match your filters."
625
625
  case "ongoing":
626
- return "No ongoing placements match your filters."
626
+ return "No rows in this segment match your filters."
627
627
  case "completed":
628
- return "No completed placements match your filters."
628
+ return "No rows in this segment match your filters."
629
629
  default:
630
- return "No placements match your filters."
630
+ return "No rows match your filters."
631
631
  }
632
632
  }
633
633
 
634
634
  export const placementLifecycleDrawerLabels: Record<PlacementLifecycleTabId, string> = {
635
- all: "Lifecycle: All placements",
636
- upcoming: "Lifecycle: Upcoming",
637
- ongoing: "Lifecycle: Ongoing",
638
- completed: "Lifecycle: Completed",
635
+ all: "Segment: All rows",
636
+ upcoming: "Segment: Due soon",
637
+ ongoing: "Segment: In progress",
638
+ completed: "Segment: Done",
639
639
  }
@@ -2,20 +2,31 @@
2
2
 
3
3
  /**
4
4
  * Question bank hub — ListPageTemplate + KeyMetrics + QuestionBankTable (Team / Compliance pattern).
5
+ * URL hash syncs the active view tab; `?scope=` + `folderId=` sync with the secondary nav (`lib/question-bank-nav.ts`).
5
6
  */
6
7
 
7
8
  import * as React from "react"
9
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
8
10
  import {
9
11
  ListPageTemplate,
10
12
  type ViewTab,
11
13
  dataListViewIcon,
12
14
  type DataListViewType,
13
15
  } from "@/components/data-views"
16
+ import { QuestionBankPanelActivator } from "@/components/question-bank-panel-activator"
14
17
  import { QuestionBankPageHeader } from "@/components/question-bank-page-header"
15
18
  import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/question-bank-table"
19
+ import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
20
+ import { useSecondaryPanel } from "@/components/secondary-panel"
16
21
  import { KeyMetrics } from "@/components/key-metrics"
17
22
  import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
23
+ import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
18
24
  import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
25
+ import {
26
+ filterQuestionBankItemsByNav,
27
+ parseQuestionBankNav,
28
+ questionBankHubHeaderModel,
29
+ } from "@/lib/question-bank-nav"
19
30
 
20
31
  const DEFAULT_TABS: ViewTab[] = [
21
32
  {
@@ -25,53 +36,160 @@ const DEFAULT_TABS: ViewTab[] = [
25
36
  icon: "fa-table",
26
37
  filterId: "all",
27
38
  },
39
+ {
40
+ id: "panel-view",
41
+ label: "Panel",
42
+ viewType: "panel",
43
+ icon: "fa-columns",
44
+ filterId: "all",
45
+ },
46
+ {
47
+ id: "tree-panel",
48
+ label: "Tree",
49
+ viewType: "tree-panel",
50
+ icon: "fa-sitemap",
51
+ filterId: "all",
52
+ },
28
53
  ]
29
54
 
55
+ function questionBankQueryPrefixFromSearchString(qs: string) {
56
+ return qs ? `?${qs}` : ""
57
+ }
58
+
30
59
  export function QuestionBankClient() {
60
+ const pathname = usePathname()
61
+ const router = useRouter()
62
+ const searchParams = useSearchParams()
63
+ const { openPanel } = useSecondaryPanel()
64
+ const [tabs, setTabs] = React.useState<ViewTab[]>(DEFAULT_TABS)
65
+ const [activeTabId, setActiveTabId] = React.useState(DEFAULT_TABS[0].id)
66
+
67
+ const tabIds = React.useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
68
+
69
+ /** String key — `useSearchParams()` identity can stay stable when only the query changes. */
70
+ const searchParamsKey = searchParams.toString()
71
+ const navState = React.useMemo(
72
+ () => parseQuestionBankNav(new URLSearchParams(searchParamsKey)),
73
+ [searchParamsKey],
74
+ )
75
+
76
+ /** “All questions” hub — keep secondary nav open when scope clears (breadcrumb, All questions link). */
77
+ React.useEffect(() => {
78
+ if (pathname !== "/question-bank") return
79
+ if (navState.scope !== "all") return
80
+ openPanel("question-bank")
81
+ }, [pathname, navState.scope, openPanel])
82
+
83
+ React.useEffect(() => {
84
+ if (pathname !== "/question-bank") return
85
+ const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
86
+ const apply = () => {
87
+ const raw = typeof window !== "undefined" ? window.location.hash.slice(1) : ""
88
+ let nextId = "questions"
89
+ if (raw === "panel-view" || raw === "tree-panel") {
90
+ nextId = raw
91
+ } else if (raw && tabIds.has(raw)) {
92
+ nextId = raw
93
+ }
94
+ setActiveTabId(nextId)
95
+ if (nextId === "questions" && raw && raw !== "questions") {
96
+ router.replace(`/question-bank${prefix}`, { scroll: false })
97
+ }
98
+ }
99
+ apply()
100
+ window.addEventListener("hashchange", apply)
101
+ return () => window.removeEventListener("hashchange", apply)
102
+ }, [pathname, router, tabIds, searchParamsKey])
103
+
104
+ const onActiveTabChange = React.useCallback(
105
+ (id: string) => {
106
+ setActiveTabId(id)
107
+ if (pathname !== "/question-bank") return
108
+ const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
109
+ if (id === "questions") {
110
+ router.replace(`/question-bank${prefix}`, { scroll: false })
111
+ } else {
112
+ router.replace(`/question-bank${prefix}#${id}`, { scroll: false })
113
+ }
114
+ },
115
+ [pathname, router, searchParamsKey],
116
+ )
117
+
31
118
  const [exportOpen, setExportOpen] = React.useState(false)
32
119
  const [showMetrics, setShowMetrics] = React.useState(true)
33
120
  const tableRef = React.useRef<QuestionBankTableHandle>(null)
34
- const count = QUESTION_BANK_ITEMS.length
121
+ const [items, setItems] = React.useState(() => QUESTION_BANK_ITEMS.map(q => ({ ...q })))
122
+ const [folders, setFolders] = React.useState(() => DEFAULT_QUESTION_BANK_FOLDERS.map(f => ({ ...f })))
35
123
 
36
- const metrics = React.useMemo(() => questionBankKpiMetrics(QUESTION_BANK_ITEMS), [])
37
- const insight = React.useMemo(() => questionBankKpiInsight(QUESTION_BANK_ITEMS), [])
124
+ const filteredItems = React.useMemo(
125
+ () => filterQuestionBankItemsByNav(items, folders, navState),
126
+ [items, folders, navState],
127
+ )
128
+
129
+ const count = filteredItems.length
130
+
131
+ const metrics = React.useMemo(() => questionBankKpiMetrics(filteredItems), [filteredItems])
132
+ const insight = React.useMemo(() => questionBankKpiInsight(filteredItems), [filteredItems])
133
+
134
+ const hubHeader = React.useMemo(
135
+ () => questionBankHubHeaderModel(folders, navState),
136
+ [folders, navState],
137
+ )
38
138
 
39
139
  return (
40
- <ListPageTemplate
41
- defaultTabs={DEFAULT_TABS}
42
- getTabCount={() => count}
43
- tablePropertiesRef={tableRef}
44
- header={(
45
- <QuestionBankPageHeader
46
- questionCount={count}
47
- onNewQuestion={() => {}}
48
- onExport={() => setExportOpen(true)}
49
- showMetrics={showMetrics}
50
- onToggleMetrics={() => setShowMetrics(v => !v)}
51
- />
52
- )}
53
- metrics={(
54
- <KeyMetrics
55
- variant="flat"
56
- metrics={metrics}
57
- insight={insight}
58
- showHeader={false}
59
- metricsSingleRow
60
- />
61
- )}
62
- showMetrics={showMetrics}
63
- exportOpen={exportOpen}
64
- onExportOpenChange={setExportOpen}
65
- exportTotalRows={count}
66
- renderContent={(tab, updateTab) => (
67
- <QuestionBankTable
68
- key={tab.id}
69
- ref={tableRef}
70
- items={QUESTION_BANK_ITEMS}
71
- view={tab.viewType}
72
- onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
73
- />
74
- )}
75
- />
140
+ <PrimaryPageTemplate
141
+ beforeSiteHeader={<QuestionBankPanelActivator />}
142
+ siteHeader={{
143
+ title: hubHeader.title,
144
+ breadcrumbs: hubHeader.breadcrumbs,
145
+ }}
146
+ >
147
+ <ListPageTemplate
148
+ defaultTabs={DEFAULT_TABS}
149
+ tabs={tabs}
150
+ onTabsChange={setTabs}
151
+ activeTabId={activeTabId}
152
+ onActiveTabChange={onActiveTabChange}
153
+ getTabCount={() => count}
154
+ tablePropertiesRef={tableRef}
155
+ header={(
156
+ <QuestionBankPageHeader
157
+ variant="collaboration"
158
+ title={hubHeader.title}
159
+ questionCount={count}
160
+ onNewQuestion={() => {}}
161
+ onExport={() => setExportOpen(true)}
162
+ showMetrics={showMetrics}
163
+ onToggleMetrics={() => setShowMetrics(v => !v)}
164
+ />
165
+ )}
166
+ metrics={(
167
+ <KeyMetrics
168
+ variant="flat"
169
+ metrics={metrics}
170
+ insight={insight}
171
+ showHeader={false}
172
+ metricsSingleRow
173
+ />
174
+ )}
175
+ showMetrics={showMetrics}
176
+ exportOpen={exportOpen}
177
+ onExportOpenChange={setExportOpen}
178
+ exportTotalRows={count}
179
+ renderContent={(tab, updateTab) => (
180
+ <QuestionBankTable
181
+ key={tab.id}
182
+ ref={tableRef}
183
+ items={items}
184
+ navState={navState}
185
+ folders={folders}
186
+ onFoldersChange={setFolders}
187
+ onItemsChange={setItems}
188
+ view={tab.viewType}
189
+ onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
190
+ />
191
+ )}
192
+ />
193
+ </PrimaryPageTemplate>
76
194
  )
77
195
  }