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
TypeScript
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;
}