@exxatdesignux/ui 0.2.16 → 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 +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
- 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 +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +4 -0
- package/template/components/data-table/index.tsx +99 -91
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +276 -100
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +19 -133
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +19 -6
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- 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/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -112,7 +112,8 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
112
112
|
const addSortRule = React.useCallback((fieldKey: string) => {
|
|
113
113
|
setSortRules(prev => {
|
|
114
114
|
if (prev.some(r => r.fieldKey === fieldKey)) return prev
|
|
115
|
-
|
|
115
|
+
// New drawer sorts are primary (same as column-header sort), not trailing.
|
|
116
|
+
return [{ id: `sort-${Date.now()}`, fieldKey, direction: "asc" }, ...prev]
|
|
116
117
|
})
|
|
117
118
|
}, [setSortRules])
|
|
118
119
|
|
|
@@ -178,9 +179,12 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
178
179
|
}
|
|
179
180
|
return f.operators?.[0] ?? "contains"
|
|
180
181
|
})()
|
|
181
|
-
|
|
182
|
+
const newFilter: ActiveFilter = { id, fieldKey, operator: firstOperator, values: [] }
|
|
183
|
+
setActiveFilters(prev => [...prev, newFilter])
|
|
182
184
|
if (fromDrawer) {
|
|
183
|
-
setDrawerExpandedFilters(new Set([id]))
|
|
185
|
+
setDrawerExpandedFilters(() => new Set([id]))
|
|
186
|
+
// Keep toolbar pills hidden until a value is chosen — avoids mounting every
|
|
187
|
+
// FilterPill (heavy) on each drawer "Add filter" click.
|
|
184
188
|
} else {
|
|
185
189
|
setOpenFilterId(id)
|
|
186
190
|
setFilterBarVisible(true)
|
|
@@ -188,8 +192,24 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
188
192
|
}, [columns, setActiveFilters, setDrawerExpandedFilters, setOpenFilterId, setFilterBarVisible])
|
|
189
193
|
|
|
190
194
|
const updateFilter = React.useCallback((id: string, patch: Partial<ActiveFilter>) => {
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
let shouldShowFilterBar = false
|
|
196
|
+
setActiveFilters(prev => {
|
|
197
|
+
const next = prev.map(f => {
|
|
198
|
+
if (f.id !== id) return f
|
|
199
|
+
const merged = { ...f, ...patch }
|
|
200
|
+
const col = columns.find(c => c.key === merged.fieldKey)
|
|
201
|
+
if (merged.values.length > 0) {
|
|
202
|
+
shouldShowFilterBar =
|
|
203
|
+
col?.filter?.type === "text"
|
|
204
|
+
? (merged.values[0] ?? "").trim().length > 0
|
|
205
|
+
: true
|
|
206
|
+
}
|
|
207
|
+
return merged
|
|
208
|
+
})
|
|
209
|
+
return next
|
|
210
|
+
})
|
|
211
|
+
if (shouldShowFilterBar) setFilterBarVisible(true)
|
|
212
|
+
}, [columns, setActiveFilters, setFilterBarVisible])
|
|
193
213
|
|
|
194
214
|
const removeFilter = React.useCallback((id: string) => {
|
|
195
215
|
// Use functional updates only — no stale-closure risk on activeFilters.
|
|
@@ -242,7 +262,8 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
242
262
|
const toggleColVisibility = React.useCallback((key: string) => {
|
|
243
263
|
setHiddenCols(prev => {
|
|
244
264
|
const next = new Set(prev)
|
|
245
|
-
next.has(key)
|
|
265
|
+
if (next.has(key)) next.delete(key)
|
|
266
|
+
else next.add(key)
|
|
246
267
|
return next
|
|
247
268
|
})
|
|
248
269
|
}, [setHiddenCols])
|
|
@@ -281,90 +302,208 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
281
302
|
// ── Hovered row ───────────────────────────────────────────────────────────
|
|
282
303
|
const [hoveredRow, setHoveredRow] = React.useState<string | number | null>(null)
|
|
283
304
|
|
|
305
|
+
// ── Column lookup index (stable per `columns` reference) ─────────────────
|
|
306
|
+
// The previous implementation called `columns.find(c => c.key === ...)` inside
|
|
307
|
+
// every filter/sort comparator and every sticky-offset getter — for large
|
|
308
|
+
// datasets that's O(rows × cols) per render. Map lookups make those O(1).
|
|
309
|
+
const columnsByKey = React.useMemo(() => {
|
|
310
|
+
const map = new Map<string, ColumnDef<TData>>()
|
|
311
|
+
for (const col of columns) map.set(col.key, col)
|
|
312
|
+
return map
|
|
313
|
+
}, [columns])
|
|
314
|
+
|
|
315
|
+
// Searchable text cache. Per row, concatenate every value into one
|
|
316
|
+
// lower-cased blob ONCE and reuse it across keystrokes. Keyed by row
|
|
317
|
+
// identity via WeakMap so it never holds onto rows the consumer dropped.
|
|
318
|
+
const searchableTextCache = React.useRef<WeakMap<object, string>>(new WeakMap())
|
|
319
|
+
const getSearchableText = React.useCallback((row: TData): string => {
|
|
320
|
+
const cache = searchableTextCache.current
|
|
321
|
+
const cached = cache.get(row)
|
|
322
|
+
if (cached !== undefined) return cached
|
|
323
|
+
let blob = ""
|
|
324
|
+
for (const v of Object.values(row)) {
|
|
325
|
+
if (v == null) continue
|
|
326
|
+
blob += String(v).toLowerCase() + "\n"
|
|
327
|
+
}
|
|
328
|
+
cache.set(row, blob)
|
|
329
|
+
return blob
|
|
330
|
+
}, [])
|
|
331
|
+
|
|
332
|
+
// Per-row per-column lower-cased value cache (column quick-search +
|
|
333
|
+
// text-mask filters). One `Map` per row, lazily filled on first lookup.
|
|
334
|
+
const lowerValueCache = React.useRef<WeakMap<object, Map<string, string>>>(new WeakMap())
|
|
335
|
+
const getLowerValue = React.useCallback((row: TData, key: string): string => {
|
|
336
|
+
const wm = lowerValueCache.current
|
|
337
|
+
let perRow = wm.get(row)
|
|
338
|
+
if (!perRow) {
|
|
339
|
+
perRow = new Map()
|
|
340
|
+
wm.set(row, perRow)
|
|
341
|
+
}
|
|
342
|
+
const cached = perRow.get(key)
|
|
343
|
+
if (cached !== undefined) return cached
|
|
344
|
+
const computed = String(row[key] ?? "").toLowerCase()
|
|
345
|
+
perRow.set(key, computed)
|
|
346
|
+
return computed
|
|
347
|
+
}, [])
|
|
348
|
+
|
|
349
|
+
// Reset the row-keyed caches whenever the dataset reference changes so we
|
|
350
|
+
// don't pin stale strings for rows the consumer just replaced.
|
|
351
|
+
React.useEffect(() => {
|
|
352
|
+
searchableTextCache.current = new WeakMap()
|
|
353
|
+
lowerValueCache.current = new WeakMap()
|
|
354
|
+
}, [data])
|
|
355
|
+
|
|
284
356
|
// ── Derived: filtered + sorted rows ──────────────────────────────────────
|
|
285
357
|
const rows = React.useMemo(() => {
|
|
286
358
|
let result = data.slice()
|
|
287
359
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
result = result.filter(r =>
|
|
291
|
-
Object.values(r).some(v => String(v ?? "").toLowerCase().includes(q))
|
|
292
|
-
)
|
|
360
|
+
const q = search.trim().toLowerCase()
|
|
361
|
+
if (q) {
|
|
362
|
+
result = result.filter(r => getSearchableText(r).includes(q))
|
|
293
363
|
}
|
|
294
364
|
|
|
295
|
-
const activeWithValues = activeFilters.filter(f =>
|
|
365
|
+
const activeWithValues = activeFilters.filter(f => {
|
|
366
|
+
if (f.values.length === 0) return false
|
|
367
|
+
const col = columnsByKey.get(f.fieldKey)
|
|
368
|
+
if (col?.filter?.type === "text") {
|
|
369
|
+
return (f.values[0] ?? "").trim().length > 0
|
|
370
|
+
}
|
|
371
|
+
return true
|
|
372
|
+
})
|
|
296
373
|
if (activeWithValues.length > 0) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
374
|
+
// Pre-resolve column, operator, normalised needle, and select-value Set
|
|
375
|
+
// for each active filter ONCE (instead of per row).
|
|
376
|
+
type CompiledFilter = {
|
|
377
|
+
col: ColumnDef<TData>
|
|
378
|
+
id: string
|
|
379
|
+
type: "select" | "date" | "text"
|
|
380
|
+
operator: ActiveFilter["operator"]
|
|
381
|
+
selectValues?: Set<string>
|
|
382
|
+
dateTarget?: string
|
|
383
|
+
textNeedle?: string
|
|
384
|
+
digitsNeedle?: string
|
|
385
|
+
isDigitsMask?: boolean
|
|
386
|
+
}
|
|
387
|
+
const compiled: CompiledFilter[] = []
|
|
388
|
+
for (const f of activeWithValues) {
|
|
389
|
+
const col = columnsByKey.get(f.fieldKey)
|
|
390
|
+
if (!col?.filter) continue
|
|
301
391
|
if (col.filter.type === "select") {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
:
|
|
392
|
+
compiled.push({
|
|
393
|
+
col,
|
|
394
|
+
id: f.id,
|
|
395
|
+
type: "select",
|
|
396
|
+
operator: f.operator,
|
|
397
|
+
selectValues: new Set(f.values),
|
|
398
|
+
})
|
|
399
|
+
} else if (col.filter.type === "date") {
|
|
400
|
+
compiled.push({
|
|
401
|
+
col,
|
|
402
|
+
id: f.id,
|
|
403
|
+
type: "date",
|
|
404
|
+
operator: f.operator,
|
|
405
|
+
dateTarget: f.values[0],
|
|
406
|
+
})
|
|
407
|
+
} else {
|
|
408
|
+
const raw = f.values[0] ?? ""
|
|
409
|
+
const isDigitsMask = col.filter.textMask === "phone" || col.filter.textMask === "zip"
|
|
410
|
+
compiled.push({
|
|
411
|
+
col,
|
|
412
|
+
id: f.id,
|
|
413
|
+
type: "text",
|
|
414
|
+
operator: f.operator,
|
|
415
|
+
isDigitsMask,
|
|
416
|
+
digitsNeedle: isDigitsMask ? digitsOnly(raw) : undefined,
|
|
417
|
+
textNeedle: !isDigitsMask ? raw.toLowerCase() : undefined,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const matchesCompiled = (r: TData, f: CompiledFilter): boolean => {
|
|
423
|
+
const rowVal = String(r[f.col.key] ?? "")
|
|
424
|
+
if (f.type === "select") {
|
|
425
|
+
const hit = f.selectValues!.has(rowVal)
|
|
426
|
+
return f.operator === "is" ? hit : !hit
|
|
305
427
|
}
|
|
306
|
-
if (
|
|
307
|
-
|
|
308
|
-
if (!targetYmd) return true
|
|
428
|
+
if (f.type === "date") {
|
|
429
|
+
if (!f.dateTarget) return true
|
|
309
430
|
const rowYmd = parseRowDateToYmd(rowVal)
|
|
310
|
-
const op =
|
|
431
|
+
const op = f.operator === "is_not" ? "is_not" : "is"
|
|
311
432
|
if (rowYmd === null) return op === "is_not"
|
|
312
|
-
return op === "is" ? rowYmd ===
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (!q) return true
|
|
319
|
-
const hay = digitsOnly(rowVal)
|
|
320
|
-
return filter.operator === "contains"
|
|
321
|
-
? hay.includes(q)
|
|
322
|
-
: !hay.includes(q)
|
|
323
|
-
}
|
|
324
|
-
const q = raw.toLowerCase()
|
|
325
|
-
if (!q) return true
|
|
326
|
-
return filter.operator === "contains"
|
|
327
|
-
? rowVal.toLowerCase().includes(q)
|
|
328
|
-
: !rowVal.toLowerCase().includes(q)
|
|
433
|
+
return op === "is" ? rowYmd === f.dateTarget : rowYmd !== f.dateTarget
|
|
434
|
+
}
|
|
435
|
+
if (f.isDigitsMask) {
|
|
436
|
+
if (!f.digitsNeedle) return true
|
|
437
|
+
const hay = digitsOnly(rowVal)
|
|
438
|
+
return f.operator === "contains" ? hay.includes(f.digitsNeedle) : !hay.includes(f.digitsNeedle)
|
|
329
439
|
}
|
|
440
|
+
if (!f.textNeedle) return true
|
|
441
|
+
const hay = getLowerValue(r, f.col.key)
|
|
442
|
+
return f.operator === "contains" ? hay.includes(f.textNeedle) : !hay.includes(f.textNeedle)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (compiled.length > 0) {
|
|
446
|
+
result = result.filter(r => {
|
|
447
|
+
let res = matchesCompiled(r, compiled[0])
|
|
448
|
+
for (let i = 1; i < compiled.length; i++) {
|
|
449
|
+
const connector = filterConnectors[compiled[i - 1].id] ?? "and"
|
|
450
|
+
const match = matchesCompiled(r, compiled[i])
|
|
451
|
+
res = connector === "and" ? res && match : res || match
|
|
452
|
+
}
|
|
453
|
+
return res
|
|
454
|
+
})
|
|
330
455
|
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Column menu quick-search — pre-normalise needles outside the row loop.
|
|
459
|
+
const colMenuEntries: { key: string; lower: string }[] = []
|
|
460
|
+
for (const [key, raw] of Object.entries(colMenuSearch)) {
|
|
461
|
+
const trimmed = raw.trim()
|
|
462
|
+
if (trimmed) colMenuEntries.push({ key, lower: trimmed.toLowerCase() })
|
|
463
|
+
}
|
|
464
|
+
if (colMenuEntries.length > 0) {
|
|
331
465
|
result = result.filter(r => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const connector = getConnector(activeWithValues[i - 1].id)
|
|
335
|
-
const match = matchesFilter(r, activeWithValues[i])
|
|
336
|
-
res = connector === "and" ? res && match : res || match
|
|
466
|
+
for (const { key, lower } of colMenuEntries) {
|
|
467
|
+
if (!getLowerValue(r, key).includes(lower)) return false
|
|
337
468
|
}
|
|
338
|
-
return
|
|
469
|
+
return true
|
|
339
470
|
})
|
|
340
471
|
}
|
|
341
472
|
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const lower = q.toLowerCase()
|
|
346
|
-
result = result.filter(r => String(r[key] ?? "").toLowerCase().includes(lower))
|
|
347
|
-
})
|
|
348
|
-
|
|
349
|
-
// Sort
|
|
473
|
+
// Sort — resolve each rule's sort key ONCE, then run the comparator over
|
|
474
|
+
// an indexed list so the inner loop is a tight array walk, not a chain of
|
|
475
|
+
// `columns.find` lookups per comparison.
|
|
350
476
|
if (sortRules.length > 0) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
477
|
+
const resolved: { sk: string; dir: SortDir }[] = []
|
|
478
|
+
for (const rule of sortRules) {
|
|
479
|
+
const col = columnsByKey.get(rule.fieldKey)
|
|
480
|
+
const sk = col?.sortKey ?? col?.key
|
|
481
|
+
if (sk) resolved.push({ sk: sk as string, dir: rule.direction })
|
|
482
|
+
}
|
|
483
|
+
if (resolved.length > 0) {
|
|
484
|
+
result.sort((a, b) => {
|
|
485
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
486
|
+
const { sk, dir } = resolved[i]
|
|
487
|
+
const cmp = compareUnknownSort(a[sk], b[sk])
|
|
488
|
+
if (cmp !== 0) return dir === "asc" ? cmp : -cmp
|
|
489
|
+
}
|
|
490
|
+
return 0
|
|
491
|
+
})
|
|
492
|
+
}
|
|
363
493
|
}
|
|
364
494
|
|
|
365
495
|
return result
|
|
366
|
-
|
|
367
|
-
|
|
496
|
+
}, [
|
|
497
|
+
data,
|
|
498
|
+
search,
|
|
499
|
+
activeFilters,
|
|
500
|
+
filterConnectors,
|
|
501
|
+
colMenuSearch,
|
|
502
|
+
sortRules,
|
|
503
|
+
columnsByKey,
|
|
504
|
+
getSearchableText,
|
|
505
|
+
getLowerValue,
|
|
506
|
+
])
|
|
368
507
|
|
|
369
508
|
// ── Paged rows (slice of rows when pagination is active) ─────────────────
|
|
370
509
|
const pagedRows = React.useMemo(() => {
|
|
@@ -404,19 +543,28 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
404
543
|
result[key] = pin
|
|
405
544
|
}
|
|
406
545
|
return result
|
|
407
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
408
546
|
}, [colPins, isOverflowing, isReflowViewport])
|
|
409
547
|
|
|
410
548
|
// ── Display columns ───────────────────────────────────────────────────────
|
|
411
549
|
const displayCols = React.useMemo(() => {
|
|
412
|
-
const leftPinned
|
|
413
|
-
const free
|
|
414
|
-
const rightPinned =
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
550
|
+
const leftPinned: string[] = []
|
|
551
|
+
const free: string[] = []
|
|
552
|
+
const rightPinned: string[] = []
|
|
553
|
+
for (const k of colOrder) {
|
|
554
|
+
const pin = colPins[k]
|
|
555
|
+
if (pin === "left") leftPinned.push(k)
|
|
556
|
+
else if (pin === "right") rightPinned.push(k)
|
|
557
|
+
else free.push(k)
|
|
558
|
+
}
|
|
559
|
+
const ordered = [...leftPinned, ...free, ...rightPinned]
|
|
560
|
+
const out: ColumnDef<TData>[] = []
|
|
561
|
+
for (const k of ordered) {
|
|
562
|
+
if (hiddenCols.has(k)) continue
|
|
563
|
+
const col = columnsByKey.get(k)
|
|
564
|
+
if (col) out.push(col)
|
|
565
|
+
}
|
|
566
|
+
return out
|
|
567
|
+
}, [colOrder, colPins, hiddenCols, columnsByKey])
|
|
420
568
|
|
|
421
569
|
// ── Column actions ────────────────────────────────────────────────────────
|
|
422
570
|
function startResize(key: string, e: React.MouseEvent) {
|
|
@@ -494,7 +642,8 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
494
642
|
const toggleRow = React.useCallback((id: string | number) => {
|
|
495
643
|
setSelected(prev => {
|
|
496
644
|
const next = new Set(prev)
|
|
497
|
-
next.has(id)
|
|
645
|
+
if (next.has(id)) next.delete(id)
|
|
646
|
+
else next.add(id)
|
|
498
647
|
return next
|
|
499
648
|
})
|
|
500
649
|
}, [setSelected])
|
|
@@ -504,33 +653,60 @@ export function useTableState<TData extends Record<string, unknown>>(
|
|
|
504
653
|
}, [setSelected])
|
|
505
654
|
|
|
506
655
|
// ── Sticky offset calculations ────────────────────────────────────────────
|
|
507
|
-
|
|
508
|
-
|
|
656
|
+
// Precompute every pinned column's offset ONCE per render so the per-cell
|
|
657
|
+
// `stickyStyle()` call is an O(1) map lookup instead of an O(cols) walk.
|
|
658
|
+
// With `cells = rows × cols`, the previous O(rows × cols²) became the
|
|
659
|
+
// dominant cost on wide tables.
|
|
660
|
+
const stickyOffsets = React.useMemo(() => {
|
|
661
|
+
const left = new Map<string, number>()
|
|
662
|
+
const right = new Map<string, number>()
|
|
663
|
+
let leftOffset = 0
|
|
509
664
|
for (const col of displayCols) {
|
|
510
665
|
if (effectivePins[col.key] !== "left") break
|
|
511
|
-
|
|
512
|
-
|
|
666
|
+
left.set(col.key, leftOffset)
|
|
667
|
+
leftOffset += colWidths[col.key] ?? col.width ?? 100
|
|
513
668
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (col.key === key) return offset
|
|
521
|
-
offset += colWidths[col.key] ?? col.width ?? 100
|
|
669
|
+
let rightOffset = 0
|
|
670
|
+
for (let i = displayCols.length - 1; i >= 0; i--) {
|
|
671
|
+
const col = displayCols[i]
|
|
672
|
+
if (effectivePins[col.key] !== "right") break
|
|
673
|
+
right.set(col.key, rightOffset)
|
|
674
|
+
rightOffset += colWidths[col.key] ?? col.width ?? 100
|
|
522
675
|
}
|
|
523
|
-
return
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
676
|
+
return { left, right }
|
|
677
|
+
}, [displayCols, effectivePins, colWidths])
|
|
678
|
+
|
|
679
|
+
const getStickyLeft = React.useCallback((key: string): number => {
|
|
680
|
+
return stickyOffsets.left.get(key) ?? 0
|
|
681
|
+
}, [stickyOffsets])
|
|
682
|
+
|
|
683
|
+
const getStickyRight = React.useCallback((key: string): number => {
|
|
684
|
+
return stickyOffsets.right.get(key) ?? 0
|
|
685
|
+
}, [stickyOffsets])
|
|
686
|
+
|
|
687
|
+
const stickyStyle = React.useCallback(
|
|
688
|
+
(key: string, isHeader = false): React.CSSProperties => {
|
|
689
|
+
if (isReflowViewport) return {}
|
|
690
|
+
const pin = effectivePins[key]
|
|
691
|
+
if (pin === "left") {
|
|
692
|
+
return isHeader
|
|
693
|
+
? { position: "sticky", left: stickyOffsets.left.get(key) ?? 0, top: 0 }
|
|
694
|
+
: { position: "sticky", left: stickyOffsets.left.get(key) ?? 0 }
|
|
695
|
+
}
|
|
696
|
+
if (pin === "right") {
|
|
697
|
+
return isHeader
|
|
698
|
+
? { position: "sticky", right: stickyOffsets.right.get(key) ?? 0, top: 0 }
|
|
699
|
+
: { position: "sticky", right: stickyOffsets.right.get(key) ?? 0 }
|
|
700
|
+
}
|
|
701
|
+
return isHeader ? { position: "sticky", top: 0 } : {}
|
|
702
|
+
},
|
|
703
|
+
[effectivePins, isReflowViewport, stickyOffsets],
|
|
704
|
+
)
|
|
532
705
|
|
|
533
|
-
const totalWidth =
|
|
706
|
+
const totalWidth = React.useMemo(
|
|
707
|
+
() => displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0),
|
|
708
|
+
[displayCols, colWidths],
|
|
709
|
+
)
|
|
534
710
|
|
|
535
711
|
return {
|
|
536
712
|
// Sort
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DataRowList — generic vertical-stack list view used by every hub's "list"
|
|
5
|
+
* tab (placements, team, compliance, sites, question-bank, …). Replaces the
|
|
6
|
+
* hand-rolled `<ul …flex-col gap-2 px-4 pb-8 pt-2 lg:px-6> {rows.map(<li>…)}`
|
|
7
|
+
* shell that was duplicated across `*-list-view.tsx` files.
|
|
8
|
+
*
|
|
9
|
+
* Composition over inheritance: callers provide a `renderRow(row)` that
|
|
10
|
+
* returns whatever ListPageBoardCard / link / chip-stack they need — this
|
|
11
|
+
* component owns the chrome (spacing, empty state, virtualization), not the
|
|
12
|
+
* row body.
|
|
13
|
+
*
|
|
14
|
+
* Auto-virtualises with `@tanstack/react-virtual` when the row count meets
|
|
15
|
+
* `virtualizeThreshold` (default 100). Disable by passing `0`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as React from "react"
|
|
19
|
+
import { useWindowVirtualizer } from "@tanstack/react-virtual"
|
|
20
|
+
import { cn } from "@/lib/utils"
|
|
21
|
+
|
|
22
|
+
const DEFAULT_VIRTUALIZE_THRESHOLD = 100
|
|
23
|
+
const DEFAULT_ESTIMATED_ROW_HEIGHT = 96
|
|
24
|
+
const DEFAULT_OVERSCAN = 8
|
|
25
|
+
|
|
26
|
+
export interface DataRowListProps<TRow> {
|
|
27
|
+
/** The filtered/sorted rows from `tableState.rows` (or wherever). */
|
|
28
|
+
rows: readonly TRow[]
|
|
29
|
+
/** Stable id used as the React `key` and (for virtualizer) the v-key. */
|
|
30
|
+
getRowId: (row: TRow, index: number) => string | number
|
|
31
|
+
/** Render the body of one row. Wrap with `<ListPageBoardCard layout="row">` etc. */
|
|
32
|
+
renderRow: (row: TRow, index: number) => React.ReactNode
|
|
33
|
+
/**
|
|
34
|
+
* Shown when `rows.length === 0`. Strings render as muted body copy; pass
|
|
35
|
+
* a `ReactNode` for richer empty states (illustration, CTA, etc.).
|
|
36
|
+
*/
|
|
37
|
+
emptyState?: React.ReactNode
|
|
38
|
+
/**
|
|
39
|
+
* Auto-virtualise when `rows.length >= virtualizeThreshold`. Default 100.
|
|
40
|
+
* Pass `0` to never virtualise (preserves predictable layout for short
|
|
41
|
+
* lists like dashboards / pinned tabs).
|
|
42
|
+
*/
|
|
43
|
+
virtualizeThreshold?: number
|
|
44
|
+
/** Hint for the virtualizer; clamps to measured size after first paint. */
|
|
45
|
+
estimatedRowHeight?: number
|
|
46
|
+
/** Override the default container padding / gap if needed. */
|
|
47
|
+
className?: string
|
|
48
|
+
/** Override the per-row `<li>` className (e.g. tighter spacing). */
|
|
49
|
+
rowClassName?: string
|
|
50
|
+
/** `aria-label` for the `<ul>` (screen-reader name for the list). */
|
|
51
|
+
ariaLabel?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_OUTER_CLASS = "flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6"
|
|
55
|
+
|
|
56
|
+
export function DataRowList<TRow>(props: DataRowListProps<TRow>) {
|
|
57
|
+
const {
|
|
58
|
+
rows,
|
|
59
|
+
getRowId,
|
|
60
|
+
renderRow,
|
|
61
|
+
emptyState,
|
|
62
|
+
virtualizeThreshold = DEFAULT_VIRTUALIZE_THRESHOLD,
|
|
63
|
+
estimatedRowHeight = DEFAULT_ESTIMATED_ROW_HEIGHT,
|
|
64
|
+
className,
|
|
65
|
+
rowClassName,
|
|
66
|
+
ariaLabel,
|
|
67
|
+
} = props
|
|
68
|
+
|
|
69
|
+
if (rows.length === 0) {
|
|
70
|
+
if (emptyState == null) return null
|
|
71
|
+
if (typeof emptyState === "string") {
|
|
72
|
+
return (
|
|
73
|
+
<div className="px-4 py-16 text-center lg:px-6">
|
|
74
|
+
<p className="text-sm text-muted-foreground">{emptyState}</p>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return <div className="px-4 py-16 text-center lg:px-6">{emptyState}</div>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (virtualizeThreshold > 0 && rows.length >= virtualizeThreshold) {
|
|
82
|
+
return (
|
|
83
|
+
<DataRowListVirtualized
|
|
84
|
+
rows={rows}
|
|
85
|
+
getRowId={getRowId}
|
|
86
|
+
renderRow={renderRow}
|
|
87
|
+
estimatedRowHeight={estimatedRowHeight}
|
|
88
|
+
className={className}
|
|
89
|
+
rowClassName={rowClassName}
|
|
90
|
+
ariaLabel={ariaLabel}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ul aria-label={ariaLabel} className={cn(DEFAULT_OUTER_CLASS, className)}>
|
|
97
|
+
{rows.map((row, i) => (
|
|
98
|
+
<li key={getRowId(row, i)} className={rowClassName}>
|
|
99
|
+
{renderRow(row, i)}
|
|
100
|
+
</li>
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// Virtualised variant — keeps the DOM short on long lists (e.g. 1000+ rows).
|
|
108
|
+
// Uses `useWindowVirtualizer` so the page scroll drives row recycling; this
|
|
109
|
+
// is the right tool for hub-level lists (not nested-scroll containers).
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function DataRowListVirtualized<TRow>({
|
|
113
|
+
rows,
|
|
114
|
+
getRowId,
|
|
115
|
+
renderRow,
|
|
116
|
+
estimatedRowHeight,
|
|
117
|
+
className,
|
|
118
|
+
rowClassName,
|
|
119
|
+
ariaLabel,
|
|
120
|
+
}: {
|
|
121
|
+
rows: readonly TRow[]
|
|
122
|
+
getRowId: (row: TRow, index: number) => string | number
|
|
123
|
+
renderRow: (row: TRow, index: number) => React.ReactNode
|
|
124
|
+
estimatedRowHeight: number
|
|
125
|
+
className?: string
|
|
126
|
+
rowClassName?: string
|
|
127
|
+
ariaLabel?: string
|
|
128
|
+
}) {
|
|
129
|
+
const anchorRef = React.useRef<HTMLDivElement | null>(null)
|
|
130
|
+
// `scrollMargin` is read by the virtualizer during render, so it has to
|
|
131
|
+
// be state (not a ref). We measure with `useLayoutEffect` after the first
|
|
132
|
+
// paint and on resize so window-scroll math stays accurate when the page
|
|
133
|
+
// layout shifts (sidebar collapse, banner, etc.).
|
|
134
|
+
const [scrollMargin, setScrollMargin] = React.useState(0)
|
|
135
|
+
|
|
136
|
+
const updateScrollMargin = React.useCallback(() => {
|
|
137
|
+
const el = anchorRef.current
|
|
138
|
+
if (!el) return
|
|
139
|
+
setScrollMargin(el.getBoundingClientRect().top + window.scrollY)
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
React.useLayoutEffect(() => {
|
|
143
|
+
updateScrollMargin()
|
|
144
|
+
window.addEventListener("resize", updateScrollMargin)
|
|
145
|
+
return () => window.removeEventListener("resize", updateScrollMargin)
|
|
146
|
+
}, [updateScrollMargin, rows.length])
|
|
147
|
+
|
|
148
|
+
const virtualizer = useWindowVirtualizer({
|
|
149
|
+
count: rows.length,
|
|
150
|
+
estimateSize: () => estimatedRowHeight,
|
|
151
|
+
overscan: DEFAULT_OVERSCAN,
|
|
152
|
+
scrollMargin,
|
|
153
|
+
getItemKey: i => String(getRowId(rows[i], i)),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const totalSize = virtualizer.getTotalSize()
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div ref={anchorRef} className={cn("px-4 pb-8 pt-2 lg:px-6", className)}>
|
|
160
|
+
<ul
|
|
161
|
+
aria-label={ariaLabel}
|
|
162
|
+
className="relative m-0 w-full list-none p-0"
|
|
163
|
+
style={{ height: `${totalSize}px` }}
|
|
164
|
+
>
|
|
165
|
+
{virtualizer.getVirtualItems().map(vr => {
|
|
166
|
+
const row = rows[vr.index]
|
|
167
|
+
if (!row) return null
|
|
168
|
+
return (
|
|
169
|
+
<li
|
|
170
|
+
key={vr.key}
|
|
171
|
+
data-index={vr.index}
|
|
172
|
+
ref={virtualizer.measureElement}
|
|
173
|
+
className={cn("absolute left-0 top-0 w-full pb-2", rowClassName)}
|
|
174
|
+
style={{ transform: `translateY(${vr.start}px)` }}
|
|
175
|
+
>
|
|
176
|
+
{renderRow(row, vr.index)}
|
|
177
|
+
</li>
|
|
178
|
+
)
|
|
179
|
+
})}
|
|
180
|
+
</ul>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Central exports for list-page data surfaces and shared view chrome.
|
|
3
3
|
*
|
|
4
|
-
* **Pattern:** `ListPageTemplate` + `
|
|
4
|
+
* **Pattern:** `ListPageTemplate` + `PlacementsTable` (or any hub-specific `*-table.tsx`) — one `useTableState`, one toolbar,
|
|
5
5
|
* table | list | board | dashboard from the same component (`AGENTS.md` §4, `docs/data-views-pattern.md`).
|
|
6
6
|
*
|
|
7
7
|
* **View UI:** `ViewSegmentedControl` matches the template’s views toolbar (`bg-muted/60` pills).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
export {
|
|
11
|
-
export type {
|
|
10
|
+
export { PlacementsTable } from "@/components/placements-table"
|
|
11
|
+
export type { PlacementsTableProps, PlacementsTableHandle } from "@/components/placements-table"
|
|
12
12
|
export type { PlacementLifecycleTabId } from "@/lib/placement-lifecycle"
|
|
13
13
|
export type { DataListViewType } from "@/lib/data-list-view"
|
|
14
14
|
export { DATA_LIST_VIEW_TILES, dataListViewIcon, dataListViewLabel } from "@/lib/data-list-view"
|
|
@@ -103,6 +103,10 @@ export {
|
|
|
103
103
|
/** Generic folder icon-grid — reusable across all list hubs. */
|
|
104
104
|
export { FolderGridView, type FolderGridViewProps } from "@/components/data-views/folder-grid-view"
|
|
105
105
|
|
|
106
|
+
/** Generic vertical row list — used by every hub's "list" tab. Composes
|
|
107
|
+
* `ListPageBoardCard layout="row"` via a `renderRow` prop. */
|
|
108
|
+
export { DataRowList, type DataRowListProps } from "@/components/data-views/data-row-list"
|
|
109
|
+
|
|
106
110
|
|
|
107
111
|
/** Unified hub tile + list row surface — see `list-page-board-card.tsx`. */
|
|
108
112
|
export {
|