UNPKG

@loke/ui

Version:
305 lines (247 loc) 10.9 kB
--- name: overlay-infrastructure type: core domain: composition requires: [loke-ui] description: > Six shared infrastructure modules under all overlay components. DismissableLayer (stacking, outside dismiss, pointer event blocking, Branch for anchor exemption). FocusScope (trapping, looping, auto-focus on mount/unmount). FocusGuards (sentinel spans for portaled content). Portal (SSR-safe createPortal via useLayoutEffect). Popper (Floating UI positioning, CSS custom properties, collision/flip/arrow). Presence (animation state machine: mounted/unmountSuspended/unmounted). Use this skill when building a custom overlay; prefer component skills (dialog, popover) when using existing primitives. references: - references/infrastructure-modules.md --- # Overlay Infrastructure This skill covers the six modules shared by all overlay components. You rarely use these directly — Dialog, Popover, Tooltip, and DropdownMenu already compose them. Use this skill when you are **building a new overlay primitive** from scratch. ## Setup A minimal custom overlay combining Portal + FocusScope + DismissableLayer: ```tsx import { DismissableLayer } from "@loke/ui/dismissable-layer"; import { FocusScope } from "@loke/ui/focus-scope"; import { Portal } from "@loke/ui/portal"; import { Presence } from "@loke/ui/presence"; import { useControllableState } from "@loke/ui/use-controllable-state"; interface FloatingPanelProps { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; children: React.ReactNode; modal?: boolean; } function FloatingPanel({ open: openProp, defaultOpen = false, onOpenChange, children, modal = false, }: FloatingPanelProps) { const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChange, caller: "FloatingPanel", }); return ( <Presence present={open}> <Portal> <DismissableLayer disableOutsidePointerEvents={modal} onDismiss={() => setOpen(false)} onEscapeKeyDown={() => setOpen(false)} > <FocusScope trapped={modal} loop> {children} </FocusScope> </DismissableLayer> </Portal> </Presence> ); } ``` ## Core Patterns ### Presence animation state machine `Presence` controls mount/unmount while keeping the element in the DOM long enough for CSS exit animations to complete. It reads `animation-name` from computed styles to detect whether an animation is running. ```tsx import { Presence } from "@loke/ui/presence"; // forceMount variant — element always in DOM; you control visibility via CSS <Presence present={open}> <div data-state={open ? "open" : "closed"} style={{ // Entry: triggered when present becomes true // Exit: Presence delays unmount until animationend fires animation: "fadeIn 150ms ease", }} > content </div> </Presence> // Function-child variant — present prop passed to child for manual control <Presence present={open}> {({ present }) => ( <div data-state={present ? "open" : "closed"} style={{ display: present ? "block" : "none" }} /> )} </Presence> ``` State machine transitions: - `unmounted` + MOUNT `mounted` - `mounted` + ANIMATION_OUT `unmountSuspended` (exit animation running) - `unmountSuspended` + ANIMATION_END `unmounted` - `unmountSuspended` + MOUNT `mounted` (interrupted — re-opened during exit) - `mounted` + UNMOUNT `unmounted` (no animation, instant) ### Popper positioning with CSS custom properties `PopperContent` sets four CSS custom properties on its wrapper that you can use in styles: | Property | Value | |---|---| | `--loke-popper-available-width` | Available width before collision boundary | | `--loke-popper-available-height` | Available height before collision boundary | | `--loke-popper-anchor-width` | Width of the anchor element | | `--loke-popper-anchor-height` | Height of the anchor element | | `--loke-popper-transform-origin` | Transform origin for scale animations | ```tsx import { Popper, PopperAnchor, PopperContent, PopperArrow } from "@loke/ui/popper"; function Tooltip({ children, content }: { children: React.ReactNode; content: string }) { return ( <Popper> <PopperAnchor asChild>{children}</PopperAnchor> <PopperContent side="top" sideOffset={4} align="center" avoidCollisions style={{ // Constrain to available space maxWidth: "var(--loke-popper-available-width)", // Animate from anchor origin transformOrigin: "var(--loke-popper-transform-origin)", }} > {content} <PopperArrow /> </PopperContent> </Popper> ); } ``` For virtual anchors (e.g., cursor position): ```tsx const virtualRef = React.useRef<{ getBoundingClientRect: () => DOMRect }>({ getBoundingClientRect: () => DOMRect.fromRect({ x: cursorX, y: cursorY, width: 0, height: 0 }), }); <PopperAnchor virtualRef={virtualRef} /> ``` ### Focus guards for portaled content Portaled content sits outside the normal DOM tree. Without sentinels at the document body edges, Tab from the last focusable element in a portal exits the browser chrome instead of wrapping. `FocusGuards` injects two `[data-loke-focus-guard]` spans — one at `afterbegin` and one at `beforeend` of `document.body` — which act as tab stops that DismissableLayer's `focusin` listener can intercept. ```tsx import { FocusGuards } from "@loke/ui/focus-guards"; import { Portal } from "@loke/ui/portal"; // Wrap the portal root with FocusGuards — one instance covers all portals function AppRoot({ children }: { children: React.ReactNode }) { return ( <FocusGuards> {children} </FocusGuards> ); } // Or use useFocusGuards() directly in a component that manages its own lifecycle import { useFocusGuards } from "@loke/ui/focus-guards"; function DialogContent() { useFocusGuards(); // injects guards on mount, removes when last consumer unmounts return <div>...</div>; } ``` Guards are reference-counted: they persist as long as at least one consumer is mounted. The second guard insertion is idempotent (it reuses the existing element via `edgeGuards[1] ?? createFocusGuard()`). ## Common Mistakes ### 1. DismissableLayer stacking with disableOutsidePointerEvents Only the highest layer with `disableOutsidePointerEvents={true}` blocks pointer events. Lower layers have `pointer-events: none` set on them automatically. If you add a second `DismissableLayer` without understanding the stack order, clicks on the lower layer's content may be eaten by the upper layer. ```tsx // WRONG — inner layer steals clicks from outer layer unexpectedly <DismissableLayer disableOutsidePointerEvents> <div>Outer</div> <DismissableLayer disableOutsidePointerEvents> <div>Inner</div> </DismissableLayer> </DismissableLayer> // CORRECT — only the topmost modal layer needs disableOutsidePointerEvents <DismissableLayer> {/* non-modal outer */} <div>Outer</div> <DismissableLayer disableOutsidePointerEvents> {/* modal inner */} <div>Inner</div> </DismissableLayer> </DismissableLayer> ``` Source: `dismissable-layer.tsx` — `isPointerEventsEnabled` is only true for layers at or above the highest `disableOutsidePointerEvents` layer index. ### 2. Missing DismissableLayerBranch for anchor elements By default, a click on any element outside the `DismissableLayer` triggers `onPointerDownOutside`. If your overlay has an anchor (e.g., the trigger button) that should NOT dismiss the overlay when clicked, wrap the anchor in `DismissableLayerBranch`. Elements inside a Branch are excluded from the "outside" check. ```tsx import { DismissableLayer, DismissableLayerBranch } from "@loke/ui/dismissable-layer"; <> <DismissableLayerBranch> {/* Clicking this trigger will NOT fire onPointerDownOutside */} <button onClick={() => setOpen(true)}>Open</button> </DismissableLayerBranch> {open && ( <DismissableLayer onDismiss={() => setOpen(false)}> <div>Overlay content</div> </DismissableLayer> )} </> ``` Source: `dismissable-layer.tsx` — `isPointerDownOnBranch` check in `usePointerDownOutside`. ### 3. Presence animation model — exit animation not running Presence detects exit animations by comparing `animation-name` before and after `present` changes to `false`. If the element's `animation-name` is `none` when `present` becomes false, Presence unmounts immediately without waiting. This means: - CSS transitions (`transition:`) do **not** trigger the suspended state — only `animation:` keyframes do - The animation must be applied to the **direct child** of `<Presence>`, not a nested element ```tsx // WRONG — transition does not trigger unmountSuspended <Presence present={open}> <div style={{ transition: "opacity 150ms", opacity: open ? 1 : 0 }}> content </div> </Presence> // CORRECT — use keyframe animation on the direct child <Presence present={open}> <div style={{ animation: open ? "fadeIn 150ms" : "fadeOut 150ms" }}> content </div> </Presence> ``` Source: `presence.tsx` — `getAnimationName` reads `styles?.animationName`, not transition properties. ### 4. Missing FocusGuards when using FocusScope inside a Portal `FocusScope` with `trapped={true}` intercepts focus via document-level `focusin` listeners. Without `FocusGuards`, Tab from the last item exits to the browser chrome, and the `focusin` listener never fires to bring it back. This produces a broken focus trap where Tab appears to "escape." ```tsx // WRONG — Tab can escape portal to browser chrome <Portal> <FocusScope trapped loop> <dialog> <input /> <button>Submit</button> </dialog> </FocusScope> </Portal> // CORRECT — FocusGuards creates sentinel tab stops at body edges <FocusGuards> <Portal> <FocusScope trapped loop> <dialog> <input /> <button>Submit</button> </dialog> </FocusScope> </Portal> </FocusGuards> ``` Source: `focus-guards.tsx` — sentinels at `afterbegin`/`beforeend` of body ensure focus cycles back into the document before DismissableLayer's listener re-traps it. ## Tension: Infrastructure vs Per-Component Skills This skill teaches infrastructure internals. If you are using Dialog, Popover, or Tooltip, you do not need to understand these modules — those components already compose them correctly. Come here only when: - Building a new overlay primitive not covered by existing components - Debugging unexpected dismiss/focus behavior in a custom overlay - Composing two overlay-type components that need to share scope For usage of existing overlay components, read the component-specific skills (`dialog`, `popover`, `tooltip`, `dropdown-menu`).