Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions .changeset/chat-layout-rides-the-keyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@astryxdesign/core': patch
'@astryxdesign/cli': patch
---

[feat] ChatLayout keeps its composer on top of the software keyboard where the visual viewport shrinks for it, instead of leaving it underneath until the browser scrolls the whole page. In Chrome on Android the dock rides Gboard's measured slides up and down, in step with the keyboard. In Safari on iOS (in the browser and as a home-screen app) and in Firefox for Android, it moves above the keyboard once the browser reports it. Where a page sets `interactive-widget=resizes-content`, Chrome shrinks the page for the keyboard itself and the composer stays above it without moving. A conversation at its newest message stays there, and the rest of the transcript can still be scrolled above the composer. On a touch screen, in a top-level page, every way into the composer's text (a tap, the keyboard's previous and next buttons, Tab, the input's `focus()`) reaches it without the page scrolling; under a mouse or inside an iframe the browser's own focus applies. In a top-level page on a touch screen, the composer's wrapper (without the scroll-to-bottom button) is a group with no name. When the keyboard's Done ends typing, focus rests there (it is no tab stop), so the next tap or Tab starts from the composer; it shows the focus ring after keyboard input only. A tap that focuses the composer still raises its click once. Under a mouse or inside an iframe, the wrapper is a plain container. Under Reduce Motion the composer never travels with Gboard: it cross-fades to its place above the keyboard. Nothing changes where no software keyboard appears. The browser-support guide's section on the on-screen keyboard covers ChatLayout's composer.
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved

@vjeux
20 changes: 17 additions & 3 deletions apps/storybook/stories/ChatLayout.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,14 @@ export const FullAIChat: StoryObj = {
);

return (
<div style={{height: '100vh', display: 'flex', flexDirection: 'column'}}>
<div
style={{
// The visible height, less the 16px the preview pads every story
// with on each side, so the composer rests on screen.
height: 'calc(100dvh - 32px)',
display: 'flex',
flexDirection: 'column',
}}>
<ChatLayout composer={composerEl}>
<ChatMessageList>
{messages.map(msg => {
Expand Down Expand Up @@ -827,7 +834,13 @@ export const PanelView: StoryObj = {
export const WithEmptyState: StoryObj = {
name: 'Empty State',
render: () => (
<div style={{height: '100vh', display: 'flex', flexDirection: 'column'}}>
<div
style={{
// The visible height, less the preview's 16px padding on each side.
height: 'calc(100dvh - 32px)',
display: 'flex',
flexDirection: 'column',
}}>
<ChatLayout
composer={
<ChatComposer
Expand Down Expand Up @@ -861,7 +874,8 @@ export const Densities: StoryObj = {
style={{
display: 'flex',
gap: 16,
height: '100vh',
// The visible height, less the preview's 16px padding on each side.
height: 'calc(100dvh - 32px)',
padding: 16,
boxSizing: 'border-box',
}}>
Expand Down
21 changes: 9 additions & 12 deletions docs/specs/AST-099-mobile-keyboard/spec.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/cli/assets/docs/browser-support.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,11 @@ export const docs = {
content: [
{
type: 'prose',
text: "On a phone, a fully expanded `height='tall'` BottomSheet responds to the on-screen keyboard: it measures the keyboard as the layout viewport's height minus the visual viewport's height times its scale. Where the visual viewport shrinks for the keyboard, as in Safari on iOS in the browser and as a home-screen app, in Firefox for Android and in Chrome on Android, it keeps the focused field above the keyboard and leaves the page where it was. If Firefox for Android pans to the field, the sheet puts the page back within a couple of frames.",
text: "On a phone, the components that respond to the on-screen keyboard, a fully expanded `height='tall'` BottomSheet and ChatLayout's composer, measure it the same way: the layout viewport's height minus the visual viewport's height times its scale. Where the visual viewport shrinks for the keyboard, as in Safari on iOS in the browser and as a home-screen app, in Firefox for Android and in Chrome on Android, they keep the focused field and the surface around it above the keyboard and leave the page where it was. If Firefox for Android pans to the field, they put the page back within a couple of frames.",
},
{
type: 'prose',
text: 'In Chrome on Android, a Tall BottomSheet\u2019s focused field rides the keyboard as Gboard slides it up, and as Gboard slides it down after the field loses focus. The scroll room stays until the keyboard is down. A hide Chrome reports late, such as Back, which keeps the focus, rides down from Chrome\u2019s report. When the keyboard is reported, the sheet places the field just above it in that same frame, so Chrome has no covered field to pan to; if Chrome pans anyway, the sheet puts the page back when that pan ends. Chrome follows your viewport setting, which Astryx documents and never sets: your app owns its viewport `<meta>` element. By default (`interactive-widget=resizes-visual`) Chrome lays the keyboard over the page and pans to a focused field elsewhere on the page. With `interactive-widget=resizes-content` it shrinks the layout viewport instead, so content anchored to the bottom sits above the keyboard, and the sheet measures no keyboard and changes nothing: the browser has already made the room.',
text: 'In Chrome on Android, ChatLayout\u2019s composer rides the keyboard as Gboard slides it up and down. A Tall BottomSheet\u2019s focused field rides the keyboard as Gboard slides it up, and as Gboard slides it down after the field loses focus. The scroll room stays until the keyboard is down. A hide Chrome reports late, such as Back, which keeps the focus, rides down from Chrome\u2019s report. When the keyboard is reported, the sheet places the field just above it in that same frame, so Chrome has no covered field to pan to; if Chrome pans anyway, the sheet puts the page back when that pan ends. Chrome follows your viewport setting, which Astryx documents and never sets: your app owns its viewport `<meta>` element. By default (`interactive-widget=resizes-visual`) Chrome lays the keyboard over the page and pans to a focused field elsewhere on the page. With `interactive-widget=resizes-content` it shrinks the layout viewport instead, so content anchored to the bottom sits above the keyboard, and those components measure no keyboard and change nothing: the browser has already made the room.',
},
{
type: 'code',
Expand Down
149 changes: 146 additions & 3 deletions packages/core/src/Chat/ChatComposer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import {beforeEach, describe, it, expect, vi} from 'vitest';
import {afterEach, beforeEach, describe, it, expect, vi} from 'vitest';
import {act, render, fireEvent, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {StrictMode, useEffect, useRef} from 'react';
import {StrictMode, useEffect, useRef, type ReactNode} from 'react';
import {ChatComposer} from './ChatComposer';
import {ChatComposerInput} from './ChatComposerInput';
import {
ChatComposerInput,
type ChatComposerInputHandle,
} from './ChatComposerInput';
import {Tooltip} from '../Tooltip/Tooltip';
import {ChatLayout} from './ChatLayout';
import {KeyboardSurfaceContext} from './keyboardSurface';
import {useChatComposerContext} from './ChatContext';
import {createStaticSource} from '../Typeahead/createStaticSource';
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
Expand Down Expand Up @@ -311,8 +316,146 @@ describe('ChatComposer input composition contract', () => {
);
const textarea = getByTestId('bare-textarea') as HTMLTextAreaElement;
const body = textarea.parentElement!.parentElement!;
const focus = vi.spyOn(textarea, 'focus');
fireEvent.click(body);
expect(document.activeElement).toBe(textarea);
// Outside a keyboard-aware surface the browser's own focus applies.
expect(focus).toHaveBeenCalledWith();
});
});

describe('ChatComposer focus inside a keyboard-aware surface (spec:AST-099 FR4)', () => {
// Under a finger, every route into the text refuses the browser's reveal,
// which scrolls the whole page on iOS. Elsewhere, and under a mouse, the
// browser's focus applies.
const inSurface = (node: ReactNode) => (
<KeyboardSurfaceContext value={true}>{node}</KeyboardSurfaceContext>
);

// The primary pointer `matchMedia` reports: a touch screen's coarse finger,
// or a desktop's fine mouse.
function primaryPointer(pointer: 'coarse' | 'fine') {
vi.stubGlobal('matchMedia', (query: string) => ({
matches: query === `(pointer: ${pointer})`,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
}

beforeEach(() => {
primaryPointer('coarse');
// A top-level document with a visual viewport, where the keyboard is
// measured. jsdom has none of its own.
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: Object.assign(new EventTarget(), {
height: window.innerHeight,
offsetTop: 0,
scale: 1,
}),
});
});

afterEach(() => {
Reflect.deleteProperty(window, 'visualViewport');
vi.unstubAllGlobals();
});

it('focuses the default input on an empty-space tap without the reveal', () => {
const {container} = render(inSurface(<ChatComposer onSubmit={() => {}} />));
const editable = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
const focus = vi.spyOn(editable, 'focus');
fireEvent.click(editable.parentElement!);
expect(focus).toHaveBeenCalledWith({preventScroll: true});
});

it('focuses a bare field the shell falls back to without the reveal', () => {
const {getByTestId} = render(
inSurface(
<ChatComposer
onSubmit={() => {}}
input={<textarea data-testid="bare-textarea" />}
/>,
),
);
const textarea = getByTestId('bare-textarea');
const focus = vi.spyOn(textarea, 'focus');
fireEvent.click(textarea.parentElement!.parentElement!);
expect(focus).toHaveBeenCalledWith({preventScroll: true});
});

it('focuses without the reveal when the app calls the input\u2019s focus()', () => {
let handle: ChatComposerInputHandle | null = null;
const {container} = render(
inSurface(
<ChatComposerInput
handleRef={h => {
handle = h;
}}
/>,
),
);
const editable = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
const focus = vi.spyOn(editable, 'focus');
act(() => handle!.focus());
expect(focus).toHaveBeenCalledWith({preventScroll: true});
});

it('keeps the browser\u2019s focus under a mouse (spec:AST-017 FR59)', () => {
primaryPointer('fine');
let handle: ChatComposerInputHandle | null = null;
const {container} = render(
inSurface(
<ChatComposer
onSubmit={() => {}}
input={
<ChatComposerInput
handleRef={h => {
handle = h;
}}
/>
}
/>,
),
);
const editable = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
const focus = vi.spyOn(editable, 'focus');
act(() => handle!.focus());
expect(focus).toHaveBeenCalledWith();
expect(focus).not.toHaveBeenCalledWith({preventScroll: true});
fireEvent.click(editable.parentElement!);
expect(focus).not.toHaveBeenCalledWith({preventScroll: true});
});

it('keeps the browser\u2019s focus for a composer outside one', () => {
const {container} = render(<ChatComposer onSubmit={() => {}} />);
const editable = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
const focus = vi.spyOn(editable, 'focus');
fireEvent.click(editable.parentElement!);
expect(focus).toHaveBeenCalledWith();
});

it('is what ChatLayout gives its composer', () => {
const {container} = render(
<ChatLayout composer={<ChatComposer onSubmit={() => {}} />}>
<p>Hello</p>
</ChatLayout>,
);
const editable = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
const focus = vi.spyOn(editable, 'focus');
fireEvent.click(editable.parentElement!);
expect(focus).toHaveBeenCalledWith({preventScroll: true});
});
});

Expand Down
67 changes: 40 additions & 27 deletions packages/core/src/Chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
*/

import React, {
use,
useState,
useCallback,
useRef,
Expand Down Expand Up @@ -51,6 +52,10 @@ import {ChatComposerInput} from './ChatComposerInput';
import {ChatComposerContext} from './ChatContext';
import type {ChatComposerInputControl} from './ChatContext';
import {ChatSendButton} from './ChatSendButton';
import {
KeyboardSurfaceContext,
focusInKeyboardSurface,
} from './keyboardSurface';
import {themeProps} from '../utils/themeProps';
import {useAnnounceRenderedText} from '../hooks/useAnnounceRenderedText';
import {useMergedRefs} from '../hooks/useMergedRefs';
Expand Down Expand Up @@ -263,7 +268,7 @@ const styles = stylex.create({
backgroundColor: colorVars['--color-error-muted'],
color: colorVars['--color-on-error-muted'],
// Forced colors replace status fills with Canvas; this boundary keeps the
// fill's shape visible (architecture:theme-tokens INV11).
// fill's shape visible.
outlineWidth: {default: null, '@media (forced-colors: active)': '1px'},
outlineStyle: {default: null, '@media (forced-colors: active)': 'solid'},
outlineColor: {
Expand All @@ -276,7 +281,7 @@ const styles = stylex.create({
backgroundColor: colorVars['--color-warning-muted'],
color: colorVars['--color-on-warning-muted'],
// Forced colors replace status fills with Canvas; this boundary keeps the
// fill's shape visible (architecture:theme-tokens INV11).
// fill's shape visible.
outlineWidth: {default: null, '@media (forced-colors: active)': '1px'},
outlineStyle: {default: null, '@media (forced-colors: active)': 'solid'},
outlineColor: {
Expand Down Expand Up @@ -433,31 +438,39 @@ export function ChatComposer(props: ChatComposerProps) {
// drive it without knowing its DOM shape. Stable ref — safe in the memoized
// context value.
const inputControlRef = useRef<ChatComposerInputControl | null>(null);

const handleBodyClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
// Focus the input when clicking empty space in the body.
// Skip if the click target is a button, link, or interactive element.
const target = e.target as HTMLElement;
if (
target.closest(
'button, a, [role="button"], [contenteditable="true"], [data-astryx-token]',
)
) {
return;
}
// Prefer the input's registered control (works for any input shape,
// including editors whose focusable node isn't a bare
// contenteditable/textarea). Fall back to a DOM query so uninstrumented
// custom inputs still get click-to-focus.
if (inputControlRef.current) {
inputControlRef.current.focus();
return;
}
const editable = bodyRef.current?.querySelector<HTMLElement>(
'[contenteditable="true"], textarea',
);
editable?.focus();
}, []);
// Inside ChatLayout's keyboard surface, a touch focus skips the browser's
// reveal. Elsewhere the browser's own focus applies.
const isKeyboardSurface = use(KeyboardSurfaceContext);

const handleBodyClick = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
// Focus the input when clicking empty space in the body.
// Skip if the click target is a button, link, or interactive element.
const target = e.target as HTMLElement;
if (
target.closest(
'button, a, [role="button"], [contenteditable="true"], [data-astryx-token]',
)
) {
return;
}
// Prefer the input's registered control (works for any input shape,
// including editors whose focusable node isn't a bare
// contenteditable/textarea). Fall back to a DOM query so uninstrumented
// custom inputs still get click-to-focus.
if (inputControlRef.current) {
inputControlRef.current.focus();
return;
}
const editable = bodyRef.current?.querySelector<HTMLElement>(
'[contenteditable="true"], textarea',
);
if (editable) {
focusInKeyboardSurface(editable, isKeyboardSurface);
}
},
[isKeyboardSurface],
);

const handleBodyFocusCapture = useCallback(
(event: FocusEvent<HTMLDivElement>) => {
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/Chat/ChatComposerInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
*/

import {
use,
useRef,
useState,
useCallback,
Expand Down Expand Up @@ -67,6 +68,10 @@ import {
} from './useChatPasteAsToken';
import {Badge, type BadgeProps} from '../Badge';
import {useChatComposerContext} from './ChatContext';
import {
KeyboardSurfaceContext,
focusInKeyboardSurface,
} from './keyboardSurface';
import {themeProps} from '../utils/themeProps';
import {withholdCallerHandlers} from '../utils/disabledInteraction';
import type {EnterKeyHint} from '../Field/textEntryTraits';
Expand Down Expand Up @@ -453,6 +458,10 @@ export function ChatComposerInput(props: ChatComposerInputProps) {
* would arm the next ArrowUp to replace it. Landing after the text is
* also what a click on the composer's trailing space means.
*/
// Inside ChatLayout's keyboard surface, every touch route into the text
// skips the browser's reveal. Elsewhere the browser's own focus applies.
const isKeyboardSurface = use(KeyboardSurfaceContext);

/**
* Focus the editable, keeping a caret or selection the user already has
* inside it.
Expand All @@ -474,13 +483,13 @@ export function ChatComposerInput(props: ChatComposerInputProps) {
// Read before focusing: `focus()` itself creates the offset-0 caret, so
// asking afterwards cannot tell the user's own caret from the engine's.
const existing = getSelectionRangeInside(editable);
editable.focus();
focusInKeyboardSurface(editable, isKeyboardSurface);
if (existing) {
restoreSelectionRange(existing);
return;
}
placeCaretAtEnd(editable);
}, []);
}, [isKeyboardSurface]);

/**
* Focus the editable and put the caret after the draft, whatever the
Expand All @@ -495,9 +504,9 @@ export function ChatComposerInput(props: ChatComposerInputProps) {
if (!editable) {
return;
}
editable.focus();
focusInKeyboardSurface(editable, isKeyboardSurface);
placeCaretAtEnd(editable);
}, []);
}, [isKeyboardSurface]);

const handle: ChatComposerInputHandle = {
insertToken: (token: ChatComposerToken) => insertTokenRef.current(token),
Expand Down
Loading
Loading