UNPKG

ranui

Version:

A framework-agnostic Web Components UI library built on native custom elements, with TypeScript types, light/dark theming, SSR and PWA support.

124 lines (123 loc) 5.56 kB
import { type Placement, type PlacementAlign, type PlacementSide } from './placement'; /** Everything a positioning pass worked out, handed to the hooks. */ export interface FloatingPosition { /** Viewport coordinates for the panel's top-left corner. */ top: number; left: number; /** The side actually used, after any flip. */ side: PlacementSide; /** The cross-axis alignment actually used. */ align: PlacementAlign; /** The anchor's box, in viewport coordinates. */ anchorRect: DOMRect; /** The panel's box, measured before this pass moved it. */ panelRect: DOMRect; /** The custom positioning container, when `containerId` names one. */ container: HTMLElement | null; } export interface FloatingOptions { /** The element that owns the panel: holds `open`, and receives the events. */ host: HTMLElement; /** The panel. Read every time, because components build theirs lazily. */ panel: () => HTMLElement | null | undefined; /** What the panel is positioned against. Defaults to the host. */ anchor?: () => HTMLElement; /** Preferred side, optionally with an alignment suffix (`bottom-end`). */ placement: () => Placement; /** Gap between anchor and panel, in px. */ offset?: number; /** * `id` of an element to position within instead of the viewport. Flip and * shift do not apply there (they reason in viewport coordinates); alignment * does, through the same `alignCrossAxis` the viewport path uses. */ containerId?: () => string; /** * Called before the panel is measured, for a component that sizes its panel * from the anchor -- r-select matches the trigger's width, and the width has * to be in place before the height that the flip decision needs is real. */ beforeMeasure?: (anchorRect: DOMRect, panel: HTMLElement) => void; /** * The panel's size, when its own box does not report it. r-select pins the * panel host's width to the trigger's, but a consumer can make the panel * *inside* that host wider (`::part(dropdown) { min-width }`, so a long option * is not clipped by a deliberately narrow trigger -- r-player's quality menu * does exactly this). The extra width simply overflows the host, invisible to * a measurement taken on it, and both the alignment and the flip decision * would then be computed against a width nobody sees. */ measurePanel?: (panel: HTMLElement) => { width: number; height: number; }; /** * Last word on the coordinates. Return a replacement to override the computed * position; return nothing to accept it. */ adjust?: (position: FloatingPosition, panel: HTMLElement) => { top: number; left: number; } | void; /** Called once the coordinates are written, for arrow nudges and the like. */ afterPosition?: (position: FloatingPosition, panel: HTMLElement) => void; } export declare class FloatingController { private options; private repositionBound; /** * Whether the panel is meant to be showing. * * Held here rather than read back from `panel.style.display`, for the same * reason the components stopped reading it: during the exit animation the * panel is still `block` while the intent is already closed, so a re-open * arriving in that window would look like a no-op and be swallowed. That is * the bug this whole controller exists to make impossible; reintroducing it * one layer down would be a poor joke. */ private opened; /** Pending reposition frame, so a scroll burst coalesces into one write. */ private repositionFrame; /** * Bumped on every transition. An async tail (waiting on animations) checks it * before touching the DOM, so a panel that has since been re-opened is never * hidden by the exit that was already in flight when it re-opened. A timeout * would only have guessed at the same thing. */ private generation; constructor(options: FloatingOptions); /** The element the panel is positioned against. */ private get anchor(); /** * Drive the panel to `open`. * * The only writer of the panel's display, its transit class, the reposition * listeners and the host's `aria-expanded` -- which is why those four cannot * disagree with each other or with the state. */ apply: (open: boolean) => void; /** The side of the requested placement, without its alignment suffix. */ private side; /** * Place the panel next to the anchor. * * Deferred to the next frame so a `display: block` set this tick, and any * content added with it, are part of the measurement -- the flip decision * needs the panel's real laid-out height, which is also why the entrance * animation is chosen here (from the side actually used) rather than by the * caller (from the side merely asked for). */ position: (applyEntranceTransit?: boolean) => void; private write; /** * The panel is portalled and positioned once on open, so it comes adrift when * the page or any scroll container moves under it -- a select in a sticky * header, a popover in a scrolling pane. Capture-phase scroll catches nested * scrollers, which do not bubble. */ private reposition; private attachReposition; private detachReposition; /** Drop the listeners. Call from `disconnectedCallback`. */ destroy: () => void; }