@exxatdesignux/ui 0.2.6 → 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 (139) hide show
  1. package/package.json +2 -1
  2. package/template/.agents/skills/shadcn/SKILL.md +242 -0
  3. package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
  4. package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
  5. package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
  6. package/template/.agents/skills/shadcn/cli.md +257 -0
  7. package/template/.agents/skills/shadcn/customization.md +202 -0
  8. package/template/.agents/skills/shadcn/evals/evals.json +47 -0
  9. package/template/.agents/skills/shadcn/mcp.md +94 -0
  10. package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
  11. package/template/.agents/skills/shadcn/rules/composition.md +195 -0
  12. package/template/.agents/skills/shadcn/rules/forms.md +192 -0
  13. package/template/.agents/skills/shadcn/rules/icons.md +101 -0
  14. package/template/.agents/skills/shadcn/rules/styling.md +162 -0
  15. package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
  16. package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
  17. package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
  18. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
  19. package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
  20. package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
  21. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
  22. package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
  23. package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
  24. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
  25. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
  26. package/template/AGENTS.md +52 -11
  27. package/template/app/(app)/dashboard/page.tsx +1 -1
  28. package/template/app/(app)/data-list/[id]/page.tsx +24 -8
  29. package/template/app/(app)/data-list/new/page.tsx +7 -4
  30. package/template/app/(app)/data-list/page.tsx +1 -1
  31. package/template/app/(app)/examples/page.tsx +41 -0
  32. package/template/app/(app)/question-bank/page.tsx +3 -3
  33. package/template/app/globals.css +1 -1
  34. package/template/components/app-sidebar.tsx +52 -35
  35. package/template/components/compliance-table.tsx +79 -0
  36. package/template/components/data-list-client.tsx +36 -25
  37. package/template/components/data-list-table.tsx +797 -10
  38. package/template/components/data-views/finder-panel-view.tsx +405 -0
  39. package/template/components/data-views/folder-grid-view.tsx +86 -0
  40. package/template/components/data-views/index.ts +59 -0
  41. package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  42. package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  43. package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
  44. package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
  45. package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  46. package/template/components/data-views/list-page-view-frame.tsx +53 -0
  47. package/template/components/data-views/os-folder-glyph.tsx +121 -0
  48. package/template/components/folder-details-shell.tsx +230 -0
  49. package/template/components/hub-tree-panel-view.tsx +672 -0
  50. package/template/components/list-hub-status-badge.tsx +17 -3
  51. package/template/components/placements-page-header.tsx +14 -8
  52. package/template/components/placements-table-columns.tsx +8 -8
  53. package/template/components/question-bank-client.tsx +157 -40
  54. package/template/components/question-bank-new-folder-sheet.tsx +248 -0
  55. package/template/components/question-bank-os-folder-view.tsx +648 -0
  56. package/template/components/question-bank-page-header.tsx +3 -3
  57. package/template/components/question-bank-panel-activator.tsx +9 -0
  58. package/template/components/question-bank-secondary-nav.tsx +226 -0
  59. package/template/components/question-bank-table.tsx +707 -22
  60. package/template/components/secondary-panel.tsx +41 -107
  61. package/template/components/sites-table.tsx +66 -0
  62. package/template/components/team-client.tsx +7 -0
  63. package/template/components/team-table.tsx +156 -1
  64. package/template/components/templates/list-page.tsx +2 -2
  65. package/template/components/ui/avatar.tsx +1 -1
  66. package/template/components/ui/badge.tsx +1 -1
  67. package/template/components/ui/banner.tsx +1 -1
  68. package/template/components/ui/breadcrumb.tsx +1 -1
  69. package/template/components/ui/button.tsx +1 -1
  70. package/template/components/ui/calendar.tsx +1 -1
  71. package/template/components/ui/card.tsx +1 -1
  72. package/template/components/ui/chart.tsx +1 -1
  73. package/template/components/ui/checkbox.tsx +1 -1
  74. package/template/components/ui/coach-mark.tsx +1 -1
  75. package/template/components/ui/collapsible.tsx +1 -1
  76. package/template/components/ui/command.tsx +1 -1
  77. package/template/components/ui/date-picker-field.tsx +1 -1
  78. package/template/components/ui/dialog.tsx +1 -1
  79. package/template/components/ui/drag-handle-grip.tsx +1 -1
  80. package/template/components/ui/drawer.tsx +1 -1
  81. package/template/components/ui/dropdown-menu.tsx +1 -1
  82. package/template/components/ui/field.tsx +1 -1
  83. package/template/components/ui/form.tsx +1 -1
  84. package/template/components/ui/input-group.tsx +1 -1
  85. package/template/components/ui/input-mask.tsx +1 -1
  86. package/template/components/ui/input.tsx +1 -1
  87. package/template/components/ui/kbd.tsx +1 -1
  88. package/template/components/ui/label.tsx +1 -1
  89. package/template/components/ui/payment-card-fields.tsx +1 -1
  90. package/template/components/ui/popover.tsx +1 -1
  91. package/template/components/ui/radio-group.tsx +1 -1
  92. package/template/components/ui/resizable.tsx +68 -0
  93. package/template/components/ui/select.tsx +1 -1
  94. package/template/components/ui/selection-tile-grid.tsx +1 -1
  95. package/template/components/ui/separator.tsx +1 -1
  96. package/template/components/ui/sheet.tsx +1 -1
  97. package/template/components/ui/sidebar.tsx +1 -1
  98. package/template/components/ui/skeleton.tsx +1 -1
  99. package/template/components/ui/sonner.tsx +1 -1
  100. package/template/components/ui/status-badge.tsx +1 -1
  101. package/template/components/ui/table.tsx +1 -1
  102. package/template/components/ui/tabs.tsx +1 -1
  103. package/template/components/ui/textarea.tsx +1 -1
  104. package/template/components/ui/tip.tsx +1 -1
  105. package/template/components/ui/toggle-group.tsx +1 -1
  106. package/template/components/ui/toggle-switch.tsx +1 -1
  107. package/template/components/ui/toggle.tsx +1 -1
  108. package/template/components/ui/tooltip.tsx +1 -1
  109. package/template/components/ui/view-segmented-control.tsx +1 -1
  110. package/template/docs/data-views-pattern.md +7 -0
  111. package/template/hooks/use-app-theme.ts +1 -1
  112. package/template/hooks/use-coach-mark.ts +1 -1
  113. package/template/hooks/use-location-hash.ts +15 -0
  114. package/template/hooks/use-mobile.ts +1 -1
  115. package/template/hooks/use-mod-key-label.ts +1 -1
  116. package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
  117. package/template/lib/ask-leo-route-context.ts +25 -57
  118. package/template/lib/coach-mark-registry.ts +13 -13
  119. package/template/lib/command-menu-config.ts +28 -23
  120. package/template/lib/command-menu-search-data.ts +10 -9
  121. package/template/lib/data-list-view-surface.ts +12 -1
  122. package/template/lib/data-list-view.ts +6 -3
  123. package/template/lib/date-filter.ts +1 -1
  124. package/template/lib/mock/dashboard.ts +11 -11
  125. package/template/lib/mock/navigation.tsx +22 -63
  126. package/template/lib/mock/placements-kpi.ts +19 -19
  127. package/template/lib/mock/question-bank-folders.ts +167 -0
  128. package/template/lib/mock/question-bank-inspector.ts +109 -0
  129. package/template/lib/mock/question-bank-kpi.ts +1 -1
  130. package/template/lib/mock/question-bank.ts +80 -0
  131. package/template/lib/question-bank-nav.ts +91 -0
  132. package/template/lib/utils.ts +1 -1
  133. package/template/next.config.mjs +8 -0
  134. package/template/package.json +1 -0
  135. package/template/public/folders/icons8-folder-windows-11.svg +1 -0
  136. package/template/app/(app)/compliance/page.tsx +0 -10
  137. package/template/app/(app)/rotations/page.tsx +0 -15
  138. package/template/app/(app)/sites/all/page.tsx +0 -13
  139. package/template/app/(app)/team/page.tsx +0 -10
