@llui/interactions

Current package version: 0.1.1

Low-level DOM interaction primitives for applications and libraries that own their component state machines and markup. It provides the interaction layer used by @llui/components without requiring that package's component catalog.

pnpm add @llui/interactions @llui/dom

@llui/dom is a peer dependency. Libraries that expose interactions should keep both packages as peers so the application owns one render context and one set of document-level registries.

Usage

import { attachFloating, pushDismissable, pushFocusTrap } from '@llui/interactions'

@llui/components/utils remains a compatibility re-export for existing component consumers.

Functions

_dismissableStackSize()

@internal — for tests

function _dismissableStackSize(): number

_focusTrapStackSize()

@internal — tests only

function _focusTrapStackSize(): number

_nestedLayerCount()

@internal — tests only

function _nestedLayerCount(): number

_scrollLockCount()

@internal — tests only

function _scrollLockCount(): number

attachFloating()

Position floating relative to anchor with live updates on scroll/resize. Applies left + top styles to the floating element. Returns a cleanup.

function attachFloating(opts: FloatingOptions): () => void

engineFocus()

Focus el as an engine-initiated move (see runEngineFocus).

function engineFocus(el: HTMLElement, options?: FocusOptions): void

firstEnabled()

Internal value navigation used by the public roving-focus primitive.

function firstEnabled(items: readonly string[], disabled: readonly string[]): string | null

flipArrow()

Map a horizontal arrow key to its logical direction, accounting for RTL. This is the SINGLE SOURCE OF TRUTH every component routes horizontal arrow interpretation through. Under rtl, ArrowLeft and ArrowRight swap meaning; vertical arrows (Up/Down), Home/End, PageUp/PageDown and every non-arrow key pass through unchanged.

The second argument is the direction source:

  • an explicit 'ltr' | 'rtl' — used directly (the authoritative form when a component stores dir in its own State and passes it in);
  • an Element — direction is resolved by walking up the DOM (dir="rtl" ancestor or document.documentElement.dir);
  • null — treated as 'ltr' (no-op).
function flipArrow(key: string, source: Element | null | TextDirection): string

focusRovingItem()

Move DOM focus to the roving item identified by value within the same widget instance as origin.

Roving-tabindex widgets track the active index in STATE, but assistive tech follows real DOM focus — so after a keyboard move the handler MUST also move focus, or arrow keys are silent for AT. origin is the event's currentTarget (the item that received the key); its closest [data-scope][data-part="root"] ancestor scopes the search so sibling widgets of the same scope never cross-focus. No-op if nothing matches.

send() is synchronous and items already exist in the DOM, so this can be called immediately after the navigation send.

function focusRovingItem(origin: Element | null, scope: string, value: string, opts: { itemPart?: string; attr?: string } = {}): void

focusRovingTab()

Move DOM focus to the trigger whose data-value matches, within container. Relies only on the role="tab" + data-value contract (shared by components/tabs and any hand-rolled tablist). No-op when no trigger matches. Call after the DOM reflects the new active tab (e.g. in a microtask if activation triggers a re-render).

function focusRovingTab(container: Element, value: string): void

getFocusables()

function getFocusables(container: Element): HTMLElement[]

getNestedLayers()

Currently-registered nested-layer elements (resolvers re-read live).

With an aspect, only registrations that participate in it; without one, all of them. With a within boundary, only registrations nested inside it (see the module comment); without one, the flat, layer-agnostic answer.

function getNestedLayers(aspect?: NestedLayerAspect, within?: NestedLayerScope): Element[]

isEngineFocusInProgress()

Whether an engine-initiated focus move is in flight. Consulted by watchInteractOutside to gate its focusin path.

function isEngineFocusInProgress(): boolean

isFocusable()

function isFocusable(el: Element): boolean

isInNestedLayer()

Whether target is inside (or equal to) a registered nested layer that participates in aspect (any layer when aspect is omitted) and is nested inside within (any layer when within is omitted).

function isInNestedLayer(target: Node | null, aspect?: NestedLayerAspect, within?: NestedLayerScope): boolean

lastEnabled()

function lastEnabled(items: readonly string[], disabled: readonly string[]): string | null

