import { createSignal, createEffect, onCleanup, splitProps, type Accessor, type JSX, } from 'solid-js'; import { Dynamic } from 'solid-js/web'; import { computePosition, autoUpdate, offset, flip, shift, arrow, hide, type Placement, } from '@floating-ui/dom'; /** * Keep a node mounted through its CSS exit animation. * Open -> present=true, state='open' (enter animation via base classes) * Close -> state='closed' (data-closed triggers tw-animate-css animate-out), * then unmount on `animationend`. If no animation is defined * (e.g. jsdom), unmount on the next microtask. */ export function createPresence(show: Accessor) { const [present, setPresent] = createSignal(show()); const [state, setState] = createSignal<'open' | 'closed'>(show() ? 'open' : 'closed'); let node: Element | undefined; let generation = 0; const setRef = (el: Element) => { node = el; }; createEffect((prev: boolean | undefined) => { const visible = show(); if (visible) { generation++; setPresent(true); setState('open'); } else if (prev) { setState('closed'); const el = node; const hasAnim = el && (() => { const cs = getComputedStyle(el); return cs.animationName !== 'none' && parseFloat(cs.animationDuration || '0') > 0; })(); if (!el || !hasAnim) { const gen = ++generation; queueMicrotask(() => { if (gen === generation) setPresent(false); }); return visible; } const animEl = el as HTMLElement; const onEnd = (e: AnimationEvent) => { if (e.target !== animEl) return; animEl.removeEventListener('animationend', onEnd); setPresent(false); }; animEl.addEventListener('animationend', onEnd); onCleanup(() => animEl.removeEventListener('animationend', onEnd)); } return visible; }); return { present, state, setRef }; } export type AsTag = string | ((props: Record) => JSX.Element); /** * Polymorphic element. `as` may be a tag name (default 'span') or a render * function that receives the forwarded props (render-prop style `as={fn}`). * Uses splitProps (NOT destructuring) so reactive forwarded props such as * aria-expanded stay reactive. All extra props (incl. `ref`, event handlers, * aria-*) are forwarded. `children` is left in `rest` so it forwards naturally. */ export function As(props: { as?: AsTag; children?: JSX.Element; [k: string]: any }) { const [local, rest] = splitProps(props, ['as']); if (typeof local.as === 'function') return local.as(rest); return ; } export interface UsePositionOptions { placement?: Placement; gutter?: number; arrowEl?: Accessor; } /** * Position `floating` relative to `reference` using fixed strategy + autoUpdate, * so the element tracks the trigger on scroll/resize (fix DD-2). Writes * position into the returned `pos` signal; caller applies it as inline style. * * `options` (placement/gutter) are read at setup time — pass static values; * reactive option changes won't reposition until the next autoUpdate tick. */ export function usePosition( reference: Accessor, floating: Accessor, options: UsePositionOptions = {}, ) { const [pos, setPos] = createSignal<{ x: number; y: number; placement: Placement }>( { x: 0, y: 0, placement: options.placement ?? 'bottom' }, ); const [arrowPos, setArrowPos] = createSignal<{ x?: number; y?: number }>({}); // True when the trigger has scrolled out of view (clipped by an ancestor or the // viewport). Callers should hide the floating node so it doesn't stick to the // edge — Floating UI's `hide` middleware. autoUpdate keeps this live on scroll. const [hidden, setHidden] = createSignal(false); createEffect(() => { const ref = reference(); const float = floating(); if (!ref || !float) return; const update = () => { // `hide()` is read last so it reflects the final, shifted/flipped position. const middleware = [offset(options.gutter ?? 8), flip(), shift({ padding: 8 })]; const aEl = options.arrowEl?.(); if (aEl) middleware.push(arrow({ element: aEl })); middleware.push(hide()); computePosition(ref, float, { placement: options.placement ?? 'bottom', strategy: 'fixed', middleware, }).then(({ x, y, placement, middlewareData }) => { setPos({ x, y, placement }); if (middlewareData.arrow) setArrowPos({ x: middlewareData.arrow.x, y: middlewareData.arrow.y }); setHidden(!!middlewareData.hide?.referenceHidden); }); }; const cleanup = autoUpdate(ref, float, update); onCleanup(cleanup); }); return { pos, arrowPos, hidden }; } export type DismissReason = 'escape' | 'outside'; export interface UseDismissOptions { enabled: Accessor; onDismiss: (reason: DismissReason) => void; /** Elements considered "inside" (trigger + content). Pointerdown outside all of them dismisses. */ refs: () => (HTMLElement | undefined)[]; } /** * Escape key + outside-pointerdown dismissal. Does NOT lock page scroll (fix DD-1). * * `onDismiss` and `refs` are captured at call time (component setup), which is * fine in SolidJS since components don't re-run — ensure they close over mutable * variables, not stale values. */ export function useDismiss(opts: UseDismissOptions) { createEffect(() => { if (!opts.enabled()) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') opts.onDismiss('escape'); }; const onPointer = (e: PointerEvent) => { const target = e.target as Node; const inside = opts.refs().some((el) => el && el.contains(target)); if (!inside) opts.onDismiss('outside'); }; document.addEventListener('keydown', onKey); document.addEventListener('pointerdown', onPointer, true); onCleanup(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onPointer, true); }); }); }