Skip to content

feat(core): make custom ChatComposer inputs first-class#4285

Open
cixzhang wants to merge 1 commit into
mainfrom
feat/chat-composer-input-composition
Open

feat(core): make custom ChatComposer inputs first-class#4285
cixzhang wants to merge 1 commit into
mainfrom
feat/chat-composer-input-composition

Conversation

@cixzhang

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #4280 (finding #9). That PR fixed the input's own Enter/key behavior; this makes a custom input a first-class citizen of ChatComposer, so you can drop in a plain <textarea> or a bespoke editor and have the whole shell work — not just ChatComposerInput.

The composer body is a shell of slots (drawer, header, input, footer, send). The input is one slot; it doesn't span the body. Two things previously locked custom inputs out:

  1. The composer↔input contract was private. useChatComposerContext() carried value/onChange/onSubmit/canSend/placeholder/isDisabled — everything the built-in input and ChatSendButton read — but it wasn't exported, so a custom input couldn't reach send/stop state or wire to the same contract.
  2. Body-click-to-focus sniffed the DOM. Clicking empty space in the body focused the input via querySelector('[contenteditable], textarea') — a hardcoded shape guess that misses any editor whose focusable node isn't one of those.

Change

  • Export the composition contract: useChatComposerContext, ChatComposerContextValue, and a new ChatComposerInputControl from @astryxdesign/core/Chat.
  • Replace focus sniffing with a registered control. The context carries a stable inputControlRef; an input registers { focus } on mount, and the shell drives it on body click — for any input shape. The DOM query stays as a fallback so uninstrumented custom inputs don't lose click-to-focus. ChatComposerInput registers its existing focus() automatically.

Since the context was private, this is a design opportunity, not just an export: ChatComposerInputControl is a minimal, named interface that can grow (optional methods) without forcing plain inputs to implement more than focus.

Examples (Storybook)

New Components/ChatComposer/Custom Input stories prove the contract with two very different inputs, both first-class:

  • PlainTextarea — a bare controlled <textarea>: reads context, submits on Enter (IME-safe), registers focus. No contentEditable.
  • RawLexical — a raw Lexical editor (its own EditorState + focus model), bridged to the composer's string value and registering editor.focus() as its control.

Testing

  • ChatComposer.test.tsx: custom input reads value/placeholder/disabled from context; body-click focuses via the registered control; DOM-query fallback still focuses a bare textarea.
  • ChatComposerInput.test.tsx: the built-in input registers a focus control so a body click focuses it.
  • pnpm build, core typecheck, typecheck:docs, storybook typecheck, verify-exports, and changeset checks clean; full Chat suite green.

Compatibility

Additive. inputControlRef is optional on the context; the focus fallback preserves today's behavior for any input that doesn't register. No change to existing ChatComposer/ChatComposerInput usage.

The composer body is a shell of slots (drawer, header, input, footer,
send); the input is one slot and doesn't span the body. Two things
locked custom inputs out of full participation:

- The composer<->input contract (value/onChange/onSubmit/canSend/
  placeholder/isDisabled) lived in a private context, so a custom input
  couldn't reach send/stop state or wire to the same contract the
  built-in input and ChatSendButton use.
- Body-click-to-focus sniffed the DOM (querySelector for
  contenteditable/textarea), missing any editor whose focusable node
  isn't one of those shapes.

Export the contract (useChatComposerContext, ChatComposerContextValue)
and add a ChatComposerInputControl registered via a stable
inputControlRef on the context. An input registers { focus } on mount;
the shell drives it on body click, for any input shape. The DOM query
stays as a fallback so uninstrumented inputs keep click-to-focus.
ChatComposerInput registers its existing focus() automatically.

Since the context was private, ChatComposerInputControl is designed as
a minimal named interface that can gain optional methods later without
forcing plain inputs to implement more than focus.

Storybook: two "Custom Input" stories prove the contract with very
different inputs — a plain controlled <textarea> and a raw Lexical
editor — both first-class (read context, submit on Enter, register
focus).
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview, Comment Jul 24, 2026 10:08am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 24, 2026
github-actions Bot added a commit that referenced this pull request Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Chat · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 5305 -
Complexity N/A Very High (437) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 0B

Accessibility Audit

Status: 1 accessibility violation(s) found — 1 serious.

Chat - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 2/8 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant