From fa8cf7b82c90835a7be7ff4a8f60dd56698253de Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Sun, 8 Nov 2026 07:56:41 -0800 Subject: [PATCH 01/16] fix(ChatLayout): Safari reveals a document-scrolling composer In a page that scrolls the document, Safari on iOS pans the visual viewport to the fixed dock when the keyboard opens, and keeps the pan. ChatLayout's lift added to that pan, so the composer went under the status bar. Where no layout guide publishes the keyboard's slide, ChatLayout now leaves the tap, the reveal and the dock's place to the browser in that mode. With the VirtualKeyboard API, as in Chrome on Android, and in self-scroll mode, nothing changes. AST-099 FR3 states it. --- ...-layout-document-scroll-safari-keyboard.md | 6 ++ docs/specs/AST-099-mobile-keyboard/spec.md | 7 +- packages/core/src/Chat/ChatLayout.doc.mjs | 4 +- packages/core/src/Chat/ChatLayout.tsx | 2 +- .../core/src/Chat/useChatKeyboard.test.tsx | 71 +++++++++++++++++++ packages/core/src/Chat/useChatKeyboard.ts | 55 +++++++++++--- 6 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 .changeset/chat-layout-document-scroll-safari-keyboard.md diff --git a/.changeset/chat-layout-document-scroll-safari-keyboard.md b/.changeset/chat-layout-document-scroll-safari-keyboard.md new file mode 100644 index 0000000000000..a4c90a021e1ef --- /dev/null +++ b/.changeset/chat-layout-document-scroll-safari-keyboard.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] **ChatLayout**: In a page that scrolls the document (`scrollRef`), the composer stays right above the software keyboard in Safari on iOS, instead of jumping under the status bar. +@vjeux diff --git a/docs/specs/AST-099-mobile-keyboard/spec.md b/docs/specs/AST-099-mobile-keyboard/spec.md index c9452a16e94ee..4ff93e8234cda 100644 --- a/docs/specs/AST-099-mobile-keyboard/spec.md +++ b/docs/specs/AST-099-mobile-keyboard/spec.md @@ -100,7 +100,12 @@ app to turn on. component applies it on its own element, such as a bottom offset, a padding, or a maximum height, so layout is correct with no animation running: the chat composer, a search field placed at the bottom, and tab bar search position - from it, and a fully expanded sheet extends its own scroll range by it. FR9's + from it, and a fully expanded sheet extends its own scroll range by it. In a + page that scrolls the document, in a browser without the VirtualKeyboard API, + ChatLayout lifts its dock by nothing. It leaves focus and the reveal to the + browser there: the composer is not a keyboard-aware surface (FR4). Safari on + iOS pans the visual viewport to the fixed dock and keeps the pan, which places + the composer above the keyboard. FR9's docked ride is the one exception: Core moves an app's docked bar as FR9 sets out. How the value reaches styles is `architecture:react-component-runtime` INV11. Riding the keyboard's motion layers on top of that layout, so layout diff --git a/packages/core/src/Chat/ChatLayout.doc.mjs b/packages/core/src/Chat/ChatLayout.doc.mjs index 4c6aab35abef5..3c2d84a2763e1 100644 --- a/packages/core/src/Chat/ChatLayout.doc.mjs +++ b/packages/core/src/Chat/ChatLayout.doc.mjs @@ -6,7 +6,7 @@ export const docs = { name: 'ChatLayout', subComponentOf: 'Chat', displayName: 'Chat Layout', - description: 'Layout shell for full chat interfaces. Messages flow in normal page flow, composer is fixed to the bottom with a frosted glass dock. Set density (compact/balanced/spacious) to control spacing, message max-width, and blur layer sizing. Includes built-in auto-scroll, a "New messages" scroll-to-bottom button, and a frosted glass blur layer behind the composer. By default the layout root is the scroll container; pass scrollRef to delegate scrolling to a parent element or the document body. Where the visual viewport shrinks for the software keyboard, the composer stays on top of the keyboard, and a conversation at its newest message stays there. In Chrome on Android the composer rides Gboard\u2019s slides in step with them. 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. On a touch screen, in a top-level page, every way into the composer\u2019s text (a tap, the keyboard\u2019s previous and next buttons, Tab, the input\u2019s focus()) focuses it without scrolling the page; under a mouse or inside an iframe the browser\u2019s own focus applies. Where your page sets interactive-widget=resizes-content, Chrome on Android shrinks the page for the keyboard itself and the composer stays above it without moving (see the browser-support guide).', + description: 'Layout shell for full chat interfaces. Messages flow in normal page flow, composer is fixed to the bottom with a frosted glass dock. Set density (compact/balanced/spacious) to control spacing, message max-width, and blur layer sizing. Includes built-in auto-scroll, a "New messages" scroll-to-bottom button, and a frosted glass blur layer behind the composer. By default the layout root is the scroll container; pass scrollRef to delegate scrolling to a parent element or the document body. Where the visual viewport shrinks for the software keyboard, the composer stays on top of the keyboard, and a conversation at its newest message stays there. In Chrome on Android the composer rides Gboard\u2019s slides in step with them. 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; in a page that scrolls the document (scrollRef), the browser reveals the composer itself there. On a touch screen, in a top-level page, every way into the composer\u2019s text (a tap, the keyboard\u2019s previous and next buttons, Tab, the input\u2019s focus()) focuses it without scrolling the page; under a mouse or inside an iframe the browser\u2019s own focus applies. Where your page sets interactive-widget=resizes-content, Chrome on Android shrinks the page for the keyboard itself and the composer stays above it without moving (see the browser-support guide).', props: [ { name: 'children', @@ -135,7 +135,7 @@ export const docsZh = { export const docsDense = { name: 'ChatLayout', displayName: 'Chat Layout', - description: 'layout shell for full chat; msgs in page flow, composer fixed bottom w/ frosted glass dock; density prop (default balanced); scrollRef delegates to parent/body; in Chrome on Android (Gboard) the composer rides the software keyboard in step; in Safari on iOS and Firefox for Android it moves above the keyboard once reported (Chrome with interactive-widget=resizes-content: the page resizes, no move); transcript stays at newest msg, on a touch screen in a top-level page every focus route (tap, prev/next, Tab, focus()) w/o page scroll and Done rests focus on the composer group (no name, ring after keyboard input only, no tab stop); under a mouse or in an iframe: plain wrapper, browser focus', + description: 'layout shell for full chat; msgs in page flow, composer fixed bottom w/ frosted glass dock; density prop (default balanced); scrollRef delegates to parent/body; in Chrome on Android (Gboard) the composer rides the software keyboard in step; in Safari on iOS and Firefox for Android it moves above the keyboard once reported (with scrollRef the browser reveals it) (Chrome with interactive-widget=resizes-content: the page resizes, no move); transcript stays at newest msg, on a touch screen in a top-level page every focus route (tap, prev/next, Tab, focus()) w/o page scroll and Done rests focus on the composer group (no name, ring after keyboard input only, no tab stop); under a mouse or in an iframe: plain wrapper, browser focus', usage: { bestPractices: [ {guidance: true, description: 'Pass ChatMessageList as children and ChatComposer as composer prop for complete chat.'}, diff --git a/packages/core/src/Chat/ChatLayout.tsx b/packages/core/src/Chat/ChatLayout.tsx index 4469396c2a9a9..9124d076a97df 100644 --- a/packages/core/src/Chat/ChatLayout.tsx +++ b/packages/core/src/Chat/ChatLayout.tsx @@ -448,7 +448,7 @@ export function ChatLayout({ {/* The composer's region. Under a finger in a top-level page, useChatKeyboard makes it a group with no name, where focus rests after the keyboard's Done. Elsewhere it is a plain container. */} - +
{ }); }); +describe('ChatLayout in a page that scrolls the document', () => { + beforeEach(() => { + // A phone: its primary pointer is a coarse finger. + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query === '(pointer: coarse)', + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); + viewport = Object.assign(new EventTarget(), { + height: window.innerHeight, + offsetTop: 0, + scale: 1, + }); + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: viewport, + }); + }); + + afterEach(() => { + Reflect.deleteProperty(window, 'visualViewport'); + Reflect.deleteProperty(navigator, 'virtualKeyboard'); + vi.unstubAllGlobals(); + }); + + function renderDocumentLayout() { + return render( + +