@loke/ui
Version:
275 lines (183 loc) • 11.3 kB
Markdown
# 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`.