@exxatdesignux/ui 0.2.9 → 0.2.11
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/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
- package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
- package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
- package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
- package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
- package/consumer-extras/patterns/data-views-pattern.md +12 -4
- package/package.json +4 -1
- package/src/components/ui/banner.tsx +20 -7
- package/src/components/ui/date-picker-field.tsx +3 -3
- package/src/components/ui/dropdown-menu.tsx +17 -6
- package/src/components/ui/input-group.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/separator.tsx +2 -2
- package/src/components/ui/sidebar.tsx +31 -3
- package/src/components/ui/textarea.tsx +1 -1
- package/src/globals.css +0 -1
- package/src/index.ts +1 -0
- package/src/lib/date-filter.ts +13 -4
- package/src/lib/dropdown-menu-surface.ts +13 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
- package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
- package/template/.nvmrc +1 -1
- package/template/AGENTS.md +82 -27
- package/template/app/(app)/examples/page.tsx +2 -1
- package/template/app/(app)/help/page.tsx +6 -0
- package/template/app/(app)/layout.tsx +7 -4
- package/template/app/(app)/question-bank/find/page.tsx +12 -0
- package/template/app/(app)/question-bank/layout.tsx +46 -0
- package/template/app/(app)/question-bank/library/page.tsx +11 -0
- package/template/app/(app)/question-bank/list/page.tsx +12 -0
- package/template/app/(app)/question-bank/page.tsx +4 -3
- package/template/app/globals.css +1 -2
- package/template/components/app-sidebar.tsx +51 -13
- package/template/components/ask-leo-composer.tsx +173 -45
- package/template/components/ask-leo-sidebar.tsx +9 -1
- package/template/components/chart-area-interactive.tsx +3 -13
- package/template/components/charts-overview.tsx +33 -6
- package/template/components/collaboration-access-flow.tsx +144 -0
- package/template/components/compliance-page-header.tsx +1 -1
- package/template/components/compliance-table.tsx +2 -2
- package/template/components/dashboard-tabs.tsx +4 -3
- package/template/components/data-list-table-cells.tsx +1 -1
- package/template/components/data-list-table.tsx +1 -1
- package/template/components/data-table/index.tsx +5 -5
- package/template/components/data-table/use-table-state.ts +18 -2
- package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
- package/template/components/data-view-dashboard-charts-team.tsx +8 -5
- package/template/components/data-view-dashboard-charts.tsx +62 -227
- package/template/components/dedicated-search-recents.tsx +96 -0
- package/template/components/dedicated-search-url-composer.tsx +112 -0
- package/template/components/getting-started.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +10 -26
- package/template/components/invite-collaborators-drawer.tsx +453 -0
- package/template/components/key-metrics.tsx +54 -8
- package/template/components/nav-documents.tsx +1 -1
- package/template/components/new-placement-form.tsx +3 -3
- package/template/components/page-header.tsx +76 -59
- package/template/components/placements-board-view.tsx +3 -3
- package/template/components/placements-page-header.tsx +1 -1
- package/template/components/placements-table-columns.tsx +3 -2
- package/template/components/product-switcher.tsx +0 -1
- package/template/components/question-bank-board-view.tsx +35 -47
- package/template/components/question-bank-client.tsx +293 -81
- package/template/components/question-bank-dashboard-charts.tsx +174 -0
- package/template/components/question-bank-favorite-button.tsx +46 -0
- package/template/components/question-bank-hub-client.tsx +436 -0
- package/template/components/question-bank-list-view.tsx +26 -19
- package/template/components/question-bank-new-folder-sheet.tsx +56 -42
- package/template/components/question-bank-os-folder-view.tsx +3 -14
- package/template/components/question-bank-page-header.tsx +85 -53
- package/template/components/question-bank-panel-activator.tsx +3 -4
- package/template/components/question-bank-secondary-nav.tsx +523 -65
- package/template/components/question-bank-table.tsx +125 -343
- package/template/components/secondary-panel.tsx +130 -63
- package/template/components/settings-client.tsx +3 -1
- package/template/components/sidebar-shell.tsx +2 -0
- package/template/components/sites-all-client.tsx +1 -1
- package/template/components/sites-table.tsx +1 -1
- package/template/components/system-banner-slot.tsx +2 -1
- package/template/components/table-properties/drawer.tsx +3 -3
- package/template/components/table-properties/sort-card.tsx +1 -1
- package/template/components/team-page-header.tsx +1 -1
- package/template/components/team-table.tsx +8 -4
- package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
- package/template/components/templates/dedicated-search-results-template.tsx +19 -0
- package/template/components/templates/discovery-hub-template.tsx +273 -0
- package/template/components/templates/list-page.tsx +11 -4
- package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
- package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
- package/template/docs/card-vs-rows-pattern.md +36 -0
- package/template/docs/collaboration-access-pattern.md +114 -0
- package/template/docs/data-views-pattern.md +12 -4
- package/template/docs/drawer-vs-dialog-pattern.md +50 -0
- package/template/docs/kpi-strip-max-four-pattern.md +29 -0
- package/template/docs/kpi-trend-pattern.md +43 -0
- package/template/fontawesome-subset.manifest.json +2 -2
- package/template/hooks/use-location-hash.ts +14 -8
- package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
- package/template/lib/ask-leo-route-context.ts +24 -0
- package/template/lib/collaborator-access.ts +92 -0
- package/template/lib/command-menu-config.ts +8 -1
- package/template/lib/command-menu-search-data.ts +11 -8
- package/template/lib/data-list-display-options.ts +1 -1
- package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
- package/template/lib/date-filter.ts +1 -0
- package/template/lib/dedicated-search-recents.ts +76 -0
- package/template/lib/dedicated-search-url.ts +23 -0
- package/template/lib/discovery-hub.ts +15 -0
- package/template/lib/list-status-badges.ts +1 -21
- package/template/lib/mock/navigation.tsx +4 -2
- package/template/lib/mock/placements.ts +9 -9
- package/template/lib/mock/question-bank-folders.ts +7 -0
- package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
- package/template/lib/mock/question-bank-inspector.ts +1 -2
- package/template/lib/mock/question-bank-kpi.ts +38 -26
- package/template/lib/mock/question-bank.ts +43 -16
- package/template/lib/question-bank-dedicated-search.ts +19 -0
- package/template/lib/question-bank-hub-search.ts +90 -0
- package/template/lib/question-bank-nav.ts +322 -6
- package/template/lib/question-bank-recent-searches.ts +22 -0
- package/template/package.json +1 -2
|
@@ -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"
|
|
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"
|
|
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
|
|
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).
|