tuil
ReferencePackages@mwillbanks/tuil-scrollAPI

ScrollAreaState

class exported by @mwillbanks/tuil-scroll.

View rawEdit

class

Public class exported by @mwillbanks/tuil-scroll.

export class ScrollAreaState {
  readonly id: string;
  readonly parentId?: string;
  readonly sticky: Readonly<
    Partial<Record<"top" | "bottom" | "left" | "right", boolean>>
  >;
  readonly followFocus: boolean;
  #viewport: ScrollViewport;
  #extent: ScrollExtent;
  #x = 0;
  #y = 0;
  readonly #observers = new Set<(snapshot: ScrollSnapshot) => void>();

  constructor(options: ScrollAreaOptions) {
    if (!options.id.trim()) throw new Error("Scroll area id cannot be empty");
    this.id = options.id;
    this.parentId = options.parentId;
    this.sticky = Object.freeze({ ...options.sticky });
    this.followFocus = options.followFocus ?? true;
    this.#viewport = this.#normalizeViewport(options.viewport);
    this.#extent = this.#normalizeExtent(options.extent);
    this.#clamp();
  }

  subscribe(observer: (snapshot: ScrollSnapshot) => void): () => void {
    this.#observers.add(observer);
    return () => this.#observers.delete(observer);
  }

