# Message composer

One generic form for every place a message is sent: a chat, a room, a comment thread, a plain form posted to a server action. It knows nothing about chats, uploads or dictation. It collects fields, holds the submit while any field is busy, and hands the message to the nearest sink or to its own onSubmit. Two contracts, field and sink, are all it relies on. It replaces Chat.Composer, Room.Composer and Upload.Composer, and the chat-attachments and chat-dictation bridges.

Import: @slices.sh/composer

Public parts: 8

## Installation

### 1. Install the headless library

```bash
npm install @slices.sh/composer@^0.1.4
```

No service configuration is required.

## Anatomy

### Composition

```text
Composer.Surface
  SinkProvider
    Composer.Root
      Composer.Input
      Composer.Pending
      Composer.Held
      Composer.Error
      Composer.Submit
```

## Your submit handler

```tsx
"use client"

import { Composer } from "@slices.sh/composer"

export function Example() {
  return (
    <Composer.Root
      onSubmit={async (message) => {
        console.log(message)
      }}
    >
      <Composer.Input aria-label="Comment" />
      <Composer.Submit>Post comment</Composer.Submit>
      <Composer.Pending>Sending…</Composer.Pending>
    </Composer.Root>
  )
}

```

Replace console.log with your application action. No service is required when you supply onSubmit.

## Your own elements

```tsx
"use client"

import { Composer } from "@slices.sh/composer"

export function Example() {
  return (
    <Composer.Root
      onSubmit={async (message) => {
        console.log(message)
      }}
    >
      <Composer.Input
        render={<textarea className="rounded-md border p-3" />}
        aria-label="Comment"
      />
      <Composer.Submit
        render={<button className="rounded-full px-4 py-2" />}
      >
        Publish
      </Composer.Submit>
    </Composer.Root>
  )
}

```

render changes the element without losing its behavior.

## Complete API reference

### Composer.Root

scope · Generic · Element: form

The form every message is sent from. It knows fields by name and one destination, and
nothing about chats, rooms, uploads or dictation. → docs/v2/target-docs/DESIGN.md §5

The check for a destination is a component of its own, so a Composer under a switched-off
sink renders nothing without having called a hook it will not use.

Import: @slices.sh/composer

```tsx
RootProps

type RootProps = Omit<PartProps<"form">, "onSubmit"> & {
  /** Used instead of the nearest sink. A rejected promise keeps the draft and renders `Composer.Error`. */
  onSubmit?(message: Message): void | Promise<void>
}
```

Source: @slices.sh/composer/src/root.tsx

### Composer.Input

input · Generic · Element: textarea

The text of the message, and a field named `text` (`name="…"` changes the key).

A textarea rather than an input, because Shift+Enter has to mean something. Enter submits,
Shift+Enter breaks the line, and an IME's Enter commits a candidate rather than sending half
a word. No `rows`: the skin sizes it with `field-sizing: content`, so it grows with the
message and stops at a maximum.

Escape stops what the destination is doing and Backspace takes back the last chip it holds
beside the draft (`stop` and `removeLast` of the sink), but only from an empty input, and
never while composing, while a wrapper holds the field busy, or after a wrapper took the key.

It is busy while a wrapper has set `aria-busy="true"` on it, which is how `Dictation.Field`
holds the submit while listening without either part knowing the other. It takes `value`
and `onChange` from such a wrapper when it is given them, and holds its own text when not.

Import: @slices.sh/composer

```tsx
InputProps

type InputProps = PartProps<"textarea">
```

Source: @slices.sh/composer/src/input.tsx

### Composer.Submit

action · Generic · Element: button

The form's only submit button: everything else in a composer is `type="button"`.
→ docs/rules/one-submit-per-form.md

It stays mounted and enabled while the submit is held, so focus never drops and the
layout never moves; `aria-disabled` and `data-held` say it instead, and a press does
nothing. Stopping a streaming reply is not a submit: that is the sink's own part.

Import: @slices.sh/composer

Composer.Submit must be inside <Composer.Root>.

```tsx
PartProps<"button", { held: boolean; pending: boolean }>
```

Source: @slices.sh/composer/src/submit.tsx

### Composer.Surface

content · Generic · Element: div

A wider place than the form that still hands files to it: a drop or a paste anywhere on a
chat panel goes to the field named `attachments` of the Composer inside it. The field is
found when the event arrives, through the forms below, so nothing here knows about uploads.

With no such field (the service is off, the sink takes no files, there is no Composer) every
event goes by untouched. Events are forwarded even when an element inside already took them:
the field decides what an event that its own drop area handled means, since it counts
enters and leaves across both.

Import: @slices.sh/composer

```tsx
SurfaceProps

type SurfaceProps = PartProps<"div"> & {
  /** The field that takes what lands here. */
  field?: string
}
```

Source: @slices.sh/composer/src/surface.tsx

### Composer.Error

gate · Generic

Renders after the sink rejected, or `accept` refused a field. The draft is still there.

Import: @slices.sh/composer

Composer.Error must be inside <Composer.Root>.

```tsx
GateProps<{ error: globalThis.Error; code: string | null }>
```

Source: @slices.sh/composer/src/gates.tsx

### Composer.Pending

gate · Generic

Renders while `sink.submit` is in flight: a slow room, a server action.

Import: @slices.sh/composer

Composer.Pending must be inside <Composer.Root>.

```tsx
GateProps
```

Source: @slices.sh/composer/src/gates.tsx

### Composer.Held

gate · Generic

Renders while the submit is held: a field is busy, or the sink says `hold` and is.

Import: @slices.sh/composer

Composer.Held must be inside <Composer.Root>.

```tsx
GateProps<{ reason: "field" | "sink"; fields: string[] }>
```

Source: @slices.sh/composer/src/gates.tsx

### SinkProvider

content · Generic

Offers a sink to every Composer below it. A root that can receive messages renders this
around its children. `null` is a sink switched off: an optional root whose service is
missing says so, and a Composer below renders nothing instead of throwing `slices/no-sink`.

Import: @slices.sh/composer

```tsx
{ value: Sink | null; children?: ReactNode }
```

Source: @slices.sh/composer/src/sink.tsx
