UNPKG

@loke/ui

Version:
275 lines (183 loc) 11.3 kB
# Overlay Infrastructure — Module Reference All imports use subpath exports: `@loke/ui/<module>`. --- ## DismissableLayer **Import:** `@loke/ui/dismissable-layer` **Exports:** `DismissableLayer`, `DismissableLayerBranch` ### Props | Prop | Type | Default | Description | |---|---|---|---| | `disableOutsidePointerEvents` | `boolean` | `false` | Sets `pointer-events: none` on `document.body` while mounted. Only the highest layer in the stack with this enabled has `pointer-events: auto`. | | `onDismiss` | `() => void` | — | Called when escape key, pointer-down outside, or focus-outside occurs and the event was not prevented. | | `onEscapeKeyDown` | `(event: KeyboardEvent) => void` | — | Called at capture phase for Escape. `event.preventDefault()` stops `onDismiss`. | | `onPointerDownOutside` | `(event: PointerDownOutsideEvent) => void` | — | Called on pointer-down outside the layer. `event.preventDefault()` stops `onDismiss`. | | `onFocusOutside` | `(event: FocusOutsideEvent) => void` | — | Called when focus moves outside the layer. `event.preventDefault()` stops `onDismiss`. | | `onInteractOutside` | `(event: PointerDownOutsideEvent \| FocusOutsideEvent) => void` | — | Called for both pointer-down-outside and focus-outside. | All standard `div` props are forwarded. ### Stacking behavior Layers register in a shared context stack in creation order. Only the top-most layer responds to Escape. When `disableOutsidePointerEvents` is set, all layers below the highest such layer have `pointer-events: none` applied inline. Pointer-down detection uses `document` listeners registered in `setTimeout(0)` to avoid the layer catching the pointer-down event that mounted it. ### DismissableLayerBranch Wraps anchor elements (e.g., trigger buttons) that share a logical connection with the layer but sit outside it in the DOM. Elements inside a Branch are excluded from the "outside" check for both pointer-down and focus events. ```tsx <DismissableLayerBranch> <button>Trigger</button> </DismissableLayerBranch> <DismissableLayer onDismiss={close}> <div>Content</div> </DismissableLayer> ``` --- ## FocusScope **Import:** `@loke/ui/focus-scope` **Exports:** `FocusScope` ### Props | Prop | Type | Default | Description | |---|---|---|---| | `trapped` | `boolean` | `false` | Prevents focus from leaving the scope via keyboard, pointer, or programmatic focus. Uses `document.addEventListener("focusin")` + MutationObserver. | | `loop` | `boolean` | `false` | When tabbing at the last focusable element, wraps to the first. When shift-tabbing at the first, wraps to the last. | | `onMountAutoFocus` | `(event: Event) => void` | — | Called before auto-focus on mount. `event.preventDefault()` disables auto-focus. | | `onUnmountAutoFocus` | `(event: Event) => void` | — | Called before focus-return on unmount. `event.preventDefault()` disables focus return. | All standard `div` props are forwarded. The root element receives `tabIndex={-1}` so the container itself is focusable as a fallback. ### Auto-focus behavior On mount, FocusScope focuses the first tabbable candidate that is not a link (`<a>`). If no candidate is found, the scope container itself receives focus. On unmount, focus returns to the previously focused element or `document.body`. ### Trapping mechanism Trapping is implemented via: 1. `focusin` listener — when focus enters outside the container, it is redirected to `lastFocusedElementRef.current`. 2. `focusout` listener — when `relatedTarget` is outside the container (and not null), redirects to last valid element. 3. MutationObserver — when focused element is removed from DOM, redirects to container. ### FocusScope stack Multiple nested FocusScopes are coordinated via a module-level stack. When a new scope mounts, the previously active scope is paused (its trapping logic becomes a noop). When a scope unmounts, the scope below it resumes. --- ## FocusGuards **Import:** `@loke/ui/focus-guards` **Exports:** `FocusGuards`, `useFocusGuards` ### Purpose Inserts two `[data-loke-focus-guard]` sentinel `<span>` elements at the start and end of `document.body`. These are tabbable (`tabIndex=0`) but visually invisible (`opacity:0`, `pointer-events:none`, `position:fixed`). They ensure that Tab from a portaled FocusScope loops back into the page rather than escaping to the browser chrome. ### Reference counting Guards are reference-counted. The first mount inserts them. Subsequent mounts reuse existing elements. When the last consumer unmounts, both elements are removed. ### Component vs hook ```tsx // Component form — use as a wrapper <FocusGuards>{children}</FocusGuards> // Hook form — use inside a component that manages its own lifecycle function DialogContent() { useFocusGuards(); return <div />; } ``` Both forms are equivalent. Prefer the component form at the app root. Prefer the hook form inside overlay content components. --- ## Portal **Import:** `@loke/ui/portal` **Exports:** `Portal` ### Props | Prop | Type | Default | Description | |---|---|---|---| | `container` | `Element \| DocumentFragment \| null` | `document.body` | Target container for `ReactDOM.createPortal`. | All standard `div` props are forwarded to the portaled `Primitive.div`. ### SSR behavior Portal uses `useLayoutEffect` (SSR-safe version) to set a `mounted` flag. Until mounted, `ReactDOM.createPortal` is not called and the component renders `null`. This avoids the `document.body` reference during SSR. On the client, the portal renders synchronously after the first paint. ```tsx // Portal wraps content in a Primitive.div — you do not need an extra wrapper <Portal> <div data-overlay="">content</div> </Portal> // Renders into document.body: <div><div data-overlay="">content</div></div> // Custom container const containerRef = useRef<HTMLDivElement>(null); <Portal container={containerRef.current}> <div>content</div> </Portal> ``` --- ## Popper **Import:** `@loke/ui/popper` **Exports:** `Popper`, `PopperAnchor`, `PopperContent`, `PopperArrow`, `createPopperScope` ### Components #### Popper Root provider. Manages the anchor reference state. No DOM output. ```tsx <Popper>{children}</Popper> ``` #### PopperAnchor **Props:** | Prop | Type | Description | |---|---|---| | `virtualRef` | `RefObject<Measurable>` | Virtual anchor (e.g., cursor). When provided, renders nothing. | | `asChild` | `boolean` | Render as child element instead of `div`. | When `virtualRef` is provided, `PopperAnchor` renders nothing and the virtual ref is used as the Floating UI reference element. #### PopperContent **Props:** | Prop | Type | Default | Description | |---|---|---|---| | `side` | `"top" \| "right" \| "bottom" \| "left"` | `"bottom"` | Preferred side. Flipped by collision avoidance. | | `sideOffset` | `number` | `0` | Distance from anchor in px (plus arrow height if arrow present). | | `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along the cross axis. | | `alignOffset` | `number` | `0` | Offset from `align` position in px. | | `avoidCollisions` | `boolean` | `true` | Enable `shift` and `flip` middleware. | | `collisionBoundary` | `Element \| Element[] \| null` | `[]` | Custom collision boundary elements. | | `collisionPadding` | `number \| Partial<Record<Side, number>>` | `0` | Padding inside the collision boundary. | | `arrowPadding` | `number` | `0` | Min distance between arrow and content edge. | | `sticky` | `"partial" \| "always"` | `"partial"` | `partial` uses `limitShift()`, `always` always shifts. | | `hideWhenDetached` | `boolean` | `false` | Hides content when anchor is scrolled out of view. | | `updatePositionStrategy` | `"optimized" \| "always"` | `"optimized"` | `always` updates on every animation frame. | | `onPlaced` | `() => void` | — | Called once Floating UI has completed initial placement. | `PopperContent` uses `strategy: "fixed"` internally to avoid scroll-induced repositioning jank. **CSS custom properties set on wrapper:** | Property | Value | |---|---| | `--loke-popper-available-width` | Available width before boundary | | `--loke-popper-available-height` | Available height before boundary | | `--loke-popper-anchor-width` | Anchor element width | | `--loke-popper-anchor-height` | Anchor element height | | `--loke-popper-transform-origin` | `"<x> <y>"` string for CSS `transform-origin` | **Data attributes on content element:** | Attribute | Values | |---|---| | `data-side` | `"top" \| "right" \| "bottom" \| "left"` (placed side after collision) | | `data-align` | `"start" \| "center" \| "end"` | #### PopperArrow Renders into the content's coordinate space. Position and rotation are calculated automatically from `placedSide`. Accepts all props of the `Arrow` primitive. ### createPopperScope When composing Popper into another component (e.g., Popover, Tooltip), use `createPopperScope` to thread scope: ```tsx import { createPopperScope } from "@loke/ui/popper"; import { createContextScope } from "@loke/ui/context"; const [createMyContext, createMyScope] = createContextScope("MyComponent", [ createPopperScope, ]); ``` --- ## Presence **Import:** `@loke/ui/presence` **Exports:** `Presence`, `usePresence` ### Presence component | Prop | Type | Description | |---|---|---| | `present` | `boolean` | Whether the content should be present. | | `children` | `ReactElement \| ((props: { present: boolean }) => ReactElement)` | Element to control, or render function receiving current presence state. | When `children` is a render function, the element is always mounted (forceMount). `present` inside the render function reflects the state machine state (`isPresent`), which stays `true` during exit animations. ### State machine ``` unmounted ──MOUNT──> mounted ──ANIMATION_OUT──> unmountSuspended ^ | | | UNMOUNT ANIMATION_END | | | └──────────────────┘ unmounted ^ MOUNT (re-open during exit)──> mounted ``` - `isPresent` is `true` in both `mounted` and `unmountSuspended` states. - Transition from `mounted` to `unmountSuspended` only fires if `animation-name` is not `"none"` at the moment `present` becomes `false`. - Transition from `unmountSuspended` to `unmounted` fires on `animationend` or `animationcancel`. ### usePresence hook Low-level hook returning `{ isPresent, ref }`. Attach `ref` to the animating element. Use `isPresent` to conditionally render. ```tsx function AnimatedContent({ present, children }: { present: boolean; children: React.ReactNode }) { const { isPresent, ref } = usePresence(present); return isPresent ? <div ref={ref}>{children}</div> : null; } ``` ### Animation fill mode When `present` becomes `false` and an exit animation runs, Presence temporarily sets `animation-fill-mode: forwards` on the element after `animationend`. This prevents a flash of the pre-animation state during React's concurrent rendering. The fill mode is reset after a `setTimeout`.