@exxatdesignux/ui 0.2.17 → 0.2.18
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 +15 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +1 -1
- 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 +6 -1
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
- package/package.json +1 -1
- package/src/components/ui/sidebar.tsx +2 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/AGENTS.md +11 -4
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +44 -14
- package/template/app/layout.tsx +2 -0
- package/template/components/app-sidebar.tsx +4 -3
- package/template/components/compliance-table.tsx +0 -20
- package/template/components/data-table/index.tsx +31 -67
- package/template/components/data-table/use-table-state.ts +33 -6
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/exxat-product-logo.tsx +2 -6
- package/template/components/key-metrics.tsx +54 -22
- package/template/components/placement-board-card.tsx +1 -1
- package/template/components/placements-list-view.tsx +1 -1
- package/template/components/placements-table.tsx +3 -36
- package/template/components/product-switcher.tsx +2 -3
- package/template/components/product-wordmark.tsx +4 -7
- package/template/components/question-bank-hub-client.tsx +2 -5
- package/template/components/question-bank-table.tsx +12 -24
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/sites-table.tsx +0 -20
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +16 -13
- package/template/components/team-table.tsx +0 -21
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +2 -2
- package/template/contexts/product-context.tsx +21 -2
- package/template/docs/data-views-pattern.md +2 -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 +52 -0
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-view.ts +6 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +58 -11
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* PlacementsTable — placements hub composition on top of the generic
|
|
5
5
|
* `DataTable`. Owns: placement-specific column defs, board column grouping,
|
|
6
6
|
* KPI dashboards, the "open table properties" imperative handle, and the
|
|
7
|
-
*
|
|
7
|
+
* Properties drawer + multi-view composition (table / list / board / …).
|
|
8
8
|
*
|
|
9
9
|
* NOTE: this is hub composition, NOT a parallel table primitive. Every hub
|
|
10
10
|
* has its own `*-table.tsx` of the same shape (`team-table.tsx`,
|
|
@@ -57,12 +57,6 @@ import {
|
|
|
57
57
|
DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
|
|
58
58
|
type DataListDisplayOptions,
|
|
59
59
|
} from "@/lib/data-list-display-options"
|
|
60
|
-
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
61
|
-
import type { PlacementsLifecycleExtras } from "@/lib/data-list-persistence"
|
|
62
|
-
|
|
63
|
-
/** Storage namespace for the placements hub. Keep `"data-list"` so existing
|
|
64
|
-
* user payloads in localStorage remain readable. */
|
|
65
|
-
const PLACEMENTS_LIFECYCLE_NAMESPACE = "data-list"
|
|
66
60
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
67
61
|
import { StatusBadge } from "@/components/placements-table-cells"
|
|
68
62
|
import { columnsToFilterFields } from "@/components/placements-table-columns"
|
|
@@ -543,7 +537,7 @@ function PlacementFolderTile({
|
|
|
543
537
|
conditionalRules?: ConditionalRule[]
|
|
544
538
|
onClick: () => void
|
|
545
539
|
}) {
|
|
546
|
-
const ruleBg = getConditionalRowBackground(row, conditionalRules)
|
|
540
|
+
const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
|
|
547
541
|
const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
|
|
548
542
|
const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
|
|
549
543
|
const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
|
|
@@ -882,7 +876,7 @@ function PlacementFinderListRow({
|
|
|
882
876
|
boardColumns: ColumnDef<Placement>[]
|
|
883
877
|
conditionalRules?: ConditionalRule[]
|
|
884
878
|
}) {
|
|
885
|
-
const ruleBg = getConditionalRowBackground(row, conditionalRules)
|
|
879
|
+
const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
|
|
886
880
|
const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
|
|
887
881
|
const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
|
|
888
882
|
const name = showStudent ? row.student : `Placement ${row.id}`
|
|
@@ -1341,39 +1335,12 @@ export const PlacementsTable = React.forwardRef<PlacementsTableHandle, Placement
|
|
|
1341
1335
|
|
|
1342
1336
|
const tableState = useTableState(tableData, columns, { key: "student", dir: "asc" }, paginationOverride)
|
|
1343
1337
|
|
|
1344
|
-
const columnKeys = React.useMemo(() => new Set(columns.map(c => c.key)), [columns])
|
|
1345
|
-
|
|
1346
1338
|
// Stable "open properties drawer" callback ref — see top of this file.
|
|
1347
1339
|
React.useEffect(() => {
|
|
1348
1340
|
openDrawerRef.current = () => tableState.setSheetOpen(true)
|
|
1349
1341
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1350
1342
|
}, [openDrawerRef, tableState.setSheetOpen])
|
|
1351
1343
|
|
|
1352
|
-
// ── Lifecycle persistence ─────────────────────────────────────────────
|
|
1353
|
-
// Centralised in `lib/table-state-lifecycle` — one hook wires both the
|
|
1354
|
-
// load (layout effect) and the debounced save (effect) including all the
|
|
1355
|
-
// table slices plus placements-specific extras. Hubs that don't want
|
|
1356
|
-
// localStorage persistence simply don't call this hook.
|
|
1357
|
-
useTableStateLifecycle<PlacementsLifecycleExtras>({
|
|
1358
|
-
namespace: PLACEMENTS_LIFECYCLE_NAMESPACE,
|
|
1359
|
-
tabId: lifecycleTabId,
|
|
1360
|
-
tableState,
|
|
1361
|
-
columnKeys,
|
|
1362
|
-
extras: {
|
|
1363
|
-
conditionalRules,
|
|
1364
|
-
pagination,
|
|
1365
|
-
paginationPage: safePage,
|
|
1366
|
-
paginationPageSize,
|
|
1367
|
-
},
|
|
1368
|
-
onLoadExtras: e => {
|
|
1369
|
-
if (!e) return
|
|
1370
|
-
if (Array.isArray(e.conditionalRules)) setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
1371
|
-
if (typeof e.pagination === "boolean") setPagination(e.pagination)
|
|
1372
|
-
if (typeof e.paginationPage === "number") setPaginationPage(e.paginationPage)
|
|
1373
|
-
if (typeof e.paginationPageSize === "number") setPaginationPageSize(e.paginationPageSize)
|
|
1374
|
-
},
|
|
1375
|
-
})
|
|
1376
|
-
|
|
1377
1344
|
function buildToolbarSlot(
|
|
1378
1345
|
s: ReturnType<typeof useTableState<Placement>>,
|
|
1379
1346
|
): React.ReactNode {
|
|
@@ -64,10 +64,9 @@ export function ProductSwitcher() {
|
|
|
64
64
|
suppressHydrationWarning
|
|
65
65
|
>
|
|
66
66
|
{iconRail ? (
|
|
67
|
-
// Collapsed icon-rail product mark
|
|
68
|
-
// school selector, without a white cutout patch on the rail.
|
|
67
|
+
// Collapsed icon-rail product mark — same frame as school avatar.
|
|
69
68
|
<span className="flex size-8 shrink-0 items-center justify-center">
|
|
70
|
-
<ExxatProductMark product={current.id} className="size-
|
|
69
|
+
<ExxatProductMark product={current.id} className="size-7" />
|
|
71
70
|
</span>
|
|
72
71
|
) : (
|
|
73
72
|
<>
|
|
@@ -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()
|
|
@@ -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">
|
|
@@ -12,7 +12,6 @@ import type { DataListViewType } from "@/lib/data-list-view"
|
|
|
12
12
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
13
13
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
14
14
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
15
|
-
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
16
15
|
import { TablePropertiesDrawerButton } from "@/components/table-properties"
|
|
17
16
|
import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
|
|
18
17
|
import { Button } from "@/components/ui/button"
|
|
@@ -656,7 +655,18 @@ export const QuestionBankTable = React.forwardRef<
|
|
|
656
655
|
onItemsChange: React.Dispatch<React.SetStateAction<QuestionBankItem[]>>
|
|
657
656
|
}
|
|
658
657
|
>(function QuestionBankTable(
|
|
659
|
-
{
|
|
658
|
+
{
|
|
659
|
+
items,
|
|
660
|
+
navState,
|
|
661
|
+
urlListSearch,
|
|
662
|
+
searchLanding,
|
|
663
|
+
landingFilters,
|
|
664
|
+
view = "table",
|
|
665
|
+
onViewChange,
|
|
666
|
+
folders,
|
|
667
|
+
onFoldersChange,
|
|
668
|
+
onItemsChange,
|
|
669
|
+
},
|
|
660
670
|
ref,
|
|
661
671
|
) {
|
|
662
672
|
const tableSourceItems = React.useMemo(() => {
|
|
@@ -718,28 +728,6 @@ export const QuestionBankTable = React.forwardRef<
|
|
|
718
728
|
searchLanding ? undefined : urlListSearch,
|
|
719
729
|
)
|
|
720
730
|
|
|
721
|
-
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
722
|
-
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
|
|
723
|
-
// NOTE: tabId is `"main"` here — the question-bank folder scope is
|
|
724
|
-
// already URL-driven (`?scope=`, `?folderId=`), so we only persist
|
|
725
|
-
// table chrome, not navigation.
|
|
726
|
-
const lifecycleColumnKeys = React.useMemo(
|
|
727
|
-
() => new Set(columns.map(c => c.key)),
|
|
728
|
-
[columns],
|
|
729
|
-
)
|
|
730
|
-
useTableStateLifecycle({
|
|
731
|
-
namespace: "question-bank",
|
|
732
|
-
tabId: "main",
|
|
733
|
-
tableState,
|
|
734
|
-
columnKeys: lifecycleColumnKeys,
|
|
735
|
-
extras: { conditionalRules },
|
|
736
|
-
onLoadExtras: e => {
|
|
737
|
-
if (e && Array.isArray(e.conditionalRules)) {
|
|
738
|
-
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
739
|
-
}
|
|
740
|
-
},
|
|
741
|
-
})
|
|
742
|
-
|
|
743
731
|
const openNewFolderForColumn = React.useCallback((parentId: string | null) => {
|
|
744
732
|
setNewFolderParentId(parentId)
|
|
745
733
|
setCustomizingFolder(null)
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* SidebarShell — SidebarProvider with layout-aware widths.
|
|
5
5
|
* Desktop expanded/collapsed is persisted in the `sidebar_state` cookie by `@exxatdesignux/ui`
|
|
6
|
-
* `SidebarProvider` (read on mount + write on toggle).
|
|
6
|
+
* `SidebarProvider` (read on mount + write on toggle). `(app)/layout` passes
|
|
7
|
+
* `defaultOpen` from the same cookie on the server so SSR matches the first client paint.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import * as React from "react"
|
|
@@ -10,7 +10,6 @@ import Link from "next/link"
|
|
|
10
10
|
import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
|
|
11
11
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
12
12
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
13
|
-
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
14
13
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
15
14
|
import type { DataListViewType } from "@/lib/data-list-view"
|
|
16
15
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
@@ -194,25 +193,6 @@ export const SitesTable = React.forwardRef<
|
|
|
194
193
|
|
|
195
194
|
const tableState = useTableState<SiteDirectoryRow>(sites, columns, { key: "name", dir: "asc" })
|
|
196
195
|
|
|
197
|
-
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
198
|
-
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
|
|
199
|
-
const lifecycleColumnKeys = React.useMemo(
|
|
200
|
-
() => new Set(columns.map(c => c.key)),
|
|
201
|
-
[columns],
|
|
202
|
-
)
|
|
203
|
-
useTableStateLifecycle({
|
|
204
|
-
namespace: "sites",
|
|
205
|
-
tabId: "main",
|
|
206
|
-
tableState,
|
|
207
|
-
columnKeys: lifecycleColumnKeys,
|
|
208
|
-
extras: { conditionalRules },
|
|
209
|
-
onLoadExtras: e => {
|
|
210
|
-
if (e && Array.isArray(e.conditionalRules)) {
|
|
211
|
-
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
})
|
|
215
|
-
|
|
216
196
|
React.useImperativeHandle(
|
|
217
197
|
ref,
|
|
218
198
|
() => ({
|
|
@@ -151,6 +151,24 @@ export function TablePropertiesDrawerButton({
|
|
|
151
151
|
sortKey,
|
|
152
152
|
} = state
|
|
153
153
|
|
|
154
|
+
// Sheet is portaled; keep latest handlers so sort/filter/conditional edits are not lost.
|
|
155
|
+
const stateRef = React.useRef(state)
|
|
156
|
+
stateRef.current = state
|
|
157
|
+
const ruleHandlersRef = React.useRef({
|
|
158
|
+
onAddConditionalRule,
|
|
159
|
+
onRemoveConditionalRule,
|
|
160
|
+
onUpdateConditionalRule,
|
|
161
|
+
onDisplayOptionsChange,
|
|
162
|
+
onPaginationChange,
|
|
163
|
+
})
|
|
164
|
+
ruleHandlersRef.current = {
|
|
165
|
+
onAddConditionalRule,
|
|
166
|
+
onRemoveConditionalRule,
|
|
167
|
+
onUpdateConditionalRule,
|
|
168
|
+
onDisplayOptionsChange,
|
|
169
|
+
onPaginationChange,
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
return (
|
|
155
173
|
<>
|
|
156
174
|
{extraActions}
|
|
@@ -185,42 +203,42 @@ export function TablePropertiesDrawerButton({
|
|
|
185
203
|
rowHeight={rowHeight}
|
|
186
204
|
onRowHeightChange={setRowHeight}
|
|
187
205
|
pagination={pagination}
|
|
188
|
-
onPaginationChange={
|
|
206
|
+
onPaginationChange={v => ruleHandlersRef.current.onPaginationChange?.(v)}
|
|
189
207
|
activeFilters={activeFilters}
|
|
190
|
-
onAddFilter={fieldKey => addFilter(fieldKey, true)}
|
|
191
|
-
onUpdateFilter={updateFilter}
|
|
192
|
-
onRemoveFilter={removeFilter}
|
|
193
|
-
getFilterConnector={getConnector}
|
|
194
|
-
onToggleFilterConnector={toggleConnector}
|
|
208
|
+
onAddFilter={fieldKey => stateRef.current.addFilter(fieldKey, true)}
|
|
209
|
+
onUpdateFilter={(id, patch) => stateRef.current.updateFilter(id, patch)}
|
|
210
|
+
onRemoveFilter={id => stateRef.current.removeFilter(id)}
|
|
211
|
+
getFilterConnector={leftId => stateRef.current.getConnector(leftId)}
|
|
212
|
+
onToggleFilterConnector={leftId => stateRef.current.toggleConnector(leftId)}
|
|
195
213
|
filterBarVisible={filterBarVisible}
|
|
196
|
-
onFilterBarVisibleChange={setFilterBarVisible}
|
|
214
|
+
onFilterBarVisibleChange={v => stateRef.current.setFilterBarVisible(v)}
|
|
197
215
|
drawerExpandedFilters={drawerExpandedFilters}
|
|
198
|
-
onDrawerExpandedFiltersChange={setDrawerExpandedFilters}
|
|
216
|
+
onDrawerExpandedFiltersChange={stateRef.current.setDrawerExpandedFilters}
|
|
199
217
|
totalRows={totalRows}
|
|
200
218
|
filteredRows={rows.length}
|
|
201
219
|
sortRules={sortRules}
|
|
202
|
-
onSortRulesChange={setSortRules}
|
|
203
|
-
onAddSortRule={addSortRule}
|
|
204
|
-
onRemoveSortRule={removeSortRule}
|
|
205
|
-
onToggleSortDir={toggleSortDir}
|
|
220
|
+
onSortRulesChange={rules => stateRef.current.setSortRules(rules)}
|
|
221
|
+
onAddSortRule={fieldKey => stateRef.current.addSortRule(fieldKey)}
|
|
222
|
+
onRemoveSortRule={id => stateRef.current.removeSortRule(id)}
|
|
223
|
+
onToggleSortDir={id => stateRef.current.toggleSortDir(id)}
|
|
206
224
|
colOrder={colOrder}
|
|
207
|
-
onColOrderChange={setColOrder}
|
|
225
|
+
onColOrderChange={order => stateRef.current.setColOrder(order)}
|
|
208
226
|
hiddenCols={hiddenCols}
|
|
209
|
-
onToggleColVisibility={toggleColVisibility}
|
|
210
|
-
onMoveCol={moveCol}
|
|
227
|
+
onToggleColVisibility={key => stateRef.current.toggleColVisibility(key)}
|
|
228
|
+
onMoveCol={(key, dir) => stateRef.current.moveCol(key, dir)}
|
|
211
229
|
groupBy={groupBy}
|
|
212
|
-
onGroupByChange={setGroupBy}
|
|
230
|
+
onGroupByChange={key => stateRef.current.setGroupBy(key)}
|
|
213
231
|
primarySortKey={sortKey}
|
|
214
232
|
conditionalRules={conditionalRules}
|
|
215
|
-
onAddConditionalRule={onAddConditionalRule}
|
|
216
|
-
onRemoveConditionalRule={onRemoveConditionalRule}
|
|
217
|
-
onUpdateConditionalRule={onUpdateConditionalRule}
|
|
233
|
+
onAddConditionalRule={rule => ruleHandlersRef.current.onAddConditionalRule(rule)}
|
|
234
|
+
onRemoveConditionalRule={id => ruleHandlersRef.current.onRemoveConditionalRule(id)}
|
|
235
|
+
onUpdateConditionalRule={(id, patch) => ruleHandlersRef.current.onUpdateConditionalRule(id, patch)}
|
|
218
236
|
filterFields={filterFields}
|
|
219
237
|
lifecycleTabLabel={lifecycleTabLabel}
|
|
220
238
|
fieldDefinitions={fieldDefinitions}
|
|
221
239
|
resolveColumnLabel={resolveColumnLabel}
|
|
222
240
|
displayOptions={displayOptions}
|
|
223
|
-
onDisplayOptionsChange={onDisplayOptionsChange}
|
|
241
|
+
onDisplayOptionsChange={patch => ruleHandlersRef.current.onDisplayOptionsChange(patch)}
|
|
224
242
|
currentView={currentView}
|
|
225
243
|
onViewChange={onViewChange}
|
|
226
244
|
boardGroupByColumnOptions={boardGroupByColumnOptions}
|
|
@@ -117,6 +117,9 @@ export interface TablePropertiesDrawerProps {
|
|
|
117
117
|
|
|
118
118
|
type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules"
|
|
119
119
|
|
|
120
|
+
/** Properties sheet uses `z-[80]`; default portaled menus are `z-50` and sit underneath. */
|
|
121
|
+
const PROPERTIES_SHEET_PORTAL_Z = "z-[90]"
|
|
122
|
+
|
|
120
123
|
export function TablePropertiesDrawer({
|
|
121
124
|
open,
|
|
122
125
|
onOpenChange,
|
|
@@ -243,7 +246,7 @@ export function TablePropertiesDrawer({
|
|
|
243
246
|
: "—"
|
|
244
247
|
|
|
245
248
|
return (
|
|
246
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
249
|
+
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
|
247
250
|
<SheetContent
|
|
248
251
|
side="right"
|
|
249
252
|
showCloseButton={false}
|
|
@@ -462,7 +465,7 @@ export function TablePropertiesDrawer({
|
|
|
462
465
|
>
|
|
463
466
|
<SelectValue />
|
|
464
467
|
</SelectTrigger>
|
|
465
|
-
<SelectContent align="end">
|
|
468
|
+
<SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
466
469
|
{boardGroupByColumnOptions.map(o => (
|
|
467
470
|
<SelectItem key={o.key} value={o.key}>
|
|
468
471
|
{o.label}
|
|
@@ -533,7 +536,7 @@ export function TablePropertiesDrawer({
|
|
|
533
536
|
<SelectTrigger size="sm" className="w-[6.5rem] shrink-0" id="board-line-count" aria-label="Line count">
|
|
534
537
|
<SelectValue />
|
|
535
538
|
</SelectTrigger>
|
|
536
|
-
<SelectContent align="end">
|
|
539
|
+
<SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
537
540
|
<SelectItem value="1">1 line</SelectItem>
|
|
538
541
|
<SelectItem value="2">2 lines</SelectItem>
|
|
539
542
|
<SelectItem value="3">3 lines</SelectItem>
|
|
@@ -659,7 +662,7 @@ export function TablePropertiesDrawer({
|
|
|
659
662
|
{[
|
|
660
663
|
{ icon: "fa-circle-1", text: "Click \"Add filter\" below" },
|
|
661
664
|
{ icon: "fa-circle-2", text: "Choose a field to filter by" },
|
|
662
|
-
{ icon: "fa-circle-3", text: "Pick
|
|
665
|
+
{ icon: "fa-circle-3", text: "Pick at least one value — the grid updates immediately" },
|
|
663
666
|
].map(step => (
|
|
664
667
|
<div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
665
668
|
<i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
|
|
@@ -726,7 +729,7 @@ export function TablePropertiesDrawer({
|
|
|
726
729
|
|
|
727
730
|
{/* Add filter + Remove all */}
|
|
728
731
|
<div className="flex items-center gap-2 pt-2">
|
|
729
|
-
<DropdownMenu>
|
|
732
|
+
<DropdownMenu modal={false}>
|
|
730
733
|
<DropdownMenuTrigger asChild>
|
|
731
734
|
<Button
|
|
732
735
|
type="button"
|
|
@@ -737,11 +740,11 @@ export function TablePropertiesDrawer({
|
|
|
737
740
|
Add filter
|
|
738
741
|
</Button>
|
|
739
742
|
</DropdownMenuTrigger>
|
|
740
|
-
<DropdownMenuContent align="start">
|
|
743
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
741
744
|
<DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
|
|
742
745
|
<DropdownMenuSeparator />
|
|
743
746
|
{filterFields.map(f => (
|
|
744
|
-
<DropdownMenuItem key={f.key}
|
|
747
|
+
<DropdownMenuItem key={f.key} onSelect={() => onAddFilter(f.key)}>
|
|
745
748
|
<i className={`fa-light ${f.icon}`} aria-hidden="true" />
|
|
746
749
|
{f.label}
|
|
747
750
|
</DropdownMenuItem>
|
|
@@ -833,7 +836,7 @@ export function TablePropertiesDrawer({
|
|
|
833
836
|
|
|
834
837
|
{/* Add sort + Remove all */}
|
|
835
838
|
<div className="flex items-center gap-2 pt-2">
|
|
836
|
-
<DropdownMenu>
|
|
839
|
+
<DropdownMenu modal={false}>
|
|
837
840
|
<DropdownMenuTrigger asChild>
|
|
838
841
|
<Button
|
|
839
842
|
type="button"
|
|
@@ -844,11 +847,11 @@ export function TablePropertiesDrawer({
|
|
|
844
847
|
Add sort
|
|
845
848
|
</Button>
|
|
846
849
|
</DropdownMenuTrigger>
|
|
847
|
-
<DropdownMenuContent align="start">
|
|
850
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
848
851
|
<DropdownMenuLabel className="text-xs">Sort by field</DropdownMenuLabel>
|
|
849
852
|
<DropdownMenuSeparator />
|
|
850
853
|
{sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).map(col => (
|
|
851
|
-
<DropdownMenuItem key={col.key}
|
|
854
|
+
<DropdownMenuItem key={col.key} onSelect={() => onAddSortRule(col.key)}>
|
|
852
855
|
<i className="fa-light fa-arrow-up-arrow-down text-xs" aria-hidden="true" />
|
|
853
856
|
{col.label}
|
|
854
857
|
</DropdownMenuItem>
|
|
@@ -1052,7 +1055,7 @@ function ConditionalRulesPanel({
|
|
|
1052
1055
|
)}
|
|
1053
1056
|
|
|
1054
1057
|
<div className="flex items-center gap-2 pt-2">
|
|
1055
|
-
<DropdownMenu>
|
|
1058
|
+
<DropdownMenu modal={false}>
|
|
1056
1059
|
<DropdownMenuTrigger asChild>
|
|
1057
1060
|
<Button
|
|
1058
1061
|
type="button"
|
|
@@ -1063,13 +1066,13 @@ function ConditionalRulesPanel({
|
|
|
1063
1066
|
Add rule
|
|
1064
1067
|
</Button>
|
|
1065
1068
|
</DropdownMenuTrigger>
|
|
1066
|
-
<DropdownMenuContent align="start">
|
|
1069
|
+
<DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
|
|
1067
1070
|
<DropdownMenuLabel className="text-xs">Rule for column</DropdownMenuLabel>
|
|
1068
1071
|
<DropdownMenuSeparator />
|
|
1069
1072
|
{filterFields.map(f => (
|
|
1070
1073
|
<DropdownMenuItem
|
|
1071
1074
|
key={f.key}
|
|
1072
|
-
|
|
1075
|
+
onSelect={() => onAdd({
|
|
1073
1076
|
fieldKey: f.key,
|
|
1074
1077
|
operator: f.operators[0],
|
|
1075
1078
|
values: [],
|
|
@@ -38,7 +38,6 @@ import type { DataListViewType } from "@/lib/data-list-view"
|
|
|
38
38
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
39
39
|
import type { ColumnDef } from "@/components/data-table/types"
|
|
40
40
|
import { useTableState } from "@/components/data-table/use-table-state"
|
|
41
|
-
import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
|
|
42
41
|
import { TablePropertiesDrawerButton } from "@/components/table-properties"
|
|
43
42
|
import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
|
|
44
43
|
import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
|
|
@@ -402,26 +401,6 @@ export const TeamTable = React.forwardRef<
|
|
|
402
401
|
|
|
403
402
|
const tableState = useTableState(members, columns, { key: "name", dir: "asc" })
|
|
404
403
|
|
|
405
|
-
// Persist this hub's table lifecycle (sort / search / filters / column
|
|
406
|
-
// visibility / etc.) to localStorage. See `lib/table-state-lifecycle` for
|
|
407
|
-
// the centralised hook; pass `extras` for any non-table state.
|
|
408
|
-
const lifecycleColumnKeys = React.useMemo(
|
|
409
|
-
() => new Set(columns.map(c => c.key)),
|
|
410
|
-
[columns],
|
|
411
|
-
)
|
|
412
|
-
useTableStateLifecycle({
|
|
413
|
-
namespace: "team",
|
|
414
|
-
tabId: "main",
|
|
415
|
-
tableState,
|
|
416
|
-
columnKeys: lifecycleColumnKeys,
|
|
417
|
-
extras: { conditionalRules },
|
|
418
|
-
onLoadExtras: e => {
|
|
419
|
-
if (e && Array.isArray(e.conditionalRules)) {
|
|
420
|
-
setConditionalRules(e.conditionalRules as ConditionalRule[])
|
|
421
|
-
}
|
|
422
|
-
},
|
|
423
|
-
})
|
|
424
|
-
|
|
425
404
|
const dashboardKpi = React.useMemo(
|
|
426
405
|
() => ({
|
|
427
406
|
metrics: teamKpiMetrics(tableState.rows),
|
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
Shortcut,
|
|
48
48
|
} from "@/components/ui/dropdown-menu"
|
|
49
49
|
import type { DataListViewType } from "@/lib/data-list-view"
|
|
50
|
-
import { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
|
|
50
|
+
import { DATA_LIST_VIEW_TILES, dataListViewAddShortcut } from "@/lib/data-list-view"
|
|
51
51
|
import {
|
|
52
52
|
createListPageEditViewHandler,
|
|
53
53
|
type OpenTablePropertiesHandle,
|
|
@@ -273,13 +273,16 @@ export function ListPageTemplate({
|
|
|
273
273
|
|
|
274
274
|
return (
|
|
275
275
|
<>
|
|
276
|
-
{!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) =>
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
276
|
+
{!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => {
|
|
277
|
+
const keys = dataListViewAddShortcut(i)
|
|
278
|
+
return keys ? (
|
|
279
|
+
<Shortcut
|
|
280
|
+
key={v.type}
|
|
281
|
+
keys={keys}
|
|
282
|
+
onInvoke={() => addView(v.type)}
|
|
283
|
+
/>
|
|
284
|
+
) : null
|
|
285
|
+
})}
|
|
283
286
|
{activeTab && !hideViewsToolbar && (
|
|
284
287
|
<>
|
|
285
288
|
<Shortcut keys="F2" onInvoke={() => openRename(activeTab)} />
|
|
@@ -480,7 +483,7 @@ export function ListPageTemplate({
|
|
|
480
483
|
{VIEW_TYPES.map((v, i) => (
|
|
481
484
|
<DropdownMenuItem
|
|
482
485
|
key={v.type}
|
|
483
|
-
shortcut={i
|
|
486
|
+
shortcut={dataListViewAddShortcut(i)}
|
|
484
487
|
onSelect={() => addView(v.type)}
|
|
485
488
|
>
|
|
486
489
|
<i className={`fa-light ${v.icon}`} aria-hidden="true" />
|
|
@@ -15,7 +15,7 @@ export interface NestedSecondaryPanelShellProps {
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Shared chrome for a nested hub rail — full width vs icon rail.
|
|
18
|
-
* Fill uses `--secondary-panel-bg`
|
|
18
|
+
* Fill uses `--secondary-panel-bg` — one step lighter than `--sidebar` (elevation 1).
|
|
19
19
|
*/
|
|
20
20
|
export function NestedSecondaryPanelShell({
|
|
21
21
|
open,
|
|
@@ -40,7 +40,7 @@ export function NestedSecondaryPanelShell({
|
|
|
40
40
|
// 2rem on mobile where the panel scrolls inline and we leave
|
|
41
41
|
// a little more breathing room). No upper cap so tall screens
|
|
42
42
|
// get a fully-extended rail.
|
|
43
|
-
"shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
|
|
43
|
+
"shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
|
|
44
44
|
compact
|
|
45
45
|
? "w-12 min-w-12 max-w-12 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]"
|
|
46
46
|
: "w-64 min-w-64 max-w-64 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]",
|
|
@@ -10,7 +10,26 @@
|
|
|
10
10
|
|
|
11
11
|
import * as React from "react"
|
|
12
12
|
import { useAppStore, type Product } from "@/stores/app-store"
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
brandForProduct,
|
|
15
|
+
EXXAT_ASSESSMENT_BRAND,
|
|
16
|
+
EXXAT_ONE_BRAND,
|
|
17
|
+
EXXAT_PRISM_BRAND,
|
|
18
|
+
} from "@/lib/product-brand"
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PRODUCT_ACCENT: Record<Product, string> = {
|
|
21
|
+
"exxat-one": EXXAT_ONE_BRAND.brandColor,
|
|
22
|
+
"exxat-prism": EXXAT_PRISM_BRAND.brandColor,
|
|
23
|
+
"exxat-assessment": EXXAT_ASSESSMENT_BRAND.brandColor,
|
|
24
|
+
"exxat-custom": "",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function accentOverrideActive(product: Product, override: string | undefined): boolean {
|
|
28
|
+
if (!override?.trim()) return false
|
|
29
|
+
const defaultAccent = DEFAULT_PRODUCT_ACCENT[product]?.trim()
|
|
30
|
+
if (!defaultAccent) return true
|
|
31
|
+
return override.trim().toLowerCase() !== defaultAccent.toLowerCase()
|
|
32
|
+
}
|
|
14
33
|
|
|
15
34
|
export type { Product }
|
|
16
35
|
|
|
@@ -62,7 +81,7 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
|
|
|
62
81
|
// / theme-assessment` classes (with bespoke hue formulas in
|
|
63
82
|
// `globals.css`) are still used for the **default** look of each
|
|
64
83
|
// built-in.
|
|
65
|
-
const hasAccentOverride =
|
|
84
|
+
const hasAccentOverride = accentOverrideActive(product, productBrandColors[product])
|
|
66
85
|
let themeClass: "theme-one" | "theme-prism" | "theme-assessment" | "theme-custom"
|
|
67
86
|
if (hasAccentOverride) {
|
|
68
87
|
themeClass = "theme-custom"
|
|
@@ -18,6 +18,8 @@ This document describes how list pages combine **views**, **toolbar** behavior,
|
|
|
18
18
|
| **Team page (primary template)** | `TeamClient` = `ListPageTemplate` + `KeyMetrics` + `TeamPageHeader` + `TeamTable` (same composition as `DataListClient`) | `components/team-client.tsx`, `lib/mock/team-kpi.ts` |
|
|
19
19
|
| **Team roster** | `TeamTable` — `DataTable` + `useTableState` + `TablePropertiesDrawer`; list/board/dashboard read **`tableState.rows`** | `components/team-table.tsx` |
|
|
20
20
|
| **Dashboard view (list tab)** | **`KeyMetrics`** (`variant="flat"` or `"card"`) — same KPI system as the template metrics strip; **do not** add ad-hoc `Card` grids for entity summaries | `TeamTable` dashboard branch, `lib/mock/team-kpi.ts` |
|
|
21
|
+
| **List hub metrics strip** | **`KeyMetrics variant="flat"`** — transparent cells, OKLCH brand glow only, border hairlines (**no** grey panel) | **`docs/kpi-flat-band-pattern.md`**, Placements / Team / Question bank clients |
|
|
22
|
+
| **Secondary panel chrome** | **`--secondary-panel-bg`** on **`NestedSecondaryPanelShell`** (lighter than sidebar, follows active product) | **`docs/shell-surface-elevation-pattern.md`**, Question bank |
|
|
21
23
|
| **Export** | `ExportDrawer` | `ListPageTemplate` export props; `DataListClient` |
|
|
22
24
|
| **View body layout** (gutter + centered max-width for folder / icon / panel-style content) | **`ListPageViewFrame`** (`components/data-views/list-page-view-frame.tsx`, re-exported from `components/data-views`) | **`FolderGridView`** (uses the frame); **`QuestionBankOsFolderView`** — see **`AGENTS.md` §4.5** |
|
|
23
25
|
|