@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.
- package/bin/cli.mjs +176 -0
- package/bin/init.mjs +15 -1
- package/bin/sync-extras.mjs +65 -0
- package/consumer-extras/README.md +21 -0
- package/consumer-extras/cursor-skills/exxat-accessibility/SKILL.md +282 -0
- package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +68 -0
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +99 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +713 -0
- package/consumer-extras/cursor-skills/exxat-fontawesome-icons/SKILL.md +31 -0
- package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +36 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +27 -0
- package/consumer-extras/patterns/command-menu-pattern.md +45 -0
- package/consumer-extras/patterns/data-views-pattern.md +167 -0
- package/package.json +7 -3
- package/src/components/ui/sidebar.tsx +7 -2
- package/template/.agents/skills/shadcn/SKILL.md +242 -0
- package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
- package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
- package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
- package/template/.agents/skills/shadcn/cli.md +257 -0
- package/template/.agents/skills/shadcn/customization.md +202 -0
- package/template/.agents/skills/shadcn/evals/evals.json +47 -0
- package/template/.agents/skills/shadcn/mcp.md +94 -0
- package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
- package/template/.agents/skills/shadcn/rules/composition.md +195 -0
- package/template/.agents/skills/shadcn/rules/forms.md +192 -0
- package/template/.agents/skills/shadcn/rules/icons.md +101 -0
- package/template/.agents/skills/shadcn/rules/styling.md +162 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
- package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
- package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
- package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
- package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
- package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
- package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
- package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
- package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
- package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
- package/template/AGENTS.md +52 -11
- package/template/app/(app)/dashboard/page.tsx +1 -1
- package/template/app/(app)/data-list/[id]/page.tsx +24 -8
- package/template/app/(app)/data-list/new/page.tsx +7 -4
- package/template/app/(app)/data-list/page.tsx +1 -1
- package/template/app/(app)/examples/page.tsx +41 -0
- package/template/app/(app)/question-bank/page.tsx +3 -3
- package/template/app/globals.css +1 -1
- package/template/components/app-sidebar.tsx +52 -35
- package/template/components/compliance-table.tsx +79 -0
- package/template/components/data-list-client.tsx +36 -25
- package/template/components/data-list-table.tsx +797 -10
- package/template/components/data-views/finder-panel-view.tsx +405 -0
- package/template/components/data-views/folder-grid-view.tsx +86 -0
- package/template/components/data-views/index.ts +59 -0
- package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
- package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
- package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
- package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
- package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
- package/template/components/data-views/list-page-view-frame.tsx +53 -0
- package/template/components/data-views/os-folder-glyph.tsx +121 -0
- package/template/components/folder-details-shell.tsx +230 -0
- package/template/components/hub-tree-panel-view.tsx +672 -0
- package/template/components/list-hub-status-badge.tsx +17 -3
- package/template/components/page-header.tsx +149 -7
- package/template/components/placements-page-header.tsx +14 -8
- package/template/components/placements-table-columns.tsx +8 -8
- package/template/components/question-bank-client.tsx +157 -39
- package/template/components/question-bank-new-folder-sheet.tsx +248 -0
- package/template/components/question-bank-os-folder-view.tsx +648 -0
- package/template/components/question-bank-page-header.tsx +31 -2
- package/template/components/question-bank-panel-activator.tsx +9 -0
- package/template/components/question-bank-secondary-nav.tsx +226 -0
- package/template/components/question-bank-table.tsx +707 -22
- package/template/components/secondary-panel.tsx +41 -107
- package/template/components/sites-table.tsx +66 -0
- package/template/components/team-client.tsx +7 -0
- package/template/components/team-table.tsx +156 -1
- package/template/components/templates/list-page.tsx +2 -2
- package/template/components/ui/avatar.tsx +1 -1
- package/template/components/ui/badge.tsx +1 -1
- package/template/components/ui/banner.tsx +1 -1
- package/template/components/ui/breadcrumb.tsx +1 -1
- package/template/components/ui/button.tsx +1 -1
- package/template/components/ui/calendar.tsx +1 -1
- package/template/components/ui/card.tsx +1 -1
- package/template/components/ui/chart.tsx +1 -1
- package/template/components/ui/checkbox.tsx +1 -1
- package/template/components/ui/coach-mark.tsx +1 -1
- package/template/components/ui/collapsible.tsx +1 -1
- package/template/components/ui/command.tsx +1 -1
- package/template/components/ui/date-picker-field.tsx +1 -1
- package/template/components/ui/dialog.tsx +1 -1
- package/template/components/ui/drag-handle-grip.tsx +1 -1
- package/template/components/ui/drawer.tsx +1 -1
- package/template/components/ui/dropdown-menu.tsx +1 -1
- package/template/components/ui/field.tsx +1 -1
- package/template/components/ui/form.tsx +1 -1
- package/template/components/ui/input-group.tsx +1 -1
- package/template/components/ui/input-mask.tsx +1 -1
- package/template/components/ui/input.tsx +1 -1
- package/template/components/ui/kbd.tsx +1 -1
- package/template/components/ui/label.tsx +1 -1
- package/template/components/ui/payment-card-fields.tsx +1 -1
- package/template/components/ui/popover.tsx +1 -1
- package/template/components/ui/radio-group.tsx +1 -1
- package/template/components/ui/resizable.tsx +68 -0
- package/template/components/ui/select.tsx +1 -1
- package/template/components/ui/selection-tile-grid.tsx +1 -1
- package/template/components/ui/separator.tsx +1 -1
- package/template/components/ui/sheet.tsx +1 -1
- package/template/components/ui/sidebar.tsx +1 -1
- package/template/components/ui/skeleton.tsx +1 -1
- package/template/components/ui/sonner.tsx +1 -1
- package/template/components/ui/status-badge.tsx +1 -1
- package/template/components/ui/table.tsx +1 -1
- package/template/components/ui/tabs.tsx +1 -1
- package/template/components/ui/textarea.tsx +1 -1
- package/template/components/ui/tip.tsx +1 -1
- package/template/components/ui/toggle-group.tsx +1 -1
- package/template/components/ui/toggle-switch.tsx +1 -1
- package/template/components/ui/toggle.tsx +1 -1
- package/template/components/ui/tooltip.tsx +1 -1
- package/template/components/ui/view-segmented-control.tsx +1 -1
- package/template/docs/data-views-pattern.md +7 -0
- package/template/hooks/use-app-theme.ts +1 -1
- package/template/hooks/use-coach-mark.ts +1 -1
- package/template/hooks/use-location-hash.ts +15 -0
- package/template/hooks/use-mobile.ts +1 -1
- package/template/hooks/use-mod-key-label.ts +1 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
- package/template/lib/ask-leo-route-context.ts +25 -57
- package/template/lib/coach-mark-registry.ts +13 -13
- package/template/lib/command-menu-config.ts +28 -23
- package/template/lib/command-menu-search-data.ts +10 -9
- package/template/lib/data-list-view-surface.ts +12 -1
- package/template/lib/data-list-view.ts +6 -3
- package/template/lib/date-filter.ts +1 -1
- package/template/lib/mock/dashboard.ts +11 -11
- package/template/lib/mock/navigation.tsx +22 -63
- package/template/lib/mock/placements-kpi.ts +19 -19
- package/template/lib/mock/question-bank-folders.ts +167 -0
- package/template/lib/mock/question-bank-header-collaborators.ts +14 -0
- package/template/lib/mock/question-bank-inspector.ts +109 -0
- package/template/lib/mock/question-bank-kpi.ts +1 -1
- package/template/lib/mock/question-bank.ts +80 -0
- package/template/lib/question-bank-nav.ts +91 -0
- package/template/lib/utils.ts +1 -1
- package/template/next.config.mjs +8 -0
- package/template/package.json +1 -0
- package/template/public/folders/icons8-folder-windows-11.svg +1 -0
- package/template/app/(app)/compliance/page.tsx +0 -10
- package/template/app/(app)/rotations/page.tsx +0 -15
- package/template/app/(app)/sites/all/page.tsx +0 -13
- 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` —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
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
|
-
*
|
|
33
|
-
* Reusable for any
|
|
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
|
-
|
|
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=
|
|
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="
|
|
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>
|
|
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
|
-
|
|
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
|
|
624
|
+
return "No rows in this segment match your filters."
|
|
625
625
|
case "ongoing":
|
|
626
|
-
return "No
|
|
626
|
+
return "No rows in this segment match your filters."
|
|
627
627
|
case "completed":
|
|
628
|
-
return "No
|
|
628
|
+
return "No rows in this segment match your filters."
|
|
629
629
|
default:
|
|
630
|
-
return "No
|
|
630
|
+
return "No rows match your filters."
|
|
631
631
|
}
|
|
632
632
|
}
|
|
633
633
|
|
|
634
634
|
export const placementLifecycleDrawerLabels: Record<PlacementLifecycleTabId, string> = {
|
|
635
|
-
all: "
|
|
636
|
-
upcoming: "
|
|
637
|
-
ongoing: "
|
|
638
|
-
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
|
|
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
|
|
37
|
-
|
|
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
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
}
|