@exxatdesignux/ui 0.2.17 → 0.2.19
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/CHANGELOG.md +30 -0
- package/consumer-extras/AGENTS.md +76 -0
- package/consumer-extras/README.md +5 -1
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
- package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
- package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
- package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
- package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
- package/consumer-extras/patterns/data-views-pattern.md +42 -3
- package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
- package/package.json +2 -1
- package/src/components/ui/button-group.tsx +81 -0
- package/src/components/ui/button.tsx +4 -4
- package/src/components/ui/sidebar.tsx +2 -2
- package/src/globals.css +7 -1807
- package/src/theme.css +10 -1126
- package/src/tokens/README.md +15 -0
- package/src/tokens/base.css +337 -0
- package/src/tokens/high-contrast.css +1195 -0
- package/src/tokens/layers.css +224 -0
- package/src/tokens/tailwind-bridge.css +118 -0
- package/src/tokens/themes.css +201 -0
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/AGENTS.md +66 -21
- package/template/app/(app)/dashboard/loading.tsx +3 -15
- package/template/app/(app)/dashboard/page.tsx +2 -14
- package/template/app/(app)/data-list/layout.tsx +43 -0
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
- package/template/app/(app)/examples/page.tsx +1 -0
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/loading.tsx +1 -18
- package/template/app/(app)/question-bank/find/page.tsx +2 -1
- package/template/app/(app)/question-bank/library/page.tsx +2 -1
- package/template/app/(app)/question-bank/list/page.tsx +2 -1
- package/template/app/(app)/question-bank/new/page.tsx +15 -23
- package/template/app/(app)/question-bank/page.tsx +2 -1
- package/template/app/(app)/settings/page.tsx +4 -5
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +7 -1934
- package/template/app/layout.tsx +2 -0
- package/template/components/app-route-loading.tsx +14 -0
- package/template/components/app-sidebar.tsx +71 -55
- package/template/components/data-table/index.tsx +31 -67
- package/template/components/data-table/use-table-state.ts +33 -6
- package/template/components/data-views/index.ts +37 -9
- package/template/components/data-views/list-page-calendar-view.tsx +593 -0
- package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
- package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
- package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/examples/focused-workflow-showcase.tsx +183 -0
- package/template/components/exxat-product-logo.tsx +2 -6
- package/template/components/key-metrics.tsx +54 -22
- package/template/components/list-hub-board-view.tsx +68 -0
- package/template/components/list-hub-client.tsx +186 -0
- package/template/components/list-hub-list-view.tsx +36 -0
- package/template/components/list-hub-panel-activator.tsx +8 -0
- package/template/components/list-hub-secondary-nav.tsx +121 -0
- package/template/components/list-hub-table.tsx +336 -0
- package/template/components/new-question-composer.tsx +6 -24
- package/template/components/product-switcher.tsx +5 -5
- package/template/components/product-wordmark.tsx +4 -7
- package/template/components/question-bank-client.tsx +4 -1
- package/template/components/question-bank-folder-columns-panel.tsx +104 -0
- package/template/components/question-bank-hub-client.tsx +2 -5
- package/template/components/question-bank-table.tsx +155 -509
- package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
- package/template/components/secondary-panel.tsx +4 -44
- package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
- package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
- package/template/components/secondary-panels/registry.tsx +15 -0
- package/template/components/settings-appearance-card.tsx +3 -2
- package/template/components/settings-client.tsx +59 -15
- package/template/components/settings-form-row.tsx +9 -4
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/table-properties/drawer-button.tsx +51 -20
- package/template/components/table-properties/drawer.tsx +81 -17
- package/template/components/templates/focused-workflow-layouts.tsx +448 -0
- package/template/components/templates/focused-workflow-page-template.tsx +69 -0
- package/template/components/templates/list-page.tsx +40 -13
- package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
- package/template/components/templates/page-loading-shell.tsx +262 -0
- package/template/components/ui/button-group.tsx +1 -0
- package/template/contexts/product-context.tsx +21 -2
- package/template/docs/consumer-app-pattern.md +39 -0
- package/template/docs/data-views-pattern.md +42 -3
- package/template/docs/drawer-vs-dialog-pattern.md +3 -1
- package/template/docs/focused-workflow-page-pattern.md +84 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +54 -0
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/command-menu-search-data.ts +11 -27
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-display-options.ts +16 -2
- package/template/lib/data-list-view-registry.ts +104 -0
- package/template/lib/data-list-view-surface.ts +15 -1
- package/template/lib/data-list-view.ts +16 -1
- package/template/lib/data-view-dashboard-storage.ts +38 -35
- package/template/lib/hub-connected-view-renderers.ts +58 -0
- package/template/lib/list-hub-nav.ts +121 -0
- package/template/lib/list-hub-supported-views.ts +10 -0
- package/template/lib/list-page-table-properties.ts +3 -7
- package/template/lib/list-status-badges.ts +4 -97
- package/template/lib/mock/list-hub-directory.ts +27 -0
- package/template/lib/mock/list-hub-kpi.ts +27 -0
- package/template/lib/mock/navigation.tsx +1 -0
- package/template/lib/page-loading-variant.ts +40 -0
- package/template/lib/question-bank-supported-views.ts +13 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +60 -13
- package/template/app/(app)/data-list/[id]/page.tsx +0 -44
- package/template/app/(app)/data-list/new/page.tsx +0 -34
- package/template/components/compliance-board-view.tsx +0 -142
- package/template/components/compliance-client.tsx +0 -92
- package/template/components/compliance-list-view.tsx +0 -54
- package/template/components/compliance-page-header.tsx +0 -89
- package/template/components/compliance-table.tsx +0 -632
- package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
- package/template/components/data-view-dashboard-charts-team.tsx +0 -971
- package/template/components/data-view-dashboard-charts.tsx +0 -1503
- package/template/components/new-placement-back-btn.tsx +0 -28
- package/template/components/new-placement-form.tsx +0 -1068
- package/template/components/placement-board-card.tsx +0 -262
- package/template/components/placement-detail.tsx +0 -438
- package/template/components/placements-board-view.tsx +0 -404
- package/template/components/placements-client.tsx +0 -252
- package/template/components/placements-list-view.tsx +0 -171
- package/template/components/placements-page-header.tsx +0 -166
- package/template/components/placements-table-cells.test.tsx +0 -22
- package/template/components/placements-table-cells.tsx +0 -173
- package/template/components/placements-table-columns.tsx +0 -640
- package/template/components/placements-table.tsx +0 -1675
- package/template/components/rotations-empty-state.tsx +0 -50
- package/template/components/rotations-panel-activator.tsx +0 -8
- package/template/components/sites-all-client.tsx +0 -154
- package/template/components/sites-board-view.tsx +0 -67
- package/template/components/sites-list-view.tsx +0 -42
- package/template/components/sites-table.tsx +0 -402
- package/template/components/team-board-view.tsx +0 -122
- package/template/components/team-client.tsx +0 -100
- package/template/components/team-list-view.tsx +0 -59
- package/template/components/team-page-header.tsx +0 -92
- package/template/components/team-table.tsx +0 -714
- package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
- package/template/lib/mock/compliance-kpi.ts +0 -61
- package/template/lib/mock/compliance.ts +0 -146
- package/template/lib/mock/placements-kpi.ts +0 -134
- package/template/lib/mock/placements.ts +0 -183
- package/template/lib/mock/sites-directory.ts +0 -16
- package/template/lib/mock/sites-kpi.ts +0 -25
- package/template/lib/mock/team-kpi.ts +0 -60
- package/template/lib/mock/team.ts +0 -118
- package/template/lib/placement-board-card-layout.ts +0 -79
- package/template/lib/placement-lifecycle.ts +0 -5
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finder-style multi-column folder + item explorer for **panel** view tabs.
|
|
5
|
+
* Generic over folder and item shapes; hubs supply labels, icons, and detail renderers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
import { Button } from "@/components/ui/button"
|
|
10
|
+
import {
|
|
11
|
+
Tooltip,
|
|
12
|
+
TooltipContent,
|
|
13
|
+
TooltipTrigger,
|
|
14
|
+
} from "@/components/ui/tooltip"
|
|
15
|
+
import {
|
|
16
|
+
ResizableHandle,
|
|
17
|
+
ResizablePanel,
|
|
18
|
+
ResizablePanelGroup,
|
|
19
|
+
} from "@/components/ui/resizable"
|
|
20
|
+
import { cn } from "@/lib/utils"
|
|
21
|
+
import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
|
|
22
|
+
import {
|
|
23
|
+
LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
|
|
24
|
+
LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
|
|
25
|
+
LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
|
|
26
|
+
} from "@/components/data-views/list-page-split-hub-tokens"
|
|
27
|
+
|
|
28
|
+
export interface ListPageFolderColumnsPanelProps<
|
|
29
|
+
TFolder extends { id: string },
|
|
30
|
+
TItem extends { id: string },
|
|
31
|
+
> {
|
|
32
|
+
folders: TFolder[]
|
|
33
|
+
items: TItem[]
|
|
34
|
+
isFolder: (item: TFolder | TItem) => item is TFolder
|
|
35
|
+
getFolderParentId: (folder: TFolder) => string | null
|
|
36
|
+
getFolderName: (folder: TFolder) => string
|
|
37
|
+
getItemFolderId: (item: TItem) => string | null
|
|
38
|
+
getItemLabel: (item: TItem) => string
|
|
39
|
+
renderItemDetail: (item: TItem) => React.ReactNode
|
|
40
|
+
renderFolderDetail?: (
|
|
41
|
+
folder: TFolder,
|
|
42
|
+
ctx: { folders: TFolder[]; items: TItem[] },
|
|
43
|
+
) => React.ReactNode
|
|
44
|
+
renderItemMeta?: (item: TItem, ctx: { isSelected: boolean }) => React.ReactNode
|
|
45
|
+
renderFolderIcon?: (
|
|
46
|
+
folder: TFolder,
|
|
47
|
+
ctx: { isSelected: boolean; depth: number },
|
|
48
|
+
) => React.ReactNode
|
|
49
|
+
renderItemIcon?: (item: TItem, ctx: { isSelected: boolean }) => React.ReactNode
|
|
50
|
+
renderFolderRowClassName?: (
|
|
51
|
+
folder: TFolder,
|
|
52
|
+
ctx: { isSelected: boolean; depth: number },
|
|
53
|
+
) => string | undefined
|
|
54
|
+
renderFolderActions?: (folder: TFolder) => React.ReactNode
|
|
55
|
+
onAddFolder: (parentId: string | null) => void
|
|
56
|
+
onAddItem?: (parentId: string | null) => void
|
|
57
|
+
rootColumnTitle?: string
|
|
58
|
+
addFolderAriaLabel?: string
|
|
59
|
+
addItemAriaLabel?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ListPageFolderColumnsPanel<
|
|
63
|
+
TFolder extends { id: string },
|
|
64
|
+
TItem extends { id: string },
|
|
65
|
+
>({
|
|
66
|
+
folders,
|
|
67
|
+
items,
|
|
68
|
+
isFolder,
|
|
69
|
+
getFolderParentId,
|
|
70
|
+
getFolderName,
|
|
71
|
+
getItemFolderId,
|
|
72
|
+
getItemLabel,
|
|
73
|
+
renderItemDetail,
|
|
74
|
+
renderFolderDetail,
|
|
75
|
+
renderItemMeta,
|
|
76
|
+
renderFolderIcon,
|
|
77
|
+
renderItemIcon,
|
|
78
|
+
renderFolderRowClassName,
|
|
79
|
+
renderFolderActions,
|
|
80
|
+
onAddFolder,
|
|
81
|
+
onAddItem,
|
|
82
|
+
rootColumnTitle = "Categories",
|
|
83
|
+
addFolderAriaLabel = "Add folder",
|
|
84
|
+
addItemAriaLabel = "Add item",
|
|
85
|
+
}: ListPageFolderColumnsPanelProps<TFolder, TItem>) {
|
|
86
|
+
type HierarchyItem = TFolder | TItem
|
|
87
|
+
|
|
88
|
+
const [selectedPath, setSelectedPath] = React.useState<HierarchyItem[]>(() => {
|
|
89
|
+
const roots = folders
|
|
90
|
+
.filter(f => getFolderParentId(f) === null)
|
|
91
|
+
.sort((a, b) => getFolderName(a).localeCompare(getFolderName(b)))
|
|
92
|
+
return roots.length > 0 ? [roots[0]] : []
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const isFirstRenderRef = React.useRef(true)
|
|
96
|
+
|
|
97
|
+
const rootFolders = React.useMemo(() => {
|
|
98
|
+
return folders
|
|
99
|
+
.filter(f => getFolderParentId(f) === null)
|
|
100
|
+
.sort((a, b) => getFolderName(a).localeCompare(getFolderName(b)))
|
|
101
|
+
}, [folders, getFolderParentId, getFolderName])
|
|
102
|
+
|
|
103
|
+
const handleSelect = (item: HierarchyItem, depth: number) => {
|
|
104
|
+
setSelectedPath(prev => [...prev.slice(0, depth), item])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
React.useEffect(() => {
|
|
108
|
+
if (isFirstRenderRef.current && selectedPath.length > 0) {
|
|
109
|
+
const lastItem = selectedPath[selectedPath.length - 1]
|
|
110
|
+
if (isFolder(lastItem)) {
|
|
111
|
+
const subfolders = folders
|
|
112
|
+
.filter(f => getFolderParentId(f) === lastItem.id)
|
|
113
|
+
.sort((a, b) => getFolderName(a).localeCompare(getFolderName(b)))
|
|
114
|
+
const childItems = items.filter(i => getItemFolderId(i) === lastItem.id)
|
|
115
|
+
const next: HierarchyItem[] = [...subfolders, ...childItems]
|
|
116
|
+
|
|
117
|
+
if (next.length > 0 && !selectedPath[selectedPath.length + 1]) {
|
|
118
|
+
setSelectedPath(prev => [...prev, next[0]])
|
|
119
|
+
isFirstRenderRef.current = false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
const columns: Array<{ items: HierarchyItem[]; depth: number; parentId?: string | null }> =
|
|
127
|
+
React.useMemo(() => {
|
|
128
|
+
const cols: Array<{ items: HierarchyItem[]; depth: number; parentId?: string | null }> = [
|
|
129
|
+
{ items: rootFolders, depth: 0, parentId: null },
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < selectedPath.length; i++) {
|
|
133
|
+
const item = selectedPath[i]
|
|
134
|
+
if (isFolder(item)) {
|
|
135
|
+
const subfolders = folders
|
|
136
|
+
.filter(f => getFolderParentId(f) === item.id)
|
|
137
|
+
.sort((a, b) => getFolderName(a).localeCompare(getFolderName(b)))
|
|
138
|
+
const childItems = items.filter(i => getItemFolderId(i) === item.id)
|
|
139
|
+
const columnItems: HierarchyItem[] = [...subfolders, ...childItems]
|
|
140
|
+
|
|
141
|
+
if (columnItems.length > 0) {
|
|
142
|
+
cols.push({ items: columnItems, depth: i + 1, parentId: item.id })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return cols
|
|
148
|
+
}, [selectedPath, rootFolders, folders, items, isFolder, getFolderParentId, getFolderName, getItemFolderId])
|
|
149
|
+
|
|
150
|
+
const selectedLeaf = selectedPath.length > 0 ? selectedPath.at(-1)! : null
|
|
151
|
+
const selectedItem =
|
|
152
|
+
selectedLeaf && !isFolder(selectedLeaf) ? (selectedLeaf as TItem) : null
|
|
153
|
+
const selectedFolderLeaf =
|
|
154
|
+
selectedLeaf && isFolder(selectedLeaf) ? (selectedLeaf as TFolder) : null
|
|
155
|
+
|
|
156
|
+
const defaultFolderIcon = (selected: boolean) => (
|
|
157
|
+
<i
|
|
158
|
+
className={cn("fa-folder shrink-0 text-sm", selected ? "fa-solid" : "fa-light")}
|
|
159
|
+
aria-hidden="true"
|
|
160
|
+
/>
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
const defaultItemIcon = (selected: boolean) => (
|
|
164
|
+
<i
|
|
165
|
+
className={cn("fa-file shrink-0 text-sm", selected ? "fa-solid" : "fa-light")}
|
|
166
|
+
aria-hidden="true"
|
|
167
|
+
/>
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<ResizablePanelGroup
|
|
172
|
+
direction="horizontal"
|
|
173
|
+
className="flex h-full min-h-0 w-full flex-1 overflow-hidden"
|
|
174
|
+
>
|
|
175
|
+
{columns.map(({ items: columnItems, depth, parentId }, columnIdx) => (
|
|
176
|
+
<React.Fragment key={`col-${depth}`}>
|
|
177
|
+
{columnIdx > 0 && (
|
|
178
|
+
<ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
|
|
179
|
+
)}
|
|
180
|
+
<ResizablePanel
|
|
181
|
+
id={`col-${depth}`}
|
|
182
|
+
defaultSize={columnIdx === 0 ? 35 : columnIdx === 1 ? 35 : 30}
|
|
183
|
+
minSize={15}
|
|
184
|
+
className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}
|
|
185
|
+
>
|
|
186
|
+
<ListPageTreeColumnHeader
|
|
187
|
+
title={
|
|
188
|
+
depth === 0
|
|
189
|
+
? rootColumnTitle
|
|
190
|
+
: selectedPath[depth - 1] && isFolder(selectedPath[depth - 1])
|
|
191
|
+
? getFolderName(selectedPath[depth - 1] as TFolder)
|
|
192
|
+
: "Items"
|
|
193
|
+
}
|
|
194
|
+
trailing={
|
|
195
|
+
<>
|
|
196
|
+
<span className="shrink-0 text-xs font-medium text-muted-foreground tabular-nums">
|
|
197
|
+
{columnItems.length}
|
|
198
|
+
</span>
|
|
199
|
+
{depth < columns.length - 1 && columnItems.length > 0 ? (
|
|
200
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
201
|
+
<Tooltip>
|
|
202
|
+
<TooltipTrigger asChild>
|
|
203
|
+
<Button
|
|
204
|
+
size="icon-sm"
|
|
205
|
+
variant="ghost"
|
|
206
|
+
onClick={() => onAddFolder(parentId ?? null)}
|
|
207
|
+
aria-label={addFolderAriaLabel}
|
|
208
|
+
>
|
|
209
|
+
<i className="fa-light fa-folder-plus text-xs" aria-hidden="true" />
|
|
210
|
+
</Button>
|
|
211
|
+
</TooltipTrigger>
|
|
212
|
+
<TooltipContent side="top" sideOffset={4}>
|
|
213
|
+
{addFolderAriaLabel}
|
|
214
|
+
</TooltipContent>
|
|
215
|
+
</Tooltip>
|
|
216
|
+
{onAddItem ? (
|
|
217
|
+
<Tooltip>
|
|
218
|
+
<TooltipTrigger asChild>
|
|
219
|
+
<Button
|
|
220
|
+
size="icon-sm"
|
|
221
|
+
variant="ghost"
|
|
222
|
+
onClick={() => onAddItem(parentId ?? null)}
|
|
223
|
+
aria-label={addItemAriaLabel}
|
|
224
|
+
>
|
|
225
|
+
<i className="fa-light fa-plus text-xs" aria-hidden="true" />
|
|
226
|
+
</Button>
|
|
227
|
+
</TooltipTrigger>
|
|
228
|
+
<TooltipContent side="top" sideOffset={4}>
|
|
229
|
+
{addItemAriaLabel}
|
|
230
|
+
</TooltipContent>
|
|
231
|
+
</Tooltip>
|
|
232
|
+
) : null}
|
|
233
|
+
</div>
|
|
234
|
+
) : null}
|
|
235
|
+
</>
|
|
236
|
+
}
|
|
237
|
+
/>
|
|
238
|
+
|
|
239
|
+
<div className="min-h-0 flex-1 overflow-y-auto py-1">
|
|
240
|
+
{columnItems.map(item => {
|
|
241
|
+
const isSelected = selectedPath[depth]?.id === item.id
|
|
242
|
+
const isFolderRow = isFolder(item)
|
|
243
|
+
const folder = isFolderRow ? item : null
|
|
244
|
+
const rowItem = isFolderRow ? null : (item as TItem)
|
|
245
|
+
|
|
246
|
+
const subfolderCount = isFolderRow
|
|
247
|
+
? folders.filter(f => getFolderParentId(f) === item.id).length
|
|
248
|
+
: 0
|
|
249
|
+
const childItemCount = isFolderRow
|
|
250
|
+
? items.filter(i => getItemFolderId(i) === item.id).length
|
|
251
|
+
: 0
|
|
252
|
+
const itemCount = subfolderCount + childItemCount
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<div
|
|
256
|
+
key={item.id}
|
|
257
|
+
className="group flex items-center hover:bg-muted/50"
|
|
258
|
+
>
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => handleSelect(item, depth)}
|
|
262
|
+
className={cn(
|
|
263
|
+
"flex flex-1 items-center gap-3 px-3 py-2 text-left text-sm transition-colors duration-75",
|
|
264
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
265
|
+
isSelected
|
|
266
|
+
? "bg-accent text-accent-foreground"
|
|
267
|
+
: "text-foreground",
|
|
268
|
+
isFolderRow && folder
|
|
269
|
+
? renderFolderRowClassName?.(folder, { isSelected, depth })
|
|
270
|
+
: undefined,
|
|
271
|
+
)}
|
|
272
|
+
aria-selected={isSelected}
|
|
273
|
+
role="option"
|
|
274
|
+
>
|
|
275
|
+
{isFolderRow && folder ? (
|
|
276
|
+
renderFolderIcon?.(folder, { isSelected, depth }) ??
|
|
277
|
+
defaultFolderIcon(isSelected)
|
|
278
|
+
) : rowItem ? (
|
|
279
|
+
renderItemIcon?.(rowItem, { isSelected }) ??
|
|
280
|
+
defaultItemIcon(isSelected)
|
|
281
|
+
) : null}
|
|
282
|
+
|
|
283
|
+
<span
|
|
284
|
+
className={cn(
|
|
285
|
+
"min-w-0 flex-1 truncate leading-tight",
|
|
286
|
+
isSelected && "font-medium",
|
|
287
|
+
)}
|
|
288
|
+
>
|
|
289
|
+
{isFolderRow && folder
|
|
290
|
+
? getFolderName(folder)
|
|
291
|
+
: rowItem
|
|
292
|
+
? getItemLabel(rowItem)
|
|
293
|
+
: ""}
|
|
294
|
+
</span>
|
|
295
|
+
|
|
296
|
+
<span
|
|
297
|
+
className={cn(
|
|
298
|
+
"ml-auto shrink-0 text-xs tabular-nums",
|
|
299
|
+
isSelected ? "text-accent-foreground/70" : "text-muted-foreground",
|
|
300
|
+
)}
|
|
301
|
+
>
|
|
302
|
+
{isFolderRow
|
|
303
|
+
? itemCount
|
|
304
|
+
: rowItem
|
|
305
|
+
? (renderItemMeta?.(rowItem, { isSelected }) ?? null)
|
|
306
|
+
: null}
|
|
307
|
+
</span>
|
|
308
|
+
</button>
|
|
309
|
+
|
|
310
|
+
{isFolderRow && folder && renderFolderActions?.(folder)}
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
})}
|
|
314
|
+
</div>
|
|
315
|
+
</ResizablePanel>
|
|
316
|
+
</React.Fragment>
|
|
317
|
+
))}
|
|
318
|
+
|
|
319
|
+
{(selectedItem || selectedFolderLeaf) && (
|
|
320
|
+
<>
|
|
321
|
+
<ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
|
|
322
|
+
<ResizablePanel
|
|
323
|
+
id="col-detail"
|
|
324
|
+
defaultSize={30}
|
|
325
|
+
minSize={20}
|
|
326
|
+
className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}
|
|
327
|
+
>
|
|
328
|
+
{selectedItem ? (
|
|
329
|
+
<>
|
|
330
|
+
<ListPageTreeColumnHeader title="Details" className="px-4" />
|
|
331
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
|
332
|
+
{renderItemDetail(selectedItem)}
|
|
333
|
+
</div>
|
|
334
|
+
</>
|
|
335
|
+
) : selectedFolderLeaf && renderFolderDetail ? (
|
|
336
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
337
|
+
{renderFolderDetail(selectedFolderLeaf, { folders, items })}
|
|
338
|
+
</div>
|
|
339
|
+
) : null}
|
|
340
|
+
</ResizablePanel>
|
|
341
|
+
</>
|
|
342
|
+
)}
|
|
343
|
+
</ResizablePanelGroup>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
@@ -21,6 +21,14 @@ export const LIST_PAGE_SPLIT_HUB_HEIGHT_STYLE: React.CSSProperties = {
|
|
|
21
21
|
minHeight: 420,
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Viewport band for calendar under `ListPageTemplate` + hub toolbar (+ optional metrics).
|
|
26
|
+
* Uses a fixed height (not min-height) so the week strip scrolls inside the card, not the page.
|
|
27
|
+
*/
|
|
28
|
+
export const LIST_PAGE_CALENDAR_HEIGHT_STYLE: React.CSSProperties = {
|
|
29
|
+
height: "clamp(420px, calc(100dvh - 22rem), 880px)",
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
const SURFACE_CLASS =
|
|
25
33
|
"flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card"
|
|
26
34
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { isChunkLoadError } from "@/lib/chunk-load-error"
|
|
6
|
+
|
|
7
|
+
const RELOAD_FLAG = "exxat-ds:chunk-reload-attempted"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dev-only: auto-reload once when Turbopack serves a stale chunk hash so users
|
|
11
|
+
* are not stuck on a blank shell before the route error boundary mounts.
|
|
12
|
+
*/
|
|
13
|
+
export function DevChunkLoadRecovery() {
|
|
14
|
+
React.useEffect(() => {
|
|
15
|
+
if (process.env.NODE_ENV !== "development") return
|
|
16
|
+
|
|
17
|
+
function maybeReload(error: unknown) {
|
|
18
|
+
if (!isChunkLoadError(error)) return
|
|
19
|
+
if (typeof window === "undefined") return
|
|
20
|
+
if (window.sessionStorage.getItem(RELOAD_FLAG) === "1") return
|
|
21
|
+
window.sessionStorage.setItem(RELOAD_FLAG, "1")
|
|
22
|
+
window.location.reload()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const onError = (event: ErrorEvent) => {
|
|
26
|
+
maybeReload(event.error ?? event.message)
|
|
27
|
+
}
|
|
28
|
+
const onRejection = (event: PromiseRejectionEvent) => {
|
|
29
|
+
maybeReload(event.reason)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
window.addEventListener("error", onError)
|
|
33
|
+
window.addEventListener("unhandledrejection", onRejection)
|
|
34
|
+
return () => {
|
|
35
|
+
window.removeEventListener("error", onError)
|
|
36
|
+
window.removeEventListener("unhandledrejection", onRejection)
|
|
37
|
+
}
|
|
38
|
+
}, [])
|
|
39
|
+
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Patterns / examples — `FocusedWorkflowPageTemplate` layout variants.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as React from "react"
|
|
8
|
+
import Link from "next/link"
|
|
9
|
+
|
|
10
|
+
import { PageHeader } from "@/components/page-header"
|
|
11
|
+
import { Button } from "@/components/ui/button"
|
|
12
|
+
import { Input } from "@/components/ui/input"
|
|
13
|
+
import { Label } from "@/components/ui/label"
|
|
14
|
+
import { ViewSegmentedControl } from "@/components/ui/view-segmented-control"
|
|
15
|
+
import {
|
|
16
|
+
FocusedWorkflowEmptyState,
|
|
17
|
+
FocusedWorkflowSingleColumn,
|
|
18
|
+
FocusedWorkflowStepForm,
|
|
19
|
+
FocusedWorkflowSidebarSections,
|
|
20
|
+
FocusedWorkflowWizardFooter,
|
|
21
|
+
type FocusedWorkflowStep,
|
|
22
|
+
} from "@/components/templates/focused-workflow-layouts"
|
|
23
|
+
import {
|
|
24
|
+
FocusedWorkflowPageTemplate,
|
|
25
|
+
FOCUSED_WORKFLOW_CONTENT_PADDING_CLASS,
|
|
26
|
+
} from "@/components/templates/focused-workflow-page-template"
|
|
27
|
+
|
|
28
|
+
const DEMO_STEPS: FocusedWorkflowStep[] = [
|
|
29
|
+
{
|
|
30
|
+
id: "basics",
|
|
31
|
+
label: "Basics",
|
|
32
|
+
description: "Name, owner, and schedule for this record.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "details",
|
|
36
|
+
label: "Details",
|
|
37
|
+
description: "Optional fields and attachments.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "review",
|
|
41
|
+
label: "Review",
|
|
42
|
+
description: "Confirm before you submit.",
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const DEMO_SECTIONS = [
|
|
47
|
+
{ id: "general", label: "General" },
|
|
48
|
+
{ id: "notifications", label: "Notifications" },
|
|
49
|
+
{ id: "security", label: "Security" },
|
|
50
|
+
] as const
|
|
51
|
+
|
|
52
|
+
type DemoVariant = "empty" | "steps" | "sidebar"
|
|
53
|
+
|
|
54
|
+
const VARIANT_OPTIONS: { value: DemoVariant; label: string }[] = [
|
|
55
|
+
{ value: "empty", label: "Empty" },
|
|
56
|
+
{ value: "steps", label: "Steps" },
|
|
57
|
+
{ value: "sidebar", label: "Sidebar" },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
export function FocusedWorkflowShowcase() {
|
|
61
|
+
const [variant, setVariant] = React.useState<DemoVariant>("steps")
|
|
62
|
+
const [stepIndex, setStepIndex] = React.useState(0)
|
|
63
|
+
const [activeSection, setActiveSection] = React.useState<string>("general")
|
|
64
|
+
|
|
65
|
+
const siteHeader = {
|
|
66
|
+
back: { label: "Patterns", href: "/examples" },
|
|
67
|
+
title: "Focused workflow layouts",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const shell = (body: React.ReactNode, maxWidth: "md" | "lg" = "lg") => (
|
|
71
|
+
<FocusedWorkflowPageTemplate
|
|
72
|
+
siteHeader={siteHeader}
|
|
73
|
+
maxWidth={maxWidth}
|
|
74
|
+
contentClassName={FOCUSED_WORKFLOW_CONTENT_PADDING_CLASS}
|
|
75
|
+
>
|
|
76
|
+
<FocusedWorkflowSingleColumn>
|
|
77
|
+
<PageHeader
|
|
78
|
+
title="Focused workflow"
|
|
79
|
+
subtitle="Dedicated routes for large forms and multi-step actions — not list hubs or split-column explorers."
|
|
80
|
+
actions={
|
|
81
|
+
<ViewSegmentedControl
|
|
82
|
+
value={variant}
|
|
83
|
+
onValueChange={v => setVariant(v as DemoVariant)}
|
|
84
|
+
options={VARIANT_OPTIONS}
|
|
85
|
+
aria-label="Layout variant"
|
|
86
|
+
/>
|
|
87
|
+
}
|
|
88
|
+
/>
|
|
89
|
+
{body}
|
|
90
|
+
</FocusedWorkflowSingleColumn>
|
|
91
|
+
</FocusedWorkflowPageTemplate>
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if (variant === "empty") {
|
|
95
|
+
return shell(
|
|
96
|
+
<FocusedWorkflowEmptyState
|
|
97
|
+
title="No draft yet"
|
|
98
|
+
description="Start a new record from the hub, or pick a template to pre-fill common fields."
|
|
99
|
+
action={
|
|
100
|
+
<Button type="button" size="lg">
|
|
101
|
+
Create record
|
|
102
|
+
</Button>
|
|
103
|
+
}
|
|
104
|
+
/>,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (variant === "sidebar") {
|
|
109
|
+
return shell(
|
|
110
|
+
<FocusedWorkflowSidebarSections
|
|
111
|
+
sections={DEMO_SECTIONS}
|
|
112
|
+
activeSectionId={activeSection}
|
|
113
|
+
onSectionSelect={id => {
|
|
114
|
+
setActiveSection(id)
|
|
115
|
+
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<section id="general" className="scroll-mt-20 space-y-4">
|
|
119
|
+
<header className="space-y-1">
|
|
120
|
+
<h2 className="text-lg font-semibold text-foreground">General</h2>
|
|
121
|
+
<p className="text-sm text-muted-foreground">
|
|
122
|
+
Settings-style section nav — same shell as long preference forms.
|
|
123
|
+
</p>
|
|
124
|
+
</header>
|
|
125
|
+
<div className="space-y-2">
|
|
126
|
+
<Label htmlFor="fw-name">Display name</Label>
|
|
127
|
+
<Input id="fw-name" defaultValue="Clinical education workspace" />
|
|
128
|
+
</div>
|
|
129
|
+
</section>
|
|
130
|
+
<section id="notifications" className="scroll-mt-20 space-y-4">
|
|
131
|
+
<header className="space-y-1">
|
|
132
|
+
<h2 className="text-lg font-semibold text-foreground">Notifications</h2>
|
|
133
|
+
<p className="text-sm text-muted-foreground">Email and in-app alerts.</p>
|
|
134
|
+
</header>
|
|
135
|
+
<p className="text-sm text-muted-foreground">Wire toggles and checkboxes here.</p>
|
|
136
|
+
</section>
|
|
137
|
+
<section id="security" className="scroll-mt-20 space-y-4">
|
|
138
|
+
<header className="space-y-1">
|
|
139
|
+
<h2 className="text-lg font-semibold text-foreground">Security</h2>
|
|
140
|
+
<p className="text-sm text-muted-foreground">Sessions and access.</p>
|
|
141
|
+
</header>
|
|
142
|
+
<Button type="button" variant="outline" asChild>
|
|
143
|
+
<Link href="/settings">Open full settings</Link>
|
|
144
|
+
</Button>
|
|
145
|
+
</section>
|
|
146
|
+
</FocusedWorkflowSidebarSections>,
|
|
147
|
+
"lg",
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return shell(
|
|
152
|
+
<FocusedWorkflowStepForm
|
|
153
|
+
steps={DEMO_STEPS}
|
|
154
|
+
currentIndex={stepIndex}
|
|
155
|
+
onStepSelect={setStepIndex}
|
|
156
|
+
footer={
|
|
157
|
+
<FocusedWorkflowWizardFooter
|
|
158
|
+
stepIndex={stepIndex}
|
|
159
|
+
stepCount={DEMO_STEPS.length}
|
|
160
|
+
onBack={() => setStepIndex(i => Math.max(0, i - 1))}
|
|
161
|
+
onCancel={() => setStepIndex(0)}
|
|
162
|
+
onNext={() => setStepIndex(i => Math.min(DEMO_STEPS.length - 1, i + 1))}
|
|
163
|
+
onSubmit={() => setStepIndex(0)}
|
|
164
|
+
submitLabel="Create record"
|
|
165
|
+
/>
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
<div className="rounded-lg border border-border bg-card p-6">
|
|
169
|
+
<h2 className="text-base font-semibold text-foreground">
|
|
170
|
+
{DEMO_STEPS[stepIndex]?.label}
|
|
171
|
+
</h2>
|
|
172
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
173
|
+
{DEMO_STEPS[stepIndex]?.description} Replace this panel with your step fields.
|
|
174
|
+
</p>
|
|
175
|
+
<div className="mt-6 space-y-2">
|
|
176
|
+
<Label htmlFor="fw-demo-field">Example field</Label>
|
|
177
|
+
<Input id="fw-demo-field" placeholder="Required on this step" />
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</FocusedWorkflowStepForm>,
|
|
181
|
+
"md",
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -38,7 +38,7 @@ export type ExxatProductLogoVariant = "default" | "mutedSuffix"
|
|
|
38
38
|
export interface ExxatProductLogoProps {
|
|
39
39
|
product: Product
|
|
40
40
|
className?: string
|
|
41
|
-
/**
|
|
41
|
+
/** Reserved for switcher chrome; suffix stays Exxat pink in all modes. */
|
|
42
42
|
variant?: ExxatProductLogoVariant
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -191,7 +191,6 @@ export function ExxatProductLogo({
|
|
|
191
191
|
const customProductBrand = useAppStore(s => s.customProductBrand)
|
|
192
192
|
const productBrandColors = useAppStore(s => s.productBrandColors)
|
|
193
193
|
const config = brandForProduct(product, customProductBrand, productBrandColors)
|
|
194
|
-
const muted = variant === "mutedSuffix"
|
|
195
194
|
const suffixColor = config.wordmarkColor ?? config.brandColor
|
|
196
195
|
|
|
197
196
|
return (
|
|
@@ -213,10 +212,7 @@ export function ExxatProductLogo({
|
|
|
213
212
|
{/* HTML suffix — IvyPresto Text SemiBold per Figma brand spec. */}
|
|
214
213
|
<span
|
|
215
214
|
data-product-wordmark-suffix
|
|
216
|
-
className=
|
|
217
|
-
"ms-[0.18em] text-[1.55em] font-semibold tracking-[-0.03em] -translate-y-[3px]",
|
|
218
|
-
muted && "dark:!text-[var(--muted-foreground)]",
|
|
219
|
-
)}
|
|
215
|
+
className="ms-[0.18em] text-[1.55em] font-semibold tracking-[-0.03em] -translate-y-[3px]"
|
|
220
216
|
style={{
|
|
221
217
|
fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
|
|
222
218
|
color: suffixColor,
|