  snapshot(): ScrollSnapshot {
    const maximumX = this.#maxX();
    const maximumY = this.#maxY();
    return Object.freeze({
      id: this.id,
      position: Object.freeze({ x: this.#x, y: this.#y }),
      viewport: Object.freeze({ ...this.#viewport }),
      extent: Object.freeze({ ...this.#extent }),
      atTop: this.#y === 0,
      atBottom: this.#y === maximumY,
      atLeft: this.#x === 0,
      atRight: this.#x === maximumX,
    });
  }

  resize(viewport: ScrollViewport, extent = this.#extent): void {
    const before = this.snapshot();
    this.#viewport = this.#normalizeViewport(viewport);
    this.#extent = this.#normalizeExtent(extent);
    this.#finishExtentChange(before);
  }

  setExtent(
    extent: ScrollExtent,
    options: { readonly insertedBefore?: number } = {},
  ): void {
    const before = this.snapshot();
    this.#extent = this.#normalizeExtent(extent);
    if (options.insertedBefore && !(this.sticky.top && before.atTop)) {
      this.#y += integer(options.insertedBefore);
    }
    this.#finishExtentChange(before);
  }

  scrollTo(position: Partial<ScrollPosition>): ScrollSnapshot {
    if (position.x !== undefined) this.#x = integer(position.x);
    if (position.y !== undefined) this.#y = integer(position.y);
    this.#clamp();
    this.#notify();
    return this.snapshot();
  }

  move(
    direction:
      | "lineUp"
      | "lineDown"
      | "lineLeft"
      | "lineRight"
      | "pageUp"
      | "pageDown"
      | "pageLeft"
      | "pageRight"
      | "top"
      | "bottom"
      | "left"
      | "right",
    amount = 1,
  ): ScrollSnapshot {
    const step = integer(amount, 1);
    const lineDeltas = {
      lineUp: [0, -step],
      lineDown: [0, step],
      lineLeft: [-step, 0],
      lineRight: [step, 0],
      pageUp: [0, -this.#viewport.height * step],
      pageDown: [0, this.#viewport.height * step],
      pageLeft: [-this.#viewport.width * step, 0],
      pageRight: [this.#viewport.width * step, 0],
    } as const;
    const delta = lineDeltas[direction as keyof typeof lineDeltas];
    if (delta) [this.#x, this.#y] = [this.#x + delta[0], this.#y + delta[1]];
    else this.#moveToEdge(direction);
    this.#clamp();
    this.#notify();
    return this.snapshot();
  }

  wheel(deltaX: number, deltaY: number): ScrollSnapshot {
    return this.scrollTo({ x: this.#x + deltaX, y: this.#y + deltaY });
  }

  scrollIntoView(
    bounds: {
      readonly x: number;
      readonly y: number;
      readonly width: number;
      readonly height: number;
    },
    alignment: ScrollAlignment = "nearest",
  ): ScrollSnapshot {
    const align = (
      start: number,
      size: number,
      offset: number,
      viewportSize: number,
    ): number => {
      if (alignment === "start") return start;
      if (alignment === "center")
        return start - Math.floor((viewportSize - size) / 2);
      if (alignment === "end") return start + size - viewportSize;
      if (start < offset) return start;
      if (start + size > offset + viewportSize)
        return start + size - viewportSize;
      return offset;
    };
    return this.scrollTo({
      x: align(bounds.x, bounds.width, this.#x, this.#viewport.width),
      y: align(bounds.y, bounds.height, this.#y, this.#viewport.height),
    });
  }

  visibleRange(
    axis: ScrollAxis,
    measurements: readonly number[],
    overscan = 0,
  ): {
    readonly start: number;
    readonly end: number;
    readonly before: number;
    readonly after: number;
  } {
    const sizes = normalizeMeasurements(measurements);
    const offset = axis === "vertical" ? this.#y : this.#x;
    const size =
      axis === "vertical" ? this.#viewport.height : this.#viewport.width;
    const { start, end } = visibleMeasurementInterval(sizes, offset, size);
    const safeOverscan = integer(overscan);
    const first = Math.max(0, start - safeOverscan);
    const last = Math.min(
      sizes.length - 1,
      Math.max(start, end - 1 + safeOverscan),
    );
    const before = sizes.slice(0, first).reduce((sum, value) => sum + value, 0);
    const included = sizes
      .slice(first, last + 1)
      .reduce((sum, value) => sum + value, 0);
    const total = sizes.reduce((sum, value) => sum + value, 0);
    return Object.freeze({
      start: first,
      end: last,
      before,
      after: Math.max(0, total - before - included),
    });
  }

  #finishExtentChange(before: ScrollSnapshot): void {
    if (this.sticky.top && before.atTop) this.#y = 0;
    if (this.sticky.bottom && before.atBottom) this.#y = this.#maxY();
    if (this.sticky.left && before.atLeft) this.#x = 0;
    if (this.sticky.right && before.atRight) this.#x = this.#maxX();
    this.#clamp();
    this.#notify();
  }

  #moveToEdge(direction: string): void {
    const positions: Readonly<Record<string, readonly [number, number]>> = {
      top: [this.#x, 0],
      bottom: [this.#x, this.#maxY()],
      left: [0, this.#y],
      right: [this.#maxX(), this.#y],
    };
    const position = positions[direction];
    if (position) [this.#x, this.#y] = position;
  }

  staticProjection(
    content: readonly string[],
    mode: "all" | "viewport" = "all",
  ): readonly string[] {
    if (mode === "all") return Object.freeze([...content]);
    return Object.freeze(
      content.slice(this.#y, this.#y + this.#viewport.height),
    );
  }

  #normalizeViewport(viewport: ScrollViewport): ScrollViewport {
    return Object.freeze({
      width: integer(viewport.width, 1),
      height: integer(viewport.height, 1),
    });
  }

  #normalizeExtent(extent: ScrollExtent): ScrollExtent {
    return Object.freeze({
      width: integer(extent.width),
      height: integer(extent.height),
    });
  }

  #maxX(): number {
    return Math.max(0, this.#extent.width - this.#viewport.width);
  }

  #maxY(): number {
    return Math.max(0, this.#extent.height - this.#viewport.height);
  }

  #clamp(): void {
    this.#x = Math.max(0, Math.min(this.#maxX(), this.#x));
    this.#y = Math.max(0, Math.min(this.#maxY(), this.#y));
  }

  #notify(): void {
    const snapshot = this.snapshot();
    for (const observer of this.#observers) observer(snapshot);
  }
}

Members

MemberTypeRequiredDescriptionRelated types
idstringYesThe id member uses the string contract.
parentIdstring | undefinedNoThe parentId member uses the string | undefined contract.
stickyReadonly<Partial<Record<"top" | "bottom" | "left" | "right", boolean>>>YesThe sticky member uses the Readonly<Partial<Record<"top" | "bottom" | "left" | "right", boolean>>> contract.
followFocusbooleanYesThe followFocus member uses the boolean contract.
#viewportScrollViewportYesThe #viewport member uses the ScrollViewport contract.ScrollViewport
#extentScrollExtentYesThe #extent member uses the ScrollExtent contract.ScrollExtent
#xnumberYesThe #x member uses the number contract.
#ynumberYesThe #y member uses the number contract.
#observersSet<(snapshot: ScrollSnapshot) => void>YesThe #observers member uses the Set<(snapshot: ScrollSnapshot) => void> contract.ScrollSnapshot
__constructoranyYesThe __constructor member uses the any contract.
subscribe(observer: (snapshot: ScrollSnapshot) => void) => () => voidYesThe subscribe member uses the (observer: (snapshot: ScrollSnapshot) => void) => () => void contract.ScrollSnapshot
snapshot() => ScrollSnapshotYesThe snapshot member uses the () => ScrollSnapshot contract.ScrollSnapshot
resize(viewport: ScrollViewport, extent?: ScrollExtent) => voidYesThe resize member uses the (viewport: ScrollViewport, extent?: ScrollExtent) => void contract.ScrollExtent, ScrollViewport
setExtent(extent: ScrollExtent, options?: &#123; readonly insertedBefore?: number; &#125;) => voidYesThe setExtent member uses the (extent: ScrollExtent, options?: &#123; readonly insertedBefore?: number; &#125;) => void contract.ScrollExtent
scrollTo(position: Partial<ScrollPosition>) => ScrollSnapshotYesThe scrollTo member uses the (position: Partial<ScrollPosition>) => ScrollSnapshot contract.ScrollPosition, ScrollSnapshot
move(direction: "lineUp" | "lineDown" | "lineLeft" | "lineRight" | "pageUp" | "pageDown" | "pageLeft" | "pageRight" | "top" | "bottom" | "left" | "right", amount?: number) => ScrollSnapshotYesThe move member uses the (direction: "lineUp" | "lineDown" | "lineLeft" | "lineRight" | "pageUp" | "pageDown" | "pageLeft" | "pageRight" | "top" | "bottom" | "left" | "right", amount?: number) => ScrollSnapshot contract.ScrollSnapshot
wheel(deltaX: number, deltaY: number) => ScrollSnapshotYesThe wheel member uses the (deltaX: number, deltaY: number) => ScrollSnapshot contract.ScrollSnapshot
scrollIntoView(bounds: &#123; readonly x: number; readonly y: number; readonly width: number; readonly height: number; &#125;, alignment?: ScrollAlignment) => ScrollSnapshotYesThe scrollIntoView member uses the (bounds: &#123; readonly x: number; readonly y: number; readonly width: number; readonly height: number; &#125;, alignment?: ScrollAlignment) => ScrollSnapshot contract.ScrollAlignment, ScrollSnapshot
visibleRange(axis: ScrollAxis, measurements: readonly number[], overscan?: number) => &#123; readonly start: number; readonly end: number; readonly before: number; readonly after: number; &#125;YesThe visibleRange member uses the (axis: ScrollAxis, measurements: readonly number[], overscan?: number) => &#123; readonly start: number; readonly end: number; readonly before: number; readonly after: number; &#125; contract.ScrollAxis
#finishExtentChange(before: ScrollSnapshot) => voidYesThe #finishExtentChange member uses the (before: ScrollSnapshot) => void contract.ScrollSnapshot
#moveToEdge(direction: string) => voidYesThe #moveToEdge member uses the (direction: string) => void contract.
staticProjection(content: readonly string[], mode?: "all" | "viewport") => readonly string[]YesThe staticProjection member uses the (content: readonly string[], mode?: "all" | "viewport") => readonly string[] contract.
#normalizeViewport(viewport: ScrollViewport) => ScrollViewportYesThe #normalizeViewport member uses the (viewport: ScrollViewport) => ScrollViewport contract.ScrollViewport
#normalizeExtent(extent: ScrollExtent) => ScrollExtentYesThe #normalizeExtent member uses the (extent: ScrollExtent) => ScrollExtent contract.ScrollExtent
#maxX() => numberYesThe #maxX member uses the () => number contract.
#maxY() => numberYesThe #maxY member uses the () => number contract.
#clamp() => voidYesThe #clamp member uses the () => void contract.
#notify() => voidYesThe #notify member uses the () => void contract.

Parameters

This declaration has no public members.

Returns

This declaration does not return a value.

Throws

No thrown errors are documented for this declaration.

Source

View the secondary source reference

Package

@mwillbanks/tuil-scroll

On this page