@llselect/core
Version:
Low-level select: a minimal, flexible, framework-agnostic replacement for the native HTML select element.
160 lines • 7.36 kB
TypeScript
/** Minimal rect shape consumed by {@link computePosition}. */
export interface AnchorRect {
top: number;
left: number;
right: number;
bottom: number;
width: number;
height: number;
}
/**
* Whether the floating element sits below or above the anchor.
* @group Positioning
*/
export type LLSelectPlacement = 'below' | 'above';
/**
* How the floating element decides its width. See `LLSelectBaseSettings`
* (`popupWidthPolicy` field) for the user-facing contract.
* @group Positioning
*/
export type LLSelectWidthPolicy = 'fit-content' | 'match-trigger';
/** Input to the pure positioning calculation. */
export interface PositionInput {
anchorRect: AnchorRect;
viewportWidth: number;
viewportHeight: number;
/**
* Origin of the VISIBLE viewport in layout-viewport (client) coordinates.
* `0` except under pinch zoom, where the visual viewport shrinks AND pans
* (`visualViewport.offsetLeft/offsetTop`); anchor rects stay in client
* coordinates, so clamping against `[0, viewportWidth]` alone would drag
* the floating element toward the layout origin. Default `0`.
*/
viewportLeft?: number;
/** See {@link PositionInput.viewportLeft}. Default `0`. */
viewportTop?: number;
/** Measured height of the floating element. Pass 0 if unknown. */
floatingHeight: number;
/** Width policy. Optional; default `'fit-content'`. */
widthPolicy?: LLSelectWidthPolicy;
/**
* Floating element's natural (max-content) width in px. Only consulted when
* `widthPolicy === 'fit-content'`. Default `0`.
*/
floatingNaturalWidth?: number;
/**
* Writing direction of the anchor's context. Only consulted when
* `widthPolicy === 'fit-content'`: `'rtl'` right-aligns the floating element
* to the anchor and grows LEFTWARD (the mirror of ltr). Default `'ltr'`.
* `'match-trigger'` is position-identical in both directions.
*/
direction?: 'ltr' | 'rtl';
/**
* Placement currently in effect, for stickiness across repositions of one
* open cycle. When set and the content still fits on that side, it is kept
* even if the other side would also fit - so a transient content shrink
* (e.g. a filter query matching nothing) does not flip the popup back and
* forth. Omit / `undefined` (first placement) to pick fresh.
*/
currentPlacement?: LLSelectPlacement | undefined;
}
/** Result of {@link computePosition}: coordinates and chosen placement. */
export interface PositionResult {
top: number;
left: number;
width: number;
/** Maximum height the floating element may occupy. */
maxHeight: number;
placement: LLSelectPlacement;
}
/**
* Compute where to place the floating element relative to the anchor.
*
* Vertical: prefers placing below; flips above when it does not fit below and
* either fits above or has more room above. When neither side fits, picks the
* side with more space and clamps `maxHeight` accordingly. A
* `currentPlacement` that still fits is kept (stickiness) - re-preferring
* "below" on every content change would make the popup jump sides whenever
* the list shrinks and regrows.
*
* Horizontal: `widthPolicy === 'fit-content'` (default) returns
* `width = max(anchor.width, floatingNaturalWidth)`, clamps to
* `viewport - 2 * VIEWPORT_PADDING`, and keeps the popup inside the viewport
* margins. Growth direction follows `direction`: ltr aligns left edges and
* grows rightward; rtl aligns RIGHT edges and grows leftward (the mirror).
*
* The "viewport" here is the VISIBLE window in client coordinates:
* `[viewportLeft, viewportLeft + viewportWidth]` x
* `[viewportTop, viewportTop + viewportHeight]`. The offsets are 0 except
* under pinch zoom (see {@link PositionInput.viewportLeft}).
* `widthPolicy === 'match-trigger'` returns `width = anchor.width` and
* `left = anchor.left` (no collision handling - popup is the same width as
* trigger; direction-independent).
*/
export declare function computePosition(input: PositionInput): PositionResult;
/**
* Whether `anchor` is currently hidden (scrolled out of the layout viewport or
* clipped by a scrollable ancestor). Exposed so a caller can refuse to open a
* popup against an off-screen trigger BEFORE building a positioner, rather than
* opening and then hiding re-entrantly.
*/
export declare function isAnchorHidden(anchor: HTMLElement): boolean;
/** Controls the lifecycle of an active positioner. */
export interface Positioner {
/** Force a re-position now. Normally called automatically. */
reposition(): void;
/**
* Stop tracking and clear all inline styles + `data-placement` from the
* floating element. Idempotent. Call once when the floating element is
* dismissed.
*/
detach(): void;
}
/** Options passed to {@link createPositioner}. */
export interface PositionerOptions {
/**
* Called when the anchor becomes invisible (fully outside the layout
* viewport, or fully clipped by a scrollable ancestor). Typical use: close the
* floating element so it does not hang in space without a visible trigger.
*/
onHide?: () => void;
/**
* Width policy. Default `'fit-content'`, matching the `popupWidthPolicy`
* setting default.
*/
widthPolicy?: LLSelectWidthPolicy;
/**
* The floating element's inner scroll container (the popup list). Under an
* active `maxHeight` clamp the floating element's overflow is absorbed as
* this element's internal scrolling, so `offsetHeight` alone under-reports
* the natural height; its `scrollHeight - clientHeight` restores the
* difference WITHOUT lifting the clamp to re-measure (a lift-and-restore
* would clamp this element's scrollTop to 0 mid-frame - losing the
* scrolled-to-chosen position - and caused a visible window-scroll jolt on
* Firefox). Omit when the floating element has no inner scroller.
*
* INVARIANT: this element must have no author-set height cap of its own -
* the positioner owns the popup's `maxHeight`. The reconstruction adds back
* ALL of its overflow, so an independent `max-height` on the inner list
* (theme or consumer CSS) is read as extra natural height and can pick a
* side as if the popup were taller than it can render. Shipped themes honor
* this; consumer themes must clamp the popup, not the inner list.
*/
innerScrollEl?: HTMLElement;
}
/**
* Attach a positioner that keeps `floating` placed relative to `anchor`.
*
* Behavior: sets `floating` to `position: fixed`, listens to window scroll
* (capture phase, so any ancestor scroll is caught), window resize, and
* `ResizeObserver` on both elements. On every reposition: if the anchor is
* outside the layout viewport or clipped by a scrollable ancestor and `onHide` is
* provided, calls `onHide` and skips style updates. Otherwise applies the
* coordinates from {@link computePosition} and sets `data-placement` on
* `floating` for CSS hooks.
*
* Caller is responsible for calling `detach()` when the floating element is
* dismissed; otherwise listeners leak.
*/
export declare function createPositioner(anchor: HTMLElement, floating: HTMLElement, options?: PositionerOptions): Positioner;
//# sourceMappingURL=positioning.d.ts.map