@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,336 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List hub table — `DataTable` + `useTableState`; table | list | board | calendar | dashboard
|
|
5
|
+
* share `tableState.rows` (centralized dataset).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react"
|
|
9
|
+
import type { ListHubRecord } from "@/lib/mock/list-hub-directory"
|
|
10
|
+
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
11
|
+
import { useTableState } from "@/components/data-table/use-table-state"
|
|
12
|
+
import type { ColumnDef } from "@/components/data-table/types"
|
|
13
|
+
import type { DataListViewType } from "@/lib/data-list-view"
|
|
14
|
+
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
15
|
+
import { LIST_HUB_SUPPORTED_VIEWS } from "@/lib/list-hub-supported-views"
|
|
16
|
+
import { TablePropertiesDrawerButton } from "@/components/table-properties"
|
|
17
|
+
import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
|
|
18
|
+
import { Button } from "@/components/ui/button"
|
|
19
|
+
import { Tip } from "@/components/ui/tip"
|
|
20
|
+
import { FinderPanelView, type FinderGroup } from "@/components/data-views/finder-panel-view"
|
|
21
|
+
import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
|
|
22
|
+
import { ListPageConnectedViewBody } from "@/components/data-views/list-page-connected-view-body"
|
|
23
|
+
import { defineHubViewRenderers } from "@/lib/hub-connected-view-renderers"
|
|
24
|
+
import { ListPageCalendarView } from "@/components/data-views/list-page-calendar-view"
|
|
25
|
+
import { ListHubCardGrid } from "@/components/list-hub-board-view"
|
|
26
|
+
import { ListHubListView } from "@/components/list-hub-list-view"
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
|
|
29
|
+
type DataListDisplayOptions,
|
|
30
|
+
} from "@/lib/data-list-display-options"
|
|
31
|
+
|
|
32
|
+
function columnToFilterFieldDef(c: ColumnDef<ListHubRecord>): FilterFieldDef | null {
|
|
33
|
+
if (!c.filter) return null
|
|
34
|
+
const f = c.filter
|
|
35
|
+
const defaultOps: FilterOperator[] =
|
|
36
|
+
f.type === "select" || f.type === "date" ? ["is", "is_not"] : ["contains", "not_contains"]
|
|
37
|
+
return {
|
|
38
|
+
key: c.key,
|
|
39
|
+
label: c.label,
|
|
40
|
+
icon: f.icon ?? "fa-filter",
|
|
41
|
+
type: f.type,
|
|
42
|
+
operators: (f.operators ?? defaultOps) as FilterOperator[],
|
|
43
|
+
options: f.options,
|
|
44
|
+
...(f.textMask ? { textMask: f.textMask } : {}),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function columnsToFilterFields(cols: ColumnDef<ListHubRecord>[]) {
|
|
49
|
+
return cols.map(columnToFilterFieldDef).filter((x): x is FilterFieldDef => x !== null)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildListHubColumns(): ColumnDef<ListHubRecord>[] {
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
key: "select",
|
|
56
|
+
label: "",
|
|
57
|
+
width: 40,
|
|
58
|
+
minWidth: 40,
|
|
59
|
+
defaultPin: "left",
|
|
60
|
+
lockPin: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "title",
|
|
64
|
+
label: "Title",
|
|
65
|
+
width: 280,
|
|
66
|
+
minWidth: 160,
|
|
67
|
+
sortable: true,
|
|
68
|
+
sortKey: "title",
|
|
69
|
+
filter: {
|
|
70
|
+
type: "text",
|
|
71
|
+
icon: "fa-file-lines",
|
|
72
|
+
operators: ["contains", "not_contains"],
|
|
73
|
+
},
|
|
74
|
+
cell: row => <span className="truncate text-sm font-medium text-foreground">{row.title}</span>,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: "id",
|
|
78
|
+
label: "ID",
|
|
79
|
+
width: 120,
|
|
80
|
+
minWidth: 100,
|
|
81
|
+
sortable: true,
|
|
82
|
+
sortKey: "id",
|
|
83
|
+
filter: {
|
|
84
|
+
type: "text",
|
|
85
|
+
icon: "fa-hashtag",
|
|
86
|
+
operators: ["contains", "not_contains"],
|
|
87
|
+
},
|
|
88
|
+
cell: row => <span className="font-mono text-sm tabular-nums text-foreground/90">{row.id}</span>,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
key: "eventDate",
|
|
92
|
+
label: "Event date",
|
|
93
|
+
width: 140,
|
|
94
|
+
minWidth: 120,
|
|
95
|
+
sortable: true,
|
|
96
|
+
sortKey: "eventDate",
|
|
97
|
+
filter: {
|
|
98
|
+
type: "date",
|
|
99
|
+
icon: "fa-calendar-days",
|
|
100
|
+
operators: ["is", "is_not"],
|
|
101
|
+
},
|
|
102
|
+
cell: row => <span className="text-sm tabular-nums text-foreground">{row.eventDate}</span>,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
key: "category",
|
|
106
|
+
label: "Category",
|
|
107
|
+
width: 140,
|
|
108
|
+
minWidth: 100,
|
|
109
|
+
sortable: true,
|
|
110
|
+
sortKey: "category",
|
|
111
|
+
filter: {
|
|
112
|
+
type: "text",
|
|
113
|
+
icon: "fa-tag",
|
|
114
|
+
operators: ["contains", "not_contains"],
|
|
115
|
+
},
|
|
116
|
+
cell: row => <span className="text-sm text-muted-foreground">{row.category}</span>,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
key: "actions",
|
|
120
|
+
label: "",
|
|
121
|
+
width: 48,
|
|
122
|
+
minWidth: 48,
|
|
123
|
+
defaultPin: "right",
|
|
124
|
+
lockPin: true,
|
|
125
|
+
cell: () => null,
|
|
126
|
+
},
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type ListHubTableHandle = OpenTablePropertiesHandle
|
|
131
|
+
|
|
132
|
+
export const ListHubTable = React.forwardRef<
|
|
133
|
+
ListHubTableHandle,
|
|
134
|
+
{
|
|
135
|
+
rows: ListHubRecord[]
|
|
136
|
+
view?: DataListViewType
|
|
137
|
+
onViewChange?: (v: DataListViewType) => void
|
|
138
|
+
/** Aligns Properties view tiles with `ListPageTemplate` `supportedViewTypes`. */
|
|
139
|
+
supportedViewTypes?: readonly DataListViewType[]
|
|
140
|
+
}
|
|
141
|
+
>(function ListHubTable(
|
|
142
|
+
{ rows, view = "board", onViewChange, supportedViewTypes = LIST_HUB_SUPPORTED_VIEWS },
|
|
143
|
+
ref,
|
|
144
|
+
) {
|
|
145
|
+
const columns = React.useMemo(() => buildListHubColumns(), [])
|
|
146
|
+
const filterFields = React.useMemo(() => columnsToFilterFields(columns), [columns])
|
|
147
|
+
const fieldDefinitionsForDrawer = React.useMemo(
|
|
148
|
+
() =>
|
|
149
|
+
columns
|
|
150
|
+
.filter(c => c.key !== "select" && c.key !== "actions")
|
|
151
|
+
.map(c => ({ key: c.key, label: c.label, sortable: !!(c.sortable && (c.sortKey ?? c.key)) })),
|
|
152
|
+
[columns],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const resolveColumnLabel = React.useCallback(
|
|
156
|
+
(key: string) => columns.find(c => c.key === key)?.label ?? key,
|
|
157
|
+
[columns],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const [displayOptions, setDisplayOptions] = React.useState<DataListDisplayOptions>(
|
|
161
|
+
DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
|
|
162
|
+
)
|
|
163
|
+
const patchDisplay = React.useCallback((patch: Partial<DataListDisplayOptions>) => {
|
|
164
|
+
setDisplayOptions(prev => ({ ...prev, ...patch }))
|
|
165
|
+
}, [])
|
|
166
|
+
|
|
167
|
+
const [conditionalRules, setConditionalRules] = React.useState<ConditionalRule[]>([])
|
|
168
|
+
|
|
169
|
+
const addConditionalRule = React.useCallback((rule: Omit<ConditionalRule, "id">) => {
|
|
170
|
+
setConditionalRules(prev => [...prev, { ...rule, id: `cr-${Date.now()}` }])
|
|
171
|
+
}, [])
|
|
172
|
+
const removeConditionalRule = React.useCallback((id: string) => {
|
|
173
|
+
setConditionalRules(prev => prev.filter(r => r.id !== id))
|
|
174
|
+
}, [])
|
|
175
|
+
const updateConditionalRule = React.useCallback((id: string, patch: Partial<ConditionalRule>) => {
|
|
176
|
+
setConditionalRules(prev => prev.map(r => (r.id === id ? { ...r, ...patch } : r)))
|
|
177
|
+
}, [])
|
|
178
|
+
|
|
179
|
+
const tableState = useTableState<ListHubRecord>(rows, columns, { key: "title", dir: "asc" })
|
|
180
|
+
|
|
181
|
+
React.useImperativeHandle(
|
|
182
|
+
ref,
|
|
183
|
+
() => ({
|
|
184
|
+
openPropertiesDrawer: () => {
|
|
185
|
+
tableState.setSheetOpen(true)
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
[tableState.setSheetOpen],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const panelGroupsBuilder = (filtered: ListHubRecord[]): FinderGroup[] => [
|
|
192
|
+
{ id: "all", label: `All records (${filtered.length})`, count: filtered.length },
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
const panelRenderListRow = (row: ListHubRecord) => (
|
|
196
|
+
<div className="min-w-0 flex-1">
|
|
197
|
+
<p className="truncate text-sm font-medium text-foreground">{row.title}</p>
|
|
198
|
+
<p className="truncate text-xs text-muted-foreground">{row.category}</p>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const panelRenderDetail = (row: ListHubRecord) => (
|
|
203
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4">
|
|
204
|
+
<div>
|
|
205
|
+
<h3 className="mb-1 text-sm font-semibold text-foreground">{row.title}</h3>
|
|
206
|
+
<p className="text-xs text-muted-foreground">{row.category}</p>
|
|
207
|
+
</div>
|
|
208
|
+
<dl className="grid gap-2 text-sm">
|
|
209
|
+
<div>
|
|
210
|
+
<dt className="text-xs font-medium text-muted-foreground">ID</dt>
|
|
211
|
+
<dd className="font-mono tabular-nums">{row.id}</dd>
|
|
212
|
+
</div>
|
|
213
|
+
<div>
|
|
214
|
+
<dt className="text-xs font-medium text-muted-foreground">Event date</dt>
|
|
215
|
+
<dd className="tabular-nums">{row.eventDate}</dd>
|
|
216
|
+
</div>
|
|
217
|
+
</dl>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const drawerToolbarProps = {
|
|
222
|
+
state: tableState,
|
|
223
|
+
totalRows: rows.length,
|
|
224
|
+
filterFields,
|
|
225
|
+
fieldDefinitions: fieldDefinitionsForDrawer,
|
|
226
|
+
resolveColumnLabel,
|
|
227
|
+
displayOptions,
|
|
228
|
+
onDisplayOptionsChange: patchDisplay,
|
|
229
|
+
conditionalRules,
|
|
230
|
+
onAddConditionalRule: addConditionalRule,
|
|
231
|
+
onRemoveConditionalRule: removeConditionalRule,
|
|
232
|
+
onUpdateConditionalRule: updateConditionalRule,
|
|
233
|
+
currentView: view,
|
|
234
|
+
onViewChange,
|
|
235
|
+
supportedViewTypes,
|
|
236
|
+
lifecycleTabLabel: "List hub",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tableProps = {
|
|
240
|
+
data: rows,
|
|
241
|
+
columns,
|
|
242
|
+
getRowId: (row: ListHubRecord) => row.id,
|
|
243
|
+
getRowSelectionLabel: (row: ListHubRecord) => row.title,
|
|
244
|
+
selectable: true,
|
|
245
|
+
searchable: displayOptions.showToolbarSearch,
|
|
246
|
+
showColumnHeaders: displayOptions.showColumnLabels,
|
|
247
|
+
groupable: true,
|
|
248
|
+
defaultSort: { key: "title", dir: "asc" as const },
|
|
249
|
+
emptyState: <p className="text-sm text-muted-foreground">No records match your filters.</p>,
|
|
250
|
+
conditionalRules,
|
|
251
|
+
state: tableState,
|
|
252
|
+
toolbarSlot: (s: ReturnType<typeof useTableState<ListHubRecord>>) => (
|
|
253
|
+
<TablePropertiesDrawerButton {...drawerToolbarProps} state={s} />
|
|
254
|
+
),
|
|
255
|
+
bulkActionsSlot: (selected: Set<string | number>) => {
|
|
256
|
+
const n = selected.size
|
|
257
|
+
if (n === 0) return null
|
|
258
|
+
return (
|
|
259
|
+
<>
|
|
260
|
+
<span className="sr-only">{n} selected</span>
|
|
261
|
+
<Tip label="Export selection (demo)">
|
|
262
|
+
<Button size="sm" variant="outline" type="button">
|
|
263
|
+
<i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
|
|
264
|
+
Export
|
|
265
|
+
</Button>
|
|
266
|
+
</Tip>
|
|
267
|
+
</>
|
|
268
|
+
)
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const sharedToolbar = (
|
|
273
|
+
<DataTableToolbar
|
|
274
|
+
state={tableState}
|
|
275
|
+
columns={columns}
|
|
276
|
+
searchable={displayOptions.showToolbarSearch}
|
|
277
|
+
searchAriaLabel="Search records"
|
|
278
|
+
toolbarSlot={s => <TablePropertiesDrawerButton {...drawerToolbarProps} state={s} />}
|
|
279
|
+
/>
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
const toolbarShell = (body: React.ReactNode) => (
|
|
283
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
284
|
+
{sharedToolbar}
|
|
285
|
+
{body}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<ListPageConnectedViewBody
|
|
291
|
+
view={view}
|
|
292
|
+
hubLabel="List hub"
|
|
293
|
+
renderers={defineHubViewRenderers(LIST_HUB_SUPPORTED_VIEWS, {
|
|
294
|
+
"data-table": (
|
|
295
|
+
<div className="pb-6">
|
|
296
|
+
<DataTable<ListHubRecord> {...tableProps} />
|
|
297
|
+
</div>
|
|
298
|
+
),
|
|
299
|
+
"list-with-toolbar": toolbarShell(<ListHubListView rows={tableState.rows} />),
|
|
300
|
+
"board-with-toolbar": toolbarShell(<ListHubCardGrid rows={tableState.rows} />),
|
|
301
|
+
"calendar-with-toolbar": toolbarShell(
|
|
302
|
+
<ListPageCalendarView
|
|
303
|
+
rows={tableState.rows}
|
|
304
|
+
getRowId={row => row.id}
|
|
305
|
+
getEventDate={row => row.eventDate}
|
|
306
|
+
getEventLabel={row => row.title}
|
|
307
|
+
getEventMeta={row => row.category}
|
|
308
|
+
emptyMonthLabel="No events on this day."
|
|
309
|
+
ariaLabel="List hub calendar"
|
|
310
|
+
showSummaryPanel={displayOptions.showCalendarSummaryPanel}
|
|
311
|
+
calendarMainView={displayOptions.calendarMainView}
|
|
312
|
+
onCalendarMainViewChange={v => patchDisplay({ calendarMainView: v })}
|
|
313
|
+
/>,
|
|
314
|
+
),
|
|
315
|
+
"panel-with-toolbar": toolbarShell(
|
|
316
|
+
<ListPageSplitHubChrome aria-label="List hub panel view">
|
|
317
|
+
<FinderPanelView<ListHubRecord>
|
|
318
|
+
embedded
|
|
319
|
+
groupsColumnTitle="Records"
|
|
320
|
+
groups={panelGroupsBuilder(tableState.rows)}
|
|
321
|
+
rows={tableState.rows}
|
|
322
|
+
getRowId={row => row.id}
|
|
323
|
+
getRowGroupId={() => "all"}
|
|
324
|
+
autoSaveId="list-hub-panel-view"
|
|
325
|
+
renderListRow={panelRenderListRow}
|
|
326
|
+
renderDetail={panelRenderDetail}
|
|
327
|
+
emptyList={<p className="text-sm text-muted-foreground">No records found.</p>}
|
|
328
|
+
/>
|
|
329
|
+
</ListPageSplitHubChrome>
|
|
330
|
+
),
|
|
331
|
+
})}
|
|
332
|
+
/>
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
ListHubTable.displayName = "ListHubTable"
|
|
@@ -7,21 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* ├─ PageHeader (title + actions; parent trail is in `SiteHeader`)
|
|
9
9
|
* │ · "New question" + "V1 · Last updated …" subtitle
|
|
10
|
-
* │ ·
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* │ │ · Question prompt (h1-style Textarea — type-aware)
|
|
15
|
-
* │ │ · Answer block — varies by question type
|
|
16
|
-
* │ │ · Explanation / rubric / model answer
|
|
17
|
-
* │ │ · References (repeatable list)
|
|
18
|
-
* │ └─ Inspector (right, bg-card panel)
|
|
19
|
-
* │ · Question format (SelectionTileGrid → compact)
|
|
20
|
-
* │ · Location (folder SelectionTileGrid)
|
|
21
|
-
* │ · Difficulty / Bloom / NBME (chips)
|
|
22
|
-
* │ · Tags (Input + Badge list)
|
|
23
|
-
* │ Sidebar-style collapse (⌘⌥]) — collapsed rail mimics
|
|
24
|
-
* │ `NestedSecondaryPanelShell` icon mode.
|
|
10
|
+
* │ · Save question (⏎) + Save as draft + ⋯ discard (⌘⌥M)
|
|
11
|
+
* └─ Single column (`FocusedWorkflowPageTemplate` — see `docs/focused-workflow-page-pattern.md`)
|
|
12
|
+
* · Details — format, folder, difficulty, Bloom, NBME, tags
|
|
13
|
+
* · Question prompt, answer block, explanation, references
|
|
25
14
|
*
|
|
26
15
|
* Composes existing primitives — `PageHeader`, `Form`/`FormField`,
|
|
27
16
|
* `Input`, `Textarea`, `Checkbox`, `Badge`, `Button`, `Tip`, `Kbd`,
|
|
@@ -776,11 +765,9 @@ export function NewQuestionComposer({
|
|
|
776
765
|
const router = useRouter()
|
|
777
766
|
const [submitting, setSubmitting] = React.useState(false)
|
|
778
767
|
const [tagDraft, setTagDraft] = React.useState("")
|
|
779
|
-
const [inspectorOpen, setInspectorOpen] = React.useState(true)
|
|
780
768
|
const [moreOpen, setMoreOpen] = React.useState(false)
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
inspector stays compact for the rest of the authoring flow. */
|
|
769
|
+
const [inspectorOpen, setInspectorOpen] = React.useState(true)
|
|
770
|
+
/** Question-type chooser — collapses to a compact row after first pick. */
|
|
784
771
|
const [typeChooserOpen, setTypeChooserOpen] = React.useState(true)
|
|
785
772
|
/** Local folder list — extended in-place when the author adds one
|
|
786
773
|
from the location picker so the new entry is selectable without
|
|
@@ -1102,11 +1089,6 @@ export function NewQuestionComposer({
|
|
|
1102
1089
|
disabled={submitting}
|
|
1103
1090
|
onInvoke={() => setMoreOpen(o => !o)}
|
|
1104
1091
|
/>
|
|
1105
|
-
<Shortcut
|
|
1106
|
-
keys="⌘⌥]"
|
|
1107
|
-
disabled={submitting}
|
|
1108
|
-
onInvoke={() => setInspectorOpen(o => !o)}
|
|
1109
|
-
/>
|
|
1110
1092
|
|
|
1111
1093
|
<form
|
|
1112
1094
|
onSubmit={form.handleSubmit(values => persist({ ...values, status: "in_review" }, "publish"))}
|
|
@@ -52,6 +52,7 @@ export function ProductSwitcher() {
|
|
|
52
52
|
<DropdownMenuTrigger asChild>
|
|
53
53
|
<SidebarMenuButton
|
|
54
54
|
size="lg"
|
|
55
|
+
tooltip={iconRail ? current.label : undefined}
|
|
55
56
|
className={cn(
|
|
56
57
|
"items-start py-2 text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
|
|
57
58
|
expandedOrMobile &&
|
|
@@ -64,10 +65,9 @@ export function ProductSwitcher() {
|
|
|
64
65
|
suppressHydrationWarning
|
|
65
66
|
>
|
|
66
67
|
{iconRail ? (
|
|
67
|
-
// Collapsed icon-rail product mark
|
|
68
|
-
// school selector, without a white cutout patch on the rail.
|
|
68
|
+
// Collapsed icon-rail product mark — same frame as school avatar.
|
|
69
69
|
<span className="flex size-8 shrink-0 items-center justify-center">
|
|
70
|
-
<ExxatProductMark product={current.id} className="size-
|
|
70
|
+
<ExxatProductMark product={current.id} className="size-7" />
|
|
71
71
|
</span>
|
|
72
72
|
) : (
|
|
73
73
|
<>
|
|
@@ -92,8 +92,8 @@ export function ProductSwitcher() {
|
|
|
92
92
|
|
|
93
93
|
<DropdownMenuContent
|
|
94
94
|
align="start"
|
|
95
|
-
side="bottom"
|
|
96
|
-
sideOffset={4}
|
|
95
|
+
side={iconRail ? "right" : "bottom"}
|
|
96
|
+
sideOffset={iconRail ? 8 : 4}
|
|
97
97
|
>
|
|
98
98
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
99
99
|
Switch product
|
|
@@ -16,9 +16,8 @@
|
|
|
16
16
|
* recolored with the brand's gradient / fill. The SVG geometry stays
|
|
17
17
|
* constant so existing layouts keep working.
|
|
18
18
|
*
|
|
19
|
-
* `variant="mutedSuffix"` (
|
|
20
|
-
*
|
|
21
|
-
* into the rail. Light mode keeps the brand color for recognition.
|
|
19
|
+
* `variant="mutedSuffix"` (sidebar / switcher): prefix recedes in dark mode;
|
|
20
|
+
* suffix always uses `wordmarkColor` (Exxat pink) for brand recognition.
|
|
22
21
|
*/
|
|
23
22
|
|
|
24
23
|
import * as React from "react"
|
|
@@ -50,7 +49,6 @@ export function ProductWordmark({
|
|
|
50
49
|
}: ProductWordmarkProps) {
|
|
51
50
|
const prefix = config.prefix ?? "Exxat"
|
|
52
51
|
const { suffix, brandColor, wordmarkColor } = config
|
|
53
|
-
const mutedSuffix = variant === "mutedSuffix"
|
|
54
52
|
const suffixColor = wordmarkColor ?? brandColor
|
|
55
53
|
|
|
56
54
|
return (
|
|
@@ -100,14 +98,13 @@ export function ProductWordmark({
|
|
|
100
98
|
// that read as a logo; pushing to 700/800 makes the letterforms
|
|
101
99
|
// visually heavier than the brand asset.
|
|
102
100
|
"ms-[0.18em] font-semibold tracking-[-0.03em]",
|
|
103
|
-
// mutedSuffix: dark mode recedes to muted; light mode keeps brand.
|
|
104
|
-
mutedSuffix && "dark:!text-[var(--muted-foreground)]",
|
|
105
101
|
)}
|
|
106
102
|
style={{
|
|
107
103
|
// Ivy Presto Text from Adobe Fonts. Upright (NOT italic) — matches
|
|
108
104
|
// the official Exxat wordmark. Fallback chain ends in `serif` so
|
|
109
105
|
// FOUT still renders a serif that reads as a logo rather than Inter.
|
|
110
106
|
fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
|
|
107
|
+
// `wordmarkColor` (Exxat pink) in light and dark — never muted to grey.
|
|
111
108
|
color: suffixColor,
|
|
112
109
|
}}
|
|
113
110
|
>
|
|
@@ -155,7 +152,7 @@ function useBrowserPaintReady() {
|
|
|
155
152
|
* Fills:
|
|
156
153
|
* - Outer circle: `markGradient` if provided, else flat `brandColor`.
|
|
157
154
|
* - Inner shadow plate: `markShadow` (defaults to `brandColor`).
|
|
158
|
-
* - Cut-out "E" strokes: white
|
|
155
|
+
* - Cut-out "E" strokes: always white in product chrome (sidebar / switcher).
|
|
159
156
|
*/
|
|
160
157
|
export function ProductMark({ config, className, cutoutColor = "white" }: ProductMarkProps) {
|
|
161
158
|
const ready = useBrowserPaintReady()
|
|
@@ -27,6 +27,7 @@ import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
|
|
|
27
27
|
import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
|
|
28
28
|
import { DEFAULT_QUESTION_BANK_FOLDERS, type QuestionBankFolder } from "@/lib/mock/question-bank-folders"
|
|
29
29
|
import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
|
|
30
|
+
import { QUESTION_BANK_SUPPORTED_VIEWS } from "@/lib/question-bank-supported-views"
|
|
30
31
|
import {
|
|
31
32
|
applyQuestionBankHubDisplayFilters,
|
|
32
33
|
isQuestionBankDefaultNav,
|
|
@@ -122,7 +123,6 @@ export function QuestionBankClient() {
|
|
|
122
123
|
|
|
123
124
|
// Stable Set of tab ids — defaults are constant so this only updates if tabs change.
|
|
124
125
|
const tabIds = React.useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
|
|
125
|
-
|
|
126
126
|
// Keep the latest pathname / searchParamsKey / tabIds available to the (stable) hashchange
|
|
127
127
|
// listener via refs, so we don't re-subscribe a window listener on every URL change.
|
|
128
128
|
const navRef = React.useRef({ pathname, searchParamsKey, tabIds, hubBasePath })
|
|
@@ -314,6 +314,7 @@ export function QuestionBankClient() {
|
|
|
314
314
|
onFoldersChange={setFolders}
|
|
315
315
|
onItemsChange={setItems}
|
|
316
316
|
view={tab.viewType}
|
|
317
|
+
supportedViewTypes={QUESTION_BANK_SUPPORTED_VIEWS}
|
|
317
318
|
onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
|
|
318
319
|
/>
|
|
319
320
|
)}
|
|
@@ -417,6 +418,7 @@ export function QuestionBankClient() {
|
|
|
417
418
|
/>
|
|
418
419
|
)}
|
|
419
420
|
showMetrics={showMetrics}
|
|
421
|
+
supportedViewTypes={QUESTION_BANK_SUPPORTED_VIEWS}
|
|
420
422
|
exportOpen={exportOpen}
|
|
421
423
|
onExportOpenChange={setExportOpen}
|
|
422
424
|
exportTotalRows={count}
|
|
@@ -433,6 +435,7 @@ export function QuestionBankClient() {
|
|
|
433
435
|
onFoldersChange={setFolders}
|
|
434
436
|
onItemsChange={setItems}
|
|
435
437
|
view={tab.viewType}
|
|
438
|
+
supportedViewTypes={QUESTION_BANK_SUPPORTED_VIEWS}
|
|
436
439
|
onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
|
|
437
440
|
/>
|
|
438
441
|
)}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Button } from "@/components/ui/button"
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "@/components/ui/dropdown-menu"
|
|
11
|
+
import { cn } from "@/lib/utils"
|
|
12
|
+
import { ListPageFolderColumnsPanel } from "@/components/data-views/list-page-folder-columns-panel"
|
|
13
|
+
import { FolderDetailsShell } from "@/components/folder-details-shell"
|
|
14
|
+
import type { QuestionBankItem } from "@/lib/mock/question-bank"
|
|
15
|
+
import {
|
|
16
|
+
type QuestionBankFolder,
|
|
17
|
+
QUESTION_BANK_FOLDER_COLOR_STYLES,
|
|
18
|
+
QUESTION_BANK_FOLDER_ICON_COLORS,
|
|
19
|
+
} from "@/lib/mock/question-bank-folders"
|
|
20
|
+
|
|
21
|
+
function isQuestionBankFolder(
|
|
22
|
+
item: QuestionBankFolder | QuestionBankItem,
|
|
23
|
+
): item is QuestionBankFolder {
|
|
24
|
+
return "parentId" in item && !("stem" in item)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QuestionBankFolderColumnsPanelProps {
|
|
28
|
+
folders: QuestionBankFolder[]
|
|
29
|
+
rows: QuestionBankItem[]
|
|
30
|
+
panelRenderDetail: (row: QuestionBankItem) => React.ReactNode
|
|
31
|
+
onAddFolder: (parentId: string | null) => void
|
|
32
|
+
onAddQuestion: (parentId: string | null) => void
|
|
33
|
+
onCustomizeFolder?: (folder: QuestionBankFolder) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Question bank **panel** view — Miller columns over folders + questions. */
|
|
37
|
+
export function QuestionBankFolderColumnsPanel({
|
|
38
|
+
folders,
|
|
39
|
+
rows,
|
|
40
|
+
panelRenderDetail,
|
|
41
|
+
onAddFolder,
|
|
42
|
+
onAddQuestion,
|
|
43
|
+
onCustomizeFolder,
|
|
44
|
+
}: QuestionBankFolderColumnsPanelProps) {
|
|
45
|
+
return (
|
|
46
|
+
<ListPageFolderColumnsPanel<QuestionBankFolder, QuestionBankItem>
|
|
47
|
+
folders={folders}
|
|
48
|
+
items={rows}
|
|
49
|
+
isFolder={isQuestionBankFolder}
|
|
50
|
+
getFolderParentId={f => f.parentId}
|
|
51
|
+
getFolderName={f => f.name}
|
|
52
|
+
getItemFolderId={i => i.folderId}
|
|
53
|
+
getItemLabel={i => i.stem}
|
|
54
|
+
renderItemDetail={panelRenderDetail}
|
|
55
|
+
onAddFolder={onAddFolder}
|
|
56
|
+
onAddItem={onAddQuestion}
|
|
57
|
+
addItemAriaLabel="Add question"
|
|
58
|
+
renderFolderDetail={(folder, { folders: allFolders, items }) => (
|
|
59
|
+
<FolderDetailsShell folder={folder} folders={allFolders} questions={items} />
|
|
60
|
+
)}
|
|
61
|
+
renderFolderRowClassName={(folder, { isSelected, depth }) =>
|
|
62
|
+
!isSelected && folder.colorKey && depth > 0
|
|
63
|
+
? QUESTION_BANK_FOLDER_COLOR_STYLES[folder.colorKey]?.tile
|
|
64
|
+
: undefined
|
|
65
|
+
}
|
|
66
|
+
renderFolderIcon={(folder, { isSelected }) => (
|
|
67
|
+
<i
|
|
68
|
+
className={cn(
|
|
69
|
+
"fa-folder shrink-0 text-sm",
|
|
70
|
+
isSelected ? "fa-solid" : "fa-light",
|
|
71
|
+
folder.colorKey && QUESTION_BANK_FOLDER_ICON_COLORS[folder.colorKey],
|
|
72
|
+
)}
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
renderItemMeta={(item, { isSelected }) =>
|
|
77
|
+
item.type === "multiple_choice"
|
|
78
|
+
? "MCQ"
|
|
79
|
+
: item.difficulty?.charAt(0).toUpperCase() ?? ""
|
|
80
|
+
}
|
|
81
|
+
renderFolderActions={folder => (
|
|
82
|
+
<DropdownMenu>
|
|
83
|
+
<DropdownMenuTrigger asChild>
|
|
84
|
+
<Button
|
|
85
|
+
type="button"
|
|
86
|
+
size="icon-xs"
|
|
87
|
+
variant="ghost"
|
|
88
|
+
aria-label={`Actions for folder ${folder.name}`}
|
|
89
|
+
className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
|
90
|
+
>
|
|
91
|
+
<i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
|
|
92
|
+
</Button>
|
|
93
|
+
</DropdownMenuTrigger>
|
|
94
|
+
<DropdownMenuContent align="end">
|
|
95
|
+
<DropdownMenuItem onSelect={() => onCustomizeFolder?.(folder)}>
|
|
96
|
+
<i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
|
|
97
|
+
Customize
|
|
98
|
+
</DropdownMenuItem>
|
|
99
|
+
</DropdownMenuContent>
|
|
100
|
+
</DropdownMenu>
|
|
101
|
+
)}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -254,11 +254,8 @@ export function QuestionBankHubClient() {
|
|
|
254
254
|
aria-label="Search and create questions"
|
|
255
255
|
className="-mx-4 overflow-hidden px-4 py-6 md:-mx-6 md:px-6"
|
|
256
256
|
style={{
|
|
257
|
-
background:
|
|
258
|
-
|
|
259
|
-
"linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
|
|
260
|
-
].join(", "),
|
|
261
|
-
boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
|
|
257
|
+
background: "var(--key-metrics-flat-band-radial)",
|
|
258
|
+
boxShadow: "var(--key-metrics-flat-band-shadow)",
|
|
262
259
|
}}
|
|
263
260
|
>
|
|
264
261
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 md:px-6">
|