lockBodyScroll()

function lockBodyScroll(): () => void

nextEnabled()

function nextEnabled(items: readonly string[], disabled: readonly string[], from: string, delta: 1 | -1, loop: boolean): string | null

pushDismissable()

Register a dismissable layer. Escape is offered to the layers top-down until one CLAIMS it (a layer declines via disableEscape or an onEscape router returning false, and the key then falls through to the layer beneath); outside-click is topmost-only. Returns a cleanup that removes the layer from the stack.

Push a layer even when both dismissal routes are disabled: the layer is the caller's PLACE ON THE STACK, which is what stops the layer beneath from treating an interaction inside this one as an outside interaction.

function pushDismissable(opts: DismissableOptions): () => void

pushFocusTrap()

Push a focus trap onto the stack. Tab/Shift+Tab will cycle within the container's focusable descendants. Returns a cleanup that removes the trap and (optionally) restores focus to the element active before push.

function pushFocusTrap(opts: FocusTrapOptions): () => void

registerNestedLayer()

Register source (an element, array of elements, or a resolver returning either) as a nested layer. Returns a cleanup that removes the registration.

Prefer the resolver form for a portaled overlay: register once on mount and return the live root only while open ([] when closed), so a single registration tracks the overlay's open/closed lifecycle without churn.

Pass opts.owner when scoped consumers must exempt the layer. Missing or unresolved ownership fails closed and warns in development.

function registerNestedLayer(source: ElementSource, opts?: NestedLayerOptions): () => void

resolveDir()

Resolve the text direction for an element by walking up the DOM tree. Returns 'rtl' or 'ltr' (default).

function resolveDir(el: Element): TextDirection

resolveRovingMove()

Map a keyboard key + the current tab value to a roving-tablist move, or null when the key isn't a navigation/activation key or the move is a no-op (empty list, no enabled sibling). Pure — does not touch the DOM or call preventDefault; the caller decides (typically: prevent default iff the result is non-null).

function resolveRovingMove(key: string, current: string, items: readonly RovingItem[], opts: RovingOptions = {}): RovingMove | null

resolveTextDirection()

Normalize any accepted direction source to a concrete TextDirection. An explicit 'ltr' | 'rtl' wins; an Element is resolved from the DOM; null / undefined default to 'ltr'.

function resolveTextDirection(source: Element | null | undefined | TextDirection): TextDirection

runEngineFocus()

Run body with engine-focus suppression active. Any focusin raised inside it is invisible to watchInteractOutside — including one raised by a focus move that re-entrant consumer code makes from a focusin listener (see the module comment: the window is the synchronous transitive closure, not just the .focus() call).

