/** * popover - shared helpers for panels that must escape the grid's * `overflow:hidden` scroll container by portalling to : the theme-var * snapshot + portal action, and the fixed-position anchor/flip math. Rune-free * and DOM-only so it is reused verbatim by SvGridDropdown, SvDateTimePicker and * any future popover. The per-component `$effect` wiring (scroll/resize * reposition, outside-click close) stays in the component since it needs the * component's own open state. */ /** * Theme tokens a portalled panel must carry with it. `--sg-*` custom properties * are scoped to whatever wrapper the grid sits inside (e.g. a per-preset theme * class); moving the panel to leaves that scope, so we snapshot the * resolved values and pin them inline. Keep this list in sync with the tokens * any popover panel actually consumes. */ export const PANEL_THEME_VARS = [ '--sg-accent', '--sg-on-accent', '--sg-bg', '--sg-fg', '--sg-muted', '--sg-border', '--sg-header-bg', '--sg-header-fg', '--sg-row-hover-bg', '--sg-row-alt-bg', '--sg-selection-bg', '--sg-selection-fg', '--sg-input-bg', '--sg-input-border', '--sg-danger', '--sg-focus-ring', '--sg-radius', '--sg-font', '--sg-invalid-bg', '--sg-invalid-border', '--sg-invalid-fg', '--sg-rating-on', '--sg-rating-empty', '--sg-rating-hover', ] as const /** * Svelte action: snapshot the resolved theme tokens from the node's current * (in-scope) position, then detach it and append to so it can never be * clipped by an ancestor's overflow. Removes itself on destroy. */ export function portalToBody(node: HTMLElement, vars: ReadonlyArray = PANEL_THEME_VARS) { const cs = getComputedStyle(node) for (const v of vars) { const val = cs.getPropertyValue(v).trim() if (val) node.style.setProperty(v, val) } document.body.appendChild(node) return { destroy() { if (node.parentNode === document.body) document.body.removeChild(node) }, } } /** * Svelte action: play a short enter animation when a portalled panel mounts * (dropdowns, popovers, dialogs). Slides from the trigger side + fades/scales in. * A no-op under `prefers-reduced-motion` and in environments without the Web * Animations API (jsdom), so tests and reduced-motion users are unaffected. * * `use:popIn={{ up: rect.openUpward }}` - pass `up` for panels that flipped above * their trigger so the slide direction matches. */ export function popIn(node: HTMLElement, param: { up?: boolean; duration?: number; scale?: number } = {}) { if (typeof node.animate !== 'function') return const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches if (reduce) return const dy = param.up ? 6 : -6 const s = param.scale ?? 0.97 node.animate( [ { opacity: 0, transform: `translateY(${dy}px) scale(${s})` }, { opacity: 1, transform: 'translateY(0) scale(1)' }, ], { duration: param.duration ?? 140, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, ) return {} } export type AnchoredRect = { top: number left: number width: number openUpward: boolean /** * Comfortable panel height: the estimate, capped to the room on the chosen * side so the panel never leaves the viewport. Apply as the panel's * `max-height` and long content scrolls inside instead of overflowing. */ maxHeight: number /** * Absolute height the panel may occupy on the chosen side (room minus the * viewport margin). The hard ceiling a user resize must clamp to. */ availHeight: number /** * For an upward-flipped panel: distance from the viewport bottom to where the * panel's bottom edge sits (just above the trigger). Bottom-anchoring an * upward panel with `style:bottom` lets it grow to its natural content height * instead of being positioned from a (possibly wrong) height estimate. * Undefined until `anchoredRect` computes it. */ bottom?: number } export type AnchorOptions = { /** Estimated panel height, used to decide whether to flip upward. */ estimatedHeight: number /** Gap between trigger and panel. Default 2px. */ gap?: number /** Force the panel at least this wide (else it matches the trigger). */ minWidth?: number /** Keep the panel within the viewport horizontally. Default true. */ clampHorizontal?: boolean /** Viewport edge kept clear top/bottom so a panel never touches it. Default 8. */ viewportMargin?: number /** Floor for `maxHeight`/`availHeight` so a cramped panel stays usable. Default 96. */ minHeight?: number } /** * Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the * trigger when there isn't room below and there's more room above. Generalized * with min-width + horizontal clamping for wider panels (date/time popovers), * and with vertical bounds detection: `maxHeight` caps the panel to the room on * the chosen side (minus a viewport margin) so a long list near a screen edge * scrolls internally rather than overflowing, and an upward flip is clamped so * its top never leaves the viewport. */ export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): AnchoredRect { const gap = opts.gap ?? 2 const margin = opts.viewportMargin ?? 8 const minH = opts.minHeight ?? 96 const spaceBelow = window.innerHeight - triggerRect.bottom - gap - margin const spaceAbove = triggerRect.top - gap - margin const openUpward = spaceBelow < opts.estimatedHeight && spaceAbove > spaceBelow const availHeight = Math.max(minH, Math.floor(openUpward ? spaceAbove : spaceBelow)) const maxHeight = Math.min(opts.estimatedHeight, availHeight) const width = Math.max(triggerRect.width, opts.minWidth ?? 0) let left = triggerRect.left if (opts.clampHorizontal !== false) { const maxLeft = window.innerWidth - width - 4 left = Math.max(4, Math.min(left, maxLeft)) } return { // Upward: pin by the comfortable height and clamp to the top margin so the // panel can never start off-screen. Downward: sit just below the trigger. top: openUpward ? Math.max(margin, triggerRect.top - gap - maxHeight) : triggerRect.bottom + gap, left, width, openUpward, maxHeight, availHeight, // Bottom-anchor value for upward panels: viewport bottom -> panel bottom // edge (which sits `gap` above the trigger top). bottom: Math.round(window.innerHeight - triggerRect.top + gap), } }