@exxatdesignux/ui 0.2.16 → 0.2.17

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 (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  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-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. package/template/stores/app-store.ts +46 -1
@@ -27,6 +27,7 @@ import * as React from "react"
27
27
  import { useTheme } from "next-themes"
28
28
  import { createPortal } from "react-dom"
29
29
  import { cn } from "@/lib/utils"
30
+ import { rafThrottle } from "@/lib/raf-throttle"
30
31
  import { Button } from "@/components/ui/button"
31
32
  import { Input } from "@/components/ui/input"
32
33
  import { Kbd, KbdGroup } from "@/components/ui/kbd"
@@ -734,23 +735,29 @@ function useBulkBarFixedToTableScrollEl(
734
735
  })
735
736
  }
736
737
  apply()
737
- const ro = new ResizeObserver(() => {
738
- requestAnimationFrame(apply)
739
- })
738
+ // rAF-coalesce so a single frame handles bursts of capture-phase scroll
739
+ // events plus the ResizeObserver firing — instead of N getBoundingClientRect
740
+ // + setState per second.
741
+ const scheduled = rafThrottle(apply)
742
+ const ro = new ResizeObserver(scheduled)
740
743
  ro.observe(el)
741
- window.addEventListener("resize", apply)
742
- window.addEventListener("scroll", apply, true)
744
+ window.addEventListener("resize", scheduled, { passive: true })
745
+ window.addEventListener("scroll", scheduled, { passive: true, capture: true })
743
746
  return () => {
747
+ scheduled.cancel()
744
748
  ro.disconnect()
745
- window.removeEventListener("resize", apply)
746
- window.removeEventListener("scroll", apply, true)
749
+ window.removeEventListener("resize", scheduled)
750
+ window.removeEventListener("scroll", scheduled, { capture: true })
747
751
  }
748
752
  }, [active, fullWidth, scrollRef])
749
753
  return style
750
754
  }
751
755
 
