@exxatdesignux/ui 0.2.15 → 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.
- package/CHANGELOG.md +23 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
- 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-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
- package/consumer-extras/patterns/collaboration-access-pattern.md +2 -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 +1 -0
- package/src/globals.css +21 -2
- package/src/theme.css +4 -2
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +23 -18
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +27 -7
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +136 -2
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +141 -59
- package/template/components/ask-leo-sidebar.tsx +1 -4
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- 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 +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/finder-panel-view.tsx +2 -2
- package/template/components/data-views/index.ts +26 -3
- package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
- package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
- package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/data-views/outline-tree-menu.tsx +157 -0
- package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +173 -379
- package/template/components/folder-details-shell.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +88 -80
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +116 -51
- 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 +21 -11
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- 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 +18 -132
- 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} +67 -58
- package/template/components/product-switcher.tsx +26 -11
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +130 -70
- package/template/components/question-bank-hub-client.tsx +108 -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-page-header.tsx +18 -2
- package/template/components/question-bank-secondary-nav.tsx +12 -228
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +24 -4
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +56 -32
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
- package/template/components/templates/list-page.tsx +1 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/collaboration-access-pattern.md +2 -0
- package/template/docs/question-bank-hub-header-pattern.md +25 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- 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/mock/navigation.tsx +30 -1
- 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 +70 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +6 -6
- package/template/stores/app-store.ts +46 -1
- package/template/components/command-menu-01.tsx +0 -133
- package/template/components/command-menu-02.tsx +0 -386
|
@@ -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
|
-
|
|
738
|
-
|
|
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",
|
|
742
|
-
window.addEventListener("scroll",
|
|
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",
|
|
746
|
-
window.removeEventListener("scroll",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
892
|
-
|
|
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
|
-
|
|
895
|
-
window.removeEventListener("
|
|
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
|
|
965
|
+
const scheduled = rafThrottle(apply)
|
|
966
|
+
const ro = new ResizeObserver(scheduled)
|
|
930
967
|
ro.observe(wrapEl)
|
|
931
|
-
window.addEventListener("scroll",
|
|
932
|
-
window.addEventListener("resize",
|
|
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",
|
|
936
|
-
window.removeEventListener("resize",
|
|
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:
|
|
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={{
|
|
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
|
|
@@ -49,7 +49,10 @@ export interface ColumnDef<TData> {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
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)
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
:
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 =
|
|
404
|
+
const op = f.operator === "is_not" ? "is_not" : "is"
|
|
311
405
|
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)
|
|
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
|
-
|
|
333
|
-
|
|
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
|
|
442
|
+
return true
|
|
339
443
|
})
|
|
340
444
|
}
|
|
341
445
|
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
|
|
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
|
|
413
|
-
const free
|
|
414
|
-
const rightPinned =
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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)
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
512
|
-
|
|
639
|
+
left.set(col.key, leftOffset)
|
|
640
|
+
leftOffset += colWidths[col.key] ?? col.width ?? 100
|
|
513
641
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 =
|
|
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
|