"use client" // ───────────────────────────────────────────────────────────────────────────── // useTableState — all non-display state shared by DataTable and DataTablePaginated // ───────────────────────────────────────────────────────────────────────────── import * as React from "react" import type { RowHeight } from "../../lib/row-height" import type { ColumnDef, SortDir } from "./types" import type { ActiveFilter, FilterOperator, SortRule } from "../../lib/table-properties-types" import { parseRowDateToYmd } from "../../lib/date-filter" import { augmentColumnsWithCompanionFilters, resolveColumnFilter } from "../../lib/column-filter" import { buildFilterFieldContextMap } from "../../lib/column-filter-context" import { computeNumericColumnBounds } from "../../lib/column-filter-range-policy" import { getReflowViewportSnapshot, getServerReflowViewportSnapshot, subscribeReflowViewport, } from "../../lib/reflow-viewport" import { useIsMobile } from "../../hooks/use-mobile" import { columnPinPolicyFromDefs, mergeColOrderWithPinPolicy, reconcileColumnPins, } from "../../lib/column-pin-policy" import { groupRowKey } from "../../lib/group-row-key" let _filterId = 0 function nextFilterId() { return `f-${++_filterId}` } /** * Below this scrollport width, sticky left/right pins crush the middle columns, * so pins go inert (see `stickyPinsDisabled`). Matches the `md` breakpoint. */ const STICKY_PIN_MIN_SCROLLPORT = 768 // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function digitsOnly(s: string): string { return s.replace(/\D/g, "") } /** Build the default widths map from column defs */ function buildDefaultWidths(columns: ColumnDef[]): Record { const map: Record = {} for (const col of columns) { if (col.width !== undefined) map[col.key] = col.width } return map } /** Build the initial pin state from column defs */ function buildDefaultPins(columns: ColumnDef[]): Record { const map: Record = {} for (const col of columns) { if (col.defaultPin) map[col.key] = col.defaultPin } return map } function compareUnknownSort(a: unknown, b: unknown): number { if (a === b) return 0 if (a == null && b == null) return 0 if (a == null) return 1 if (b == null) return -1 if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0 if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0 const as = String(a) const bs = String(b) return as < bs ? -1 : as > bs ? 1 : 0 } /** Build the locked-pin set (columns that can never be unpinned) */ function buildLockedPins(columns: ColumnDef[]): Record { const map: Record = {} for (const col of columns) { if (col.lockPin && col.defaultPin) map[col.key] = col.defaultPin } return map } /** Row identity: the caller's `getId`, else the row's own `id`, else its index. */ function getRowId>( row: TData, index: number, getIdFn?: (r: TData, i: number) => string | number, ): string | number { return getIdFn ? getIdFn(row, index) : (row.id as string | number ?? index) } // ───────────────────────────────────────────────────────────────────────────── // Hook // ───────────────────────────────────────────────────────────────────────────── export function useTableState>( data: TData[], columns: ColumnDef[], defaultSort?: { key: string; dir: SortDir }, paginationOverride?: { page: number; pageSize: number }, /** * When defined (including `""`), toolbar search is synced from the URL (`?q=`). * Use `searchParams.get("q") ?? ""` on library list routes; omit for other hubs. */ syncedSearchFromUrl?: string, ) { // ── Sort ────────────────────────────────────────────────────────────────── const [sortRules, setSortRules] = React.useState(() => { if (defaultSort) { return [{ id: "sort-default", fieldKey: defaultSort.key, direction: defaultSort.dir }] } return [] }) const primarySort = sortRules[0] ?? null const sortKey: string = primarySort?.fieldKey ?? "" const sortDir: SortDir = primarySort?.direction ?? "asc" const addSortRule = React.useCallback((fieldKey: string) => { setSortRules(prev => { if (prev.some(r => r.fieldKey === fieldKey)) return prev // New drawer sorts are primary (same as column-header sort), not trailing. return [{ id: `sort-${Date.now()}`, fieldKey, direction: "asc" }, ...prev] }) }, [setSortRules]) const removeSortRule = React.useCallback((id: string) => { setSortRules(prev => prev.filter(r => r.id !== id)) }, [setSortRules]) const toggleSortDir = React.useCallback((id: string) => { setSortRules(prev => prev.map(r => r.id === id ? { ...r, direction: r.direction === "asc" ? "desc" : "asc" } : r )) }, [setSortRules]) const handleSortByKey = React.useCallback((colKey: string) => { setSortRules(prev => { const idx = prev.findIndex(r => r.fieldKey === colKey) if (idx === 0) { return prev.map((r, i) => i === 0 ? { ...r, direction: r.direction === "asc" ? "desc" : "asc" } : r) } const filtered = prev.filter(r => r.fieldKey !== colKey) return [{ id: `sort-${Date.now()}`, fieldKey: colKey, direction: "asc" }, ...filtered] }) }, [setSortRules]) // ── Filters ─────────────────────────────────────────────────────────────── const [search, setSearch] = React.useState(() => syncedSearchFromUrl !== undefined ? syncedSearchFromUrl.trim() : "", ) const [searchOpen, setSearchOpen] = React.useState(() => syncedSearchFromUrl !== undefined && Boolean(syncedSearchFromUrl.trim()), ) const searchRef = React.useRef(null) const [activeFilters, setActiveFilters] = React.useState([]) const [filterConnectors, setFilterConnectors] = React.useState>({}) const [openFilterId, setOpenFilterId] = React.useState(null) const [filterBarVisible, setFilterBarVisible] = React.useState(true) const [drawerExpandedFilters, setDrawerExpandedFilters] = React.useState>(new Set()) React.useEffect(() => { if (syncedSearchFromUrl === undefined) return const next = syncedSearchFromUrl.trim() setSearch(next) setSearchOpen(next.length > 0) }, [syncedSearchFromUrl]) const toggleConnector = React.useCallback((leftId: string) => { setFilterConnectors(prev => ({ ...prev, [leftId]: prev[leftId] === "or" ? "and" : "or" })) }, [setFilterConnectors]) function getConnector(leftId: string): "and" | "or" { return filterConnectors[leftId] ?? "and" } const filterColumns = React.useMemo( () => augmentColumnsWithCompanionFilters(columns), [columns], ) const buildEmptyFilter = React.useCallback((fieldKey: string): ActiveFilter | null => { const col = filterColumns.find(c => c.key === fieldKey) const f = col ? resolveColumnFilter(col) : null if (!f) return null const firstOperator: FilterOperator = (() => { if (f.type === "select" || f.type === "date" || f.type === "date-range") { const pick = f.operators?.find(o => o === "is" || o === "is_not") return pick ?? "is" } return f.operators?.[0] ?? "contains" })() return { id: nextFilterId(), fieldKey, operator: firstOperator, values: [] } }, [filterColumns]) const addFilter = React.useCallback((fieldKey: string, fromDrawer = false) => { const newFilter = buildEmptyFilter(fieldKey) if (!newFilter) return setActiveFilters(prev => [...prev, newFilter]) if (fromDrawer) { setDrawerExpandedFilters(() => new Set([newFilter.id])) // Keep toolbar pills hidden until a value is chosen — avoids mounting every // FilterPill (heavy) on each drawer "Add filter" click. } else { setOpenFilterId(newFilter.id) setFilterBarVisible(true) } }, [buildEmptyFilter, setActiveFilters, setDrawerExpandedFilters, setOpenFilterId, setFilterBarVisible]) const updateFilter = React.useCallback((id: string, patch: Partial) => { let shouldShowFilterBar = false setActiveFilters(prev => { const next = prev.map(f => { if (f.id !== id) return f const merged = { ...f, ...patch } const col = filterColumns.find(c => c.key === merged.fieldKey) const filterDef = col ? resolveColumnFilter(col) : null if (merged.values.length > 0) { shouldShowFilterBar = filterDef?.type === "text" ? (merged.values[0] ?? "").trim().length > 0 : filterDef?.type === "range" ? merged.values.length >= 2 : filterDef?.type === "date-range" ? Boolean(merged.values[0] || merged.values[1]) : true } return merged }) return next }) if (shouldShowFilterBar) setFilterBarVisible(true) }, [filterColumns, setActiveFilters, setFilterBarVisible]) const removeFilter = React.useCallback((id: string) => { // The connector map is keyed by the filter on its left, so a removed // filter has to hand its connector to that neighbour before its own entry // goes. The two updates are queued side by side rather than nesting the // connector one inside the filter updater: an updater that queues another // update is not pure, and React can replay it. const idx = activeFilters.findIndex(f => f.id === id) const remaining = activeFilters.length - 1 setActiveFilters(prev => prev.filter(f => f.id !== id)) setFilterConnectors(prev => { const next = { ...prev } if (idx > 0 && remaining > 0) { const leftId = activeFilters[idx - 1].id next[leftId] = prev[id] ?? prev[leftId] ?? "and" } delete next[id] return next }) setOpenFilterId(prev => prev === id ? null : prev) }, [activeFilters, setActiveFilters, setFilterConnectors, setOpenFilterId]) // ── Group by ────────────────────────────────────────────────────────────── const [groupBy, setGroupBy] = React.useState(null) // ── Per-column quick-search ─────────────────────────────────────────────── const [colMenuSearch, setColMenuSearch] = React.useState>({}) // ── Selection ───────────────────────────────────────────────────────────── const [selected, setSelected] = React.useState>(new Set()) // ── Column widths ───────────────────────────────────────────────────────── const [colWidths, setColWidths] = React.useState>(() => buildDefaultWidths(columns)) const resizeRef = React.useRef<{ key: string startX: number startW: number liveW?: number } | null>(null) // ── Column order ────────────────────────────────────────────────────────── const [colOrder, setColOrder] = React.useState(() => columns.map(c => c.key)) // ── Column pins ─────────────────────────────────────────────────────────── const [colPins, setColPins] = React.useState>(() => buildDefaultPins(columns)) const lockedPins = React.useMemo(() => buildLockedPins(columns), [columns]) const columnPinPolicy = React.useMemo(() => columnPinPolicyFromDefs(columns), [columns]) const columnKeysForOrder = React.useMemo(() => new Set(columns.map(c => c.key)), [columns]) React.useLayoutEffect(() => { setColPins(prev => { const next = reconcileColumnPins(prev, columnPinPolicy) return Object.keys(next).length === Object.keys(prev).length && Object.entries(next).every(([k, v]) => prev[k] === v) ? prev : next }) setColOrder(prev => { const next = mergeColOrderWithPinPolicy(prev, columnKeysForOrder, columnPinPolicy) return next.length === prev.length && next.every((k, i) => k === prev[i]) ? prev : next }) }, [columnPinPolicy, columnKeysForOrder]) // ── Column wrap ─────────────────────────────────────────────────────────── const [colWrap, setColWrap] = React.useState>({}) // ── Drawer / display settings ───────────────────────────────────────────── const [sheetOpen, setSheetOpen] = React.useState(false) /** * Deep-link target for the Properties drawer. When a callsite wants to open * the drawer focused on a specific panel (e.g. "conditional-rules" from the * column header menu), it sets this before calling `setSheetOpen(true)`. The * drawer's `initialPanel` prop reads it and syncs its internal `sheetPanel` * accordingly. The toolbar Properties button clears it so it opens to "main". */ const [sheetInitialPanel, setSheetInitialPanel] = React.useState(null) /** * Toolbar filter icon (mobile / high zoom) — opens Properties on the Filter panel. */ const openFilterPanelInDrawer = React.useCallback(() => { setSheetInitialPanel("filter") setSheetOpen(true) }, [setSheetInitialPanel, setSheetOpen]) /** * Column header **Filter by this column** — opens Properties on the Filter * panel with the target field expanded. Used instead of toolbar FilterPill * popovers so sticky floating headers stay keyboard-accessible when the * filter bar is off-screen. */ const openColumnFilterInDrawer = React.useCallback((fieldKey: string) => { // Do not rely on side effects inside `setActiveFilters` — React may defer // the updater, which left `sheetOpen` false and broke the Vitest contract. const existing = activeFilters.find(f => f.fieldKey === fieldKey) const filter = existing ?? buildEmptyFilter(fieldKey) if (!filter) return if (!existing) { setActiveFilters(prev => { if (prev.some(f => f.fieldKey === fieldKey)) return prev return [...prev, filter] }) } setDrawerExpandedFilters(() => new Set([filter.id])) setOpenFilterId(null) setSheetInitialPanel("filter") setSheetOpen(true) }, [ activeFilters, buildEmptyFilter, setActiveFilters, setDrawerExpandedFilters, setOpenFilterId, setSheetInitialPanel, setSheetOpen, ]) const [showGridlines, setShowGridlines] = React.useState(true) const [rowHeight, setRowHeight] = React.useState("default") const [hiddenCols, setHiddenCols] = React.useState>(new Set()) const toggleColVisibility = React.useCallback((key: string) => { setHiddenCols(prev => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) }, [setHiddenCols]) const moveCol = React.useCallback((key: string, dir: "up" | "down") => { setColOrder(prev => { const lockedLeft = columns.filter(c => c.lockPin && c.defaultPin === "left").map(c => c.key) const lockedRight = columns.filter(c => c.lockPin && c.defaultPin === "right").map(c => c.key) const locked = new Set([...lockedLeft, ...lockedRight]) const orderable = prev.filter(k => !locked.has(k)) const idx = orderable.indexOf(key) if (dir === "up" && idx <= 0) return prev if (dir === "down" && idx >= orderable.length - 1) return prev const next = [...orderable] const swap = dir === "up" ? idx - 1 : idx + 1 ;[next[idx], next[swap]] = [next[swap], next[idx]] return [...lockedLeft, ...next, ...lockedRight] }) }, [columns, setColOrder]) // ── Drag-to-reorder ─────────────────────────────────────────────────────── const draggedKey = React.useRef(null) const [dragOverKey, setDragOverKey] = React.useState(null) // ── Scroll / overflow ───────────────────────────────────────────────────── const scrollRef = React.useRef(null) const [scrolled, setScrolled] = React.useState(false) const [scrollEnd, setScrollEnd] = React.useState(false) const [isOverflowing, setIsOverflowing] = React.useState(false) /** * Verdict, not the measurement. The scrollport width is only ever consumed as * the `STICKY_PIN_MIN_SCROLLPORT` threshold below, so storing raw pixels made * every frame of a rail or window resize a new state value and re-rendered the * whole grid to recompute one boolean that changes at most once per drag. */ const [scrollportNarrow, setScrollportNarrow] = React.useState(false) const isReflowViewport = React.useSyncExternalStore( subscribeReflowViewport, getReflowViewportSnapshot, getServerReflowViewportSnapshot, ) const isMobile = useIsMobile() // Phone / tablet (< md), a narrow table container, or WCAG reflow: sticky // L/R pins crush middle columns. Measure the table scrollport as well as the // window because secondary rails can leave a desktop viewport with a narrow // content canvas. const stickyPinsDisabled = isReflowViewport || isMobile || scrollportNarrow const filterFieldContext = React.useMemo( () => buildFilterFieldContextMap(data, filterColumns), [data, filterColumns], ) // ── Column lookup index (stable per `columns` reference) ───────────────── // The previous implementation called `columns.find(c => c.key === ...)` inside // every filter/sort comparator and every sticky-offset getter — for large // datasets that's O(rows × cols) per render. Map lookups make those O(1). const columnsByKey = React.useMemo(() => { const map = new Map>() for (const col of filterColumns) map.set(col.key, col) return map }, [filterColumns]) // Searchable text cache. Per row, concatenate every value into one // lower-cased blob ONCE and reuse it across keystrokes. Keyed by row // identity via WeakMap so it never holds onto rows the consumer dropped. // Built on first lookup, not on first render: `useRef` has no lazy form, so // passing `new WeakMap()` would allocate one every render and drop it. const searchableTextCache = React.useRef | null>(null) const getSearchableText = React.useCallback((row: TData): string => { const cache = (searchableTextCache.current ??= new WeakMap()) const cached = cache.get(row) if (cached !== undefined) return cached let blob = "" for (const v of Object.values(row)) { if (v == null) continue blob += String(v).toLowerCase() + "\n" } cache.set(row, blob) return blob }, []) // Per-row per-column lower-cased value cache (column quick-search + // text-mask filters). One `Map` per row, lazily filled on first lookup. const lowerValueCache = React.useRef > | null>(null) const getLowerValue = React.useCallback((row: TData, key: string): string => { const wm = (lowerValueCache.current ??= new WeakMap()) let perRow = wm.get(row) if (!perRow) { perRow = new Map() wm.set(row, perRow) } const cached = perRow.get(key) if (cached !== undefined) return cached const computed = String(row[key] ?? "").toLowerCase() perRow.set(key, computed) return computed }, []) // Reset the row-keyed caches whenever the dataset reference changes so we // don't pin stale strings for rows the consumer just replaced. React.useEffect(() => { searchableTextCache.current = null lowerValueCache.current = null }, [data]) // Keep the search input urgent; defer the rows pipeline so keystrokes stay // responsive on large hubs (vercel-react-best-practices: useDeferredValue). const deferredSearch = React.useDeferredValue(search) // ── Derived: filtered + sorted rows ────────────────────────────────────── const rows = React.useMemo(() => { let result = data.slice() const q = deferredSearch.trim().toLowerCase() if (q) { result = result.filter(r => { const text = getSearchableText(r) return text.includes(q) }) } const activeWithValues = activeFilters.filter(f => { if (f.values.length === 0) return false const col = columnsByKey.get(f.fieldKey) const filterDef = col ? resolveColumnFilter(col) : null if (filterDef?.type === "text") { return (f.values[0] ?? "").trim().length > 0 } if (filterDef?.type === "range") { return f.values.length >= 2 } if (filterDef?.type === "date-range") { return Boolean(f.values[0] || f.values[1]) } return true }) if (activeWithValues.length > 0) { // Pre-resolve column, operator, normalised needle, and select-value Set // for each active filter ONCE (instead of per row). type CompiledFilter = { col: ColumnDef id: string type: "select" | "date" | "date-range" | "text" | "range" operator: ActiveFilter["operator"] selectValues?: Set personRail?: boolean dateTarget?: string dateRangeStart?: string dateRangeEnd?: string textNeedle?: string digitsNeedle?: string isDigitsMask?: boolean rangeLo?: number rangeHi?: number } const compiled: CompiledFilter[] = [] for (const f of activeWithValues) { const col = columnsByKey.get(f.fieldKey) const filterDef = col ? resolveColumnFilter(col) : null if (!col || !filterDef) continue if (filterDef.type === "select") { compiled.push({ col, id: f.id, type: "select", operator: f.operator, selectValues: new Set(f.values), personRail: col.cellKind === "people-rail", }) } else if (filterDef.type === "date") { compiled.push({ col, id: f.id, type: "date", operator: f.operator, dateTarget: f.values[0], }) } else if (filterDef.type === "date-range") { compiled.push({ col, id: f.id, type: "date-range", operator: f.operator, dateRangeStart: f.values[0], dateRangeEnd: f.values[1], }) } else if (filterDef.type === "range") { const ctx = filterFieldContext[col.key] const min = filterDef.rangeMin ?? ctx?.rangeMin ?? computeNumericColumnBounds(data, col.key).min const max = filterDef.rangeMax ?? ctx?.rangeMax ?? computeNumericColumnBounds(data, col.key).max const loRaw = f.values[0] !== undefined && f.values[0] !== "" ? Number(f.values[0]) : min const hiRaw = f.values[1] !== undefined && f.values[1] !== "" ? Number(f.values[1]) : max const lo = Number.isFinite(loRaw) ? Math.max(min, Math.min(max, loRaw)) : min const hi = Number.isFinite(hiRaw) ? Math.max(min, Math.min(max, hiRaw)) : max compiled.push({ col, id: f.id, type: "range", operator: f.operator, rangeLo: Math.min(lo, hi), rangeHi: Math.max(lo, hi), }) } else { const raw = f.values[0] ?? "" const isDigitsMask = filterDef.textMask === "phone" || filterDef.textMask === "zip" compiled.push({ col, id: f.id, type: "text", operator: f.operator, isDigitsMask, digitsNeedle: isDigitsMask ? digitsOnly(raw) : undefined, textNeedle: !isDigitsMask ? raw.toLowerCase() : undefined, }) } } const matchesCompiled = (r: TData, f: CompiledFilter): boolean => { const raw = r[f.col.key] if (f.type === "select") { const rowVal = raw === true ? "true" : raw === false ? "false" : String(raw ?? "") if (f.personRail) { const people = raw const names = Array.isArray(people) ? people.map(p => p && typeof p === "object" && "name" in p ? String((p as { name: unknown }).name) : String(p ?? ""), ) : [] const hit = names.some(n => f.selectValues!.has(n)) return f.operator === "is" ? hit : !hit } const hit = f.selectValues!.has(rowVal) return f.operator === "is" ? hit : !hit } if (f.type === "date-range") { const rowYmd = parseRowDateToYmd(String(raw ?? "")) if (rowYmd === null) return f.operator === "is_not" if (f.dateRangeStart && rowYmd < f.dateRangeStart) { return f.operator === "is_not" } if (f.dateRangeEnd && rowYmd > f.dateRangeEnd) { return f.operator === "is_not" } return f.operator === "is" } if (f.type === "range") { const num = typeof raw === "number" ? raw : Number(raw) if (!Number.isFinite(num)) return false return num >= f.rangeLo! && num <= f.rangeHi! } const rowVal = String(raw ?? "") if (f.type === "date") { if (!f.dateTarget) return true const rowYmd = parseRowDateToYmd(rowVal) const op = f.operator === "is_not" ? "is_not" : "is" if (rowYmd === null) return op === "is_not" return op === "is" ? rowYmd === f.dateTarget : rowYmd !== f.dateTarget } if (f.isDigitsMask) { if (!f.digitsNeedle) return true const hay = digitsOnly(rowVal) return f.operator === "contains" ? hay.includes(f.digitsNeedle) : !hay.includes(f.digitsNeedle) } if (!f.textNeedle) return true const hay = getLowerValue(r, f.col.key) return f.operator === "contains" ? hay.includes(f.textNeedle) : !hay.includes(f.textNeedle) } if (compiled.length > 0) { result = result.filter(r => { let res = matchesCompiled(r, compiled[0]) for (let i = 1; i < compiled.length; i++) { const connector = filterConnectors[compiled[i - 1].id] ?? "and" const match = matchesCompiled(r, compiled[i]) res = connector === "and" ? res && match : res || match } return res }) } } // Column menu quick-search — pre-normalise needles outside the row loop. const colMenuEntries: { key: string; lower: string }[] = [] for (const [key, raw] of Object.entries(colMenuSearch)) { const trimmed = raw.trim() if (trimmed) colMenuEntries.push({ key, lower: trimmed.toLowerCase() }) } if (colMenuEntries.length > 0) { result = result.filter(r => { for (const { key, lower } of colMenuEntries) { const text = getLowerValue(r, key) if (!text.includes(lower)) return false } return true }) } // Sort — resolve each rule's sort key ONCE, then run the comparator over // an indexed list so the inner loop is a tight array walk, not a chain of // `columns.find` lookups per comparison. if (sortRules.length > 0) { const resolved: { sk: string; dir: SortDir }[] = [] for (const rule of sortRules) { const col = columnsByKey.get(rule.fieldKey) const sk = col?.sortKey ?? col?.key if (sk) resolved.push({ sk: sk as string, dir: rule.direction }) } if (resolved.length > 0) { result.sort((a, b) => { for (let i = 0; i < resolved.length; i++) { const { sk, dir } = resolved[i] const cmp = compareUnknownSort(a[sk], b[sk]) if (cmp !== 0) return dir === "asc" ? cmp : -cmp } return 0 }) } } return result }, [ data, deferredSearch, activeFilters, filterConnectors, colMenuSearch, sortRules, columnsByKey, filterFieldContext, getSearchableText, getLowerValue, ]) // ── Paged rows (slice of rows when pagination is active) ───────────────── const pagedRows = React.useMemo(() => { if (!paginationOverride || paginationOverride.pageSize <= 0) return rows const { page, pageSize } = paginationOverride const safePage = Math.max(1, page) return rows.slice((safePage - 1) * pageSize, safePage * pageSize) // eslint-disable-next-line react-hooks/exhaustive-deps }, [rows, paginationOverride?.page, paginationOverride?.pageSize]) // ── Grouped rows ────────────────────────────────────────────────────────── const groupedRows = React.useMemo(() => { if (!groupBy) { return [{ groupKey: null as string | null, groupLabel: null as string | null, groupValue: null as unknown, rows, }] } const groupCellKind = columnsByKey.get(groupBy)?.cellKind const groups = new Map() rows.forEach(row => { const val = groupRowKey(row[groupBy], groupCellKind) if (!groups.has(val)) groups.set(val, []) groups.get(val)!.push(row) }) return [...groups.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([key, groupRows]) => ({ groupKey: key, groupLabel: key, groupValue: groupRows[0]?.[groupBy], rows: groupRows, })) }, [rows, groupBy, columnsByKey]) // ── Display columns ─────────────────────────────────────────────────────── const displayCols = React.useMemo(() => { const leftPinned: string[] = [] const free: string[] = [] const rightPinned: string[] = [] for (const k of colOrder) { const pin = colPins[k] if (pin === "left") leftPinned.push(k) else if (pin === "right") rightPinned.push(k) else free.push(k) } const ordered = [...leftPinned, ...free, ...rightPinned] const out: ColumnDef[] = [] for (const k of ordered) { if (hiddenCols.has(k)) continue const col = columnsByKey.get(k) if (col?.filterOnly) continue if (col) out.push(col) } return out }, [colOrder, colPins, hiddenCols, columnsByKey]) const totalWidth = React.useMemo( () => displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0), [displayCols, colWidths], ) // ── Effective pins (respect overflow) ───────────────────────────────────── const LOCKED_KEYS = React.useMemo(() => new Set(Object.keys(lockedPins)), [lockedPins]) // When the table fits within its container (not overflowing) there is no need // to sticky-pin any column — even locked ones. Pins only activate once the // user has to scroll horizontally so the selection / action edges stay visible. // Disable stickies on mobile / reflow — pinned Action + Course Name otherwise // overlap middle columns in a ~375px scrollport. const effectivePins = React.useMemo(() => { if (stickyPinsDisabled || !isOverflowing) return {} const result: Record = {} for (const [key, pin] of Object.entries(colPins)) { result[key] = pin } return result }, [colPins, isOverflowing, stickyPinsDisabled]) // ── Column actions ──────────────────────────────────────────────────────── function startResize(key: string, e: React.MouseEvent) { e.preventDefault() e.stopPropagation() const minW = columns.find(c => c.key === key)?.minWidth ?? 60 const startW = colWidths[key] ?? (columns.find(c => c.key === key)?.width ?? 100) resizeRef.current = { key, startX: e.clientX, startW, liveW: startW } const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return const { key: k, startX, startW: sw } = resizeRef.current const next = Math.max(minW, sw + ev.clientX - startX) // Paint widths on the live elements during drag so the grid // tracks the pointer without a React commit per mousemove. const cols = document.querySelectorAll(`[data-dt-col="${CSS.escape(k)}"]`) for (const col of cols) col.style.width = `${next}px` resizeRef.current.liveW = next } const onUp = () => { const live = resizeRef.current?.liveW if (typeof live === "number") { setColWidths(p => ({ ...p, [key]: live })) } resizeRef.current = null document.removeEventListener("mousemove", onMove) document.removeEventListener("mouseup", onUp) } document.addEventListener("mousemove", onMove) document.addEventListener("mouseup", onUp) } function handleDragStart(key: string, e: React.DragEvent) { draggedKey.current = key e.dataTransfer.effectAllowed = "move" } function handleDragOver(key: string, e: React.DragEvent) { e.preventDefault() e.dataTransfer.dropEffect = "move" if (draggedKey.current && draggedKey.current !== key) setDragOverKey(key) } function handleDrop(key: string) { if (!draggedKey.current || draggedKey.current === key) { setDragOverKey(null); return } const order = [...colOrder] const from = order.indexOf(draggedKey.current) const to = order.indexOf(key) order.splice(from, 1) order.splice(to, 0, draggedKey.current!) setColOrder(order) draggedKey.current = null setDragOverKey(null) } function handleDragEnd() { draggedKey.current = null; setDragOverKey(null) } function pinColumn(key: string, pin: "left" | "right") { setColPins(p => ({ ...p, [key]: pin })) } function unpinColumn(key: string) { if (lockedPins[key]) return setColPins(p => { const n = { ...p }; delete n[key]; return n }) } function toggleWrap(key: string) { setColWrap(p => ({ ...p, [key]: !p[key] })) } // ── Scroll handlers ─────────────────────────────────────────────────────── const checkOverflow = React.useCallback(() => { const el = scrollRef.current if (!el) return // One read per call — every `clientWidth` access can flush pending layout. const width = el.clientWidth setScrollportNarrow(width < STICKY_PIN_MIN_SCROLLPORT) // Compare declared column width to scrollport — not scrollWidth, which matched // clientWidth when the table stretched with `w-full`. setIsOverflowing(totalWidth > width + 1) }, [totalWidth]) function handleScroll() { const el = scrollRef.current if (!el) return const width = el.clientWidth const nextScrolled = el.scrollLeft > 1 const nextScrollEnd = el.scrollLeft >= el.scrollWidth - width - 1 const nextNarrow = width < STICKY_PIN_MIN_SCROLLPORT const nextOverflowing = totalWidth > width + 1 setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled)) setScrollEnd((prev) => (prev === nextScrollEnd ? prev : nextScrollEnd)) setScrollportNarrow((prev) => (prev === nextNarrow ? prev : nextNarrow)) setIsOverflowing((prev) => (prev === nextOverflowing ? prev : nextOverflowing)) } React.useLayoutEffect(() => { checkOverflow() }, [checkOverflow]) // ── Selection helpers ───────────────────────────────────────────────────── const toggleRow = React.useCallback((id: string | number) => { setSelected(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) }, [setSelected]) const toggleAll = React.useCallback((allRowIds: (string | number)[]) => { setSelected(prev => prev.size === allRowIds.length ? new Set() : new Set(allRowIds)) }, [setSelected]) /** * Select or clear a subset of rows without touching the rest of the * selection. `toggleAll` replaces the whole set, which is right for the header * checkbox and wrong for a group: picking one group must not drop the rows a * user already picked in another. */ const setRowsSelected = React.useCallback( (rowIds: (string | number)[], nextSelected: boolean) => { if (rowIds.length === 0) return setSelected(prev => { const next = new Set(prev) for (const id of rowIds) { if (nextSelected) next.add(id) else next.delete(id) } return next }) }, [setSelected], ) // ── Sticky offset calculations ──────────────────────────────────────────── // Precompute every pinned column's offset ONCE per render so the per-cell // `stickyStyle()` call is an O(1) map lookup instead of an O(cols) walk. // With `cells = rows × cols`, the previous O(rows × cols²) became the // dominant cost on wide tables. const stickyOffsets = React.useMemo(() => { const left = new Map() const right = new Map() let leftOffset = 0 for (const col of displayCols) { if (effectivePins[col.key] !== "left") break left.set(col.key, leftOffset) leftOffset += colWidths[col.key] ?? col.width ?? 100 } let rightOffset = 0 for (let i = displayCols.length - 1; i >= 0; i--) { const col = displayCols[i] if (effectivePins[col.key] !== "right") break right.set(col.key, rightOffset) rightOffset += colWidths[col.key] ?? col.width ?? 100 } return { left, right } }, [displayCols, effectivePins, colWidths]) const getStickyLeft = React.useCallback((key: string): number => { return stickyOffsets.left.get(key) ?? 0 }, [stickyOffsets]) const getStickyRight = React.useCallback((key: string): number => { return stickyOffsets.right.get(key) ?? 0 }, [stickyOffsets]) const stickyStyle = React.useCallback( (key: string, isHeader = false): React.CSSProperties => { if (stickyPinsDisabled) return {} const pin = effectivePins[key] if (pin === "left") { return isHeader ? { position: "sticky", left: stickyOffsets.left.get(key) ?? 0, top: 0 } : { position: "sticky", left: stickyOffsets.left.get(key) ?? 0 } } if (pin === "right") { return isHeader ? { position: "sticky", right: stickyOffsets.right.get(key) ?? 0, top: 0 } : { position: "sticky", right: stickyOffsets.right.get(key) ?? 0 } } return isHeader ? { position: "sticky", top: 0 } : {} }, [effectivePins, stickyPinsDisabled, stickyOffsets], ) return { // Sort sortRules, setSortRules, sortKey, sortDir, addSortRule, removeSortRule, toggleSortDir, handleSortByKey, // Filters search, setSearch, searchOpen, setSearchOpen, searchRef, activeFilters, setActiveFilters, filterConnectors, setFilterConnectors, toggleConnector, getConnector, openFilterId, setOpenFilterId, filterBarVisible, setFilterBarVisible, drawerExpandedFilters, setDrawerExpandedFilters, addFilter, updateFilter, removeFilter, openColumnFilterInDrawer, // Group groupBy, setGroupBy, // Column quick-search colMenuSearch, setColMenuSearch, // Selection selected, setSelected, toggleRow, toggleAll, setRowsSelected, getRowId, // Column widths / order / pins / wrap colWidths, setColWidths, resizeRef, startResize, colOrder, setColOrder, moveCol, colPins, setColPins, lockedPins, LOCKED_KEYS, pinColumn, unpinColumn, colWrap, setColWrap, toggleWrap, // Drag-to-reorder draggedKey, dragOverKey, handleDragStart, handleDragOver, handleDrop, handleDragEnd, // Scroll scrollRef, scrolled, scrollEnd, isOverflowing, checkOverflow, handleScroll, // Derived rows, pagedRows, groupedRows, effectivePins, displayCols, isReflowViewport, stickyPinsDisabled, getStickyLeft, getStickyRight, stickyStyle, totalWidth, // Display settings sheetOpen, setSheetOpen, sheetInitialPanel, setSheetInitialPanel, openFilterPanelInDrawer, filterFieldContext, showGridlines, setShowGridlines, rowHeight, setRowHeight, hiddenCols, setHiddenCols, toggleColVisibility, } }