ScrollAreaState
class exported by @mwillbanks/tuil-scroll.
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
| Member | Type | Required | Description | Related types |
|---|---|---|---|---|
id | string | Yes | The id member uses the string contract. | — |
parentId | string | undefined | No | The parentId member uses the string | undefined contract. | — |
sticky | Readonly<Partial<Record<"top" | "bottom" | "left" | "right", boolean>>> | Yes | The sticky member uses the Readonly<Partial<Record<"top" | "bottom" | "left" | "right", boolean>>> contract. | — |
followFocus | boolean | Yes | The followFocus member uses the boolean contract. | — |
#viewport | ScrollViewport | Yes | The #viewport member uses the ScrollViewport contract. | ScrollViewport |
#extent | ScrollExtent | Yes | The #extent member uses the ScrollExtent contract. | ScrollExtent |
#x | number | Yes | The #x member uses the number contract. | — |
#y | number | Yes | The #y member uses the number contract. | — |
#observers | Set<(snapshot: ScrollSnapshot) => void> | Yes | The #observers member uses the Set<(snapshot: ScrollSnapshot) => void> contract. | ScrollSnapshot |
__constructor | any | Yes | The __constructor member uses the any contract. | — |
subscribe | (observer: (snapshot: ScrollSnapshot) => void) => () => void | Yes | The subscribe member uses the (observer: (snapshot: ScrollSnapshot) => void) => () => void contract. | ScrollSnapshot |
snapshot | () => ScrollSnapshot | Yes | The snapshot member uses the () => ScrollSnapshot contract. | ScrollSnapshot |
resize | (viewport: ScrollViewport, extent?: ScrollExtent) => void | Yes | The resize member uses the (viewport: ScrollViewport, extent?: ScrollExtent) => void contract. | ScrollExtent, ScrollViewport |
setExtent | (extent: ScrollExtent, options?: { readonly insertedBefore?: number; }) => void | Yes | The setExtent member uses the (extent: ScrollExtent, options?: { readonly insertedBefore?: number; }) => void contract. | ScrollExtent |
scrollTo | (position: Partial<ScrollPosition>) => ScrollSnapshot | Yes | The 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) => ScrollSnapshot | Yes | The 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) => ScrollSnapshot | Yes | The wheel member uses the (deltaX: number, deltaY: number) => ScrollSnapshot contract. | ScrollSnapshot |
scrollIntoView | (bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number; }, alignment?: ScrollAlignment) => ScrollSnapshot | Yes | The scrollIntoView member uses the (bounds: { readonly x: number; readonly y: number; readonly width: number; readonly height: number; }, alignment?: ScrollAlignment) => ScrollSnapshot contract. | ScrollAlignment, ScrollSnapshot |
visibleRange | (axis: ScrollAxis, measurements: readonly number[], overscan?: number) => { readonly start: number; readonly end: number; readonly before: number; readonly after: number; } | Yes | The visibleRange member uses the (axis: ScrollAxis, measurements: readonly number[], overscan?: number) => { readonly start: number; readonly end: number; readonly before: number; readonly after: number; } contract. | ScrollAxis |
#finishExtentChange | (before: ScrollSnapshot) => void | Yes | The #finishExtentChange member uses the (before: ScrollSnapshot) => void contract. | ScrollSnapshot |
#moveToEdge | (direction: string) => void | Yes | The #moveToEdge member uses the (direction: string) => void contract. | — |
staticProjection | (content: readonly string[], mode?: "all" | "viewport") => readonly string[] | Yes | The staticProjection member uses the (content: readonly string[], mode?: "all" | "viewport") => readonly string[] contract. | — |
#normalizeViewport | (viewport: ScrollViewport) => ScrollViewport | Yes | The #normalizeViewport member uses the (viewport: ScrollViewport) => ScrollViewport contract. | ScrollViewport |
#normalizeExtent | (extent: ScrollExtent) => ScrollExtent | Yes | The #normalizeExtent member uses the (extent: ScrollExtent) => ScrollExtent contract. | ScrollExtent |
#maxX | () => number | Yes | The #maxX member uses the () => number contract. | — |
#maxY | () => number | Yes | The #maxY member uses the () => number contract. | — |
#clamp | () => void | Yes | The #clamp member uses the () => void contract. | — |
#notify | () => void | Yes | The #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.
Related types
ScrollAlignmentScrollAreaOptionsScrollAxisScrollExtentScrollPositionScrollSnapshotScrollViewport
Source
View the secondary source reference