752
756
  function DataTableInner<TData extends Record<string, unknown>>({
753
- data,
757
+ // `data` / `defaultSort` flow into `useTableState` upstream; the inner table
758
+ // reads them via `state` and never directly here. Keep the prop slots so
759
+ // the public `DataTable<TData>` API stays unchanged.
760
+ data: _data,
754
761
  columns,
755
762
  getRowId: getRowIdProp,
756
763
  getRowSelectionLabel,
@@ -758,7 +765,7 @@ function DataTableInner<TData extends Record<string, unknown>>({
758
765
  searchable = true,
759
766
  emptyState,
760
767
  onRowClick,
761
- defaultSort,
768
+ defaultSort: _defaultSort,
762
769
  toolbarSlot,
763
770
  bulkActionsSlot,
764
771
  addRowLabel = false,
@@ -769,7 +776,7 @@ function DataTableInner<TData extends Record<string, unknown>>({
769
776
  state,
770
777
  }: DataTableInnerProps<TData>) {
771
778
  const {
772
- sortRules, setSortRules,
779
+ setSortRules,
773
780
  sortKey, sortDir,
774
781
  handleSortByKey,
775
782
  addFilter,
@@ -777,7 +784,6 @@ function DataTableInner<TData extends Record<string, unknown>>({
777
784
  colMenuSearch, setColMenuSearch,
778
785
  selected, setSelected, toggleRow, toggleAll, getRowId,
779
786
  colWidths, startResize,
780
- colOrder,
781
787
  colPins, lockedPins,
782
788
  pinColumn, unpinColumn,
783
789
  colWrap, toggleWrap,
@@ -785,7 +791,7 @@ function DataTableInner<TData extends Record<string, unknown>>({
785
791
  handleDragStart, handleDragOver, handleDrop, handleDragEnd,
786
792
  scrollRef, handleScroll, checkOverflow,
787
793
  isOverflowing,
788
- hoveredRow, setHoveredRow,
794
+ setHoveredRow,
789
795
  rows, pagedRows, groupedRows,
790
796
  effectivePins, displayCols,
791
797
  isReflowViewport,
@@ -843,6 +849,24 @@ function DataTableInner<TData extends Record<string, unknown>>({
843
849
  const lastLeftPinKey = [...displayCols].reverse().find(c => effectivePins[c.key] === "left")?.key
844
850
  const firstRightPinKey = displayCols.find(c => effectivePins[c.key] === "right")?.key
845
851
 
852
+ function floatingHeaderPinnedStyle(key: string): React.CSSProperties | undefined {
853
+ const pin = effectivePins[key]
854
+ if (!pin) return undefined
855
+
856
+ const visibleWidth =
857
+ typeof floatingHeaderStyle?.width === "number"
858
+ ? floatingHeaderStyle.width
859
+ : tableWrapRef.current?.clientWidth ?? floatingHeaderTableWidth
860
+ const maxScroll = Math.max(0, floatingHeaderTableWidth - visibleWidth)
861
+ const translateX = pin === "left"
862
+ ? headerScrollLeft
863
+ : headerScrollLeft - maxScroll
864
+
865
+ // The floating sticky header is horizontally translated as one table.
866
+ // Counter-translate pinned header cells so they remain locked to the viewport edge.
867
+ return { position: "relative", transform: `translateX(${translateX}px)` }
868
+ }
869
+
846
870
  // Row IDs for the current visible rows
847
871
  const allRowIds = rows.map((r, i) => getRowId(r, i, getRowIdProp))
848
872
  const allSelected = rows.length > 0 && selected.size === rows.length
@@ -864,6 +888,7 @@ function DataTableInner<TData extends Record<string, unknown>>({
864
888
  const [headerIsStuck, setHeaderIsStuck] = React.useState(false)
865
889
  const [headerScrollLeft, setHeaderScrollLeft] = React.useState(0)
866
890
  const [floatingHeaderStyle, setFloatingHeaderStyle] = React.useState<React.CSSProperties | undefined>(undefined)
891
+ const [floatingHeaderTableWidth, setFloatingHeaderTableWidth] = React.useState(totalWidth)
867
892
  const [isClient, setIsClient] = React.useState(false)
868
893
 
869
894
  React.useEffect(() => {
@@ -888,11 +913,16 @@ function DataTableInner<TData extends Record<string, unknown>>({
888
913
  }
889
914
 
890
915
  update()
891
- window.addEventListener("scroll", update, { passive: true, capture: true })
892
- window.addEventListener("resize", update)
916
+ // rAF-coalesce: capture-phase scroll fires for every ancestor (sidebar,
917
+ // dashboard panels, anchored sheets), so a single getBoundingClientRect
918
+ // per frame is more than enough to keep the sticky header aligned.
919
+ const scheduled = rafThrottle(update)
920
+ window.addEventListener("scroll", scheduled, { passive: true, capture: true })
921
+ window.addEventListener("resize", scheduled, { passive: true })
893
922
  return () => {
894
- window.removeEventListener("scroll", update, true)
895
- window.removeEventListener("resize", update)
923
+ scheduled.cancel()
924
+ window.removeEventListener("scroll", scheduled, { capture: true })
925
+ window.removeEventListener("resize", scheduled)
896
926
  }
897
927
  }, [showColumnHeaders, rows.length, displayCols.length])
898
928
 
@@ -915,6 +945,11 @@ function DataTableInner<TData extends Record<string, unknown>>({
915
945
  const borderLeft = parseFloat(cs.borderLeftWidth) || 0
916
946
  const borderRight = parseFloat(cs.borderRightWidth) || 0
917
947
  const visibleWidth = Math.max(0, wrapEl.clientWidth - borderLeft - borderRight)
948
+ const renderedTableWidth = Math.max(
949
+ totalWidth,
950
+ visibleWidth,
951
+ wrapEl.querySelector("table")?.getBoundingClientRect().width ?? 0,
952
+ )
918
953
  setFloatingHeaderStyle({
919
954
  position: "fixed",
920
955
  top: headerOffset,
@@ -922,18 +957,21 @@ function DataTableInner<TData extends Record<string, unknown>>({
922
957
  width: visibleWidth,
923
958
  zIndex: 50,
924
959
  })
960
+ setFloatingHeaderTableWidth(renderedTableWidth)
925
961
  setHeaderScrollLeft(wrapEl.scrollLeft)
926
962
  }
927
963
 
928
964
  apply()
929
- const ro = new ResizeObserver(() => requestAnimationFrame(apply))
965
+ const scheduled = rafThrottle(apply)
966
+ const ro = new ResizeObserver(scheduled)
930
967
  ro.observe(wrapEl)
931
- window.addEventListener("scroll", apply, true)
932
- window.addEventListener("resize", apply)
968
+ window.addEventListener("scroll", scheduled, { passive: true, capture: true })
969
+ window.addEventListener("resize", scheduled, { passive: true })
933
970
  return () => {
971
+ scheduled.cancel()
934
972
  ro.disconnect()
935
- window.removeEventListener("scroll", apply, true)
936
- window.removeEventListener("resize", apply)
973
+ window.removeEventListener("scroll", scheduled, { capture: true })
974
+ window.removeEventListener("resize", scheduled)
937
975
  }
938
976
  }, [headerIsStuck, showColumnHeaders, totalWidth, displayCols.length])
939
977
 
@@ -968,7 +1006,7 @@ function DataTableInner<TData extends Record<string, unknown>>({
968
1006
  <div style={{ transform: `translateX(${-headerScrollLeft}px)` }}>
969
1007
  <table
970
1008
  className="w-full text-sm border-separate border-spacing-0"
971
- style={{ tableLayout: "fixed", width: totalWidth }}
1009
+ style={{ tableLayout: "fixed", width: floatingHeaderTableWidth }}
972
1010
  >
973
1011
  <colgroup>
974
1012
  {displayCols.map(col => (
@@ -984,6 +1022,7 @@ function DataTableInner<TData extends Record<string, unknown>>({
984
1022
  <th
985
1023
  key={col.key}
986
1024
  scope="col"
1025
+ style={floatingHeaderPinnedStyle(col.key)}
987
1026
  className={cn(
988
1027
  "h-9 px-3 text-left align-middle select-none",
989
1028
  "text-xs font-medium text-muted-foreground tracking-wide",
@@ -992,6 +1031,8 @@ function DataTableInner<TData extends Record<string, unknown>>({
992
1031
  ? "border-r border-border last:border-r-0"
993
1032
  : "last:border-r-0"),
994
1033
  isPinned ? "z-40" : "z-30",
1034
+ isPinned && "relative",
1035
+ isEdgePinCol && stickyShadow(effectivePins[col.key]),
995
1036
  )}
996
1037
  >
997
1038
  <div className="flex items-center justify-between gap-1 min-w-0">
@@ -1058,7 +1099,11 @@ function DataTableInner<TData extends Record<string, unknown>>({
1058
1099
  >
1059
1100
  <table
1060
1101
  className="w-full text-sm border-separate border-spacing-0"
1061
- style={{ tableLayout: "fixed", minWidth: totalWidth }}
1102
+ style={{
1103
+ tableLayout: "fixed",
1104
+ minWidth: totalWidth,
1105
+ width: headerIsStuck ? floatingHeaderTableWidth : undefined,
1106
+ }}
1062
1107
  >
1063
1108
  <colgroup>
1064
1109
  {displayCols.map(col => (
@@ -1324,7 +1369,6 @@ function DataTableInner<TData extends Record<string, unknown>>({
1324
1369
  {groupRows.map((row, rowIndex) => {
1325
1370
  const rowId = getRowId(row, rowIndex, getRowIdProp)
1326
1371
  const isSelected = selected.has(rowId)
1327
- const isHovered = hoveredRow === rowId
1328
1372
  const rowClickable = Boolean(onRowClick) || selectable
1329
1373
  function handleRowClick(e: React.MouseEvent<HTMLTableRowElement>) {
1330
1374
  if (!rowClickable) return
@@ -201,7 +201,6 @@ export function DataTablePaginated<TData extends Record<string, unknown>>({
201
201
  {originalToolbarSlot ? originalToolbarSlot(state) : null}
202
202
  </>
203
203
  ),
204
- // eslint-disable-next-line react-hooks/exhaustive-deps
205
204
  [originalToolbarSlot],
206
205
  )
207
206
 
@@ -49,7 +49,10 @@ export interface ColumnDef<TData> {
49
49
  }
50
50
  }
51
51
 
52
- export interface CellContext<TData> {
52
+ // `TData` is part of the public surface so callers can write
53
+ // `CellContext<Placement>` for symmetry with column-def renderers, even
54
+ // though the interface body doesn't currently reference it.
55
+ export interface CellContext<_TData> {
53
56
  rowIndex: number
54
57
  selected: boolean
55
58
  onSelect: (selected: boolean) => void
@@ -242,7 +242,8 @@ export function useTableState<TData extends Record<string, unknown>>(
242
242
  const toggleColVisibility = React.useCallback((key: string) => {
243
243
  setHiddenCols(prev => {
244
244
  const next = new Set(prev)
245
- next.has(key) ? next.delete(key) : next.add(key)
245
+ if (next.has(key)) next.delete(key)
246
+ else next.add(key)
246
247
  return next
247
248
  })
248
249
  }, [setHiddenCols])
@@ -281,90 +282,201 @@ export function useTableState<TData extends Record<string, unknown>>(
281
282
  // ── Hovered row ───────────────────────────────────────────────────────────
282
283
  const [hoveredRow, setHoveredRow] = React.useState<string | number | null>(null)
283
284
 
285
+ // ── Column lookup index (stable per `columns` reference) ─────────────────
286
+ // The previous implementation called `columns.find(c => c.key === ...)` inside
287
+ // every filter/sort comparator and every sticky-offset getter — for large
288
+ // datasets that's O(rows × cols) per render. Map lookups make those O(1).
289
+ const columnsByKey = React.useMemo(() => {
290
+ const map = new Map<string, ColumnDef<TData>>()
291
+ for (const col of columns) map.set(col.key, col)
292
+ return map
293
+ }, [columns])
294
+
295
+ // Searchable text cache. Per row, concatenate every value into one
296
+ // lower-cased blob ONCE and reuse it across keystrokes. Keyed by row
297
+ // identity via WeakMap so it never holds onto rows the consumer dropped.
298
+ const searchableTextCache = React.useRef<WeakMap<object, string>>(new WeakMap())
299
+ const getSearchableText = React.useCallback((row: TData): string => {
300
+ const cache = searchableTextCache.current
301
+ const cached = cache.get(row)
302
+ if (cached !== undefined) return cached
303
+ let blob = ""
304
+ for (const v of Object.values(row)) {
305
+ if (v == null) continue
306
+ blob += String(v).toLowerCase() + "\n"
307
+ }
308
+ cache.set(row, blob)
309
+ return blob
310
+ }, [])
311
+
312
+ // Per-row per-column lower-cased value cache (column quick-search +
313
+ // text-mask filters). One `Map` per row, lazily filled on first lookup.
314
+ const lowerValueCache = React.useRef<WeakMap<object, Map<string, string>>>(new WeakMap())
315
+ const getLowerValue = React.useCallback((row: TData, key: string): string => {
316
+ const wm = lowerValueCache.current
317
+ let perRow = wm.get(row)
318
+ if (!perRow) {
319
+ perRow = new Map()
320
+ wm.set(row, perRow)
321
+ }
322
+ const cached = perRow.get(key)
323
+ if (cached !== undefined) return cached
324
+ const computed = String(row[key] ?? "").toLowerCase()
325
+ perRow.set(key, computed)
326
+ return computed
327
+ }, [])
328
+
329
+ // Reset the row-keyed caches whenever the dataset reference changes so we
330
+ // don't pin stale strings for rows the consumer just replaced.
331
+ React.useEffect(() => {
332
+ searchableTextCache.current = new WeakMap()
333
+ lowerValueCache.current = new WeakMap()
334
+ }, [data])
335
+
284
336
  // ── Derived: filtered + sorted rows ──────────────────────────────────────
285
337
  const rows = React.useMemo(() => {
286
338
  let result = data.slice()
287
339
 
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
- )
340
+ const q = search.trim().toLowerCase()
341
+ if (q) {
342
+ result = result.filter(r => getSearchableText(r).includes(q))
293
343
  }
294
344
 
295
345
  const activeWithValues = activeFilters.filter(f => f.values.length > 0)
296
346
  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] ?? "")
347
+ // Pre-resolve column, operator, normalised needle, and select-value Set
348
+ // for each active filter ONCE (instead of per row).
349
+ type CompiledFilter = {
350
+ col: ColumnDef<TData>
351
+ id: string
352
+ type: "select" | "date" | "text"
353
+ operator: ActiveFilter["operator"]
354
+ selectValues?: Set<string>
355
+ dateTarget?: string
356
+ textNeedle?: string
357
+ digitsNeedle?: string
358
+ isDigitsMask?: boolean
359
+ }
360
+ const compiled: CompiledFilter[] = []
361
+ for (const f of activeWithValues) {
362
+ const col = columnsByKey.get(f.fieldKey)
363
+ if (!col?.filter) continue
301
364
  if (col.filter.type === "select") {
302
- return filter.operator === "is"
303
- ? filter.values.includes(rowVal)
304
- : !filter.values.includes(rowVal)
365
+ compiled.push({
366
+ col,
367
+ id: f.id,
368
+ type: "select",
369
+ operator: f.operator,
370
+ selectValues: new Set(f.values),
371
+ })
372
+ } else if (col.filter.type === "date") {
373
+ compiled.push({
374
+ col,
375
+ id: f.id,
376
+ type: "date",
377
+ operator: f.operator,
378
+ dateTarget: f.values[0],
379
+ })
380
+ } else {
381
+ const raw = f.values[0] ?? ""
382
+ const isDigitsMask = col.filter.textMask === "phone" || col.filter.textMask === "zip"
383
+ compiled.push({
384
+ col,
385
+ id: f.id,
386
+ type: "text",
387
+ operator: f.operator,
388
+ isDigitsMask,
389
+ digitsNeedle: isDigitsMask ? digitsOnly(raw) : undefined,
390
+ textNeedle: !isDigitsMask ? raw.toLowerCase() : undefined,
391
+ })
305
392
  }
306
- if (col.filter.type === "date") {
307
- const targetYmd = filter.values[0]
308
- if (!targetYmd) return true
393
+ }
394
+
395
+ const matchesCompiled = (r: TData, f: CompiledFilter): boolean => {
396
+ const rowVal = String(r[f.col.key] ?? "")
397
+ if (f.type === "select") {
398
+ const hit = f.selectValues!.has(rowVal)
399
+ return f.operator === "is" ? hit : !hit
400
+ }
401
+ if (f.type === "date") {
402
+ if (!f.dateTarget) return true
309
403
  const rowYmd = parseRowDateToYmd(rowVal)
310
- const op = filter.operator === "is_not" ? "is_not" : "is"
404
+ const op = f.operator === "is_not" ? "is_not" : "is"
311
405
  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)
406
+ return op === "is" ? rowYmd === f.dateTarget : rowYmd !== f.dateTarget
407
+ }
408
+ if (f.isDigitsMask) {
409
+ if (!f.digitsNeedle) return true
410
+ const hay = digitsOnly(rowVal)
411
+ return f.operator === "contains" ? hay.includes(f.digitsNeedle) : !hay.includes(f.digitsNeedle)
329
412
  }
413
+ if (!f.textNeedle) return true
414
+ const hay = getLowerValue(r, f.col.key)
415
+ return f.operator === "contains" ? hay.includes(f.textNeedle) : !hay.includes(f.textNeedle)
416
+ }
417
+
418
+ if (compiled.length > 0) {
419
+ result = result.filter(r => {
420
+ let res = matchesCompiled(r, compiled[0])
421
+ for (let i = 1; i < compiled.length; i++) {
422
+ const connector = filterConnectors[compiled[i - 1].id] ?? "and"
423
+ const match = matchesCompiled(r, compiled[i])
424
+ res = connector === "and" ? res && match : res || match
425
+ }
426
+ return res
427
+ })
330
428
  }
429
+ }
430
+
431
+ // Column menu quick-search — pre-normalise needles outside the row loop.
432
+ const colMenuEntries: { key: string; lower: string }[] = []
433
+ for (const [key, raw] of Object.entries(colMenuSearch)) {
434
+ const trimmed = raw.trim()
435
+ if (trimmed) colMenuEntries.push({ key, lower: trimmed.toLowerCase() })
436
+ }
437
+ if (colMenuEntries.length > 0) {
331
438
  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
439
+ for (const { key, lower } of colMenuEntries) {
440
+ if (!getLowerValue(r, key).includes(lower)) return false
337
441
  }
338
- return res
442
+ return true
339
443
  })
340
444
  }
341
445
 
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
446
+ // Sort resolve each rule's sort key ONCE, then run the comparator over
447
+ // an indexed list so the inner loop is a tight array walk, not a chain of
448
+ // `columns.find` lookups per comparison.
350
449
  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
- })
450
+ const resolved: { sk: string; dir: SortDir }[] = []
451
+ for (const rule of sortRules) {
452
+ const col = columnsByKey.get(rule.fieldKey)
453
+ const sk = col?.sortKey ?? col?.key
454
+ if (sk) resolved.push({ sk: sk as string, dir: rule.direction })
455
+ }
456
+ if (resolved.length > 0) {
457
+ result.sort((a, b) => {
458
+ for (let i = 0; i < resolved.length; i++) {
459
+ const { sk, dir } = resolved[i]
460
+ const cmp = compareUnknownSort(a[sk], b[sk])
461
+ if (cmp !== 0) return dir === "asc" ? cmp : -cmp
462
+ }
463
+ return 0
464
+ })
465
+ }
363
466
  }
364
467
 
365
468
  return result
366
- // eslint-disable-next-line react-hooks/exhaustive-deps
367
- }, [data, search, activeFilters, filterConnectors, colMenuSearch, sortRules, columns])
469
+ }, [
470
+ data,
471
+ search,
472
+ activeFilters,
473
+ filterConnectors,
474
+ colMenuSearch,
475
+ sortRules,
476
+ columnsByKey,
477
+ getSearchableText,
478
+ getLowerValue,
479
+ ])
368
480
 
369
481
  // ── Paged rows (slice of rows when pagination is active) ─────────────────
370
482
  const pagedRows = React.useMemo(() => {
@@ -404,19 +516,28 @@ export function useTableState<TData extends Record<string, unknown>>(
404
516
  result[key] = pin
405
517
  }
406
518
  return result
407
- // eslint-disable-next-line react-hooks/exhaustive-deps
408
519
  }, [colPins, isOverflowing, isReflowViewport])
409
520
 
410
521
  // ── Display columns ───────────────────────────────────────────────────────
411
522
  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])
523
+ const leftPinned: string[] = []
524
+ const free: string[] = []
525
+ const rightPinned: string[] = []
526
+ for (const k of colOrder) {
527
+ const pin = colPins[k]
528
+ if (pin === "left") leftPinned.push(k)
529
+ else if (pin === "right") rightPinned.push(k)
530
+ else free.push(k)
531
+ }
532
+ const ordered = [...leftPinned, ...free, ...rightPinned]
533
+ const out: ColumnDef<TData>[] = []
534
+ for (const k of ordered) {
535
+ if (hiddenCols.has(k)) continue
536
+ const col = columnsByKey.get(k)
537
+ if (col) out.push(col)
538
+ }
539
+ return out
540
+ }, [colOrder, colPins, hiddenCols, columnsByKey])
420
541
 
421
542
  // ── Column actions ────────────────────────────────────────────────────────
422
543
  function startResize(key: string, e: React.MouseEvent) {
@@ -494,7 +615,8 @@ export function useTableState<TData extends Record<string, unknown>>(
494
615
  const toggleRow = React.useCallback((id: string | number) => {
495
616
  setSelected(prev => {
496
617
  const next = new Set(prev)
497
- next.has(id) ? next.delete(id) : next.add(id)
618
+ if (next.has(id)) next.delete(id)
619
+ else next.add(id)
498
620
  return next
499
621
  })
500
622
  }, [setSelected])
@@ -504,33 +626,60 @@ export function useTableState<TData extends Record<string, unknown>>(
504
626
  }, [setSelected])
505
627
 
506
628
  // ── Sticky offset calculations ────────────────────────────────────────────
507
- function getStickyLeft(key: string): number {
508
- let offset = 0
629
+ // Precompute every pinned column's offset ONCE per render so the per-cell
630
+ // `stickyStyle()` call is an O(1) map lookup instead of an O(cols) walk.
631
+ // With `cells = rows × cols`, the previous O(rows × cols²) became the
632
+ // dominant cost on wide tables.
633
+ const stickyOffsets = React.useMemo(() => {
634
+ const left = new Map<string, number>()
635
+ const right = new Map<string, number>()
636
+ let leftOffset = 0
509
637
  for (const col of displayCols) {
510
638
  if (effectivePins[col.key] !== "left") break
511
- if (col.key === key) return offset
512
- offset += colWidths[col.key] ?? col.width ?? 100
639
+ left.set(col.key, leftOffset)
640
+ leftOffset += colWidths[col.key] ?? col.width ?? 100
513
641
  }
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
642
+ let rightOffset = 0
643
+ for (let i = displayCols.length - 1; i >= 0; i--) {
644
+ const col = displayCols[i]
645
+ if (effectivePins[col.key] !== "right") break
646
+ right.set(col.key, rightOffset)
647
+ rightOffset += colWidths[col.key] ?? col.width ?? 100
522
648
  }
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
- }
649
+ return { left, right }
650
+ }, [displayCols, effectivePins, colWidths])
651
+
652
+ const getStickyLeft = React.useCallback((key: string): number => {
653
+ return stickyOffsets.left.get(key) ?? 0
654
+ }, [stickyOffsets])
655
+
656
+ const getStickyRight = React.useCallback((key: string): number => {
657
+ return stickyOffsets.right.get(key) ?? 0
658
+ }, [stickyOffsets])
659
+
660
+ const stickyStyle = React.useCallback(
661
+ (key: string, isHeader = false): React.CSSProperties => {
662
+ if (isReflowViewport) return {}
663
+ const pin = effectivePins[key]
664
+ if (pin === "left") {
665
+ return isHeader
666
+ ? { position: "sticky", left: stickyOffsets.left.get(key) ?? 0, top: 0 }
667
+ : { position: "sticky", left: stickyOffsets.left.get(key) ?? 0 }
668
+ }
669
+ if (pin === "right") {
670
+ return isHeader
671
+ ? { position: "sticky", right: stickyOffsets.right.get(key) ?? 0, top: 0 }
672
+ : { position: "sticky", right: stickyOffsets.right.get(key) ?? 0 }
673
+ }
674
+ return isHeader ? { position: "sticky", top: 0 } : {}
675
+ },
676
+ [effectivePins, isReflowViewport, stickyOffsets],
677
+ )
532
678
 
533
- const totalWidth = displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0)
679
+ const totalWidth = React.useMemo(
680
+ () => displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0),
681
+ [displayCols, colWidths],
682
+ )
534
683
 
535
684
  return {
536
685
  // Sort