@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. 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
- return [...prev, { id: `sort-${Date.now()}`, fieldKey, direction: "asc" }]
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
- setActiveFilters(prev => [...prev, { id, fieldKey, operator: firstOperator, values: [] }])
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
- setActiveFilters(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f))
192
- }, [setActiveFilters])
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) ? next.delete(key) : next.add(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
- if (search.trim()) {
289
- const q = search.toLowerCase()
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 => f.values.length > 0)
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
- const matchesFilter = (r: TData, filter: ActiveFilter) => {
298
- const col = columns.find(c => c.key === filter.fieldKey)
299
- if (!col?.filter) return true
300
- const rowVal = String(r[filter.fieldKey] ?? "")
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
- return filter.operator === "is"
303
- ? filter.values.includes(rowVal)
304
- : !filter.values.includes(rowVal)
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 (col.filter.type === "date") {
307
- const targetYmd = filter.values[0]
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 = filter.operator === "is_not" ? "is_not" : "is"
431
+ const op = f.operator === "is_not" ? "is_not" : "is"
311
432
  if (rowYmd === null) return op === "is_not"
312
- return op === "is" ? rowYmd === targetYmd : rowYmd !== targetYmd
313
- } else {
314
- const raw = filter.values[0] ?? ""
315
- const textMask = col.filter.textMask
316
- if (textMask === "phone" || textMask === "zip") {
317
- const q = digitsOnly(raw)
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
- let res = matchesFilter(r, activeWithValues[0])
333
- for (let i = 1; i < activeWithValues.length; i++) {
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 res
469
+ return true
339
470
  })
340
471
  }
341
472
 
342
- // Column menu quick-search
343
- Object.entries(colMenuSearch).forEach(([key, q]) => {
344
- if (!q.trim()) return
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
- result.sort((a, b) => {
352
- for (const rule of sortRules) {
353
- const col = columns.find(c => c.key === rule.fieldKey)
354
- const sk = col?.sortKey ?? col?.key
355
- if (!sk) continue
356
- const aVal = a[sk as string]
357
- const bVal = b[sk as string]
358
- const cmp = compareUnknownSort(aVal, bVal)
359
- if (cmp !== 0) return rule.direction === "asc" ? cmp : -cmp
360
- }
361
- return 0
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
367
- }, [data, search, activeFilters, filterConnectors, colMenuSearch, sortRules, columns])
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 = colOrder.filter(k => colPins[k] === "left")
413
- const free = colOrder.filter(k => !colPins[k])
414
- const rightPinned = colOrder.filter(k => colPins[k] === "right")
415
- return [...leftPinned, ...free, ...rightPinned]
416
- .map(k => columns.find(c => c.key === k))
417
- .filter((c): c is ColumnDef<TData> => !!c)
418
- .filter(c => !hiddenCols.has(c.key))
419
- }, [colOrder, colPins, hiddenCols, columns])
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) ? next.delete(id) : next.add(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
- function getStickyLeft(key: string): number {
508
- let offset = 0
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
- if (col.key === key) return offset
512
- offset += colWidths[col.key] ?? col.width ?? 100
666
+ left.set(col.key, leftOffset)
667
+ leftOffset += colWidths[col.key] ?? col.width ?? 100
513
668
  }
514
- return 0
515
- }
516
- function getStickyRight(key: string): number {
517
- let offset = 0
518
- const rightCols = [...displayCols].filter(c => effectivePins[c.key] === "right").reverse()
519
- for (const col of rightCols) {
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 0
524
- }
525
- function stickyStyle(key: string, isHeader = false): React.CSSProperties {
526
- if (isReflowViewport) return {}
527
- const pin = effectivePins[key]
528
- if (pin === "left") return { position: "sticky", left: getStickyLeft(key), ...(isHeader ? { top: 0 } : {}) }
529
- if (pin === "right") return { position: "sticky", right: getStickyRight(key), ...(isHeader ? { top: 0 } : {}) }
530
- return isHeader ? { position: "sticky", top: 0 } : {}
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 = displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0)
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` + `DataListTable` — one `useTableState`, one toolbar,
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 { DataListTable } from "@/components/data-list-table"
11
- export type { DataListTableProps, DataListTableHandle } from "@/components/data-list-table"
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 {