Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/scroll-into-view-within.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion packages/ui/src/ask-user/ask-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 10 additions & 16 deletions packages/ui/src/composer/command-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<CommandListNavContextValue>(
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/composer/editor-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

// ---------------------------------------------------------------------------
Expand Down
129 changes: 109 additions & 20 deletions packages/ui/src/internal/scroll-into-view-within.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
57 changes: 0 additions & 57 deletions packages/ui/src/thread/scroll-animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/thread/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -45,7 +46,6 @@ import {
wasPrepended,
} from "./geometry";
import {
alignedScrollTop,
animateScroll,
readScrollTiming,
resolveDuration,
Expand Down
Loading
Loading