@sandlada/mdc
Version:
@sandlada/mdc is an open source component library that follows the Material Design 3 design specifications.
262 lines • 10.3 kB
TypeScript
/**
* @license
* Copyright 2026 Kai-Orion & Sandlada
* SPDX-License-Identifier: MIT
*
* @fileoverview
* ReactiveController that manages an element's show/close animation based on
* edge-slide (in-flow collapse or floating peek) behavior, with optional
* scroll-driven auto-show/hide.
*
* **Positioning model**:
* - `floating = false`: The host stays in the document flow; on hide its
* height/width animates to `0` (fully removed from layout). No blank gap
* is left behind.
* - `floating = true`: The host floats on the page (`position: fixed` must
* be set by the host component). On hide it animates to `peekSize` — a
* small sliver remains visible. Mouse hover expands the bar.
*
* **Direction mapping** (derived from `placement`):
* - Vertical placements (`top` / `bottom`):
* scroll down → hide, scroll up → show, at top → show.
* - Horizontal placements (`left` / `right`):
* scroll right → hide, scroll left → show, at left → show.
*
* **Animation**:
* The host dimension height/width keyframes are interleaved with the inner
* container translateX/translateY keyframes. The controller splits them and
* animates each element independently via the Web Animations API.
*
* **Lifecycle** mirrors the FAB/dialog pattern: a `generation` counter
* invalidates stale async operations, an `isConnectedPromise` defers DOM
* work until connected, and `open` / `close` events are cancelable (followed
* by non-cancelable `opened` / `closed`).
*
* @example
* ```ts
* const slide = new EdgeSlideController(this, {
* placement: 'bottom',
* floating: true,
* autoHide: true,
* peekSize: 24,
* })
* ```
*/
import { type ReactiveController, type ReactiveControllerHost } from 'lit';
/**
* The viewport / document edge the host is docked to. The slide-out direction
* is derived from this value:
* - `top` → slides up (out the top)
* - `bottom` → slides down (out the bottom)
* - `left` → slides left (out the left)
* - `right` → slides right (out the right)
*/
export type Placement = 'top' | 'bottom' | 'left' | 'right';
export declare const Placement: {
readonly Top: 'top';
readonly Bottom: 'bottom';
readonly Left: 'left';
readonly Right: 'right';
};
/**
* Host contract required by {@link EdgeSlideController}.
*
* The host must provide a `containerElement` — the inner element whose
* `transform` is animated during show/close.
*/
export interface IEdgeSlideHost extends ReactiveControllerHost, HTMLElement {
/** The inner element that is translated during show/close animation. */
containerElement: HTMLElement | null;
}
/**
* Configuration options for {@link EdgeSlideController}.
* All fields are optional — unspecified fields retain their defaults.
*/
export interface EdgeSlideOptions {
/**
* When `true` the host is expected to use `position: fixed` and floats
* above the page content. When hiding it shrinks to `peekSize` (peek
* mode). Mouse hover expands the bar.
*
* When `false` (default) the host stays in the document flow. When hiding
* it collapses to `0` (full mode).
*/
floating?: boolean;
/**
* The edge the host is docked to. Drives the slide-out / slide-in
* direction.
* @default 'bottom'
*/
placement?: Placement;
/**
* When `true` the controller listens to scroll events on the target
* element (resolved via `scrollElementId`, falling back to `window`) and
* automatically hides or shows the host based on scroll direction.
* @default false
*/
autoHide?: boolean;
/**
* The `id` of the scrollable element to observe. When empty or missing,
* the controller falls back to `window`.
* @default ''
*/
scrollElementId?: string;
/**
* Visible sliver size in pixels when hidden in `floating` (peek) mode.
* @default 24
*/
peekSize?: number;
/**
* When `true` the show/close transitions are skipped — the host jumps
* directly to the target state.
* @default false
*/
quick?: boolean;
}
export declare class EdgeSlideController implements ReactiveController {
private readonly host;
private _floating;
private _placement;
private _autoHide;
private _scrollElementId;
private _peekSize;
private _quick;
private _isOpen;
private _isOpening;
/** Generation counter — incremented before each `show()` / `close()` to
* invalidate stale async operations. */
private generation;
/** Resolved once in `hostConnected()`, re-created in `hostDisconnected()`. */
private isConnectedPromiseResolve;
private isConnectedPromise;
private animationAbort;
private hoverTimer;
private readonly handleMouseEnterBound;
private readonly handleMouseLeaveBound;
private scrollTarget;
private lastScrollTop;
private lastScrollLeft;
/** Tracks the last scroll-driven action so we don't repeat it every frame. */
private lastScrollAction;
private scrollRafId;
private readonly handleScrollBound;
constructor(host: IEdgeSlideHost, options?: EdgeSlideOptions);
get floating(): boolean;
set floating(value: boolean);
get placement(): Placement;
set placement(value: Placement);
get autoHide(): boolean;
set autoHide(value: boolean);
get scrollElementId(): string;
set scrollElementId(value: string);
get peekSize(): number;
set peekSize(value: number);
get quick(): boolean;
set quick(value: boolean);
/** Whether the host is currently shown. */
get isOpen(): boolean;
/** Whether the host is currently animating open. */
get isOpening(): boolean;
hostConnected(): void;
hostDisconnected(): void;
/**
* Re-resolve the scroll target and re-attach the listener. Call this when
* `scrollElementId`, `autoHide`, or `placement` changes externally.
*/
rebindScroll(): void;
/**
* Imperatively show the host (animate in).
* Fires cancelable `open` → animates → fires `opened`.
*/
show(): Promise<void>;
/**
* Imperatively hide the host (animate out).
* Fires cancelable `close` → animates → fires `closed`.
*/
close(): Promise<void>;
private createConnectedPromise;
/**
* Cancel all WAAPI animations on the host and container, and abort any
* in-flight show/close. Call this before measuring the natural size or
* starting a new animation cycle so stale `fill:forwards` doesn't
* interfere with layout measurement or leave a ghost state behind.
*/
private cleanupAnimations;
/**
* Animate the host dimension + inner container translate. Splits the
* interleaved keyframes array: even-indexed entries → host dimension
* keyframes, odd-indexed entries → inner container translate keyframes.
*/
private animateBar;
/** Build open keyframes: host grows to natural size, inner slides in. */
private getOpenKeyframes;
/** Build close keyframes: host shrinks to hidden size, inner slides out. */
private getCloseKeyframes;
/** Which host dimension collapses: vertical placements → height, horizontal → width. */
private collapseAxis;
/** Which inner translate axis: vertical placements → translateY, horizontal → translateX. */
private translateAxis;
/**
* Natural (shown) host size in px along the collapse axis. Cancels any
* stale fill:forwards and temporarily clears the inline hidden-size style
* so the true natural layout size can be read via getBoundingClientRect.
*/
private naturalSize;
/**
* Target host size when hidden:
* - `floating = true` (peek mode): `peekSize`
* - `floating = false` (full mode): `0`
*/
private hiddenSize;
/**
* Container translate offset (px) when entering the hidden / peek state
* (the CLOSE direction). The bar moves from shown (translate=0) toward
* this offset so the un-docked edge disappears first:
*
* Full mode (!floating):
* bottom → +natural (down) → 1→2→3 (top vanishes first)
* top → 0 → 3→2→1 (host natural clip)
* left → +natural (right) → c→b→a (right vanishes first)
* right → -natural (left) → a→b→c (left vanishes first)
*
* Peek mode (floating, container slides **away** from the anchor so the
* edge-opposite content stays visible in the clipped host):
* bottom → 0 → shows top part via host clip
* top → -(natural-peek) → shows bottom part
* left → -(natural-peek) → shows right part
* right → 0 → shows left part via host clip
*/
private closeOffset;
/**
* Container translate offset (px) when starting the SHOW animation.
* The bar animates FROM this offset TO 0 while the host grows.
*
* Full mode (!floating) — the container starts on the opposite side of
* the close direction so it slides **through** the host clip:
* bottom → -natural (up) → slides down into view
* top → 0 → no translation, host just grows
* left → -natural (left) → slides right into view
* right → +natural (right) → slides left into view
*
* Peek mode (floating) — same as `closeOffset` since the container is
* already at the peek position and animates to 0 during show.
*/
private openOffset;
/** Clear inline animation styles once shown (let CSS take over). */
private applyShownState;
/** Apply the resting hidden state inline (host sized, inner translated). */
private applyHiddenState;
/** Hover shows the full bar (floating / peek mode); mouse leave restores peek. */
private handleMouseEnter;
private handleMouseLeave;
private clearHoverTimer;
private resolveScrollTarget;
private recordScrollBaseline;
private detachScroll;
private cancelScrollRaf;
private scheduleScrollCheck;
private checkScroll;
private dispatchScrollAction;
private updateScrollBaseline;
}
//# sourceMappingURL=edge-slide-controller.d.ts.map