Interaction hooks

7 hooks carry the state, keyboard, dismissal, and mount-gating behavior the organisms share. They're exported so a composite widget you build yourself can inherit the same contracts instead of re-implementing them — prefer these over hand-rolled key handlers.

useActiveDescendant

import { useActiveDescendant } from '@elirobinson/react/hooks/useActiveDescendant';

The `aria-activedescendant` half of the WAI-ARIA combobox/listbox pattern: DOM focus stays on the input at all times and the "highlighted" option is conveyed purely by id reference.

Owns one invariant that is easy to get wrong in two different places, which is why it lives here rather than in each widget: the highlighted index must always be inside the current option range. Filtering shrinks that range out from under a previously-valid index, and an unclamped index hands `aria-activedescendant` a dangling id — visually fine, silently broken for assistive tech.

Note that the clamp is applied twice, deliberately. The *derived* `activeIndex` is clamped so the rendered output is always valid, and `moveActive` clamps the stored value again *before* applying its delta. Only clamping the derived value leaves the raw state parked above the valid range, where a single arrow key moves it from (say) 5 to 4 — both of which still clamp to 1, so the highlight appears stuck until the user presses the key enough times to walk back into range.

Used by Combobox and CommandPalette.

useAnchoredPosition

import { useAnchoredPosition } from '@elirobinson/react/hooks/useAnchoredPosition';

Positions a floating panel against its trigger with `position: fixed`, and keeps it there while the page scrolls or the window resizes.

Geometry is written straight to the node's inline style rather than held in state: this runs in a layout effect, before paint, so the panel never shows up in the wrong place first. The fourth argument accepts a bare side for the common case (`'top'` / `'bottom'`) or an options object.

A panel that does not fit on the side it asked for flips to the opposite one — `bottom` to `top`, `start` to `end` — provided the opposite side has room. Each axis flips at most once per open and never flips back, so the panel cannot chase a threshold it is sitting on; `align: 'center'` is symmetric and never flips. A panel that fits on *neither* side is shifted instead: it keeps its width and slides along the axis until it is inside the viewport, so it stops being edge-aligned with its trigger, and may overlap it, but it is never narrowed into the room left beside a pinned edge and never reflows. Only a panel taller than the viewport fits at no offset at all; that one takes a `max-height` of the viewport and scrolls its own content.

Used by Popover, DropdownMenu, and Tooltip.

useClickOutside

import { useClickOutside } from '@elirobinson/react/hooks/useClickOutside';

Calls back when a pointer-down lands outside the given refs — the dismissal half of every overlay.

Used by the anchored overlays — Popover, DropdownMenu, Combobox, DatePicker.

useDisclosure

import { useDisclosure } from '@elirobinson/react/hooks/useDisclosure';

Open/closed state for a component that has to work both ways round: a consumer either passes `open` and owns the value, or passes neither and lets the component own it, seeded by `defaultOpen`.

`open` resolves to the controlled prop whenever there is one and to internal state otherwise, and `setOpen` writes internal state only in the uncontrolled case while always reporting through `onOpenChange` — so a controlled consumer never ends up fighting a second copy of the truth.

`setOpen` keeps one identity for the component's lifetime; the current `onOpenChange` is read from a ref at call time. That matters because the dismissal hooks (`useClickOutside`, `useEscapeKey`) take a callback as a dependency, and callers write that callback inline.

Used by Dialog, Sheet, Popover, and DropdownMenu.

useEscapeKey

import { useEscapeKey } from '@elirobinson/react/hooks/useEscapeKey';

Calls back on Escape keydown while enabled — shared dismissal behavior for overlays.

Used by the anchored overlays alongside useClickOutside.

useHasMounted

import { useHasMounted } from '@elirobinson/react/hooks/useHasMounted';

Reports `false` on the server and during the first client render, then `true` once the component has mounted in a browser.

Gate anything that reads a browser global *during render* on this — in this package that means `createPortal(…, document.body)`, which otherwise throws `ReferenceError: document is not defined` when a Next.js or Remix consumer server-renders the tree. Effects and event handlers do not need it: neither runs on the server.

Returning `false` for the first client render (rather than checking `typeof document` directly) is what keeps hydration clean — the client's initial output has to match the server's, so both must skip the portal and the content appears on the commit that follows.

Used by Toaster, PopoverContent, DropdownMenuContent, and TooltipContent to skip their portals during server rendering.

useRovingFocus

import { useRovingFocus } from '@elirobinson/react/hooks/useRovingFocus';

Arrow/Home/End traversal for a composite widget that exposes a single tab stop (a "roving tabindex"): Tab moves into the widget, arrows move within it.

Both axes are accepted because the same widget can be laid out either way and a user should not have to guess which; movement wraps at both ends, per the WAI-ARIA authoring practices for tabs and radio groups.

Only the traversal is shared. What a move *means* differs by widget — tabs move focus without selecting, radio-style groups select as they move — so that decision stays with the caller in `onNavigate`.

Used by Tabs and SegmentedControl.