diff --git a/.changeset/scroll-into-view-within.md b/.changeset/scroll-into-view-within.md new file mode 100644 index 0000000..4bc61e4 --- /dev/null +++ b/.changeset/scroll-into-view-within.md @@ -0,0 +1,6 @@ +--- +"@intentface/ui": patch +--- + +- [Composer] Fix arrowing through the command list scrolling the page in Safari and Firefox. +- [AskUser] Fix options scrolled into view ignoring their `scroll-margin` and the list's `scroll-padding`. diff --git a/packages/ui/src/ask-user/ask-user.tsx b/packages/ui/src/ask-user/ask-user.tsx index 0645a92..6713d10 100644 --- a/packages/ui/src/ask-user/ask-user.tsx +++ b/packages/ui/src/ask-user/ask-user.tsx @@ -248,7 +248,7 @@ export const AskUserOptions = ({ if (!element) return; element.focus({ preventScroll: true }); const boundary = element.closest("[data-ask-user]") ?? element; - scrollIntoViewWithin(element.getBoundingClientRect(), element, boundary); + scrollIntoViewWithin(element, boundary); }, []); useImperativeHandle( diff --git a/packages/ui/src/composer/command-list.tsx b/packages/ui/src/composer/command-list.tsx index 8cb509f..77cd054 100644 --- a/packages/ui/src/composer/command-list.tsx +++ b/packages/ui/src/composer/command-list.tsx @@ -20,6 +20,7 @@ import { } from "react"; import type { PrimitiveProps } from "../internal/primitive-props"; import { useRenderElement } from "../internal/render/useRenderElement"; +import { scrollIntoViewWithin } from "../internal/scroll-into-view-within"; import { Commands } from "./commands"; import { filterArrayItems, suggestionRemainder } from "./fuzzy"; import { useAsRef, useComposerInternals, useIsomorphicLayoutEffect } from "./internals"; @@ -322,23 +323,16 @@ export const ComposerCommand = ({ // Scroll the highlighted row into view as it becomes the highlight. Stable, so // React calls it only on highlight change — no per-render scroll, no effect. - // - // container:"nearest" hard-scopes the scroll to the list on Chromium. - // Safari/Firefox silently ignore the member (unknown dictionary keys) and - // run an UNSCOPED block:"nearest" — scrollIntoView then scrolls every - // ancestor scrolling box, the window included, whenever the row isn't - // viewport-visible. - // - // KNOWN BUG (accepted 2026-07-10): on Safari, arrowing through the list - // while the popup opens can scroll the page behind it (reproduced; the - // positioner's viewport containment is not sufficient there). Accepted in - // exchange for native scroll-padding support and −24 lines; goes away when - // WebKit ships `container`. If it starts to hurt sooner, the scoped manual - // implementation lives in this function's git history (PR #39, first - // commit). + // Scoped to the list's own container, the popover or (for an in-flow panel) + // the composer, so the page behind it never moves; the list alone when it's + // mounted anywhere else. const scrollHighlightedIntoView = useCallback((node: HTMLElement | null) => { - // Cast: lib.dom doesn't know the `container` member yet (Chromium-only). - node?.scrollIntoView({ block: "nearest", container: "nearest" } as ScrollIntoViewOptions); + if (!node) return; + const boundary = + node.closest("[data-composer-popover], [data-composer-root]") ?? + node.closest("[data-composer-command-list]") ?? + node; + scrollIntoViewWithin(node, boundary); }, []); const navContext = useMemo( diff --git a/packages/ui/src/composer/editor-dom.ts b/packages/ui/src/composer/editor-dom.ts index acbfda6..2fbd8a3 100644 --- a/packages/ui/src/composer/editor-dom.ts +++ b/packages/ui/src/composer/editor-dom.ts @@ -534,7 +534,7 @@ export const scrollCaretIntoView = (root: HTMLElement): void => { caret.collapse(true); const rect = measureCaret(caret); if (!rect) return; - scrollIntoViewWithin(rect, root, root.closest("[data-composer-root]") ?? root); + scrollIntoViewWithin({ rect, from: root }, root.closest("[data-composer-root]") ?? root); }; // --------------------------------------------------------------------------- diff --git a/packages/ui/src/internal/scroll-into-view-within.ts b/packages/ui/src/internal/scroll-into-view-within.ts index a67692f..ebd16f0 100644 --- a/packages/ui/src/internal/scroll-into-view-within.ts +++ b/packages/ui/src/internal/scroll-into-view-within.ts @@ -1,25 +1,114 @@ -// Bring a rect into view by scrolling each scrollable ancestor of `from` just -// enough — but never one outside `boundary`. A primitive scrolls only its own -// scroll areas: walking further would reach the page around it (an embedded -// composer's docs page, say) and yank it, which is exactly what scrollIntoView -// does and why it isn't used. `boundary` itself may scroll. -export const scrollIntoViewWithin = (rect: DOMRect, from: HTMLElement, boundary: Element): void => { - let { top, bottom } = rect; - let scroller: HTMLElement | null = from; +// Scroll something into view inside a primitive's own scroll areas, never the +// page around it. scrollIntoView walks every scrollable ancestor up to the +// window, so an embedded composer or thread would yank its page, and its +// `container: "nearest"` scoping is Chromium-only. Hence by hand, with +// scrollIntoView's `block` semantics. + +// scrollIntoView's `block` alignment. +export type ScrollAlign = "start" | "center" | "end" | "nearest"; + +// Vertical edges in viewport coordinates. +type Edges = { top: number; bottom: number }; + +const px = (value: string) => Number.parseFloat(value) || 0; + +// scroll-padding's computed value keeps percentages, which resolve against the +// scroll container's own height. (scroll-margin takes lengths only.) +const paddingPx = (value: string, height: number) => + value.trim().endsWith("%") ? (px(value) / 100) * height : px(value); + +// An element's box grown by its scroll-margin: the box scrollIntoView aligns. +const marginEdges = (element: Element): Edges => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + top: rect.top - px(style.scrollMarginTop), + bottom: rect.bottom + px(style.scrollMarginBottom), + }; +}; + +/** + * The scrollTop that aligns `target` inside `scroller` the way scrollIntoView + * would: honoring an element's scroll-margin and the scroller's scroll-padding, + * clamped to the scroll range. Returned, not applied, so a scripted animation + * can head for it. A bare rect (a caret has no element) has no margin. + */ +export const alignedScrollTop = ( + scroller: HTMLElement, + target: Element | Edges, + align: ScrollAlign, +): number => { + const edges = target instanceof Element ? marginEdges(target) : target; + const scrollerStyle = getComputedStyle(scroller); + const scrollerTop = scroller.getBoundingClientRect().top + scroller.clientTop; + + // Target edges in scroll coordinates. + const targetTop = edges.top - scrollerTop + scroller.scrollTop; + const targetBottom = edges.bottom - scrollerTop + scroller.scrollTop; + // The visible band, shrunk by the scroller's scroll-padding. + const paddingTop = paddingPx(scrollerStyle.scrollPaddingTop, scroller.clientHeight); + const paddingBottom = paddingPx(scrollerStyle.scrollPaddingBottom, scroller.clientHeight); + const band = scroller.clientHeight - paddingTop - paddingBottom; + const viewTop = scroller.scrollTop + paddingTop; + const viewBottom = viewTop + band; + + const toStart = targetTop - paddingTop; + const toEnd = targetBottom - paddingTop - band; + const destination = (() => { + if (align === "start") return toStart; + if (align === "end") return toEnd; + if (align === "center") return (targetTop + targetBottom) / 2 - paddingTop - band / 2; + // "nearest", per the CSSOM View rules: leave a target that is fully in + // view, or that overflows the band at both edges, where it is; otherwise + // bring in the edge that is out of view, or the top of one too tall to fit. + const fits = targetBottom - targetTop <= band; + const above = targetTop < viewTop; + const below = targetBottom > viewBottom; + if (above === below) return scroller.scrollTop; + return above === fits ? toStart : toEnd; + })(); + + const max = scroller.scrollHeight - scroller.clientHeight; + return Math.min(Math.max(0, destination), Math.max(0, max)); +}; + +// A scroll area the walk may move: it scrolls on its own and has room to. +const scrolls = (element: HTMLElement) => { + const { overflowY } = getComputedStyle(element); + return ( + (overflowY === "auto" || overflowY === "scroll") && element.scrollHeight > element.clientHeight + ); +}; + +export type ScrollIntoViewWithinOptions = { + /** Where the target lands in each scroll area; scrollIntoView's `block`. Default "nearest". */ + block?: ScrollAlign; +}; + +/** + * Brings `target` into view by scrolling each scrollable ancestor in turn, as + * scrollIntoView does, but never one outside `boundary` (which may scroll + * itself). `target` is an element, or a bare rect measured from inside `from`. + */ +export const scrollIntoViewWithin = ( + target: HTMLElement | { rect: DOMRect; from: HTMLElement }, + boundary: Element, + { block = "nearest" }: ScrollIntoViewWithinOptions = {}, +): void => { + const isElement = target instanceof HTMLElement; + let edges: Edges = isElement ? marginEdges(target) : target.rect; + // An element scrolls its ancestors, never itself; a rect's `from` may scroll. + let scroller = isElement ? target.parentElement : target.from; while (scroller && boundary.contains(scroller)) { - const { overflowY } = getComputedStyle(scroller); - if ( - (overflowY === "auto" || overflowY === "scroll") && - scroller.scrollHeight > scroller.clientHeight - ) { - const view = scroller.getBoundingClientRect(); + if (scrolls(scroller)) { const before = scroller.scrollTop; - if (top < view.top) scroller.scrollTop -= view.top - top; - else if (bottom > view.bottom) scroller.scrollTop += bottom - view.bottom; - // The rect moves with the scrolled content; carry that to the next scroller out. - const moved = scroller.scrollTop - before; - top -= moved; - bottom -= moved; + const top = alignedScrollTop(scroller, edges, block); + scroller.scrollTop = top; + // The target moves with the scrolled content; carry that to the next + // scroller out. From the destination, not a read-back: a CSS smooth + // scroll hasn't moved yet. + const moved = top - before; + edges = { top: edges.top - moved, bottom: edges.bottom - moved }; } scroller = scroller.parentElement; } diff --git a/packages/ui/src/thread/scroll-animation.ts b/packages/ui/src/thread/scroll-animation.ts index b5c5a76..523de97 100644 --- a/packages/ui/src/thread/scroll-animation.ts +++ b/packages/ui/src/thread/scroll-animation.ts @@ -77,63 +77,6 @@ export const resolveDuration = ({ duration, speed }: ScrollTiming, distance: num return Math.max(0, resolved ?? 0); }; -// scrollIntoView's `block` alignment. -export type ScrollAlign = "start" | "center" | "end" | "nearest"; - -const px = (value: string) => Number.parseFloat(value) || 0; - -// scroll-padding's computed value keeps percentages, which resolve against the -// scroll container's own height. (scroll-margin takes lengths only.) -const paddingPx = (value: string, height: number) => - value.trim().endsWith("%") ? (px(value) / 100) * height : px(value); - -/** - * The scrollTop that aligns `row` inside `scroller` the way scrollIntoView - * would: honoring the row's scroll-margin and the scroller's scroll-padding, - * clamped to the scroll range. Computed once — a scripted animation needs the - * destination up front, which scrollIntoView never exposes. - */ -export const alignedScrollTop = ( - scroller: HTMLElement, - row: HTMLElement, - align: ScrollAlign, -): number => { - const rowStyle = getComputedStyle(row); - const scrollerStyle = getComputedStyle(scroller); - const scrollerTop = scroller.getBoundingClientRect().top + scroller.clientTop; - const rect = row.getBoundingClientRect(); - - // Row edges in scroll coordinates, expanded by its scroll-margin. - const rowTop = rect.top - scrollerTop + scroller.scrollTop - px(rowStyle.scrollMarginTop); - const rowBottom = - rect.bottom - scrollerTop + scroller.scrollTop + px(rowStyle.scrollMarginBottom); - // The visible band, shrunk by the scroller's scroll-padding. - const paddingTop = paddingPx(scrollerStyle.scrollPaddingTop, scroller.clientHeight); - const paddingBottom = paddingPx(scrollerStyle.scrollPaddingBottom, scroller.clientHeight); - const band = scroller.clientHeight - paddingTop - paddingBottom; - const viewTop = scroller.scrollTop + paddingTop; - const viewBottom = viewTop + band; - - const toStart = rowTop - paddingTop; - const toEnd = rowBottom - paddingTop - band; - const target = (() => { - if (align === "start") return toStart; - if (align === "end") return toEnd; - if (align === "center") return (rowTop + rowBottom) / 2 - paddingTop - band / 2; - // "nearest", per the CSSOM View rules: leave a row that is fully in view — - // or that overflows the band at both edges — where it is; otherwise bring - // in the edge that is out of view, or the top edge of a row too tall to fit. - const fits = rowBottom - rowTop <= band; - const above = rowTop < viewTop; - const below = rowBottom > viewBottom; - if (above === below) return scroller.scrollTop; - return above === fits ? toStart : toEnd; - })(); - - const max = scroller.scrollHeight - scroller.clientHeight; - return Math.min(Math.max(0, target), Math.max(0, max)); -}; - // The easing curve is evaluated by the browser: a target-less effect runs on // the document timeline, and its computed progress is the eased progress — so // every CSS easing works, linear() springs included, with no parser here. An diff --git a/packages/ui/src/thread/thread.tsx b/packages/ui/src/thread/thread.tsx index ba94ab3..7e96d7b 100644 --- a/packages/ui/src/thread/thread.tsx +++ b/packages/ui/src/thread/thread.tsx @@ -32,6 +32,7 @@ import type { PrimitiveProps } from "../internal/primitive-props"; import type { StateAttributesMapping } from "../internal/render/getStateAttributesProps"; import { useRefWithInit } from "../internal/render/useRefWithInit"; import { useRenderElement } from "../internal/render/useRenderElement"; +import { alignedScrollTop } from "../internal/scroll-into-view-within"; import { DEFAULT_BOTTOM_OFFSET, measureDockInset, @@ -45,7 +46,6 @@ import { wasPrepended, } from "./geometry"; import { - alignedScrollTop, animateScroll, readScrollTiming, resolveDuration, diff --git a/packages/ui/tests/composer-scroll-containment.test.tsx b/packages/ui/tests/composer-scroll-containment.test.tsx index f918dd5..718934d 100644 --- a/packages/ui/tests/composer-scroll-containment.test.tsx +++ b/packages/ui/tests/composer-scroll-containment.test.tsx @@ -4,12 +4,12 @@ import { AskUser } from "../src/ask-user"; import { Composer } from "../src/composer"; import { scrollCaretIntoView } from "../src/composer/editor-dom"; import { useComposer } from "../src/composer/store"; -import type { AskUserQuestion } from "../src/composer/types"; +import type { AskUserQuestion, CommandItemData } from "../src/composer/types"; import { scrollIntoViewWithin } from "../src/internal/scroll-into-view-within"; // A composer embedded in a scrolling page scrolls only its own scroll areas: -// programmatic writes and the ask-user flow's focus moves must leave the page -// where it is (CHAT-42). +// programmatic writes, the ask-user flow's focus moves and the command list's +// highlight must leave the page where it is (CHAT-42, CHAT-48). // happy-dom has no layout, so each element gets a stubbed box. type Box = { top: number; height: number; scrollHeight?: number }; @@ -151,8 +151,10 @@ describe("scrollIntoViewWithin", () => { test("the boundary itself may scroll", () => { const { textArea, editor } = embeddedComposer({ textAreaOverflows: true }); - scrollIntoViewWithin({ top: 380, bottom: 400, height: 20 } as DOMRect, editor, textArea); - expect(textArea.scrollTop).toBe(-30); + textArea.scrollTop = 100; + const rect = { top: 380, bottom: 400, height: 20 } as DOMRect; + scrollIntoViewWithin({ rect, from: editor }, textArea); + expect(textArea.scrollTop).toBe(70); }); test("nested scroll areas inside the boundary each scroll their share", () => { @@ -161,7 +163,8 @@ describe("scrollIntoViewWithin", () => { composer.style.overflowY = "auto"; place(composer, { top: 400, height: 140, scrollHeight: 300 }); place(textArea, { top: 520, height: 100, scrollHeight: 400 }); - scrollIntoViewWithin({ top: 700, bottom: 720, height: 20 } as DOMRect, editor, composer); + const rect = { top: 700, bottom: 720, height: 20 } as DOMRect; + scrollIntoViewWithin({ rect, from: editor }, composer); expect(textArea.scrollTop).toBe(100); expect(composer.scrollTop).toBe(80); expect(page.scrollTop).toBe(0); @@ -169,7 +172,22 @@ describe("scrollIntoViewWithin", () => { test("an element with no scrollable ancestor inside the boundary scrolls nothing", () => { const { page, editor } = embeddedComposer({ textAreaOverflows: false }); - scrollIntoViewWithin({ top: 0, bottom: 20, height: 20 } as DOMRect, editor, editor); + scrollIntoViewWithin( + { rect: { top: 0, bottom: 20, height: 20 } as DOMRect, from: editor }, + editor, + ); + expect(page.scrollTop).toBe(0); + }); + + test("an element target aligns by `block`, its scroll-margin included", () => { + const { page, composer, textArea, editor } = embeddedComposer({ textAreaOverflows: true }); + const line = document.createElement("p"); + line.style.scrollMarginTop = "8px"; + editor.append(line); + // 120px into the text area's content. + place(line, { top: 530, height: 20 }); + scrollIntoViewWithin(line, composer, { block: "start" }); + expect(textArea.scrollTop).toBe(112); expect(page.scrollTop).toBe(0); }); }); @@ -286,3 +304,62 @@ describe("ask-user focus moves keep the page in place", () => { expect(focusesOf(input).at(-1)?.options).toEqual({ preventScroll: true }); }); }); + +// --------------------------------------------------------------------------- +// The command list's highlight +// --------------------------------------------------------------------------- + +const ITEMS: CommandItemData[] = [ + { value: "rasmus", label: "Rasmus" }, + { value: "maija", label: "Maija" }, +]; + +describe("the command list's highlight keeps the page in place", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("arrowing to a row outside the list's view scrolls the list, not the page", () => { + const page = document.createElement("div"); + page.style.overflowY = "auto"; + document.body.append(page); + const store = Composer.createStore(); + render( + {}} + commands={{ "@": { kind: "insert", trigger: "word-boundary", items: ITEMS } }} + > + + + + {(item) => ( + + {item.label} + + )} + + + , + { container: page }, + ); + act(() => store.setCommands({ active: true, trigger: "@", query: "" })); + + // The list pads its view by 4px at each edge; the second row sits just + // below it, and the list runs past the page's view, so a scroll that + // leaked out of the composer would move the page. + const list = page.querySelector("[data-composer-command-list]"); + if (!list) throw new Error("command list not rendered"); + list.style.overflowY = "auto"; + list.style.scrollPaddingTop = "4px"; + list.style.scrollPaddingBottom = "4px"; + place(page, { top: 0, height: 420, scrollHeight: 2000 }); + place(list, { top: 400, height: 60, scrollHeight: 160 }); + place(screen.getByRole("option", { name: "Maija" }), { top: 460, height: 40 }); + + act(() => store.moveHighlight(1)); + // The row's bottom (100px in) at the padded band's bottom: 100 - 4 - (60 - 8). + expect(list.scrollTop).toBe(44); + expect(page.scrollTop).toBe(0); + }); +}); diff --git a/packages/ui/tests/scroll-into-view-within.test.ts b/packages/ui/tests/scroll-into-view-within.test.ts new file mode 100644 index 0000000..e264d7f --- /dev/null +++ b/packages/ui/tests/scroll-into-view-within.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { alignedScrollTop } from "../src/internal/scroll-into-view-within"; + +// The shared alignment math behind every scroll-into-view in the package. The +// walk across nested scroll areas and each call site's containment are covered +// with their components (composer-scroll-containment, thread-scroll-animation). + +afterEach(() => { + document.body.innerHTML = ""; +}); + +const pair = ({ scroller, row }: { scroller: HTMLElement; row: HTMLElement }) => + [scroller, row] as const; + +describe("alignedScrollTop", () => { + // A 400px-tall scroller at viewport top 100, scrolled to 1000 of 3000. Rows + // are placed by their offset inside the scroll content. + const setup = (rowTop: number, rowHeight: number, rowStyle = "", scrollerStyle = "") => { + const scroller = document.createElement("div"); + const row = document.createElement("div"); + scroller.setAttribute("style", scrollerStyle); + row.setAttribute("style", rowStyle); + scroller.append(row); + document.body.append(scroller); + for (const [key, value] of Object.entries({ + scrollTop: 1000, + scrollHeight: 3000, + clientHeight: 400, + clientTop: 0, + })) { + Object.defineProperty(scroller, key, { value, configurable: true }); + } + scroller.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; + row.getBoundingClientRect = () => + ({ top: 100 + rowTop - 1000, bottom: 100 + rowTop - 1000 + rowHeight }) as DOMRect; + return { scroller, row }; + }; + + test("start puts the row's top at the scroller's top", () => { + const { scroller, row } = setup(1800, 100); + expect(alignedScrollTop(scroller, row, "start")).toBe(1800); + }); + + test("end puts the row's bottom at the scroller's bottom", () => { + const { scroller, row } = setup(1800, 100); + expect(alignedScrollTop(scroller, row, "end")).toBe(1500); + }); + + test("center centers it", () => { + const { scroller, row } = setup(1800, 100); + expect(alignedScrollTop(scroller, row, "center")).toBe(1650); + }); + + test("honors the row's scroll-margin and the scroller's scroll-padding", () => { + const { scroller, row } = setup( + 1800, + 100, + "scroll-margin-top: 16px", + "scroll-padding-top: 8px", + ); + expect(alignedScrollTop(scroller, row, "start")).toBe(1776); + }); + + test("resolves a percentage scroll-padding against the viewport's height", () => { + // 400px viewport: 10% = 40px at the top, 25% = 100px at the bottom. + const padded = "scroll-padding-top: 10%; scroll-padding-bottom: 25%"; + expect(alignedScrollTop(...pair(setup(1800, 100, "", padded)), "start")).toBe(1760); + // The row's bottom (1900) at the band's bottom: 1900 - 40 - (400 - 40 - 100). + expect(alignedScrollTop(...pair(setup(1800, 100, "", padded)), "end")).toBe(1600); + }); + + test("a bare rect (a caret's) aligns the same way, with no margin to read", () => { + const { scroller } = setup(0, 0); + // 1800–1900 in the content, in viewport coordinates. + const rect = { top: 900, bottom: 1000 }; + expect(alignedScrollTop(scroller, rect, "start")).toBe(1800); + expect(alignedScrollTop(scroller, rect, "nearest")).toBe(1500); + }); + + test("clamps to the scroll range", () => { + expect(alignedScrollTop(...pair(setup(2900, 100)), "start")).toBe(2600); + expect(alignedScrollTop(...pair(setup(50, 100)), "end")).toBe(0); + }); + + describe("nearest", () => { + test("a row in view stays put", () => { + const { scroller, row } = setup(1100, 100); + expect(alignedScrollTop(scroller, row, "nearest")).toBe(1000); + }); + + test("a row above comes in at the top, a row below at the bottom", () => { + expect(alignedScrollTop(...pair(setup(600, 100)), "nearest")).toBe(600); + expect(alignedScrollTop(...pair(setup(1800, 100)), "nearest")).toBe(1500); + }); + + test("a row taller than the view, below it, brings in its top", () => { + expect(alignedScrollTop(...pair(setup(1200, 600)), "nearest")).toBe(1200); + }); + + test("a row overflowing both edges stays put", () => { + expect(alignedScrollTop(...pair(setup(900, 600)), "nearest")).toBe(1000); + }); + }); +}); diff --git a/packages/ui/tests/thread-scroll-animation.test.tsx b/packages/ui/tests/thread-scroll-animation.test.tsx index 2fc30d0..6589f54 100644 --- a/packages/ui/tests/thread-scroll-animation.test.tsx +++ b/packages/ui/tests/thread-scroll-animation.test.tsx @@ -3,7 +3,6 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import type { CSSProperties } from "react"; import { Thread, useThread } from "../src/thread"; import { - alignedScrollTop, animateScroll, parseTime, readScrollTiming, @@ -106,92 +105,6 @@ describe("resolveDuration", () => { }); }); -describe("alignedScrollTop", () => { - // A 400px-tall scroller at viewport top 100, scrolled to 1000 of 3000. Rows - // are placed by their offset inside the scroll content. - const setup = (rowTop: number, rowHeight: number, rowStyle = "", scrollerStyle = "") => { - const scroller = document.createElement("div"); - const row = document.createElement("div"); - scroller.setAttribute("style", scrollerStyle); - row.setAttribute("style", rowStyle); - scroller.append(row); - document.body.append(scroller); - for (const [key, value] of Object.entries({ - scrollTop: 1000, - scrollHeight: 3000, - clientHeight: 400, - clientTop: 0, - })) { - Object.defineProperty(scroller, key, { value, configurable: true }); - } - scroller.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; - row.getBoundingClientRect = () => - ({ top: 100 + rowTop - 1000, bottom: 100 + rowTop - 1000 + rowHeight }) as DOMRect; - return { scroller, row }; - }; - - test("start puts the row's top at the scroller's top", () => { - const { scroller, row } = setup(1800, 100); - expect(alignedScrollTop(scroller, row, "start")).toBe(1800); - }); - - test("end puts the row's bottom at the scroller's bottom", () => { - const { scroller, row } = setup(1800, 100); - expect(alignedScrollTop(scroller, row, "end")).toBe(1500); - }); - - test("center centers it", () => { - const { scroller, row } = setup(1800, 100); - expect(alignedScrollTop(scroller, row, "center")).toBe(1650); - }); - - test("honors the row's scroll-margin and the scroller's scroll-padding", () => { - const { scroller, row } = setup( - 1800, - 100, - "scroll-margin-top: 16px", - "scroll-padding-top: 8px", - ); - expect(alignedScrollTop(scroller, row, "start")).toBe(1776); - }); - - test("resolves a percentage scroll-padding against the viewport's height", () => { - // 400px viewport: 10% = 40px at the top, 25% = 100px at the bottom. - const padded = "scroll-padding-top: 10%; scroll-padding-bottom: 25%"; - expect(alignedScrollTop(...pair(setup(1800, 100, "", padded)), "start")).toBe(1760); - // The row's bottom (1900) at the band's bottom: 1900 - 40 - (400 - 40 - 100). - expect(alignedScrollTop(...pair(setup(1800, 100, "", padded)), "end")).toBe(1600); - }); - - test("clamps to the scroll range", () => { - expect(alignedScrollTop(...pair(setup(2900, 100)), "start")).toBe(2600); - expect(alignedScrollTop(...pair(setup(50, 100)), "end")).toBe(0); - }); - - describe("nearest", () => { - test("a row in view stays put", () => { - const { scroller, row } = setup(1100, 100); - expect(alignedScrollTop(scroller, row, "nearest")).toBe(1000); - }); - - test("a row above comes in at the top, a row below at the bottom", () => { - expect(alignedScrollTop(...pair(setup(600, 100)), "nearest")).toBe(600); - expect(alignedScrollTop(...pair(setup(1800, 100)), "nearest")).toBe(1500); - }); - - test("a row taller than the view, below it, brings in its top", () => { - expect(alignedScrollTop(...pair(setup(1200, 600)), "nearest")).toBe(1200); - }); - - test("a row overflowing both edges stays put", () => { - expect(alignedScrollTop(...pair(setup(900, 600)), "nearest")).toBe(1000); - }); - }); -}); - -const pair = ({ scroller, row }: { scroller: HTMLElement; row: HTMLElement }) => - [scroller, row] as const; - // happy-dom has no Web Animations, and its frames run on timers. The animator // only needs a timing effect's progress and play state, and a frame queue we // can step by hand.