/** Primary hub scrollport — set on `PrimaryPageTemplate` body wrapper in apps/web. */ export const PAGE_SCROLL_ATTR = "data-page-scroll" /** Breadcrumb / site chrome — bottom edge is the table sticky-header pin line. */ export const SITE_HEADER_ATTR = "data-site-header" /** Compact / comfort utility bar — sits above `[data-page-scroll]`. */ export const UTILITY_BAR_SLOT = "utility-bar" /** * Sticky subheaders inside the page scrollport (under the utility bar). * Layer stack while scrolling a hub/detail: utility (outside) → these (z-40) * → floating table head (portaled, z-50). Must sit above DataTable pinned * cells (z-20) so row content cannot paint through tab labels. */ export const STICKY_SUBHEADER_SLOTS = [ "tabs-sticky-subheader", "list-views-sticky-subheader", ] as const /** * The element that vertically scrolls hub page content (not `window`). * Falls back to `#main-content` for legacy shells. */ export function getPageScrollElement(): HTMLElement | null { if (typeof document === "undefined") return null return ( document.querySelector(`[${PAGE_SCROLL_ATTR}]`) ?? document.getElementById("main-content") ) } /** * Resolve a CSS length custom property on `:root` to CSS pixels. * Supports `px` and `rem` (root font-size). Falls back when unset/invalid. */ function readRootLengthPx(property: string, fallbackPx: number): number { if (typeof document === "undefined") return fallbackPx const rootStyle = getComputedStyle(document.documentElement) const raw = rootStyle.getPropertyValue(property).trim() if (!raw) return fallbackPx const value = Number.parseFloat(raw) if (!Number.isFinite(value)) return fallbackPx if (raw.endsWith("rem")) { const rootFont = Number.parseFloat(rootStyle.fontSize) || 16 return value * rootFont } return value } /** * Viewport Y of the bottom of shell chrome that body-portaled floating * windows must stay below (utility bar, optional system banner). * * Prefers the live top of `[data-page-scroll]` when that canvas has already * cleared the bar. Falls back to the bar's own bottom, then 0 when the * shell has no bar (exam lock / focus workflows). */ export function getShellChromeBottomPx(): number { if (typeof document === "undefined") return 0 const pageScroll = getPageScrollElement() const portTop = pageScroll?.getBoundingClientRect().top ?? 0 const bar = document.querySelector( `[data-slot="${UTILITY_BAR_SLOT}"]`, ) const barRect = bar?.getBoundingClientRect() const barBottom = barRect && barRect.height > 1 ? Math.max(0, barRect.bottom) : 0 if (portTop > 0 || barBottom > 0) { return Math.max(portTop, barBottom) } return 0 } /** * Viewport Y where floating table column headers should pin. * * Prefers live geometry: * 1. Top of `[data-page-scroll]` (canvas under the utility bar) * 2. Bottom of a real `SiteHeader` when it is stuck in the top pin band * 3. Bottom of sticky subheaders (module tabs / hub view toolbar) stacked * under that band — matched against the running offset, not only * `portTop`, so a strip with margin/`top` offset still counts * 4. `--header-height` as last resort * * Ignores zero-height `data-site-header` shells (Compact portals the trail * into the utility bar and leaves an empty wrapper that must not affect pin). */ export function getStickyTableHeaderOffset(): number { if (typeof document === "undefined") return 48 const pageScroll = getPageScrollElement() const portTop = pageScroll?.getBoundingClientRect().top ?? 0 let offset = portTop > 0 ? portTop : 0 const siteHeader = document.querySelector(`[${SITE_HEADER_ATTR}]`) if (siteHeader) { const rect = siteHeader.getBoundingClientRect() // Empty Compact wrappers are height 0 — skip so they don't poison the stack. if (rect.height > 1 && rect.bottom > portTop && rect.top <= portTop + 1) { offset = Math.max(offset, rect.bottom) } } const stickySelector = STICKY_SUBHEADER_SLOTS.map( slot => `[data-slot="${slot}"]`, ).join(", ") for (const el of document.querySelectorAll(stickySelector)) { const rect = el.getBoundingClientRect() if (rect.height <= 0) continue // Stack under the current pin line (utility / site header / prior strip), // not only `portTop`. A `mt-*` on the sticky node can leave `rect.top` // slightly below `portTop` while the strip is already the active ceiling. if (rect.top <= offset + 1 && rect.bottom > offset) { offset = Math.max(offset, rect.bottom) } } if (offset > 0) return offset return readRootLengthPx("--header-height", 48) } /** Subscribe to vertical scroll sources that move tables relative to the viewport. */ export function bindVerticalScrollListeners(onScroll: () => void): () => void { const handler = () => onScroll() window.addEventListener("scroll", handler, { passive: true }) window.addEventListener("resize", handler, { passive: true }) const pageScroll = getPageScrollElement() pageScroll?.addEventListener("scroll", handler, { passive: true }) return () => { window.removeEventListener("scroll", handler) window.removeEventListener("resize", handler) pageScroll?.removeEventListener("scroll", handler) } }