Composer
<aparte-composer> — with 7 parts: <aparte-composer-input>, <aparte-composer-send>, <aparte-composer-cancel>, <aparte-composer-action>, <aparte-composer-add-attachment>, <aparte-composer-attachments>, <aparte-composer-toolbar>
The root context for every aparte-composer-* primitive. It imposes no visual
layout — the consumer owns the structure — and holds the shared state the parts
read: the value, the streaming flag, pending attachments, whether a panel is up.
It renders nothing of its own — no shadow root, no markup, no default children — so
an <aparte-composer> with nothing inside is an empty block. The parts that need
that state locate it with closest('aparte-composer'), which is why they may sit at
any depth and why the opt-in .aparte-composer-shell / .aparte-composer-row
wrappers can exist without this element knowing about them.
Not every part looks it up, though: <aparte-composer-toolbar> is purely structural
— it lays its children out and never resolves this element at all.
WHAT GOES INSIDE — ordinary light-DOM children. Core has no shadow root and no
<slot>, so there is no slot name to write: drop in <aparte-composer-input>,
<aparte-composer-send>, <aparte-composer-cancel>,
<aparte-composer-attachments>, <aparte-composer-add-attachment>,
<aparte-composer-action>, <aparte-composer-toolbar>, plus whatever markup you
wrap them in. Order and nesting are yours. Two behaviours read the tree rather than a
flag, so they depend on what you put in: focus() forwards to the first
<aparte-composer-input> descendant, and showPanel() inserts the panel right after
it (appending to the host when there is none).
A PANEL is neither markup you write nor a named slot: showPanel() takes the element,
stamps it data-aparte-panel and inserts it — inside the descendant you marked
data-aparte-panel-host if there is one, else right after the first input —
and hidePanel() removes it. One at a time
— a second showPanel() evicts the first and calls its onEvict. While one is up the
host carries [data-panel-active], which hides <aparte-composer-input> and
<aparte-composer-add-attachment> and leaves the attachments strip and the toolbar in
place.
It is not a transport either. submit() trims, checks the gates (disabled, empty,
no model selected), dispatches aparte-send and clears — nothing here talks to a
model, so without AparteClient or a listener of your own a send is a dispatched
event and no answer. With no panel up it doubles as the stop button: while streaming
it routes to cancel(), which is why the getState example below keeps a custom send
button clickable rather than disabling it mid-stream. With a panel up it means “answer
the question” instead — it calls the panel’s onSubmit and returns, so neither the
stop branch nor a send is reached.
The streaming flag comes from WINDOW lifecycle events, filtered by target:
aparte-message-start sets it, and any of aparte-message-done /
aparte-message-error / aparte-message-aborted clears it (and evicts an open
panel). AparteClient dispatches them on the chat host, bubbling, so they reach
both this element’s window listener and the host-bound readers; a host that runs
its own loop dispatches the same two (start, done) the same way, with
detail.targetId set to this composer’s target — that is the whole contract,
documented under
“Make the composer follow your turn” in the bring-your-own-loop guide. On a page
with two chats, give the composer a target — or put it under a chat host that has
an id — otherwise it answers to every chat’s events, and one chat’s Stop resets the
other’s composer and evicts its open panel.
Prose first, on purpose: when @element opens a docblock there is no free text
left for the analyser to use, and this component’s description came out empty in
the manifest and blank on the generated reference page.
aparte-abort and aparte-message-aborted have to be declared by hand and always
will: they go out through window.dispatchEvent (they concern the whole page, not
this subtree), and the analyser’s fallback only recognises this.dispatchEvent.
Example
Section titled “Example”<!-- It renders nothing of its own — no shadow root, no default children — so this markup IS the component. The shell draws the border; the row keeps the controls on the bottom edge of the text as it grows. Both are opt-in classes: drop them and the parts still work, they just sit wherever your own layout puts them. --><aparte-composer placeholder="Ask anything…"> <div class="aparte-composer-shell"> <div class="aparte-composer-row"> <aparte-composer-input></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div> </div></aparte-composer>// A custom send button. Keep it CLICKABLE while streaming — submit()// routes to cancel() when a response is in flight, so one button is// Send/Stop. Disabling it on `streaming` would make "stop" unreachable.composer.addEventListener('aparte-composer-change', (e) => { const { streaming, disabled, value, attachments } = e.detail.state; myButton.textContent = streaming ? 'Stop' : 'Send'; myButton.disabled = disabled || (!streaming && !value.trim() && attachments.length === 0);});myButton.addEventListener('click', () => composer.submit()); // send or stopComposer input
Section titled “Composer input”<aparte-composer-input>
Contenteditable text input primitive.
The element owns its subtree: on connect it writes one .aparte-ci-editor
contenteditable and binds its listeners to that node, so children you place inside are
replaced. There is nothing to project here — style the generated editor through the CSS
variables below, or replace the whole primitive.
Enter submits and Shift+Enter inserts a newline; submit-on-enter="false" on the
composer inverts that mapping, and Enter never submits mid-IME-composition — the key
that confirms a CJK candidate must not send the message. The editor auto-expands with
its content up to max-height, then scrolls. Paste is intercepted: text lands as plain
text with its markup stripped, and a pasted image goes to the composer’s attachments.
Without an <aparte-composer> ancestor it still works, and that is deliberate: a
submitting Enter then dispatches aparte-composer-submit instead of calling
root.submit(), which is how the bubble’s inline editor reuses this primitive.
Everything the root owns goes with it though — the mirrored value, the placeholder
fallback, the disabled sync and image paste all need the composer. The editor stays
editable while a reply streams (the next message is typed while the current one
arrives); only the send is gated then — Enter is swallowed, the button is Stop.
Not a <textarea> and not a stand-in for one: being a contenteditable it has no form
value, no name and no native validation, and getValue() returns trimmed text with
<br> serialized back to newlines. Use it for the chat draft, not as a form control.
<aparte-composer> <div class="aparte-composer-shell"> <div class="aparte-composer-row"> <aparte-composer-input placeholder="Ask anything…" max-height="320"></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div> </div></aparte-composer>Composer send
Section titled “Composer send”<aparte-composer-send>
Submit button primitive for <aparte-composer>.
One control, three meanings: send, stop while the root is streaming, and — when
an elicitation panel is open — submit the answer; a panel whose mode is 'none'
(its options settle on the click) takes the button out of the layout entirely. The
panel outranks streaming: while one is open the button stays the answer control and a
streaming change is ignored. The icon moves with the meaning (paper plane, square,
check), because a paper plane that means “answer” is a lie. All three are decided by the root’s state
— its value, attachments, disabled, streaming and the panel payload it
broadcasts — not by anything this element owns, which is why it recomputes its chrome
rather than re-rendering: a rebuild would put a paper plane back mid-stream, drop out
of answer mode, and take the focus off the control most likely to be holding it.
“Empty” counts attachments: a pending attachment with no text still enables the button, because that is a message the composer can send.
It owns its subtree — the button is generated on connect and children placed inside
are replaced, so there is nothing to project. The host element itself is
display: contents, so it adds no box: the layout comes from whatever flex row you put
it in, and the CSS variables below style the inner button.
It needs an <aparte-composer> ancestor: without one the button renders disabled, no
root event ever reaches it, and a click has nothing to submit to.
It is not the place to gate on model selection: the opt-in
aparteGlobalConfig.setRequireModelSelection() gate already blocks this element’s
pointer events through aparte-composer[data-model-gated].
<!-- One button for both halves of the turn: it submits, and while a reply streams it becomes the stop button. --><aparte-composer> <div class="aparte-composer-row"> <aparte-composer-input></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div></aparte-composer>Composer cancel
Section titled “Composer cancel”<aparte-composer-cancel>
Cancel/stop streaming button primitive for <aparte-composer>.
Most composers should not use this element. <aparte-composer-send> already becomes
the stop button while a reply streams, so adding this one gives you a second, equally
working way to stop; reach for it only when you want stop to live somewhere the send
button is not.
It renders hidden, and only the root reveals it — the root.streaming check on connect,
then each streaming-change — so it needs an <aparte-composer> ancestor to be
reachable at all: standalone, nothing flips hidden and the click has no cancel() to
call. A locale or icon-set change is re-read in place rather than re-rendered, for the
same reason: a rebuild renders it hidden again, making the stop button vanish mid-turn.
It owns its subtree — the button is generated on connect and children placed inside
are replaced, so there is nothing to project. The host element is display: contents
and adds no box of its own; the row you put it in provides the layout, and the CSS
variables below style the inner button, which is deliberately a quiet action button
rather than a filled one.
<!-- Only needed when you want a SEPARATE stop button: <aparte-composer-send> already turns into one while streaming. This stays hidden until then. --><aparte-composer> <div class="aparte-composer-row"> <aparte-composer-input></aparte-composer-input> <aparte-composer-cancel></aparte-composer-cancel> <aparte-composer-send></aparte-composer-send> </div></aparte-composer>Composer action
Section titled “Composer action”<aparte-composer-action>
Generic action button primitive for <aparte-composer>.
The consumer declares it directly in markup — no global registration needed.
It is the escape hatch for a button core has no opinion about: it renders one icon
button wearing .aparte-action-button (the shared icon-button look — colour from
--aparte-neutral, hover tint derived from --aparte-primary) and emits
aparte-action-click. It carries no behaviour of its own and nothing in core listens
for that event, so the app is the only thing that can make it do something. Prefer the
dedicated element wherever one exists — <aparte-composer-send>,
<aparte-composer-cancel>, <aparte-composer-add-attachment> — since those already
talk to the composer.
The host is display: contents, so the <button> rather than this element is the flex
child of the surrounding .aparte-composer-row. It subscribes to the nearest composer’s
disabled and streaming changes, so it greys out while a turn is running without the
app tracking that. Used outside a composer it still mounts and still fires, with
composer: null in the detail.
A child already carrying class="aparte-cact-button" suppresses core’s own render — and
core then wires nothing to it: no click listener (so no aparte-action-click), no
label → aria-label/title write, no icon write, no disabled/streaming sync. Take
that path only for a button your own code drives end to end. Any other child is replaced
on the first render.
<!-- Inside a composer, because that is what it resolves with `closest()`. `action-id` is what tells two custom buttons apart: it comes back on the event's detail, and a second button without one is indistinguishable from the first. --><aparte-composer> <div class="aparte-composer-shell"> <div class="aparte-composer-row"> <aparte-composer-input></aparte-composer-input> <aparte-composer-action icon="star" label="Favourite" action-id="favourite"></aparte-composer-action> <aparte-composer-send></aparte-composer-send> </div> </div></aparte-composer>
<script> // The event bubbles, so one listener above the composer serves every action. document.addEventListener('aparte-action-click', (event) => { if (event.detail.actionId === 'favourite') console.log('starred'); });</script>Composer add attachment
Section titled “Composer add attachment”<aparte-composer-add-attachment>
File picker button for <aparte-composer>.
Opens a native file picker on click, then pushes picked files to root.addAttachments().
Also sets up drag & drop on the nearest <aparte-composer> root.
It only COLLECTS files: it never reads, uploads or renders them.
<aparte-composer-attachments> draws the pending strip, and sending is the host’s job
(event.detail.files on aparte-send) — which is why the default <aparte-chat> shell
only includes this button when the attachments attribute is set. With nothing reading
the files, an attach button is an affordance core cannot honour (ratified decision #8).
Drag & drop is installed on the composer ROOT, not on this button, so a drop anywhere
over the composer attaches and the root carries aparte-is-dragover while a drag is
over it. The dashed outline is drawn on .aparte-composer-shell when the markup has one
and on the composer element itself when it does not — width from
--aparte-focus-outline-width, colour from --aparte-primary, radius from
--aparte-radius-input, none of them declared here. The drop handler always calls
preventDefault(), even while disabled, so the browser can never navigate away to the
dropped file. disabled on the ROOT removes the drop target and greys the button;
streaming does neither — a file queued while a reply arrives is part of preparing
the next message, and only the send is gated meanwhile.
The label and the icon are not attributes — they come from the config (t('actionUpload')
and the paperclip icon), so a locale or icon-provider change rewrites the existing
button in place instead of re-rendering it.
A child already carrying class="aparte-caa-button" suppresses core’s own render — and
core then wires nothing to it: no click listener (so no picker opens), and no label,
icon or disabled writes. Drag & drop still works, since it is installed on the
root regardless. Any other child is replaced on the first render. The file input itself
is never a child: it is created on document.body per click and removed again.
<!-- Opt-in: nothing consumes the files unless your host does (an AparteClient, or your own listener reading `event.detail.files` off `aparte-send`). --><aparte-composer> <div class="aparte-composer-row"> <aparte-composer-add-attachment accept="image/*,.pdf"></aparte-composer-add-attachment> <aparte-composer-input></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div></aparte-composer>Composer attachments
Section titled “Composer attachments”<aparte-composer-attachments>
Renders a square thumbnail tile for each file attached to the root composer.
Image files show the actual picture; other files show an extension badge.
The filename and a remove (✗) button surface on hover. Clicking an image asks
the app to open it full-size (aparte-attachment-preview) — only when the app
declared attachmentPreview via aparteGlobalConfig.setHostHandlers().
Automatically hidden when there are no attachments. It reads the nearest
<aparte-composer> ancestor; without one it renders nothing and stays hidden.
This is the PENDING strip: what the user has attached and not yet sent. It mirrors
composer.attachments and rewrites itself on every attachments-change — it is not the
strip under a sent message, which the bubble draws with the same .aparte-thumb tile
rules (minus the remove button), so a tile variable set at the theme root reaches both
strips, while one set on this element reaches only this one. It owns its innerHTML and
therefore projects nothing:
children written inside it are discarded on the first render. Removing a tile calls
root.removeAttachment() rather than mutating a list of its own, and the image previews
are blob URLs minted per render and revoked on the next one and on disconnect.
<!-- The strip hides itself while nothing is attached. Pair it with the picker, and only if your loop actually reads the files from the send event. --><aparte-composer> <div class="aparte-composer-shell"> <aparte-composer-attachments></aparte-composer-attachments> <div class="aparte-composer-row"> <aparte-composer-add-attachment></aparte-composer-add-attachment> <aparte-composer-input></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div> </div></aparte-composer>Composer toolbar
Section titled “Composer toolbar”<aparte-composer-toolbar>
The composer’s bottom row — the strip a mode picker, a model selector or a token counter belongs in, rather than a bar of your own floating below the chat. Purely structural: it lays its children out in a row and gets out of the way.
Position is the DOM order. margin-inline-start: auto on a child pushes it (and
everything after it) to the end of the row. That is the whole placement API on
purpose: there is no left/right to be wrong about, so the row reads correctly in a
right-to-left locale without the author thinking about it.
The controls the row is made of can be any element, or plain text. Nothing is wrapped or
reordered — the children ARE the row, laid out by flex in DOM order, and they may arrive
after connection (a framework commits children in its own order). Non-whitespace TEXT
counts as content too, so a hand-written row holding a bare token count stays visible
instead of tripping the data-empty hide.
The row is not part of the default <aparte-chat> shell — nothing is drawn until you
put something in it.
It declares no custom property of its own: the gap, the padding and the top separator
come from the global --aparte-space-* and --aparte-border* tokens, so it inherits a
theme rather than exposing knobs to re-set.
<aparte-composer> <div class="aparte-composer-shell"> <div class="aparte-composer-row"> <aparte-composer-input></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div>
<!-- `aparte-model-selector` is NOT part of core: importing `@aparte/plugin-model-selector` is what defines it. Until then the tag renders empty and inert with no error, and upgrades by itself when the definition arrives. Any element of your own works here too. --> <aparte-composer-toolbar> <my-mode-picker></my-mode-picker> <aparte-model-selector style="margin-inline-start:auto"></aparte-model-selector> </aparte-composer-toolbar> </div></aparte-composer><aparte-composer>
Section titled “<aparte-composer>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
placeholder | Fallback placeholder for <aparte-composer-input>, which reads it off this element when it carries none of its own. Changing it here reaches the inputs already on the page too. |
disabled | Disables the composer’s own controls — the input, send, add-attachment and <aparte-composer-action> buttons each read it. What you put in the toolbar is yours to disable. |
target | The id of the <aparte-chat> this composer drives. |
submit-on-enter | Enter sends and Shift+Enter breaks the line (the default); set it to the string "false" to swap them. Read lazily by the submitOnEnter getter rather than observed, which is why it was missing from the manifest — and so from every typed surface — while all four wrappers wrote it. |
data-panel-active | Reflected BY the element while a panel (an elicitation, an approval) is mounted inside it; the shipped stylesheet keys off it. Read-only. |
data-panel-mode | Reflected BY the element while a panel is active: what the send button does meanwhile ('none' removes it — through this attribute, not by reaching into the button). Read-only. |
data-model-gated | Reflected BY the element when requireModelSelection is on and no model is selected: sending is blocked and the shipped stylesheet greys the composer. Read-only. |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
value (readonly) | string | |
streaming (readonly) | boolean | |
disabled (readonly) | boolean | |
submitOnEnter (readonly) | boolean | When false, Shift+Enter submits and a bare Enter inserts a newline — the inverse of the default. Driven by the submit-on-enter attribute. |
attachments (readonly) | File[] | |
placeholder (readonly) | string | |
targetId (readonly) | string | null | |
panelActive (readonly) | boolean |
Methods
Section titled “Methods”| Method | Description |
|---|---|
getState(): AparteComposerState | Snapshot of the composer’s observable state. Pair with the aparte-composer-change DOM event to drive a custom send button or footer control that lives outside the composer package: |
setValue(value: string): void | Set the composer’s value — both what a send will submit and what the editor shows. <aparte-composer-input> writes through any value it does not already hold, so this prefills the visible field (a template button, a restored draft) as readily as it stages text for an immediate submit(). |
addAttachments(files: FileList | File[]): void | Append files to the pending attachments and notify. Does not de-duplicate. |
removeAttachment(file: File): void | Drop one pending attachment and notify. Matched by IDENTITY — pass the same File object the composer handed you, not an equal one; two picks of the same file on disk are two distinct objects. |
clearAttachments(): void | Drop every pending attachment and notify. |
showPanel(panel: HTMLElement, options?: { submitEnabled?: boolean; onSubmit?: () => void; /** * What the send button means for this panel — ’none’ if it has no act * for it, in which case the button is not drawn. See * {@link AparteComposerPanelMode}. */ mode?: AparteComposerPanelMode; /** Called when something other than this owner closes the panel. */ onEvict?: () => void; }): symbol | Inject a panel into the composer. The send button calls onSubmit when clicked. While a panel is up, the composer is answering a QUESTION, not composing a message — so the affordances that lead nowhere go away with the text input: the attachment picker above all, which stayed clickable while the user was being asked something (“on voyait encore l’icône de upload”, reported from a real session). Ratified decision #8: an affordance nothing can honour is not rendered. What STAYS, deliberately: the attachments strip, because pending attachments are the user’s state and not an action to offer — hiding them would look like losing them; and the toolbar, because switching model still does something. The send button is the part the PANEL decides, through mode. 'submit' keeps it; 'none' says this panel has no act for it and it is not drawn. That third value is what a panel whose options settle on the first click needs — a single-choice question, an approval — and until it existed such a panel left a permanently disabled button beside options that never routed through it. Flip between them at any time with setPanelSubmitEnabled, which is how a panel that grows an act (an “Other…” field, a written instruction) turns the button back on. Declared with an attribute + CSS rather than the inline style.display this used to set on a child: an attribute is themeable, is visible to a consumer’s own rules, and does not clobber a display the consumer had set (the restore wrote '', not the previous value). Returns the TOKEN for this panel. Pass it back to hidePanel so a presenter that has already lost the slot cannot close the panel that replaced it, and supply onEvict to be told when that happens — an owner that is not told is an owner whose promise nobody can settle. |
hidePanel(token?: symbol): void | Remove the panel, tell its owner, and restore the composer’s own controls. With a token, this closes the panel only if that token still owns the slot — so a presenter settling late cannot tear down the panel that replaced it. With no token it closes whatever is there, which is what a consumer driving the composer directly means. BOTH forms notify. The no-token form used to call _teardownPanel directly, which nulls _panelOnEvict without ever calling it — so the documented public hidePanel() closed an open approval panel and left its request pending forever. The turn hung on “waiting for you”, and because AparteConfig.requestUserInput chains each request on the previous one, NO further question or approval on that config was ever presented again, for the life of the page. The old JSDoc justified the silent branch as “what reset() needs”. It was not: reset() calls _evictPanel(), which notifies. The branch had no consumer and one failure mode. The two forms differ on purpose, and the difference is who already knows: - With a matching token the OWNER is closing its own panel, which is what <aparte-elicitation>’s close() does right after it resolves. It must NOT be notified — telling it “you were evicted” for a request it just settled would fire onEvict against a finished promise. - With no token somebody else is closing a panel they do not own, so the owner cannot know and has to be told. That includes the presenter’s own defensive hidePanel(undefined) when it settles before showPanel ran: whatever is open then belongs to another request, and that request must not orphan. |
setPanelSubmitEnabled(enabled: boolean, mode?: AparteComposerPanelMode): void | Update the send button’s state while a panel is active. mode moves with it because both can change on the same event — opening the “Other…” field of a panel whose options settle on the click gives the button an act it did not have — and two separate calls would flash a wrong state between them. |
submit(): void | Submit the current value. Called by aparte-composer-send or programmatically. |
cancel(): void | Cancel the current streaming response. |
reset(): void | Reset the composer to its initial state. Clears value, attachments, and hides any active panel. Call this when switching conversations. |
focus(): void | Focus the input primitive inside this composer. |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-send | CustomEvent<AparteSendEventDetail> | A message was submitted: the text, its attachments and the target. |
aparte-cancel | CustomEvent | The stop button was pressed. No detail; the two window events below carry the target. |
aparte-composer-change | CustomEvent<AparteComposerChangeEventDetail> | Any of value / streaming / disabled / attachments / panel changed, folded into one event. |
aparte-abort | CustomEvent<AparteAbortEventDetail> | Dispatched on window: stop the run for this target. |
aparte-message-aborted | CustomEvent<AparteMessageAbortedEventDetail> | The run for this target ended early — the user pressed Stop, or abort() was called. This element dispatches it on window; AparteClient also dispatches it on the chat host, so it is listenable on either. |
<aparte-composer-input>
Section titled “<aparte-composer-input>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
placeholder | Placeholder text (fallback: reads from aparte-composer) |
max-height | Max height in px before scroll (default: 200) |
min-height | Min height in px. When omitted, the stylesheet’s min-height governs (44px in aparte.css) — so themes can resize the editor in pure CSS without being fought by an inline height. |
disabled | Makes the field non-editable; the composer’s own disabled also reaches it. |
Methods
Section titled “Methods”| Method | Description |
|---|---|
getValue(): string | The editor’s text, with <br> serialized back to newlines — textContent alone would collapse a multi-line draft onto a single line. |
setValue(value: string): void | Replace the editor’s content and mirror the value onto the parent composer. |
clear(): void | Empty the editor and mirror the empty value onto the parent composer. |
syncPlaceholder(): void | Re-read the placeholder fallback chain. Called by the composer when ITS placeholder changes — this element observes only its own attribute, and the composer’s is one of the three sources it falls back to. |
focus(): void | Focus the inner contenteditable rather than the host element. |
blur(): void | Blur the inner contenteditable rather than the host element. |
focusEnd(): void | Focus the editor and place the caret at the very end of its content. |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-composer-submit | CustomEvent | A submitting Enter was pressed with no <aparte-composer> ancestor to submit to; with one it calls root.submit() and dispatches nothing. No detail — the host that placed this primitive reads getValue(). |
<aparte-composer-send>
Section titled “<aparte-composer-send>”<aparte-composer-cancel>
Section titled “<aparte-composer-cancel>”<aparte-composer-action>
Section titled “<aparte-composer-action>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
icon | Icon key for aparteGlobalConfig.getIcon(), or raw SVG/HTML starting with < |
label | Accessible label (also used as tooltip) |
disabled | Disables the button |
action-id | Identifies WHICH button fired; carried as AparteActionClickEventDetail.actionId. Read lazily at dispatch time rather than observed, so changing it takes effect on the next click. |
Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-action-click | CustomEvent<AparteActionClickEventDetail> | Bubbles up when the button is clicked, carrying which button it was and the composer it belongs to. The type argument is not decoration: a BARE @fires records CustomEvent with no argument, and the bindings generator then emits EventEmitter<void> with a listener that drops $event — so an Angular consumer with two custom buttons could not tell which one fired. detail: { actionId: string, composer: AparteComposer | null } |
<aparte-composer-add-attachment>
Section titled “<aparte-composer-add-attachment>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
accept | MIME types / extensions passed to the file input (e.g. “image/*,.pdf”) |
multiple | Allow multiple file selection (default: true) |
disabled | Greys out the picker. Drops are gated by the composer root’s disabled, not by this one. |
<aparte-composer-attachments>
Section titled “<aparte-composer-attachments>”Events
Section titled “Events”| Event | Type | Description |
|---|---|---|
aparte-attachment-preview | CustomEvent<AparteAttachmentPreviewEventDetail> | An attached image was clicked; the app opens it full-size, and only if it declared attachmentPreview. |
<aparte-composer-toolbar>
Section titled “<aparte-composer-toolbar>”Attributes
Section titled “Attributes”| Attribute | Description |
|---|---|
data-empty | Reflected BY the element while it holds neither an element child nor non-whitespace text; the stylesheet hides it then. Read-only, do not set it yourself. |
Theming
Section titled “Theming”Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.
<aparte-composer>
Section titled “<aparte-composer>”| Variable | Default | Description |
|---|---|---|
--aparte-composer-control-size | var(--aparte-btn-size-lg) | Width and height of the composer’s own control buttons (each is an .aparte-btn--icon, so it needs the opt-in .aparte-composer-row wrapper, which is what carries the size down) and the minimum height of the input’s editor, which needs no wrapper. One knob for the whole control set, so buttons stay aligned with a single line of text and anchored to the bottom once the input grows. It reaches the buttons by declaration, not by out-specifying them, so a panel mounted in the row keeps its own content’s sizing. |
--aparte-input-bg | var(--aparte-surface-1) | Background of the opt-in .aparte-composer-shell wrapper. |
--aparte-input-border | var(--aparte-border) | Border colour of that shell. Its :focus-within colour is --aparte-primary, a global token rather than a composer one. |
--aparte-radius-input | var(--aparte-radius-lg) | Corner radius of the shell, and of the dashed outline drawn while files are dragged over the composer. |
--aparte-message-max-width | 800px | Max width of the shell, which is margin: 0 auto at this width — the same width .aparte-message uses, so the composer keeps the transcript’s column. Set on THIS element it moves the shell only: custom properties inherit downward and the transcript is a sibling subtree, so set it on a shared ancestor (the chat host, :root) to move both. |
--aparte-viewport-padding | var(--aparte-space-8) | Inline gutter between this element and the chat’s edge — the same token the transcript reads, so the shell keeps .aparte-message’s column BELOW the max-width above as well as above it, where the two would otherwise diverge by the transcript’s whole inset. Only the inline halves are read here: the block axis is spaced by the viewport above and --aparte-chat-bottom-gap below. A container narrower than 520px tightens the transcript’s copy of this token but not the composer’s — a container query cannot reach this element, which is its own container root. |
<aparte-composer-input>
Section titled “<aparte-composer-input>”| Variable | Default | Description |
|---|---|---|
--aparte-composer-control-size | var(--aparte-btn-size-lg) | Single-line min-height of the editor. Inside the .aparte-composer-row layout helper the composer’s buttons read the same token, so one value resizes that whole control set and the row stays aligned. |
--aparte-input-padding-y | calc((var(--aparte-composer-control-size) - 1lh) / 2) | Vertical padding inside the editor. |
--aparte-input-padding-x | var(--aparte-space-6) | Horizontal padding inside the editor. |
--aparte-input-font-size | var(--aparte-font-size-base) | Editor font size. |
--aparte-input-line-height | var(--aparte-line-height-normal) | Editor line height — also what the auto-expand measures, so changing it changes the height the editor settles at (until max-height clamps it). |
--aparte-text | — | Text and caret colour of the editor. |
--aparte-input-placeholder | — | Colour of the placeholder drawn by :empty::before (falls back to --aparte-text-muted). |
--aparte-input-bg | — | Field background, applied only when this input is the bubble’s inline editor (.aparte-message[data-editing]) — inside a composer the shell paints the surface instead. |
--aparte-input-border | — | Border colour of that same edit-mode box. |
--aparte-radius-input | var(--aparte-radius-lg) | Corner radius of the edit-mode box. |
--aparte-input-focus-border | — | Border colour of the edit-mode box while it holds focus (:focus-within). |
<aparte-composer-send>
Section titled “<aparte-composer-send>”| Variable | Default | Description |
|---|---|---|
--aparte-composer-control-size | var(--aparte-btn-size-lg) | Width/height of the button inside the .aparte-composer-row layout helper, shared with the input’s single-line height so the row stays aligned. It wins over --aparte-send-btn-size there. |
--aparte-send-btn-size | var(--aparte-btn-size-lg) | Width/height of the button outside that row helper. On coarse pointers it is raised to --aparte-touch-target-size. |
--aparte-touch-target-size | 44px | Hit-area floor applied to the button under @media (pointer: coarse). |
--aparte-radius-send-btn | var(--aparte-radius-md) | Corner radius of the button. |
--aparte-primary | — | Button background. |
--aparte-primary-hover | — | Button background on hover, while enabled. |
--aparte-on-primary | — | The glyph’s colour. Undeclared by default, which means the recipe derives it from --aparte-primary itself, so a theme that changes the fill gets a readable glyph with no second edit. Declare it to choose one — it then applies to every primary control, which is the honest scope. |
--aparte-ink-flip | 0.57 | Fill lightness at which the derived ink flips from dark to light, for every solid control. |
--aparte-ink-dark | 0.176 | How dark that derived ink goes. Not 0: at zero lightness OKLCH drops the chroma, and the ink loses the fill’s own hue. |
--aparte-send-disabled-bg | — | Background while disabled (falls back to --aparte-primary, which is then dimmed by opacity). |
<aparte-composer-cancel>
Section titled “<aparte-composer-cancel>”| Variable | Default | Description |
|---|---|---|
--aparte-composer-control-size | var(--aparte-btn-size-lg) | Width/height of the button inside the .aparte-composer-row layout helper, shared with the composer’s other controls so the row stays aligned. |
--aparte-radius-action-btn | var(--aparte-radius-sm) | Corner radius of the button. |
--aparte-neutral | — | Icon colour at rest (the button’s background is transparent). |
--aparte-text | — | Icon colour on hover. |
--aparte-surface-2 | — | Button background on hover. |
<aparte-composer-action>
Section titled “<aparte-composer-action>”| Variable | Default | Description |
|---|---|---|
--aparte-input-action-btn-size | var(--aparte-btn-size-lg) | Square size of the button. On a coarse pointer the stylesheet re-sets it to --aparte-touch-target-size (44px) on .aparte-action-button itself, which wins over a value inherited from your theme. |
--aparte-input-action-btn-icon-size | 20px | Size of the <svg> inside it. |
--aparte-radius-action-btn | var(--aparte-radius-sm) | Corner radius. |
<aparte-composer-add-attachment>
Section titled “<aparte-composer-add-attachment>”| Variable | Default | Description |
|---|---|---|
--aparte-input-action-btn-size | var(--aparte-btn-size-lg) | Square size of the button. On a coarse pointer the stylesheet re-sets it to --aparte-touch-target-size (44px) on .aparte-action-button itself, which wins over a value inherited from your theme. |
--aparte-input-action-btn-icon-size | 20px | Size of the <svg> inside it. |
--aparte-radius-action-btn | var(--aparte-radius-sm) | Corner radius. |
<aparte-composer-attachments>
Section titled “<aparte-composer-attachments>”| Variable | Default | Description |
|---|---|---|
--aparte-attachments-max-height | 140px | Height cap on the strip; past it the tiles scroll instead of pushing the composer up. |
--aparte-attachment-image-size | 72px | Tile edge. The stylesheet sets 56px on this element (the :root default is 72px, and the sent-message strip re-sets 40px on itself), so a theme-level value reaches neither strip — target aparte-composer-attachments to resize these tiles. |
--aparte-thumb-radius | var(--aparte-radius-lg) | Tile corner radius. |
--aparte-attachment-chip-bg | var(--aparte-surface-2) | Tile background, seen behind a non-image file. |
--aparte-attachment-chip-border | var(--aparte-border) | Tile border colour. |
--aparte-thumb-name-color | #ffffff | Filename colour on the hover overlay. |
--aparte-thumb-name-scrim | linear-gradient(to top, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0)) | Background behind the filename; a bottom-up black gradient by default, so the name stays legible over any picture. |
--aparte-thumb-name-padding | 14px 5px 4px | Padding of that overlay. |
--aparte-thumb-remove-size | 18px | Diameter of the ✗ button. |
--aparte-thumb-remove-inset | 3px | Its inset from the tile’s top and right edges (physical right, so it does not flip in a right-to-left locale). |
--aparte-thumb-remove-bg | rgba(0, 0, 0, 0.6) | Its background. |
--aparte-thumb-remove-bg-hover | rgba(0, 0, 0, 0.85) | Its hover background. |
--aparte-thumb-remove-color | #ffffff | Its glyph colour. |
In a framework
Section titled “In a framework”The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.
<aparte-composer disabled=""></aparte-composer>el.addEventListener('aparte-send', (e) => use(e.detail));<aparte-composer disabled=""></aparte-composer>The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. A presence attribute takes '', never true — React stringifies it, and disabled={false} would render disabled="false", which hasAttribute reads as on. Events reach you by ref, typed through the DOM.
<template> <aparte-composer disabled="" @aparte-send="(e) => use(e.detail)" ></aparte-composer></template>Declared through Vue’s GlobalComponents, so vue-tsc checks the tag in any template. A presence attribute takes '' to set and null to remove, never false.
<aparte-composer disabled="" on:aparte-send={(e) => use(e.detail)}></aparte-composer>Declared through SvelteHTMLElements, so svelte-check covers the attributes and the on: handlers. A presence attribute takes '', never false.
import { AparteComposerDirective } from '@aparte/angular';<aparte-composer [disabled]="true" (send)="use($event)"></aparte-composer>A standalone directive whose selector IS the tag, so the real element sits in the template — @if, @for and content projection all reach it — and no CUSTOM_ELEMENTS_SCHEMA is needed.
Installation and the framework-specific traps: React · Vue · Svelte · Angular.