Skip to content
Open
7 changes: 7 additions & 0 deletions .changeset/chat-layout-keyboard-dismiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] ChatLayout: with a software keyboard up in a top-level page (not under `interactive-widget=resizes-content`), dragging the conversation down to the composer with a finger or a pen now dismisses the keyboard as the finger reaches the composer, as in Messages. The composer gives one small spring bounce, never faster than the finger, and the scroll-to-bottom button moves with it; under Reduce Motion nothing moves. A drag down that starts on the composer dismisses the keyboard too, unless it began as a long press that moves the caret. While the keyboard is up, a one-finger drag on the composer never scrolls the conversation under it, and a part of the composer that scrolls on its own, such as a long draft or a strip of attachments, keeps its scroll. Two fingers still pinch-zoom the page. A mouse drag still selects text and moves focus as before. Dismissing moves focus nowhere, and drag-to-dismiss has no caller control. Typing with a keyboard is unchanged.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/keyboard-height-safari-pan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] The on-screen keyboard's height reads the whole keyboard while Safari on iOS pans the page to a focused field. Safari reports `innerHeight` as the visual viewport's height during that pan, so the height read 0, as if no keyboard were up. At a visual-viewport scale of 1, where `innerHeight` equals the visual viewport's height and the root is as wide as the visual viewport, the layout viewport's height now takes the root's client height where it is larger.

@vjeux
25 changes: 19 additions & 6 deletions docs/specs/AST-094-touch-presentations/spec.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/specs/AST-099-mobile-keyboard/spec.md

Large diffs are not rendered by default.

75 changes: 51 additions & 24 deletions docs/specs/components/Chat/ChatLayout.spec.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/core/src/Chat/ChatLayout.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
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. With a software keyboard up in a top-level page (not under `interactive-widget=resizes-content`), dragging the conversation down to the composer with a finger or a pen dismisses the keyboard as the finger reaches the composer, and the composer and the scroll-to-bottom button give one small bounce together. A drag down that starts on the composer dismisses it too, and a drag on the composer never scrolls the conversation under the keyboard. A mouse drag still selects text (no caller control).',
props: [
{
name: 'children',
Expand Down Expand Up @@ -131,7 +131,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',
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; with a software keyboard up in a top-level page (not under `interactive-widget=resizes-content`), a finger or pen drag down the transcript dismisses it as it reaches the composer (one small bounce of composer + scroll button), as does a drag down that starts on the composer, which never scrolls the transcript; a mouse drag still selects text (no prop)',
usage: {
bestPractices: [
{guidance: true, description: 'Pass ChatMessageList as children and ChatComposer as composer prop for complete chat.'},
Expand Down
58 changes: 54 additions & 4 deletions packages/core/src/Chat/ChatLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

/**
* @file ChatLayout.tsx
* @input Uses React, StyleX, theme tokens, useChatStreamScroll, useChatNewMessages
* @input Uses React, StyleX, theme tokens, useChatStreamScroll, useChatNewMessages,
* useGestureRecognizer, useAttachment, the keyboard dismissal pans and the
* composer's bounce
* @output Exports ChatLayout component and ChatLayoutProps
* @position Layout shell for full chat interfaces — messages in page flow, composer fixed to bottom
*
Expand All @@ -27,6 +29,8 @@
* - /packages/core/src/Chat/index.ts (exports)
* - /apps/storybook/stories/ChatLayout.stories.tsx
* - /packages/cli/assets/templates/blocks/components/ChatLayout/ (block examples)
* - /packages/core/src/Chat/transcriptDismiss.ts (the dismiss line)
* - /packages/core/src/Chat/composerBounce.ts (the dock container's bounce)
*/

import {type ReactNode, useMemo, useRef} from 'react';
Expand All @@ -38,6 +42,16 @@ import {useChatStreamScroll} from './useChatStreamScroll';
import {useChatNewMessages} from './useChatNewMessages';
import {ChatLayoutScrollButton} from './ChatLayoutScrollButton';
import {ChatLayoutContext} from './ChatContext';
import {
createComposerDismissPan,
createKeyboardDismissPan,
dismissKeyboardAtCrossing,
dismissKeyboardOnComposerDrag,
holdPageUnderComposer,
} from './transcriptDismiss';
import {bounceComposer} from './composerBounce';
import {useGestureRecognizer} from '../gestures/useGestureRecognizer';
import {useAttachment} from '../hooks/useAttachment';
import {themeProps} from '../utils/themeProps';

import {useMergedRefs} from '../hooks/useMergedRefs';
Expand Down Expand Up @@ -304,6 +318,8 @@ export function ChatLayout({
}: ChatLayoutProps) {
const t = useComponentTranslator();
const rootRef = useRef<HTMLDivElement>(null);
const dockContainerRef = useRef<HTMLDivElement>(null);
const composerRef = useRef<HTMLDivElement>(null);

const scrollContainerRef = externalScrollRef ?? rootRef;
const isSelfScrolling = !externalScrollRef;
Expand All @@ -328,6 +344,35 @@ export function ChatLayout({
/>
);

useGestureRecognizer(
rootRef,
() =>
createKeyboardDismissPan(
// Where the page draws the composer. A browser's own pan to the
// focused field moves it with the page.
() =>
composerRef.current?.getBoundingClientRect().top ??
Number.POSITIVE_INFINITY,
),
pan =>
dismissKeyboardAtCrossing(pan, fingerSpeed => {
const container = dockContainerRef.current;
const composer = composerRef.current;
if (container != null && composer != null) {
// No deeper than the composer's bottom padding.
const depth =
Number.parseFloat(getComputedStyle(composer).paddingBottom) || 0;
bounceComposer(container, fingerSpeed, depth);
}
}),
);
useGestureRecognizer(
composerRef,
() => createComposerDismissPan(() => composerRef.current),
dismissKeyboardOnComposerDrag,
);
useAttachment(composerRef, holdPageUnderComposer, null);

// --- Layout context ---
const layoutContext = useMemo(
() => ({scrollContainerRef, contentRef: newMsgs.contentRef}),
Expand Down Expand Up @@ -385,8 +430,10 @@ export function ChatLayout({
)}
</div>

{/* Dock container — sticky/fixed, holds blur + scroll button + composer */}
{/* Dock container — sticky/fixed, holds blur + scroll button + composer.
Its bounce moves all three as one. */}
<div
ref={dockContainerRef}
{...stylex.props(
styles.dockContainer,
isSelfScrolling
Expand All @@ -399,8 +446,11 @@ export function ChatLayout({
{/* Frosted glass layer */}
<div {...stylex.props(styles.blurLayer, currentDensity.blurLayer)} />

{/* Composer */}
<div {...stylex.props(styles.dock, currentDensity.dock)}>
{/* Composer: a transcript drag that reaches its top, or a drag down
that starts on it, dismisses the software keyboard. */}
<div
ref={composerRef}
{...stylex.props(styles.dock, currentDensity.dock)}>
<div {...stylex.props(styles.dockInner, currentDensity.dockInner)}>
{composer}
</div>
Expand Down
Loading
Loading