@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.
- package/package.json +2 -1
- 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/placements-page-header.tsx +14 -8
- package/template/components/placements-table-columns.tsx +8 -8
- package/template/components/question-bank-client.tsx +157 -40
- 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 +3 -3
- 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-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
|
@@ -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"
|