@@ -0,0 +1,405 @@
1
+ "use client"
2
+
3
+ /**
4
+ * FinderPanelView — Miller-style 3-column split for list-page hubs.
5
+ *
6
+ * Visual shell matches Question bank panel (`ListPageTreeColumnHeader`, `bg-muted/15` columns,
7
+ * shared resizable handles) — see `list-page-split-hub-tokens.ts`.
8
+ */
9
+
10
+ import * as React from "react"
11
+ import { cn } from "@/lib/utils"
12
+ import {
13
+ ResizableHandle,
14
+ ResizablePanel,
15
+ ResizablePanelGroup,
16
+ } from "@/components/ui/resizable"
17
+ import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
18
+ import { ListPageSplitDetailsPlaceholder } from "@/components/data-views/list-page-split-details-placeholder"
19
+ import {
20
+ LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
21
+ LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
22
+ LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
23
+ } from "@/components/data-views/list-page-split-hub-tokens"
24
+
25
+ export interface FinderGroup {
26
+ id: string
27
+ label: string
28
+ icon?: string
29
+ accent?: string
30
+ count: number
31
+ }
32
+
33
+ export interface FinderPanelViewProps<T> {
34
+ groups: FinderGroup[]
35
+ rows: T[]
36
+ getRowId: (row: T) => string | number
37
+ getRowGroupId: (row: T) => string
38
+ renderListRow: (row: T, isSelected: boolean) => React.ReactNode
39
+ renderDetail: (row: T) => React.ReactNode
40
+ emptyDetail?: React.ReactNode
41
+ emptyList?: React.ReactNode
42
+ defaultGroupId?: string
43
+ ariaLabel?: string
44
+ autoSaveId?: string
45
+ className?: string
46
+ style?: React.CSSProperties
47
+ onAddItem?: () => void
48
+ /**
49
+ * When true, omit outer margin, border, and card fill so the grid fits inside
50
+ * `ListPageSplitHubChrome` (shared split surface across hubs).
51
+ */
52
+ embedded?: boolean
53
+ /** Left column title (Question bank: “Categories”). */
54
+ groupsColumnTitle?: string
55
+ /** Middle column title; defaults from the active group label. */
56
+ getListColumnTitle?: (activeGroup: FinderGroup | undefined) => string
57
+ }
58
+
59
+ export function GroupsColumn({
60
+ groups,
61
+ selectedGroupId,
62
+ onSelect,
63
+ columnTitle,
64
+ }: {
65
+ groups: FinderGroup[]
66
+ selectedGroupId: string
67
+ onSelect: (id: string) => void
68
+ columnTitle: string
69
+ }) {
70
+ return (
71
+ <div className="flex h-full min-h-0 flex-col overflow-hidden">
72
+ <ListPageTreeColumnHeader title={columnTitle} />
73
+ <div
74
+ className="min-h-0 flex-1 overflow-y-auto py-1"
75
+ role="listbox"
76
+ aria-label="Group navigation"
77
+ aria-multiselectable="false"
78
+ >
79
+ {groups.map(group => {
80
+ const isSelected = group.id === selectedGroupId
81
+ return (
82
+ <div key={group.id} className="group flex items-center hover:bg-muted/50">
83
+ <button
84
+ type="button"
85
+ role="option"
86
+ aria-selected={isSelected}
87
+ onClick={() => onSelect(group.id)}
88
+ className={cn(
89
+ "flex w-full flex-1 items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-75",
90
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
91
+ isSelected
92
+ ? "bg-accent font-medium text-accent-foreground"
93
+ : "text-foreground",
94
+ )}
95
+ >
96
+ {group.accent ? (
97
+ <span className={cn("size-2 shrink-0 rounded-full", group.accent)} aria-hidden="true" />
98
+ ) : group.icon ? (
99
+ <i
100
+ className={cn(
101
+ "fa-light shrink-0 text-xs",
102
+ group.icon,
103
+ isSelected ? "text-accent-foreground" : "text-muted-foreground",
104
+ )}
105
+ aria-hidden="true"
106
+ />
107
+ ) : null}
108
+ <span className="min-w-0 flex-1 truncate leading-tight">{group.label}</span>
109
+ <span
110
+ className={cn(
111
+ "shrink-0 tabular-nums text-xs",
112
+ isSelected ? "text-accent-foreground/80" : "text-muted-foreground",
113
+ )}
114
+ >
115
+ {group.count}
116
+ </span>
117
+ </button>
118
+ </div>
119
+ )
120
+ })}
121
+ </div>
122
+ </div>
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Horizontal scope strip (toolbar toggles). Optional: place above a hub body when you need
128
+ * status scope without consuming a Finder column.
129
+ */
130
+ export function FinderGroupStrip({
131
+ groups,
132
+ selectedGroupId,
133
+ onSelect,
134
+ ariaLabel = "Scope",
135
+ }: {
136
+ groups: FinderGroup[]
137
+ selectedGroupId: string
138
+ onSelect: (id: string) => void
139
+ ariaLabel?: string
140
+ }) {
141
+ return (
142
+ <div
143
+ role="toolbar"
144
+ aria-label={ariaLabel}
145
+ className="flex min-h-10 flex-wrap items-center gap-1.5 border-b border-border bg-muted/15 px-2 py-2"
146
+ >
147
+ {groups.map(group => {
148
+ const isSelected = group.id === selectedGroupId
149
+ return (
150
+ <button
151
+ key={group.id}
152
+ type="button"
153
+ aria-pressed={isSelected}
154
+ onClick={() => onSelect(group.id)}
155
+ className={cn(
156
+ "inline-flex h-8 max-w-full min-w-0 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors",
157
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
158
+ isSelected
159
+ ? "border-brand/40 bg-accent text-accent-foreground"
160
+ : "border-transparent bg-background/80 text-foreground hover:bg-muted",
161
+ )}
162
+ >
163
+ {group.accent ? (
164
+ <span className={cn("size-2 shrink-0 rounded-full", group.accent)} aria-hidden="true" />
165
+ ) : group.icon ? (
166
+ <i
167
+ className={cn(
168
+ "fa-light shrink-0 text-[11px]",
169
+ group.icon,
170
+ isSelected ? "text-accent-foreground" : "text-muted-foreground",
171
+ )}
172
+ aria-hidden="true"
173
+ />
174
+ ) : null}
175
+ <span className="min-w-0 truncate">{group.label}</span>
176
+ <span
177
+ className={cn(
178
+ "shrink-0 tabular-nums text-[11px]",
179
+ isSelected ? "text-accent-foreground/85" : "text-muted-foreground",
180
+ )}
181
+ >
182
+ {group.count}
183
+ </span>
184
+ </button>
185
+ )
186
+ })}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ function ListColumn<T>({
192
+ rows,
193
+ getRowId,
194
+ selectedId,
195
+ renderListRow,
196
+ onSelect,
197
+ emptyList,
198
+ groupLabel,
199
+ columnTitle,
200
+ onAddItem,
201
+ }: {
202
+ rows: T[]
203
+ getRowId: (row: T) => string | number
204
+ selectedId: string | number | null
205
+ renderListRow: (row: T, isSelected: boolean) => React.ReactNode
206
+ onSelect: (id: string | number) => void
207
+ emptyList?: React.ReactNode
208
+ groupLabel: string
209
+ columnTitle: string
210
+ onAddItem?: () => void
211
+ }) {
212
+ return (
213
+ <div className="flex h-full min-h-0 flex-col overflow-hidden">
214
+ <ListPageTreeColumnHeader title={columnTitle} />
215
+ <div className="flex min-h-0 flex-1 flex-col">
216
+ <div
217
+ className="min-h-0 flex-1 overflow-y-auto py-1"
218
+ role="listbox"
219
+ aria-label={`${groupLabel} items`}
220
+ aria-multiselectable="false"
221
+ >
222
+ {rows.length === 0 ? (
223
+ <div className="flex flex-col items-center justify-center px-4 py-12 text-center text-sm text-muted-foreground">
224
+ {emptyList ?? <p>No items in this group.</p>}
225
+ </div>
226
+ ) : (
227
+ rows.map(row => {
228
+ const id = getRowId(row)
229
+ const isSelected = id === selectedId
230
+ return (
231
+ <div key={id} className="group flex items-center hover:bg-muted/50">
232
+ <button
233
+ type="button"
234
+ role="option"
235
+ aria-selected={isSelected}
236
+ onClick={() => onSelect(id)}
237
+ className={cn(
238
+ "flex w-full flex-1 items-center gap-3 px-3 py-2 text-left text-sm transition-colors duration-75",
239
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
240
+ isSelected
241
+ ? "bg-accent font-medium text-accent-foreground"
242
+ : "text-foreground",
243
+ )}
244
+ >
245
+ {renderListRow(row, isSelected)}
246
+ </button>
247
+ </div>
248
+ )
249
+ })
250
+ )}
251
+ </div>
252
+ {onAddItem ? (
253
+ <div className="flex shrink-0 items-center justify-center border-t border-border/50 px-2 py-1.5">
254
+ <button
255
+ type="button"
256
+ onClick={onAddItem}
257
+ className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
258
+ aria-label={`Add item to ${groupLabel}`}
259
+ >
260
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
261
+ </button>
262
+ </div>
263
+ ) : null}
264
+ </div>
265
+ </div>
266
+ )
267
+ }
268
+
269
+ function DetailColumn({ children }: { children: React.ReactNode }) {
270
+ return (
271
+ <div className={cn(LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS, "overflow-hidden")}>
272
+ <ListPageTreeColumnHeader title="Details" />
273
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
274
+ </div>
275
+ )
276
+ }
277
+
278
+ function DefaultEmptyDetail() {
279
+ return (
280
+ <ListPageSplitDetailsPlaceholder title="Nothing selected" />
281
+ )
282
+ }
283
+
284
+ export function FinderPanelView<T>({
285
+ groups,
286
+ rows,
287
+ getRowId,
288
+ getRowGroupId,
289
+ renderListRow,
290
+ renderDetail,
291
+ emptyDetail,
292
+ emptyList,
293
+ defaultGroupId,
294
+ ariaLabel = "List and details",
295
+ autoSaveId = "finder-panel-view",
296
+ className,
297
+ style,
298
+ onAddItem,
299
+ embedded = false,
300
+ groupsColumnTitle = "Categories",
301
+ getListColumnTitle = g => g?.label ?? "Items",
302
+ }: FinderPanelViewProps<T>) {
303
+ const mergedStyle = React.useMemo<React.CSSProperties>(() => {
304
+ if (embedded) {
305
+ return { height: "100%", minHeight: 0, ...style }
306
+ }
307
+ return {
308
+ height: "calc(100vh - 280px)",
309
+ minHeight: 420,
310
+ ...style,
311
+ }
312
+ }, [embedded, style])
313
+ const firstGroupId = defaultGroupId ?? groups[0]?.id ?? "all"
314
+ const [selectedGroupId, setSelectedGroupId] = React.useState(firstGroupId)
315
+ const [selectedRowId, setSelectedRowId] = React.useState<string | number | null>(null)
316
+
317
+ const visibleRows = React.useMemo(() => {
318
+ if (selectedGroupId === "all") return rows
319
+ return rows.filter(r => getRowGroupId(r) === selectedGroupId)
320
+ }, [rows, selectedGroupId, getRowGroupId])
321
+
322
+ React.useEffect(() => {
323
+ setSelectedRowId(visibleRows[0] ? getRowId(visibleRows[0]) : null)
324
+ }, [selectedGroupId, visibleRows, getRowId])
325
+
326
+ React.useEffect(() => {
327
+ if (selectedRowId !== null && !visibleRows.find(r => getRowId(r) === selectedRowId)) {
328
+ setSelectedRowId(visibleRows[0] ? getRowId(visibleRows[0]) : null)
329
+ }
330
+ }, [visibleRows, selectedRowId, getRowId])
331
+
332
+ const selectedRow =
333
+ selectedRowId !== null
334
+ ? (visibleRows.find(r => getRowId(r) === selectedRowId) ?? null)
335
+ : null
336
+
337
+ const selectedGroup = groups.find(g => g.id === selectedGroupId)
338
+ const listColumnTitle = getListColumnTitle(selectedGroup)
339
+
340
+ return (
341
+ <div
342
+ className={cn(
343
+ embedded
344
+ ? "flex h-full min-h-0 flex-col overflow-hidden"
345
+ : "mx-4 mb-6 overflow-hidden rounded-xl border border-border bg-card lg:mx-6",
346
+ className,
347
+ )}
348
+ style={mergedStyle}
349
+ aria-label={ariaLabel}
350
+ >
351
+ <ResizablePanelGroup
352
+ id={String(autoSaveId)}
353
+ direction="horizontal"
354
+ className="h-full min-h-0 w-full flex-1"
355
+ >
356
+ <ResizablePanel
357
+ id="groups"
358
+ defaultSize="22%"
359
+ minSize="16%"
360
+ maxSize="32%"
361
+ className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}
362
+ >
363
+ <GroupsColumn
364
+ columnTitle={groupsColumnTitle}
365
+ groups={groups}
366
+ selectedGroupId={selectedGroupId}
367
+ onSelect={id => setSelectedGroupId(id)}
368
+ />
369
+ </ResizablePanel>
370
+
371
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
372
+
373
+ <ResizablePanel
374
+ id="list"
375
+ defaultSize="34%"
376
+ minSize="22%"
377
+ maxSize="48%"
378
+ className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}
379
+ >
380
+ <ListColumn
381
+ columnTitle={listColumnTitle}
382
+ rows={visibleRows}
383
+ getRowId={getRowId}
384
+ selectedId={selectedRowId}
385
+ renderListRow={renderListRow}
386
+ onSelect={id => setSelectedRowId(id)}
387
+ emptyList={emptyList}
388
+ groupLabel={selectedGroup?.label ?? "All"}
389
+ onAddItem={onAddItem}
390
+ />
391
+ </ResizablePanel>
392
+
393
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
394
+
395
+ <ResizablePanel id="detail" defaultSize="44%" minSize="30%" className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}>
396
+ <DetailColumn>
397
+ {selectedRow
398
+ ? renderDetail(selectedRow)
399
+ : (emptyDetail ?? <DefaultEmptyDetail />)}
400
+ </DetailColumn>
401
+ </ResizablePanel>
402
+ </ResizablePanelGroup>
403
+ </div>
404
+ )
405
+ }
@@ -0,0 +1,86 @@
1
+ "use client"
2
+
3
+ /**
4
+ * FolderGridView — generic icon-grid layout for any list-page hub.
5
+ *
6
+ * Handles the responsive CSS grid shell, empty state, and accessibility list role.
7
+ * Pass a `renderTile` render-prop for domain-specific tile content
8
+ * (Placements, Team, Rotations, etc.).
9
+ *
10
+ * Usage:
11
+ * ```tsx
12
+ * <FolderGridView
13
+ * rows={placements}
14
+ * getRowId={r => r.id}
15
+ * renderTile={row => <PlacementFolderTile row={row} onClick={…} />}
16
+ * ariaLabel="Placements folder view"
17
+ * />
18
+ * ```
19
+ */
20
+
21
+ import * as React from "react"
22
+ import { cn } from "@/lib/utils"
23
+ import {
24
+ ListPageViewFrame,
25
+ LIST_PAGE_VIEW_FRAME_MAX_ICON_GRID,
26
+ } from "@/components/data-views/list-page-view-frame"
27
+
28
+ export interface FolderGridViewProps<T> {
29
+ rows: T[]
30
+ getRowId: (row: T) => string | number
31
+ /** Render one tile — receives the row, returns a React node (typically a `<button>`). */
32
+ renderTile: (row: T) => React.ReactNode
33
+ /** Shown when `rows` is empty. */
34
+ emptyContent?: React.ReactNode
35
+ /** `aria-label` on the grid list. */
36
+ ariaLabel?: string
37
+ className?: string
38
+ /**
39
+ * When true, constrains the grid to a centered max width so folder tiles don’t stretch
40
+ * edge-to-edge on very wide viewports.
41
+ */
42
+ constrainWidth?: boolean
43
+ }
44
+
45
+ export function FolderGridView<T>({
46
+ rows,
47
+ getRowId,
48
+ renderTile,
49
+ emptyContent,
50
+ ariaLabel = "Folder view",
51
+ className,
52
+ constrainWidth = false,
53
+ }: FolderGridViewProps<T>) {
54
+ if (rows.length === 0) {
55
+ return (
56
+ <ListPageViewFrame
57
+ maxWidthClassName={constrainWidth ? LIST_PAGE_VIEW_FRAME_MAX_ICON_GRID : undefined}
58
+ className={cn(className)}
59
+ >
60
+ <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16 text-sm text-muted-foreground">
61
+ <i className="fa-solid fa-grid-2 mb-3 text-3xl opacity-40" aria-hidden="true" />
62
+ {emptyContent ?? <p>No records found.</p>}
63
+ </div>
64
+ </ListPageViewFrame>
65
+ )
66
+ }
67
+
68
+ return (
69
+ <ListPageViewFrame className={cn(className)}>
70
+ <div
71
+ className={cn(
72
+ "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8",
73
+ constrainWidth && "mx-auto max-w-6xl",
74
+ )}
75
+ role="list"
76
+ aria-label={ariaLabel}
77
+ >
78
+ {rows.map(row => (
79
+ <div key={getRowId(row)} role="listitem">
80
+ {renderTile(row)}
81
+ </div>
82
+ ))}
83
+ </div>
84
+ </ListPageViewFrame>
85
+ )
86
+ }
@@ -26,6 +26,65 @@ export {
26
26
  type ViewSegmentOption,
27
27
  } from "@/components/ui/view-segmented-control"
28
28
 
29
+ /** Shared gutter + centered max-width for list view bodies (folder grid, OS explorer, etc.). */
30
+ export {
31
+ ListPageViewFrame,
32
+ LIST_PAGE_VIEW_FRAME_GUTTER,
33
+ LIST_PAGE_VIEW_FRAME_MAX_ICON_GRID,
34
+ LIST_PAGE_VIEW_FRAME_MAX_WIDE,
35
+ type ListPageViewFrameProps,
36
+ } from "@/components/data-views/list-page-view-frame"
37
+
38
+ /** Centered bordered card + viewport height for finder / tree / multi-column split hubs. */
39
+ export {
40
+ ListPageSplitHubChrome,
41
+ LIST_PAGE_SPLIT_HUB_HEIGHT_STYLE,
42
+ type ListPageSplitHubChromeProps,
43
+ } from "@/components/data-views/list-page-split-hub-chrome"
44
+
45
+ export {
46
+ ListPageTreeColumnHeader,
47
+ type ListPageTreeColumnHeaderProps,
48
+ } from "@/components/data-views/list-page-tree-column-header"
49
+
50
+ export {
51
+ LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
52
+ LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
53
+ LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
54
+ } from "@/components/data-views/list-page-split-hub-tokens"
55
+
56
+ export {
57
+ ListPageSplitDetailsPlaceholder,
58
+ type ListPageSplitDetailsPlaceholderProps,
59
+ } from "@/components/data-views/list-page-split-details-placeholder"
60
+
61
+ /** Tree / outline + inspector — `ListPageTreePanelShell`; composed hub wiring (`HubTreePanelView`); folder metrics (`FolderDetailsShell`). */
62
+ export {
63
+ FolderDetailsShell,
64
+ type FolderDetailsShellProps,
65
+ } from "@/components/folder-details-shell"
66
+
67
+ export {
68
+ HubTreePanelView,
69
+ type HubTreePanelViewProps,
70
+ } from "@/components/hub-tree-panel-view"
71
+
72
+ export {
73
+ ListPageTreePanelShell,
74
+ type ListPageTreePanelShellProps,
75
+ } from "@/components/data-views/list-page-tree-panel-shell"
76
+
77
+ /** Windows 11–style folder art (Icons8) + FA overlay for icon-folder hubs. */
78
+ export {
79
+ OsFolderGlyph,
80
+ OS_FOLDER_GLYPH_SRC,
81
+ type OsFolderGlyphProps,
82
+ } from "@/components/data-views/os-folder-glyph"
83
+
84
+ /** Generic folder icon-grid — reusable across all list hubs. */
85
+ export { FolderGridView, type FolderGridViewProps } from "@/components/data-views/folder-grid-view"
86
+
87
+
29
88
  /** Unified hub tile + list row surface — see `list-page-board-card.tsx`. */
30
89
  export {
31
90
  HubRecordCard,
@@ -0,0 +1,39 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "@/lib/utils"
5
+
6
+ export interface ListPageSplitDetailsPlaceholderProps {
7
+ title?: string
8
+ description?: React.ReactNode
9
+ className?: string
10
+ }
11
+
12
+ /**
13
+ * Empty right pane for split hubs — matches Question bank tree “Nothing selected”.
14
+ */
15
+ export function ListPageSplitDetailsPlaceholder({
16
+ title = "Nothing selected",
17
+ description,
18
+ className,
19
+ }: ListPageSplitDetailsPlaceholderProps) {
20
+ return (
21
+ <div
22
+ className={cn(
23
+ "flex h-full min-h-0 flex-col items-center justify-center bg-gradient-to-b from-muted/25 via-card to-card px-6 py-10 text-center",
24
+ className,
25
+ )}
26
+ >
27
+ <div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/25">
28
+ <i
29
+ className="fa-light fa-sidebar text-[1.65rem] leading-none text-muted-foreground/70"
30
+ aria-hidden="true"
31
+ />
32
+ </div>
33
+ <p className="text-sm font-medium text-foreground">{title}</p>
34
+ {description ? (
35
+ <div className="mt-1.5 max-w-[16rem] text-xs leading-relaxed text-muted-foreground">{description}</div>
36
+ ) : null}
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,60 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Shared **centered** chrome for list-hub split surfaces (finder / tree / multi-column explorers).
5
+ *
6
+ * Composes `ListPageViewFrame` (gutter + max width) with a single bordered card + fixed viewport
7
+ * height so panel and tree views match across routes (`AGENTS.md` §4.5).
8
+ */
9
+
10
+ import * as React from "react"
11
+ import { cn } from "@/lib/utils"
12
+ import {
13
+ ListPageViewFrame,
14
+ LIST_PAGE_VIEW_FRAME_GUTTER,
15
+ LIST_PAGE_VIEW_FRAME_MAX_WIDE,
16
+ } from "@/components/data-views/list-page-view-frame"
17
+
18
+ /** Default height band for split views under `ListPageTemplate` + toolbar. */
19
+ export const LIST_PAGE_SPLIT_HUB_HEIGHT_STYLE: React.CSSProperties = {
20
+ height: "calc(100vh - 280px)",
21
+ minHeight: 420,
22
+ }
23
+
24
+ const SURFACE_CLASS =
25
+ "flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card"
26
+
27
+ export interface ListPageSplitHubChromeProps {
28
+ children: React.ReactNode
29
+ "aria-label"?: string
30
+ gutterClassName?: string
31
+ maxWidthClassName?: string
32
+ /** Override default split viewport height / min-height */
33
+ surfaceStyle?: React.CSSProperties
34
+ surfaceClassName?: string
35
+ }
36
+
37
+ export function ListPageSplitHubChrome({
38
+ children,
39
+ "aria-label": ariaLabel,
40
+ gutterClassName = LIST_PAGE_VIEW_FRAME_GUTTER,
41
+ maxWidthClassName = LIST_PAGE_VIEW_FRAME_MAX_WIDE,
42
+ surfaceStyle,
43
+ surfaceClassName,
44
+ }: ListPageSplitHubChromeProps) {
45
+ return (
46
+ <ListPageViewFrame
47
+ gutterClassName={gutterClassName}
48
+ maxWidthClassName={maxWidthClassName}
49
+ className="flex min-h-0 flex-1 flex-col"
50
+ >
51
+ <div
52
+ className={cn(SURFACE_CLASS, surfaceClassName)}
53
+ style={{ ...LIST_PAGE_SPLIT_HUB_HEIGHT_STYLE, ...surfaceStyle }}
54
+ aria-label={ariaLabel}
55
+ >
56
+ {children}
57
+ </div>
58
+ </ListPageViewFrame>
59
+ )
60
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Shared layout tokens for list-hub split surfaces (Miller columns, tree + details).
3
+ * Keeps Question bank panel / tree and generic `FinderPanelView` visually aligned.
4
+ */
5
+
6
+ /** `ResizableHandle` between miller / tree columns — matches Question bank panel. */
7
+ export const LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS =
8
+ "w-1 bg-border/40 hover:bg-brand/20 transition-colors"
9
+
10
+ /** Primary column stack (scope list, folder list, record list, …). */
11
+ export const LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS =
12
+ "flex min-h-0 min-w-0 flex-col bg-muted/15"
13
+
14
+ /** Right-hand inspector / detail column shell. */
15
+ export const LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS =
16
+ "flex min-h-0 min-w-0 flex-col bg-card"