SYNCHRONOUS BY CONTRACT, AND THE CONTRACT IS ENFORCED (#172). The suppression is released when body RETURNS. An async body returns its promise at the first await, so the depth counter drops immediately and the focus move that eventually happens gets NO protection at all — a call that looks correct, compiles, and does nothing. The failure is safe (no protection, never a stuck guard: the decrement is in a finally), which is exactly why it is invisible, and this is a PUBLIC export documented as the thing a custom overlay "must" route its engine-initiated focus moves through. A consumer following that advice with an async body would reintroduce #155 in their app while believing they had prevented it.

Two guards, because neither covers the other's case:

  • The SIGNATURE rejects a promise-returning body at compile time. It is the real guard — it fires before the code ever runs. Its one blind spot is a body whose return type is an unresolved type parameter (a generic pass-through wrapper): the conditional is deferred, so such a wrapper is rejected too and must carry its own constraint. No caller does.
  • A DEV-MODE warning catches what the type system cannot see: a JavaScript consumer, an any-typed body, or a body that returns a thenable without being declared as returning one. It cannot restore the protection — by the time a thenable is in hand the guard is already released — so it only reports.

Deliberately NOT offered: an async-aware variant that holds the guard across an await. The guard is safe because no user event can be delivered inside its window (see the module comment); holding it across a suspension point hands the event loop back and would start swallowing genuine interactions.

function runEngineFocus<T>(body: () => SyncEngineFocusBody<T>): T

setAriaHiddenOutside()

function setAriaHiddenOutside(target: Element): () => void

watchInteractOutside()

Watch for pointer or focus events outside a given element. Returns a cleanup function. Uses the capture phase so upstream stopPropagation calls cannot hide events.

  • pointerdown (or mousedown/touchstart fallback) triggers "outside" if the target is not contained by element or ignore.
  • focusin triggers "outside" when focus moves outside the element, except when the new target is in ignore.
function watchInteractOutside(opts: InteractOutsideOptions): () => void

Types

DismissSource

Reason a dismissable layer was closed.

export type DismissSource = 'escape' | 'outside'

ElementSource

Shared DOM helpers used by interaction utilities.

export type ElementSource<T extends Element = Element> = T | T[] | (() => T | T[] | null)

NestedLayerAspect

A consumer of the registry. A registration participates only in the aspects it names, because a single answer is wrong for at least one consumer: engine overlays leave outside to the ordered dismissable stack (see the module comment).

The dialog-with-an-inner-select case is NOT what the aspect list protects. That one is covered by a modal never registering AT ALL, whatever aspects it would have named.

  • outside — {@link watchInteractOutside} does not treat interactions inside the layer as outside interactions.
  • focus — {@link pushFocusTrap} includes the layer as an extra focusable container, so Tab/Shift+Tab can reach it.
  • hide — {@link setAriaHiddenOutside} hides AROUND the layer rather than hiding it.
export type NestedLayerAspect = 'outside' | 'focus' | 'hide'

NestedLayerScope

The asking layer's own boundary — what "nested inside ME" is measured against. Omit it for the flat, layer-agnostic answer.

export type NestedLayerScope = ElementSource

Placement

export declare type Placement = Prettify<Side | AlignedPlacement>;

RovingMove

The navigation a key implies on a roving tablist.

export type RovingMove =
  /** An arrow / Home / End resolved to a (different, enabled) tab value. */
  | { type: 'focus'; value: string }
  /** Enter or Space — activate the currently focused tab (manual mode). */
  | { type: 'activate' }

RovingOrientation

Headless roving-tablist navigation — the keyboard logic of a WAI-ARIA tablist, decoupled from any particular DOM contract.

components/tabs.ts builds its reactive part-bags on top of this; a consumer that wants its OWN markup (different classes, ids, no data-scope/data-part) can drive the same keyboard behaviour by calling resolveRovingMove from its trigger's onKeyDown and focusRovingTab to move DOM focus — without adopting the component's markup or its connect() state machine.

The resolver is pure (key + current value + items → a move); the only shared DOM assumption lives in focusRovingTab, and it is the minimal one both surfaces already satisfy: triggers carry role="tab" and data-value="<value>".

The list walk itself lives in list-navigation.ts — this module is the keyboard + DOM-focus surface over it, nothing more.

export type RovingOrientation = 'horizontal' | 'vertical'

SyncEngineFocusBodyRequired

The type an ASYNC body collapses to in {@link runEngineFocus}'s parameter position. Nothing is assignable to it, so runEngineFocus(async () => …) is a compile error naming the contract rather than a silently inert call.

export type SyncEngineFocusBodyRequired = {
  readonly [SYNC_BODY_REQUIRED]: 'runEngineFocus requires a SYNCHRONOUS body — the guard is released the moment body returns'
}

TextDirection

Text reading direction. The single shared RTL vocabulary for the package.

export type TextDirection = 'ltr' | 'rtl'

Interfaces

DismissableOptions

export interface DismissableOptions {
  /** The layer element (e.g. a dialog content or popover). */
  element: ElementSource
  /** Trigger / anchor elements that should not count as outside interactions. */
  ignore?: ElementSource
  /** Called when the user dismisses the layer. */
  onDismiss: (source: DismissSource, event: Event) => void
  /**
   * Custom Escape router. When provided it runs for the Escape key INSTEAD of
   * `onDismiss('escape', …)`, letting the layer unwind an internal level first
   * (e.g. a menu closes its open submenu before closing the whole menu). Return
   * `false` to decline — the event is not claimed and propagates as if this
   * layer had `disableEscape`. Any other return (incl. `undefined`) claims it.
   */
  onEscape?: (event: KeyboardEvent) => boolean | void
  /** Disable outside-click dismissal (default: false). */
  disableOutside?: boolean
  /** Disable Escape-key dismissal (default: false). */
  disableEscape?: boolean
}

FloatingOptions

export interface FloatingOptions {
  /** The reference element (trigger/anchor). */
  anchor: Element
  /** The floating element (content). */
  floating: HTMLElement
  /** Preferred placement (default: 'bottom'). */
  placement?: Placement
  /** Gap between anchor and floating, in px (default: 0). */
  offset?: number
  /** Flip to opposite side when there isn't enough room (default: true). */
  flip?: boolean
  /** Shift along axis to stay in view (default: padding 8 unless false). */
  shift?: boolean | { padding?: number }
  /**
   * Reading direction. Under `'rtl'`, logical `*-start`/`*-end` placements
   * track the inline-start/inline-end edges. When given it is AUTHORITATIVE —
   * it overrides the direction the floating element happens to compute to,
   * which for a portaled overlay is the direction of wherever it landed.
   * Omit it to leave that decision to the page, as floating-ui does by default.
   */
  dir?: TextDirection
  /** Optional arrow element to position. */
  arrow?: HTMLElement
  /** Notify after each position computation. */
  onUpdate?: (data: {
    x: number
    y: number
    placement: Placement
    arrow?: { x?: number; y?: number }
  }) => void
}

FocusTrapOptions

export interface FocusTrapOptions {
  /** The container whose focusable descendants form the trap. */
  container: ElementSource
  /** Element to focus when the trap activates. Defaults to first focusable. */
  initialFocus?: Element | (() => Element | null)
  /** Restore focus to the previously active element on release (default: true). */
  restoreFocus?: boolean
}

InteractOutsideOptions

export interface InteractOutsideOptions {
  /** Element(s) that define the "inside" region. */
  element: ElementSource
  /** Additional elements whose interactions should not count as outside (e.g. triggers). */
  ignore?: ElementSource
  /** Called on pointerdown or focus outside the inside region. */
  onInteractOutside: (event: Event) => void
  /**
   * If provided, called first with the event. Return `false` to suppress the
   * outside callback (for an in-flight layer to claim the event).
   */
  shouldDispatch?: (event: Event) => boolean
}

NestedLayerOptions

export interface NestedLayerOptions {
  /**
   * Consumers this registration participates in. Defaults to all of them, which
   * is what a surface with no dismissable layer of its own needs. Narrow it when
   * another mechanism already covers an aspect — see the module comment.
   */
  aspects?: readonly NestedLayerAspect[]
  /**
   * The element this layer is logically nested INSIDE — its trigger/anchor, or
   * the host element it belongs to. This is what makes the registry per-layer:
   * an asking layer exempts this registration only when the owner is inside the
   * asker's own boundary (transitively through other nested layers).
   *
   * The owner is NOT the layer's portal root — that is the `source` argument.
   * It is the thing in the main document tree that the portal speaks for.
   *
   * Resolver form is supported and re-read on every lookup, so an owner that
   * mounts and unmounts with its component can be named once.
   *
   * A missing or unresolved owner grants no scoped exemption and emits a
   * development warning. Even a registration used only through the unscoped
   * registry-wide view should name its logical owner to keep the contract
   * explicit.
   */
  owner?: ElementSource
}

RovingItem

export interface RovingItem {
  value: string
  /** Disabled items are skipped by arrow/Home/End navigation. */
  disabled?: boolean
}

RovingOptions

export interface RovingOptions {
  /** Arrow axis — 'horizontal' uses Left/Right, 'vertical' uses Up/Down. Default 'horizontal'. */
  orientation?: RovingOrientation
  /** Whether arrow navigation wraps at the ends. Default true. */
  loop?: boolean
  /**
   * An element used to resolve text direction for RTL arrow flipping
   * (typically the event's `currentTarget`). When it resolves to
   * `dir="rtl"`, ArrowLeft/ArrowRight swap. Optional.
   */
  element?: Element | null
}

Constants

ALL_NESTED_LAYER_ASPECTS

Every aspect — the default for a registration that names none.

const ALL_NESTED_LAYER_ASPECTS: readonly NestedLayerAspect[]