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
11 changes: 11 additions & 0 deletions .changeset/composer-scroll-containment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@intentface/chat": patch
---

A composer embedded in a scrolling page no longer scrolls that page.

**Programmatic text changes.** `clear()`, `setText()` and the ask-user step changes used to nudge the page by a few pixels each time. Keeping the caret in view after a write now scrolls only the composer's own scroll areas, never past `Composer.Root`. A caret on an empty line, including an empty editor's, is measured by that line's break: on its own it has no box, and its all-zero rect read as "above the view". A trailing empty line in an overflowing composer now scrolls into view too.

**Ask-user focus moves.** Moving into the options, arrowing between them, and typing to answer move focus without the browser's focus-scroll. An option outside its own list's view is still brought into view within the question.

**`focus(options)`.** The composer controller's `focus` now takes `HTMLElement.focus()`'s options, so a consumer can focus an on-screen composer with `{ preventScroll: true }`. Called with no arguments, it behaves as before.
4 changes: 3 additions & 1 deletion content/docs/primitives/composer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,9 @@ export const rootAttrs = [
Returns a `ComposerStore` handle. Pass it to `store`, read it with
`useComposerStore(store, selector)`, and drive it imperatively through
`store.controller` (`focus`, `blur`, `clear`, `insertText`, `insertChip`,
`getText`, `setText`, `serialize`, `ensureFocus`).
`getText`, `setText`, `serialize`, `ensureFocus`). `focus` takes the same
options as `HTMLElement.focus()`: pass `{ preventScroll: true }` to focus a
composer that's already on screen without scrolling the page around it.

```tsx
import { Composer, useComposerStore } from "@intentface/chat/composer";
Expand Down
21 changes: 12 additions & 9 deletions content/docs/primitives/composer/demos/ask-user-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,18 @@ export const AskUserFlow = () => {
</Composer.Actions>
</Composer.Container>
</Composer.Root>
{done && (
<button
type="button"
onClick={reset}
className="h-8 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
>
Reset questions
</button>
)}
{/* A fixed slot: the button appearing must not push the composer up. */}
<div className="h-8">
{done && (
<button
type="button"
onClick={reset}
className="h-8 cursor-pointer rounded-full bg-white bg-linear-to-b from-white to-[#fdfdfd] px-3 font-medium text-[13px] text-zinc-900 shadow-[inset_0_1px_0_#fff,0_0_0_1px_rgb(0_0_0/0.075),0_1px_2px_rgb(0_0_0/0.07),0_2px_6px_-2px_rgb(0_0_0/0.05)] transition-colors hover:from-[#fafafa] hover:to-[#f6f6f6] focus-visible:outline-2 focus-visible:outline-[#0169cc]/60 focus-visible:outline-offset-2 dark:bg-[#2d2d30] dark:from-[#313134] dark:to-[#2a2a2d] dark:text-zinc-100 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.1),inset_0_0_0_1px_rgb(255_255_255/0.05),0_0_0_1px_rgb(0_0_0/0.16),0_1px_2px_rgb(0_0_0/0.1)] dark:hover:from-[#38383b] dark:hover:to-[#313134]"
>
Reset questions
</button>
)}
</div>
</div>
);
};
10 changes: 9 additions & 1 deletion packages/chat/src/ask-user/ask-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { useComposer } from "../composer/store";
import type { PrimitiveProps } from "../internal/primitive-props";
import { useRenderElement } from "../internal/render/useRenderElement";
import { scrollIntoViewWithin } from "../internal/scroll-into-view-within";

// Shared ids so Options can label itself from the question text and describe
// itself from the step indicator without any consumer wiring: Root mints both,
Expand Down Expand Up @@ -230,9 +231,16 @@ export const AskUserOptions = ({
}, []);

// Roving tabindex: DOM focus follows the highlight for keyboard navigation.
// Focus without the browser's scroll, which would also move the page around
// an embedded question; bring the option into view within the question's
// own scroll area instead.
const focusValue = useCallback((itemValue: string | null) => {
if (itemValue === null) return;
itemElements.current.get(itemValue)?.()?.focus();
const element = itemElements.current.get(itemValue)?.();
if (!element) return;
element.focus({ preventScroll: true });
const boundary = element.closest("[data-ask-user]") ?? element;
scrollIntoViewWithin(element.getBoundingClientRect(), element, boundary);
}, []);

useImperativeHandle(
Expand Down
50 changes: 29 additions & 21 deletions packages/chat/src/composer/editor-dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
// Round-trips: "" ↔ <br·pad>, "a\n" ↔ a<br><br·pad>, "a\n\n" ↔ a<br><br><br·pad>.

import type { ChipData } from "../chip-markdown";
import { scrollIntoViewWithin } from "../internal/scroll-into-view-within";
import { nextChipId, type SegmentDoc } from "./segments";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -504,29 +505,36 @@ export const writeCaretToDom = (root: HTMLElement, start: number, end: number =
selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
};

// Native typing auto-scrolls; programmatic writes don't. Nudge only the nearest
// scrollable ancestor (never scrollIntoView — it yanks the whole page).
// The caret's box. A collapsed caret on an empty line has none of its own —
// browsers return an all-zero rect, which would read as "above the view" — so
// measure the line instead: the <br> that follows it (a line break, or the
// padding break that gives an empty document its line). Null if neither has
// a box.
const measureCaret = (range: Range): DOMRect | null => {
const rect = range.getBoundingClientRect();
if (rect.height > 0) return rect;
const next = range.startContainer.childNodes[range.startOffset];
if (next?.nodeName !== "BR") return null;
const line = next.ownerDocument?.createRange();
if (!line) return null;
line.selectNode(next);
const lineRect = line.getBoundingClientRect();
return lineRect.height > 0 ? lineRect : null;
};

// Native typing auto-scrolls; programmatic writes don't. Scroll the caret into
// view within the composer's own scroll areas, never the page around it. The
// caret is the selection's focus end: a restored selection can be taller than
// the view, and its top edge would win.
export const scrollCaretIntoView = (root: HTMLElement): void => {
const selection = root.ownerDocument.getSelection();
if (!selection || selection.rangeCount === 0) return;
const rect = selection.getRangeAt(0).getBoundingClientRect();

let scroller: HTMLElement | null = root;
while (scroller && scroller !== root.ownerDocument.body) {
const { overflowY } = getComputedStyle(scroller);
if (
(overflowY === "auto" || overflowY === "scroll") &&
scroller.scrollHeight > scroller.clientHeight
) {
break;
}
scroller = scroller.parentElement;
}
if (!scroller || scroller === root.ownerDocument.body) return;

const view = scroller.getBoundingClientRect();
if (rect.top < view.top) scroller.scrollTop -= view.top - rect.top;
else if (rect.bottom > view.bottom) scroller.scrollTop += rect.bottom - view.bottom;
if (!selection?.focusNode) return;
const caret = root.ownerDocument.createRange();
caret.setStart(selection.focusNode, selection.focusOffset);
caret.collapse(true);
const rect = measureCaret(caret);
if (!rect) return;
scrollIntoViewWithin(rect, root, root.closest("[data-composer-root]") ?? root);
};

// ---------------------------------------------------------------------------
Expand Down
13 changes: 9 additions & 4 deletions packages/chat/src/composer/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export type ComposerEditorState = ComposerEditorHandle & {
const createEditorController = (
editorRef: RefObject<RegisteredEditor | null>,
): ComposerEditorState => ({
focus: () => editorRef.current?.focus(),
focus: (options) => editorRef.current?.focus(options),
blur: () => editorRef.current?.blur(),
clear: () => editorRef.current?.clear(),
insertText: (text) => editorRef.current?.insertText(text),
Expand Down Expand Up @@ -193,6 +193,11 @@ export type ComposerStore = {
// the store factory so activateAskUser reads as just "blur, listen, unlisten".
// ---------------------------------------------------------------------------

// Focus moves the ask-user flow makes itself — back to the input, or in from
// an option. The composer is on screen already, and the browser's
// focus-scroll would also move the page around an embedded composer.
const FOCUS_IN_PLACE: FocusOptions = { preventScroll: true };

// Scope a document-level listener to this composer: keystrokes aimed at
// another editable or inside another composer's options are theirs; our own
// options/editor and unclaimed targets (body — e.g. after a click on panel
Expand Down Expand Up @@ -253,7 +258,7 @@ const createAskUserKeydownHandler =
return;
case "navigate-options": {
const newValue = optionsHandle?.navigate(action.direction);
if (newValue === null) controller.focus();
if (newValue === null) controller.focus(FOCUS_IN_PLACE);
return;
}
case "select-option": {
Expand All @@ -269,7 +274,7 @@ const createAskUserKeydownHandler =
return;
case "insert-character":
optionsHandle?.clearHighlight();
controller.focus();
controller.focus(FOCUS_IN_PLACE);
controller.insertText(action.character);
return;
}
Expand Down Expand Up @@ -424,7 +429,7 @@ export const createComposerStore = (): ComposerStore => {
controller.setText(effect.text);
break;
case "focus-input":
controller.focus();
controller.focus(FOCUS_IN_PLACE);
break;
case "focus-options":
// Step advance / navigation: focus lands on the new step's
Expand Down
3 changes: 2 additions & 1 deletion packages/chat/src/composer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ export type TriggerRule = "doc-start" | "word-boundary";
export type { ChipData };

export type ComposerEditorHandle = {
focus: () => void;
/** Focus the editor. Takes `HTMLElement.focus()`'s options: pass `{ preventScroll: true }` to leave the page where it is. */
focus: (options?: FocusOptions) => void;
blur: () => void;
clear: () => void;
insertText: (text: string) => void;
Expand Down
10 changes: 7 additions & 3 deletions packages/chat/src/composer/use-composer-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,13 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => {
mirrorCommandState();
};

const focusEditor = () => {
const focusEditor = (options?: FocusOptions) => {
if (!root) return;
root.focus();
root.focus(options);
writeCaretToDom(root, caret.start, caret.end);
// Focus scrolls the editor box into view (unless prevented), not the
// caret: keep the caret visible inside the composer's own scroll area.
scrollCaretIntoView(root);
};

// --- Event handlers (attached as native listeners — React synthetics
Expand Down Expand Up @@ -658,7 +661,8 @@ const createEditorEngine = (getDependencies: () => EngineDependencies) => {
},
closeCommands,
dismissCommands: () => {
root?.focus();
// The editor is on screen already: moving focus back must not scroll the page.
root?.focus({ preventScroll: true });
closeCommands();
},
};
Expand Down
26 changes: 26 additions & 0 deletions packages/chat/src/internal/scroll-into-view-within.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 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;
while (scroller && boundary.contains(scroller)) {
const { overflowY } = getComputedStyle(scroller);
if (
(overflowY === "auto" || overflowY === "scroll") &&
scroller.scrollHeight > scroller.clientHeight
) {
const view = scroller.getBoundingClientRect();
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;
}
scroller = scroller.parentElement;
}
};
Loading
Loading