// ~/src/siteplane/components/message-scroller/message-scroller.tsx "use client"; import { ArrowDownIcon, ArrowUpIcon } from "lucide-react"; import * as React from "react"; import { useSiteplaneUI } from "../../system/provider/siteplane-ui-provider"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { announceCopyStatus } from "../action-icons"; import { Button, type ButtonProps } from "../button"; import { MotionItem, MotionList } from "../motion"; import { ScrollArea, type ScrollAreaProps } from "../scroll-area"; import { MessageScrollableContext, MessageScrollerContext, defaultMessageScrollerLabels, useMessageScrollerContext, type MessageScrollerLabels, } from "./message-scroller-context"; import { MessageScrollerController, type Scrollable, type ScrollToOptions, } from "./message-scroller-controller"; import type { ScrollPosition } from "./message-scroller-math"; import "./message-scroller.css"; export type { MessageScrollerLabels }; const noScrollable: Scrollable = { end: false, start: false }; /** How long the shared live region keeps a status, in milliseconds (rulebook T7). */ const statusHold = 2000; /** Replaces a ref's value, whether it is a callback or an object. */ function setRef(ref: React.Ref | undefined, value: T | null): void { if (typeof ref === "function") ref(value); else if (ref) (ref as React.RefObject).current = value; } export type MessageScrollerProps = Omit & { /** * Whether a growing thread carries the viewport along while the reader is * at the live edge. Off by default: nothing moves unless the reader asked * it to. Wheel, touch, keys, the scrollbar or a jump release the follow, * and scrolling back to the end, or the button, takes it up again. */ autoScroll?: boolean | undefined; /** * Where the thread opens: at its `end`, at its `start`, or at the last row * that starts a turn (`last-anchor`), which falls back to the end when no * row does or the turn already fits. */ defaultScrollPosition?: ScrollPosition | undefined; /** Replaces the English words of the scroller. */ labels?: Partial | undefined; /** Whether the row the reader looks at stays in place when older rows are added above it. */ preserveScrollOnPrepend?: boolean | undefined; /** How close to an edge counts as being at it, in pixels. */ scrollEdgeThreshold?: number | undefined; /** How much of the row before a new turn stays visible above it, in pixels. */ scrollPreviousItemPeek?: number | undefined; }; function scrollableAttribute( scrollable: Scrollable, ): "end" | "start end" | "start" | undefined { if (scrollable.start && scrollable.end) return "start end"; if (scrollable.start) return "start"; if (scrollable.end) return "end"; return undefined; } /** * The scroll area of a conversation: the kit ScrollArea, which keeps the * reader's place while a reply streams. A new turn settles near the top, a * growing thread is followed only while the reader is at the live edge, older * rows added above leave the view where it is, and a button leads back to the * latest message. * * Compose it as `MessageScrollerContent` around `MessageScrollerItem`s, with * `MessageScrollerButton` after them. The root fills its container, so give * that container a height. `data-scrollable` names the edges that can still be * scrolled toward (`start`, `end` or both) and `data-autoscrolling` is present * while the scroller scrolls on its own. */ export function MessageScroller({ "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, autoScroll = false, children, className, defaultScrollPosition = "end", labels, preserveScrollOnPrepend = true, scrollEdgeThreshold = 8, scrollPreviousItemPeek = 64, ...props }: MessageScrollerProps): React.ReactElement { const text: MessageScrollerLabels = { ...defaultMessageScrollerLabels, ...labels, }; const [scrollable, setScrollable] = React.useState(noScrollable); const behavior = { autoScroll, defaultScrollPosition, preserveScrollOnPrepend, scrollEdgeThreshold, scrollPreviousItemPeek, }; // The first behavior is the controller's own: the elements attach during // the first commit, before any effect of this component has run. const [controller] = React.useState( () => new MessageScrollerController(behavior, setScrollable), ); React.useLayoutEffect(() => { controller.configure({ autoScroll, defaultScrollPosition, preserveScrollOnPrepend, scrollEdgeThreshold, scrollPreviousItemPeek, }); }, [ autoScroll, controller, defaultScrollPosition, preserveScrollOnPrepend, scrollEdgeThreshold, scrollPreviousItemPeek, ]); const setViewport = React.useCallback( (element: HTMLDivElement | null) => controller.attachViewport(element), [controller], ); // Keyed on the words, not on the object they arrive in, so a labels prop // written inline does not render every part again. const context = React.useMemo( () => ({ controller, labels: text }), [ controller, text.complete, text.region, text.responding, text.scrollToEnd, text.scrollToStart, ], ); return ( {children} ); } /** * Says "Assistant is responding" when a reply starts and "Response complete" * when it is done, once each and never for the text that streams in between. * The words go to the kit's shared polite live region, the one that says a * copy, which exists before the first reply. */ function useStatusAnnouncement( busy: boolean, labels: Pick, ): void { React.useEffect(() => { announceCopyStatus("", 0)(); }, []); const spoken = React.useRef(false); React.useEffect(() => { // A thread that is idle when it mounts has no reply to complete. if (!busy && !spoken.current) return; spoken.current = true; return announceCopyStatus( busy ? labels.responding : labels.complete, statusHold, ); }, [busy, labels.complete, labels.responding]); } export type MessageScrollerContentProps = React.ComponentProps<"div"> & { /** * Whether a reply is on its way. The log is `aria-busy` meanwhile, so a * screen reader reads the finished row once instead of each token, and the * shared live region says that the assistant is responding and when it is * done. */ busy?: boolean | undefined; }; /** * The rows of the thread, as a log: new rows are announced, text that streams * into a row is not. Each direct child is a `MessageScrollerItem`. */ export function MessageScrollerContent({ busy, children, className, ref, ...props }: MessageScrollerContentProps): React.ReactElement { const { controller, labels } = useMessageScrollerContext( "MessageScrollerContent", ); useStatusAnnouncement(Boolean(busy), labels); const setContent = React.useCallback( (element: HTMLDivElement | null) => { controller.attachContent(element); setRef(ref, element); }, [controller, ref], ); const setSpacer = React.useCallback( (element: HTMLDivElement | null) => controller.attachSpacer(element), [controller], ); return (
{children} {/* The room a turn that settles near the top needs below it; the scroller sizes it as the reply grows. */} ); } export type MessageScrollerItemProps = React.ComponentProps<"div"> & { /** Names the row, to scroll to it and to keep it in place. Keep it stable. */ messageId: string; /** Whether the row starts a turn: when it arrives, it settles near the top. */ scrollAnchor?: boolean | undefined; }; /** * One row of the thread: a message, a marker, a typing indicator or a "load * earlier" row. A row that arrives after the thread opened fades in with a * small rise, and takes its full height at once so that the scroller's * measurements stay true. Rows far from the viewport skip their rendering * work. */ export function MessageScrollerItem({ messageId, scrollAnchor, className, ...props }: MessageScrollerItemProps): React.ReactElement { const { controller } = useMessageScrollerContext("MessageScrollerItem"); // A row that mounts with the thread is at rest; one that mounts later enters. const entering = React.useRef(controller.ready).current; return (
); } type DistributiveOmit = T extends unknown ? Omit : never; export type MessageScrollerButtonProps = DistributiveOmit< ButtonProps, "children" > & { /** Scrolls to the latest row (`end`, the default) or back to the first one (`start`). */ direction?: "end" | "start" | undefined; }; /** * The button that leads back to the latest message, or to the first one. It * shows only while there is something to scroll toward, and appears and leaves * with the kit's presence. Place it after the content, or before it for * `direction="start"`; it floats 16 px inside the edge of the scroller. A * keyboard user who presses it keeps their place: focus moves to the scroll * region as the button leaves. */ export function MessageScrollerButton({ "aria-label": ariaLabel, direction = "end", onClick, ...props }: MessageScrollerButtonProps): React.ReactElement { const { controller, labels } = useMessageScrollerContext( "MessageScrollerButton", ); const scrollable = React.useContext(MessageScrollableContext); const { controlSize } = useSiteplaneUI(); const toEnd = direction === "end"; const Icon = toEnd ? ArrowDownIcon : ArrowUpIcon; return ( // The dock is as tall as the largest control, whatever the button's size, // so the presence has no height to follow: the button fades and scales in // place (rulebook M2) and nothing around it moves. } > {(toEnd ? scrollable.end : scrollable.start) ? ( } > ) : null} ); } export type MessageScrollerApi = { /** Scrolls to the latest row, and follows the thread from there. */ scrollToEnd: (options?: ScrollToOptions) => void; /** Scrolls to the first row. */ scrollToStart: (options?: ScrollToOptions) => void; /** * Scrolls the row named `messageId` to the top, with the peek of the row * before it. Returns false when no such row is mounted. */ scrollToMessage: (messageId: string, options?: ScrollToOptions) => boolean; /** The edges the viewport can still scroll toward. */ scrollable: { end: boolean; start: boolean }; }; /** * The commands of the nearest MessageScroller, and the edges it can still * scroll toward, for a control that lives inside it, such as a jump menu. The * commands are stable; the component renders again only when an edge turns * scrollable or stops being so. */ export function useMessageScroller(): MessageScrollerApi { const { controller } = useMessageScrollerContext("useMessageScroller"); const scrollable = React.useContext(MessageScrollableContext); const commands = React.useMemo( () => ({ scrollToEnd: (options?: ScrollToOptions) => controller.scrollToEnd(options), scrollToMessage: (messageId: string, options?: ScrollToOptions) => controller.scrollToMessage(messageId, options), scrollToStart: (options?: ScrollToOptions) => controller.scrollToStart(options), }), [controller], ); return React.useMemo( () => ({ ...commands, scrollable }), [commands, scrollable], ); } // ~/src/siteplane/components/message-scroller/message-scroller-context.ts import * as React from "react"; import type { MessageScrollerController, Scrollable, } from "./message-scroller-controller"; /** The words a Message Scroller shows and speaks. */ export type MessageScrollerLabels = { /** The name of the scrolling region. */ region: string; /** The name of the button that scrolls to the latest message. */ scrollToEnd: string; /** The name of the button that scrolls to the first message. */ scrollToStart: string; /** Said once when a reply starts, not for each token. */ responding: string; /** Said once when the reply is complete. */ complete: string; }; export const defaultMessageScrollerLabels: MessageScrollerLabels = { complete: "Response complete", region: "Messages", responding: "Assistant is responding", scrollToEnd: "Scroll to latest", scrollToStart: "Scroll to first", }; /** What the parts need from the scroller; it changes only with the words. */ export type MessageScrollerContextValue = { controller: MessageScrollerController; labels: MessageScrollerLabels; }; export const MessageScrollerContext: React.Context = React.createContext(null); /** * The edges the viewport can still scroll toward. It sits in a context of its * own: rows read the scroller but not this, so they do not render when the * flags change. */ export const MessageScrollableContext: React.Context = React.createContext({ end: false, start: false }); export function useMessageScrollerContext( part: string, ): MessageScrollerContextValue { const context = React.useContext(MessageScrollerContext); if (!context) throw new Error(`${part} must be rendered inside MessageScroller.`); return context; } // ~/src/siteplane/components/message-scroller/message-scroller-controller.ts import { readReducedMotion } from "../../system/motion/use-reduced-motion-preference"; import { anchorScrollTop, isAtEdge, keyScrollsTowardStart, maxScrollTop, openingScrollTop, prependScrollTop, scrollableEdges, shouldFollow, spacerHeight, touchScrollsTowardStart, wheelScrollsTowardStart, type ScrollMetrics, type ScrollPosition, } from "./message-scroller-math"; /** * The scroll behavior of a Message Scroller. It lives outside React: a scroll * event, a resize or an arriving row is handled here, on the DOM, and React * hears of it only when the two `scrollable` flags change (the hot path * renders nothing). The component creates one controller, hands it the * viewport, the content and the spacer through ref callbacks and calls the * methods the hook exposes. * * What it does, in the words of the plan: * - opens at the position the app chose (`defaultScrollPosition`); * - lets a row that starts a turn (`data-scroll-anchor`) settle near the top, * with room below it (the spacer) until the reply fills the viewport; * - follows a growing thread (`autoScroll`) only while the reader has not * scrolled away from the live edge: a wheel turn, a touch drag, a key, a * scrollbar press or a jump to a message releases it, and reaching the end * again takes it up; * - keeps the row the reader looks at in place when rows are added above it. */ export type MessageScrollerBehavior = { autoScroll: boolean; defaultScrollPosition: ScrollPosition; preserveScrollOnPrepend: boolean; scrollEdgeThreshold: number; scrollPreviousItemPeek: number; }; export type ScrollToOptions = { behavior?: ScrollBehavior | undefined }; /** The edges the viewport can still scroll toward. */ export type Scrollable = { end: boolean; start: boolean }; const itemSlot = "message-scroller-item"; const scrollbarSlot = "scroll-area-scrollbar"; /** How long the scroller waits for a smooth scroll before it stops waiting, in ms. */ const smoothScrollLimit = 1200; /** How long a thread that has just opened keeps its place while it settles, in ms. */ const openingWindow = 1000; function isItem(element: Element): element is HTMLElement { return element.getAttribute("data-slot") === itemSlot; } function isAnchor(element: Element): boolean { return element.hasAttribute("data-scroll-anchor"); } function previousItem(item: Element): HTMLElement | null { let sibling = item.previousElementSibling; while (sibling && !isItem(sibling)) sibling = sibling.previousElementSibling; return sibling as HTMLElement | null; } /** Text fields keep their arrow keys: they move a caret, not the thread. */ function isEditable(target: EventTarget | null): boolean { const element = target as HTMLElement | null; if (!element || typeof element.tagName !== "string") return false; return ( element.isContentEditable === true || /^(?:input|select|textarea)$/i.test(element.tagName) ); } function isScrollbar(target: EventTarget | null): boolean { const element = target as HTMLElement | null; return ( typeof element?.closest === "function" && element.closest(`[data-slot="${scrollbarSlot}"]`) !== null ); } export class MessageScrollerController { /** * Whether a row that mounts now arrived after the thread opened and so * enters. False on the server, while the thread mounts and for the first * frame after it. */ ready = false; /** The edges the viewport can still scroll toward, as last told. */ scrollable: Scrollable = { end: false, start: false }; private options: MessageScrollerBehavior; private readonly onScrollable: (next: Scrollable) => void; private viewport: HTMLElement | null = null; private root: HTMLElement | null = null; private content: HTMLElement | null = null; private spacer: HTMLElement | null = null; private spacerPx = 0; private resizeObserver: ResizeObserver | null = null; private mutationObserver: MutationObserver | null = null; /** Rows the scroller has seen, to tell the rows that arrive from the rest. */ private known = new WeakSet(); /** The row that started the turn the viewport last settled on. */ private hold: HTMLElement | null = null; /** The viewport as it was when it last changed, before a commit moves it. */ private snapshot = { scrollHeight: 0, scrollTop: 0 }; /** Whether the reader is at the live edge and has not scrolled away since. */ private following = true; /** Whether the thread has just opened and keeps its position while it settles. */ private opening = false; private openingTimer: ReturnType | undefined; private frame = 0; private opened = false; /** A smooth scroll of our own that is on its way. */ private smooth: { target: number; timer: ReturnType; } | null = null; private touchStartY = 0; constructor( options: MessageScrollerBehavior, onScrollable: (next: Scrollable) => void, ) { this.options = options; this.onScrollable = onScrollable; } configure(options: MessageScrollerBehavior): void { this.options = options; if (this.opened) this.syncScrollable(); } // --------------------------------------------------------------------------- // The elements attachViewport(element: HTMLElement | null): void { if (element === this.viewport) return; this.detachViewport(); this.viewport = element; if (!element) return; this.root = element.parentElement; element.addEventListener("scroll", this.onScroll, { passive: true }); element.addEventListener("scrollend", this.onScrollEnd, { passive: true }); element.addEventListener("wheel", this.onWheel, { passive: true }); element.addEventListener("touchstart", this.onTouchStart, { passive: true, }); element.addEventListener("touchmove", this.onTouchMove, { passive: true }); element.addEventListener("keydown", this.onKeyDown); // The scrollbars are siblings of the viewport, children of the root. this.root?.addEventListener("pointerdown", this.onPointerDown, { passive: true, }); this.observe(); this.openWhenReady(); } attachContent(element: HTMLElement | null): void { if (element === this.content) return; this.detachContent(); this.content = element; if (!element) return; this.observe(); this.openWhenReady(); } attachSpacer(element: HTMLElement | null): void { this.spacer = element; if (element) element.style.height = `${this.spacerPx}px`; } dispose(): void { this.attachViewport(null); this.attachContent(null); this.attachSpacer(null); } private detachViewport(): void { const viewport = this.viewport; if (viewport) { viewport.removeEventListener("scroll", this.onScroll); viewport.removeEventListener("scrollend", this.onScrollEnd); viewport.removeEventListener("wheel", this.onWheel); viewport.removeEventListener("touchstart", this.onTouchStart); viewport.removeEventListener("touchmove", this.onTouchMove); viewport.removeEventListener("keydown", this.onKeyDown); } this.root?.removeEventListener("pointerdown", this.onPointerDown); this.finishSmoothScroll(false); this.resizeObserver?.disconnect(); this.resizeObserver = null; if (this.frame && typeof cancelAnimationFrame !== "undefined") cancelAnimationFrame(this.frame); this.frame = 0; clearTimeout(this.openingTimer); this.openingTimer = undefined; this.opening = false; this.viewport = null; this.root = null; this.opened = false; this.ready = false; } private detachContent(): void { this.mutationObserver?.disconnect(); this.mutationObserver = null; this.resizeObserver?.disconnect(); this.resizeObserver = null; this.content = null; this.hold = null; this.opened = false; } /** Watches the viewport and the content for size changes, and the content for rows. */ private observe(): void { const { content, viewport } = this; if (!viewport || !content) return; if (typeof ResizeObserver !== "undefined" && !this.resizeObserver) { this.resizeObserver = new ResizeObserver(() => this.layoutChanged()); this.resizeObserver.observe(viewport); this.resizeObserver.observe(content); } if (typeof MutationObserver !== "undefined" && !this.mutationObserver) { // Only the rows: text that streams into a row changes nothing here. this.mutationObserver = new MutationObserver(() => this.itemsChanged()); this.mutationObserver.observe(content, { childList: true }); } } // --------------------------------------------------------------------------- // Opening private openWhenReady(): void { if (!this.opened && this.viewport && this.content) this.open(); } private open(): void { const { viewport } = this; if (!viewport || !this.content) return; this.opened = true; this.hold = null; this.setSpacer(0); this.known = new WeakSet(this.items()); this.applyOpening(); // A thread that opens at the start or in the middle is not at the live edge. this.following = isAtEdge( this.metrics(), "end", this.options.scrollEdgeThreshold, ); this.opening = true; if (this.openingTimer) clearTimeout(this.openingTimer); this.openingTimer = setTimeout(() => { this.opening = false; }, openingWindow); this.syncScrollable(); this.remember(); this.markReady(); } /** Puts the viewport where the thread opens. */ private applyOpening(): void { const { viewport } = this; if (!viewport) return; const last = this.items().filter(isAnchor).at(-1); const top = openingScrollTop({ anchor: last ? { target: this.anchorTarget(last), top: this.topOf(last) } : undefined, metrics: this.metrics(), position: this.options.defaultScrollPosition, }); if (Math.abs(top - viewport.scrollTop) >= 1) this.jump(top); } private markReady(): void { if (typeof requestAnimationFrame === "undefined") { this.ready = true; return; } this.frame = requestAnimationFrame(() => { this.frame = 0; this.ready = true; }); } // --------------------------------------------------------------------------- // Reading the layout private metrics(): ScrollMetrics { const viewport = this.viewport!; return { clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, scrollTop: viewport.scrollTop, }; } private items(): HTMLElement[] { return this.content ? Array.from(this.content.children).filter(isItem) : []; } /** * Where a row starts in the viewport's scroll space. A row's offset is the * layout position, which an entering row's transform does not move, from the * content, which is the row's offset parent. */ private topOf(item: HTMLElement): number { const viewport = this.viewport!; const content = this.content!; const contentTop = content.getBoundingClientRect().top - viewport.getBoundingClientRect().top - viewport.clientTop + viewport.scrollTop; return contentTop + content.clientTop + item.offsetTop; } /** Where a turn that starts at `item` settles: near the top, with the peek above it. */ private anchorTarget(item: HTMLElement): number { const previous = previousItem(item); return previous ? Math.max( 0, anchorScrollTop( this.topOf(item), previous.offsetHeight, this.options.scrollPreviousItemPeek, ), ) : 0; } private remember(metrics: ScrollMetrics = this.metrics()): void { this.snapshot = { scrollHeight: metrics.scrollHeight, scrollTop: metrics.scrollTop, }; } // --------------------------------------------------------------------------- // Moving the viewport /** Moves at once, also where the page asks for smooth scrolling. */ private jump(top: number): void { this.viewport?.scrollTo({ behavior: "instant", top }); this.remember(); } /** * Scrolls to `top`: smoothly unless the caller or reduced motion (M5) asks * for an instant jump. A smooth scroll marks the root `data-autoscrolling` * until it arrives. */ private go(top: number, behavior: ScrollBehavior | undefined): void { const { viewport } = this; if (!viewport) return; const target = Math.min(Math.max(0, top), maxScrollTop(this.metrics())); this.finishSmoothScroll(false); const smooth = (behavior ?? "smooth") === "smooth" && !readReducedMotion(viewport); if (!smooth || Math.abs(target - viewport.scrollTop) < 1) { this.jump(target); return; } this.smooth = { target, timer: setTimeout(() => this.finishSmoothScroll(true), smoothScrollLimit), }; this.mark(true); viewport.scrollTo({ behavior: "smooth", top: target }); } private finishSmoothScroll(resume: boolean): void { if (!this.smooth) return; clearTimeout(this.smooth.timer); this.smooth = null; this.mark(false); if (resume) this.followIfNeeded(); } /** `data-autoscrolling` on the root and the viewport while a smooth scroll runs. */ private mark(on: boolean): void { this.viewport?.toggleAttribute("data-autoscrolling", on); this.root?.toggleAttribute("data-autoscrolling", on); } // --------------------------------------------------------------------------- // Following /** The reader scrolled away, or jumped: stop carrying the viewport along. */ private release(): void { this.following = false; this.opening = false; this.finishSmoothScroll(false); } private followIfNeeded(): void { if (!this.viewport || this.smooth) return; if (this.opening) { // A thread that has just opened keeps its place while images and fonts // settle, whatever `autoScroll` says: nobody has read it yet. this.applyOpening(); return; } if ( !shouldFollow({ autoScroll: this.options.autoScroll, following: this.following, }) ) return; const top = maxScrollTop(this.metrics()); if (Math.abs(top - this.viewport.scrollTop) >= 1) this.jump(top); } /** The size of the viewport or of the thread changed: before the next paint. */ layoutChanged(): void { if (!this.viewport) return; this.updateSpacer(); this.followIfNeeded(); this.syncScrollable(); this.remember(); } // --------------------------------------------------------------------------- // The room below a turn private setSpacer(height: number): void { this.spacerPx = height; if (this.spacer) this.spacer.style.height = `${height}px`; } /** * A turn settles near the top of the viewport, which needs room below its * last row. The spacer gives it: it fills what the viewport could not * otherwise scroll past, shrinks as the reply grows and is gone when the * reply fills the viewport. */ private updateSpacer(): void { const { viewport } = this; if (!viewport) return; if (this.hold && !this.hold.isConnected) this.hold = null; // Nothing settled, nothing to size: no layout is read. if (!this.hold && this.spacerPx === 0) return; const height = this.hold ? spacerHeight( this.anchorTarget(this.hold), viewport.clientHeight, // The height of the rows alone, without the room that is there now. viewport.scrollHeight - this.spacerPx, ) : 0; if (height !== this.spacerPx) this.setSpacer(height); } /** A row that starts a turn arrived at the end: settle it near the top. */ private settle(anchor: HTMLElement): void { this.hold = anchor; this.opening = false; this.updateSpacer(); // The turn settles at the live edge, so the reply that grows into the room // below it is followed once it fills the viewport. this.following = true; this.go(this.anchorTarget(anchor), "smooth"); } // --------------------------------------------------------------------------- // Rows that arrive /** Rows were added or removed. */ itemsChanged(): void { if (!this.opened || !this.viewport || !this.content) return; const items = this.items(); const survivors = items.filter((item) => this.known.has(item)); const fresh = items.filter((item) => !this.known.has(item)); for (const item of fresh) this.known.add(item); if (fresh.length === 0) return; const order = new Map(items.map((item, index) => [item, index])); const firstSurvivor = survivors[0] ? order.get(survivors[0]) : undefined; const lastSurvivor = survivors.at(-1) ? order.get(survivors.at(-1)!) : undefined; const appended = fresh.filter( (item) => lastSurvivor !== undefined && order.get(item)! > lastSurvivor, ); const prepended = fresh.filter( (item) => firstSurvivor !== undefined && order.get(item)! < firstSurvivor, ); // Only a row appended below the reader enters. Rows that fill the thread, // join above it or are inserted between others take their place without // the entrance, before the first style is resolved and the animation starts. for (const item of fresh) if (!appended.includes(item)) item.removeAttribute("data-entering"); if (survivors.length === 0) { // The whole thread was replaced, or the first rows arrived: open it again. this.open(); return; } if (prepended.length > 0 && this.options.preserveScrollOnPrepend) this.keepPlace(survivors[0]!, items[0]!); const anchor = appended.filter(isAnchor).at(-1); if (anchor) this.settle(anchor); } /** * Rows were added above the first row the reader had: move the viewport * down by the height they took, so the row in view stays where it was. */ private keepPlace(previousFirst: HTMLElement, first: HTMLElement): void { const gained = this.topOf(previousFirst) - this.topOf(first); if (gained <= 0) return; const { snapshot } = this; this.jump(prependScrollTop(snapshot, snapshot.scrollHeight + gained)); } // --------------------------------------------------------------------------- // What the reader does private readonly onScroll = (): void => { const metrics = this.metrics(); if (isAtEdge(metrics, "end", this.options.scrollEdgeThreshold)) this.following = true; if (this.smooth && Math.abs(metrics.scrollTop - this.smooth.target) <= 1) this.finishSmoothScroll(true); this.syncScrollable(metrics); this.remember(metrics); }; private readonly onScrollEnd = (): void => { this.finishSmoothScroll(true); }; private readonly onWheel = (event: WheelEvent): void => { this.opening = false; if (wheelScrollsTowardStart(event.deltaY)) this.release(); }; private readonly onTouchStart = (event: TouchEvent): void => { this.opening = false; this.touchStartY = event.touches[0]?.clientY ?? 0; }; private readonly onTouchMove = (event: TouchEvent): void => { const y = event.touches[0]?.clientY; if (y !== undefined && touchScrollsTowardStart(this.touchStartY, y)) this.release(); }; private readonly onKeyDown = (event: KeyboardEvent): void => { if (event.defaultPrevented || isEditable(event.target)) return; this.opening = false; if (keyScrollsTowardStart(event.key)) this.release(); }; private readonly onPointerDown = (event: PointerEvent): void => { if (isScrollbar(event.target)) this.release(); }; /** Tells React when an edge becomes scrollable or stops being so. */ private syncScrollable(metrics?: ScrollMetrics): void { if (!this.viewport) return; const next = scrollableEdges( metrics ?? this.metrics(), this.options.scrollEdgeThreshold, ); if ( next.start === this.scrollable.start && next.end === this.scrollable.end ) return; this.scrollable = next; this.onScrollable(next); } // --------------------------------------------------------------------------- // The commands the hook gives out /** Scrolls to the latest row and follows the thread from there. */ scrollToEnd(options: ScrollToOptions = {}): void { this.opening = false; this.following = true; this.go(maxScrollTop(this.metrics()), options.behavior); } scrollToStart(options: ScrollToOptions = {}): void { this.release(); this.go(0, options.behavior); } /** Scrolls a row to the top of the viewport; false when no such row is mounted. */ scrollToMessage(id: string, options: ScrollToOptions = {}): boolean { if (!this.viewport) return false; const item = this.items().find( (row) => row.getAttribute("data-message-id") === id, ); if (!item) return false; this.release(); this.go(this.anchorTarget(item), options.behavior); return true; } /** Moves focus to the viewport, for a control that leaves when it is pressed. */ focusViewport(): void { this.viewport?.focus({ preventScroll: true }); } } // ~/src/siteplane/components/message-scroller/message-scroller-math.ts /** * The arithmetic of the Message Scroller, kept apart from the DOM so it can be * checked without a browser. Every function takes plain numbers and returns * one: the scroller reads the viewport, asks here and writes the answer back. * Nothing here is exported from the component's `index.ts`. */ /** What the viewport reports about its scroll position. */ export type ScrollMetrics = { clientHeight: number; scrollHeight: number; scrollTop: number; }; export type ScrollEdge = "start" | "end"; /** Where a thread opens, as the `defaultScrollPosition` prop names it. */ export type ScrollPosition = "end" | "start" | "last-anchor"; /** The furthest the viewport can scroll. */ export function maxScrollTop( metrics: Pick, ): number { return Math.max(0, metrics.scrollHeight - metrics.clientHeight); } /** * Whether the viewport rests within `threshold` pixels of an edge. A * rubber-band overscroll past an edge counts as being at it. */ export function isAtEdge( metrics: ScrollMetrics, edge: ScrollEdge, threshold: number, ): boolean { return edge === "start" ? metrics.scrollTop <= threshold : metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop <= threshold; } /** * The edges the viewport can still scroll toward: `start` when there is * content above the fold, `end` when there is content below it. An edge the * viewport rests at is not scrollable toward, so a thread that fits in its * box is scrollable toward neither. */ export function scrollableEdges( metrics: ScrollMetrics, threshold: number, ): { end: boolean; start: boolean } { return { end: !isAtEdge(metrics, "end", threshold), start: !isAtEdge(metrics, "start", threshold), }; } /** * Where a turn settles when it starts: its first row near the top of the * viewport, with up to `peek` pixels of the row before it above. A previous * row shorter than the peek shows whole and no more, so the view never * reaches past it. */ export function anchorScrollTop( itemTop: number, previousItemHeight: number, peek: number, ): number { return itemTop - Math.min(peek, previousItemHeight); } /** * The scroll position that keeps the row the reader looks at in place when * rows are added above it: the old position, moved down by the height the * thread has gained. */ export function prependScrollTop( before: Pick, nextScrollHeight: number, ): number { return before.scrollTop + (nextScrollHeight - before.scrollHeight); } /** * Whether a growing thread carries the viewport with it. Both must hold: the * app asked for it (`autoScroll`) and the reader has not scrolled away since * they were last at the live edge (`following`). A reader who scrolled up is * never pulled back down. */ export function shouldFollow(state: { autoScroll: boolean; following: boolean; }): boolean { return state.autoScroll && state.following; } /** * Where a thread opens. `last-anchor` shows the last turn from its first row, * where that row would settle (`anchor.target`: near the top, with the peek of * the row before it above); it falls back to the end when no row anchors a * turn or the last turn, from its first row to the end, already fits in the * viewport. */ export function openingScrollTop(input: { /** The last anchor row: where it settles, and where it starts in scroll space. */ anchor?: { target: number; top: number } | undefined; metrics: Pick; position: ScrollPosition; }): number { const { anchor, metrics, position } = input; const end = maxScrollTop(metrics); if (position === "start") return 0; if (position === "end" || !anchor) return end; if (metrics.scrollHeight - anchor.top <= metrics.clientHeight) return end; return Math.min(end, Math.max(0, anchor.target)); } /** * The room a thread needs below its last row so that a turn can settle at * `target`: the viewport has to reach `target` and still be filled, which is * `target + clientHeight` of scroll height. `naturalScrollHeight` is the * height of the rows alone. Whole pixels, rounded up, so the target is * reachable. */ export function spacerHeight( target: number, clientHeight: number, naturalScrollHeight: number, ): number { return Math.max(0, Math.ceil(target + clientHeight - naturalScrollHeight)); } /** Keys that scroll toward the start and so end the follow. */ const keysTowardStart: ReadonlySet = new Set([ "ArrowUp", "Home", "PageUp", ]); /** Whether a key press scrolls the viewport toward its start. */ export function keyScrollsTowardStart(key: string): boolean { return keysTowardStart.has(key); } /** Whether a wheel turn scrolls toward the start. */ export function wheelScrollsTowardStart(deltaY: number): boolean { return deltaY < 0; } /** * Whether a touch that began at `startY` and is now at `currentY` drags the * content down, which scrolls toward the start. The slop absorbs the jitter * of a finger that scrolls the other way. */ export function touchScrollsTowardStart( startY: number, currentY: number, slop = 4, ): boolean { return currentY - startY > slop; } // ~/src/siteplane/components/message-scroller/message-scroller.css @layer theme, base, components, siteplane, utilities; @layer siteplane { /* A row of the thread. Rows far from the viewport skip their rendering * work; a row that has been rendered once keeps the size it had (`auto`), * and one that has not is taken to be a line or two high. Paint * containment clips what a row draws outside its box, so a row keeps room * for the focus ring of its controls. */ .sp-message-scroller-item { content-visibility: auto; contain-intrinsic-block-size: auto var(--sp-rw-space-12); } /* A row that arrives after the thread opened fades in with a small rise. It * takes its full height at once: height, margin and padding never animate, * because the scroller measures the row to settle a new turn and to follow * the end, and an expanding row (rulebook M6) would move both while it * runs. The animation ends where the row rests and fills nothing forward, so * no stacking context or transform is left on the row afterwards. */ .sp-message-scroller-item[data-entering] { animation: sp-message-scroller-item-enter var(--motion-duration-base) var(--motion-ease-standard); } @keyframes sp-message-scroller-item-enter { from { opacity: 0; transform: translateY(4px); } } /* Reduced motion is instant (M5): the row is at rest at once. */ @media (prefers-reduced-motion: reduce) { .sp-message-scroller-item[data-entering] { animation: none; } } html[data-motion-reduce="true"] .sp-message-scroller-item[data-entering], [data-sp-motion="reduced"] .sp-message-scroller-item[data-entering] { animation: none; } } // ~/src/siteplane/components/message-scroller/index.ts export * from "./message-scroller";