feat(compose): the send pill is the cancel, in every editor

A send used to answer by taking the window away and offering the way back
somewhere else — an Undo in a toast for the dock, a countdown bar in the
thread for an inline reply — plus a "Cancel" link that appeared beside the
pill while the request was out. Three surfaces for one act, none of them where
the user had just clicked, two of them in a window that no longer existed.

The window now stays exactly where it is for the cancel window. Its Send
button reads "Sending…" with living dots and "click to cancel" under it, and a
second click on that same button calls the send off. Both faces are stacked in
one grid cell and only toggle visibility, so the button cannot change size when
a send starts, when it ends, or on any frame of the animation.

The server's answer no longer re-renders anything: it hands the live window the
two URLs it cannot address by itself (compose/_sending.stream), and when the
offer expires the window asks ComposeController::sent() to file the message and
close it. A forward starts its own conversation, so the settle streams close the
frame unconditionally and touch the thread only when it is the thread on screen
— without that a forwarded mail left the composer reading "Sending…" for ever,
addressing ids that were nowhere in the page.

A toast now says the message went, because a composer that simply vanished was
the one moment in the flow with no feedback at all. Cancelling says nothing: the
draft coming back with everything in it is the answer, and the dock's old "Send
cancelled" toast was the last asymmetry between the two composers.

Also here, because they are the same window:

  • The inline composer gets a reduced header — the To field itself, typable,
    with From/Cc/Bcc/Subject folded behind the chevron. A forward opens needing
    exactly one thing and used to open with a label where that thing goes.
  • The suggestion panel opens on typing and on nothing else. `openOnFocus` is
    read at focus time and a forward's caret beats the code that sets it, so an
    empty query is refused at the moment the panel opens instead.
  • It overlays the body rather than reserving room, which in a header meant
    growing the header and shoving the editor down on every keystroke.
  • `mode` moved onto ComposeContext. As a render-time extra it was set by one
    action and lost by every other render, so a cancelled forward came back as a
    plain new message and asked whether you meant to send an empty mail.

compose/_send_toast, compose/_inline_send.stream, compose--undo-send and
compose--inline-send are kept, unwired and documented as such, while the
replacement is reviewed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-18 19:46:52 +02:00
1 parent bea74d7c71
commit 35cf17f05d
28 files changed
+1574 -283

No files matched your search

@@ -1,8 +1,15 @@
{#
compose/_undo_toast.html.twig
compose/_dock_undo.stream.html.twig
─────────────────────────────────────────────────────────────────────────────
Cancelling a send from the dock: say so, and put the window back exactly as it
was — recipients, subject, body and attachments included.
Cancelling a send from the dock: put the window back exactly as it was —
recipients, subject, body and attachments included.
**No toast.** There was one — "Send cancelled — back to your draft" — and it
was the last asymmetry left between the two composers: the inline cancel never
had one, because there the draft reappearing in the thread was the answer. It
is the answer in the dock too. The window coming back with everything in it
says more than a line of text can, and the button that was clicked has already
said the rest.
`replace` rather than `append`, and `only` rather than an inherited context:
@@ -19,15 +26,6 @@ was — recipients, subject, body and attachments included.
Variables: form, message, ctx, pickerIntegrations
#}
<turbo-stream action="append" target="toast-region">
<template>
{% include '_partials/_toast.html.twig' with {
type: 'info',
message: 'toast.send_cancelled'|trans,
} only %}
</template>
</turbo-stream>
<turbo-stream action="replace" target="{{ ctx.frame }}">
<template>
{% include 'compose/_window.html.twig' with {
@@ -1,8 +1,15 @@
{#
compose/_inline_send.stream.html.twig
─────────────────────────────────────────────────────────────────────────────
Response to sending from the inline reply card. No toast: the message joins
the open conversation and the reply bar turns into a cancel countdown.
Response to sending from the inline reply card. **NOT RENDERED ANY MORE.**
The other half of the pair the send rewrite replaced — where the dock got a
toast, the thread got a countdown bar in its reply zone, and both took the
composer away to do it. One surface now: the window stays and its Send pill is
the cancel. What this did on send, compose/_sent.stream.html.twig now does
when the cancel window runs out.
Kept, unwired, while that is reviewed. compose--inline-send goes with it.
Variables: message, thread (MessageThread|null), undoUrl, delay
#}
+57 -16
View File
@@ -78,27 +78,68 @@ Variables: message, params (from the including context); placement.
}),
}|json_encode|e('html_attr') }}">
{# Hidden until a send is in flight. While the request is out the pill
says "Sending" with living dots, and this is the way out: a click here
marks the send as unwanted, and the moment the server answers with its
cancel window (every send gets one — see CANCEL_WINDOW_MS) the undo is
fired on the user's behalf. Honest about the race: if the window is
already gone the cancel simply fails the way a late click on the
countdown bar does. #}
<button type="button" hidden
data-compose--compose-target="sendCancel"
data-action="click->compose--compose#cancelInFlight"
class="flex items-center justify-center text-xs text-ink-muted underline
px-2 py-1.5 max-md:min-h-11 hover:text-ink transition-colors">
{{ 'compose.sending_cancel'|trans }}
</button>
{# ── Send, and while a send is in flight, the way out of it ──────────
ONE control, two faces. The window used to grow a second button beside
the pill while a send was out ("Cancel"), and the dock answered a send
by closing itself and putting an Undo in a toast — so at any moment
there were two different places a cancel might live and neither was
where the user had just clicked. The pill IS the cancel now: it says
"Sending" with living dots, "click to cancel" under it, and a click on
it calls the send off. Nothing else appears anywhere.
**The two faces are stacked in one grid cell, and that is the whole
sizing story.** Both spans sit at `col-start-1 row-start-1`, so the cell
— and therefore the button — is as wide and as tall as the LARGER of
them, at rest and in flight alike. Switching between them toggles
`invisible` (visibility, which keeps the box) rather than `hidden`
(display, which does not), so the button cannot change size when a send
starts, when it ends, or on any frame of the animation in between. The
dots are three real characters that are always there and only change
opacity, for the same reason: a label that grows a character per tick is
a button that jitters four times a second.
The resting pill is a few pixels taller than it was, because it now
reserves the two-line face it will need. Below md that is invisible
anyway — `min-h-11` was already taller than both. #}
<div class="flex items-center rounded-full overflow-hidden bg-accent">
<button type="submit"
data-compose--compose-target="sendBtn"
class="flex items-center justify-center text-white text-sm font-semibold
data-action="click->compose--compose#sendOrCancel"
class="grid items-center justify-items-center cursor-pointer
text-white text-sm font-semibold
px-4 py-1.5 max-md:min-h-11 hover:bg-white/10 transition-colors">
{{ 'compose.send'|trans }}
<span data-compose--compose-target="sendLabel"
class="col-start-1 row-start-1 leading-5 whitespace-nowrap">
{{- 'compose.send'|trans -}}
</span>
{# `invisible`, not `hidden` — see above. Kept out of the
accessibility tree while it is invisible by visibility:hidden
itself, so the button reads "Send" at rest and "Sending… click
to cancel" in flight, with no aria juggling. #}
<span data-compose--compose-target="sendProgress"
class="col-start-1 row-start-1 invisible text-center leading-tight">
<span class="block whitespace-nowrap">
{# The word without its ellipsis: the three dots after it
are the animation, and a label that already ended in one
would make four. #}
{{- 'compose.sending'|trans|replace({'…': '', '...': ''})|trim -}}
{#- Three dots that never leave; only their opacity moves.
Whitespace-trimmed on both sides, or the template's own
indentation lands between the word and them and the
button reads "Sending ." -#}
<span data-compose--compose-target="sendDots" aria-hidden="true"
><span>.</span><span>.</span><span>.</span></span>
</span>
{# The instruction, not a second button. Hidden (again by
visibility) for a scheduled send, which has nothing to call
off from here — the menu owns that. #}
<span data-compose--compose-target="sendCancelHint"
class="block text-[10px] font-normal opacity-80 whitespace-nowrap">
{{- 'compose.click_to_cancel'|trans -}}
</span>
</span>
</button>
<button type="button"
data-action="click->ui--dropdown#toggle"
+11 -1
View File
@@ -1,7 +1,17 @@
{#
compose/_send_toast.html.twig
─────────────────────────────────────────────────────────────────────────────
The dock's "Sending… — Undo" toast.
The dock's "Sending… — Undo" toast. **NOT RENDERED ANY MORE.**
Kept, unwired, while the replacement is reviewed. The dock no longer closes
when a send goes out: the window stays put and its Send pill becomes the
cancel — one surface, in the place the user just clicked, in every editor. See
compose/_sending.stream.html.twig, compose/_sent.stream.html.twig and
ComposeController::sendResponse(). compose--undo-send, this template's
controller, is unwired with it.
Everything below is the old behaviour, left verbatim.
`hide_after` is ComposeController::CANCEL_WINDOW_MS rather than a literal, and
that matters: this toast's lifetime IS the cancel window, because the Undo link
@@ -0,0 +1,36 @@
{#
compose/_sending.stream.html.twig
─────────────────────────────────────────────────────────────────────────────
The answer to a send, and it changes nothing on screen.
That is the point of it. Every earlier version of this response took the
window away — the dock's closed into a toast carrying an Undo link, the
inline one was replaced by a countdown bar in the thread — so the cancel was
always offered somewhere other than where the user had just clicked, and the
window had to be rebuilt from scratch to get it back. The window now stays,
its Send pill reads "Sending… click to cancel" for the whole cancel window,
and this stream's only job is to hand it the two things it cannot know by
itself:
• `undoUrl` — how to call the send off. Addressed by the message id, which
for a message composed and sent without an autosave is
minted by the send itself, so it cannot be baked into the
window at render time.
• `settleUrl` — how to tidy up when the offer expires: put the sent message
in the conversation and take the window away. See
ComposeController::sent().
Appended into the frame rather than replacing anything, and it takes itself
back out as soon as it has spoken — see compose--send-hold.
Variables: ctx, undoUrl, settleUrl, cancelWindow
#}
<turbo-stream action="append" target="{{ ctx.frame }}">
<template>
<div hidden
data-controller="compose--send-hold"
data-compose--send-hold-undo-url-value="{{ undoUrl }}"
data-compose--send-hold-settle-url-value="{{ settleUrl }}"
data-compose--send-hold-delay-value="{{ cancelWindow }}"></div>
</template>
</turbo-stream>
+86
View File
@@ -0,0 +1,86 @@
{#
compose/_sent.stream.html.twig
─────────────────────────────────────────────────────────────────────────────
The cancel window is over: the message is where it belongs, the composer is
gone, and a toast says so.
Rendered when the composer asks for it (ComposeController::sent(), driven by
compose--compose#_settleSend) and when a cancel arrives too late to take —
both are the same fact, "this message has gone", and they must leave the same
page behind.
Everything here used to happen the instant the send was accepted, which is
what made the window's own pill useless as a cancel surface: it was not on
screen any more. Deferring it by the cancel window is the whole shape of this
round.
**The conversation is only touched when it is the conversation on screen**,
and that condition is not decoration — it is the forward bug. A reply is
filed in the thread it answers, so `ctx.thread` (the thread the composer was
opened against) and the sent message's thread are the same and the message can
simply join the conversation below. A FORWARD starts a new conversation: the
sent message lands in a thread that is nowhere on the page, so the three
streams below addressed `thread_messages_…` and `reply_zone_…` ids that did
not exist, every one of them was silently dropped, and the composer sat there
saying "Sending…" for ever. Closing the frame is therefore unconditional and
comes first; the conversation is updated only when there is one to update.
A forward's mail is not shown anywhere on this page as a result, because it is
not part of this conversation — it is in Sent, and the toast is what says it
went.
Variables: message, thread (MessageThread|null), ctx, notify (default true)
#}
{# The too-late answer includes this template for the tidying and brings its
own toast — an error one, which is the news that matters there. Two toasts
stacked, one saying the send failed to stop and one saying it went, is the
same fact told twice with the second half burying the first. #}
{% set notify = notify|default(true) %}
{# Is the sent message part of the conversation this composer was opened in?
Both halves are needed: the dock carries `thread` too (a reply opened below
md lands there), and a message can have no thread at all. #}
{% set inConversation = thread is not null and ctx.thread is not null and thread.id == ctx.thread %}
{# The composer goes, wherever it was. For an inline window that frame IS the
window's root; for the dock it is the dock. Emptying it disconnects
compose--compose, which is what gives a thread its Reply / Forward buttons
back — see the `composing` class in app.css. #}
<turbo-stream action="update" target="{{ ctx.frame }}">
<template></template>
</turbo-stream>
{% if inConversation %}
{# The draft's row is already in the conversation — hidden while the editor
was open — so it is dropped and the sent message re-appended at the end,
which is where it now belongs. #}
<turbo-stream action="remove" target="thread_message_{{ message.id }}">
</turbo-stream>
<turbo-stream action="append" target="thread_messages_{{ thread.id }}">
<template>
{% include 'mail/_thread_message.html.twig' with { message: message, expanded: true, draft: false } only %}
</template>
</turbo-stream>
{# Redrawn without the composer and without the countdown bar this used to
carry: the pill was the only cancel there was, and it is spent. #}
<turbo-stream action="replace" target="reply_zone_{{ thread.id }}">
<template>
{% include 'mail/_reply_zone.html.twig' with { thread: thread } only %}
</template>
</turbo-stream>
{% endif %}
{# The one piece of feedback the send gets that is not the button itself.
Without it a mail sent from the dock was a window that simply vanished, and
a forward was a composer that closed with nothing said at all. #}
{% if notify %}
<turbo-stream action="append" target="toast-region">
<template>
{% include '_partials/_toast.html.twig' with {
type: 'success',
message: 'toast.sent'|trans,
} only %}
</template>
</turbo-stream>
{% endif %}
+96
View File
@@ -0,0 +1,96 @@
{#
compose/_to_row.html.twig
─────────────────────────────────────────────────────────────────────────────
The To row, and directly under it the refusal that belongs to it.
Rendered in ONE of two places, never both, and which one depends on where the
window lives:
• `compact: true` — in an inline window's HEADER, where the recipient
summary used to be. That summary was a button reading "to Someone" (or
"No recipients") that unfolded the whole address block, and on a forward —
which opens with no recipient at all and needs exactly one thing typed
into it — it was a label standing in front of the only field that
mattered. It IS the field now: same widget, same target, same everything
below, just laid out for a header row. The chevron beside it still unfolds
From/Cc/Bcc/Subject for the rarer case.
• `compact: false` — first in the address block of a dock window, where it
has always been.
One partial rather than two markups because everything that reads this row
addresses it by target — the Tom Select taming, the panel-room reservation,
the tab order, the aria wiring, the invalid marking — and a second copy is a
second thing for those to drift out of step with.
The <label for> is load-bearing, not decoration: Tom Select hides the <select>
and builds its own text input, and copies the accessible name across only when
a label is bound to the select's id. The controller mirrors it onto the
generated input as well, belt with braces.
Variables: form, compact (default false)
#}
{% set compact = compact|default(false) %}
<div class="{{ compact ? 'min-w-0 flex-1' : 'shrink-0' }}">
<div data-compose--compose-target="toField"
{# The suggestion panel OVERLAYS from here rather than pushing the
window about. In the dock the panel is given real room — it hangs
over the Subject row otherwise, and a click aimed at Subject
commits a contact nobody chose (see
compose--compose#_reserveRoomForPanel). Here there is no Subject
below to cover: the row is the header, and reserving room for the
panel grew the header and shoved the whole editor down every time
a result arrived. This attribute is what tells the controller which
of the two rows it is looking at. #}
{% if compact %}data-compose-overlay-panel{% endif %}
{# `select-text`: the inline header carries `select-none` so the
window chrome cannot be dragged into a text selection, and it is
inherited — which in a header that now contains a typing surface
would fight the caret. #}
class="flex items-center gap-3 min-h-[36px] select-text
{{ compact ? '' : 'px-4 py-1.5' }}">
<label for="{{ form.toAddresses.vars.id }}"
id="{{ form.toAddresses.vars.id }}_label"
class="text-xs font-medium text-ink-faint shrink-0
{{ compact ? '' : 'w-10 text-right' }}">{{ 'compose.to'|trans }}</label>
<div class="flex-1 min-w-0">
{{ form_widget(form.toAddresses) }}
</div>
<div class="flex gap-2 shrink-0">
<button type="button"
data-compose--compose-target="ccBtn"
data-action="click->compose--compose#showCc"
{# title, not aria-label: the button already has an
accessible name — the word "Cc" on its face — and
an aria-label would replace it rather than add to
it, leaving the visible label and the spoken one
saying different things (WCAG 2.5.3). #}
title="{{ 'compose.add_cc'|trans }}"
class="text-xs text-ink-faint hover:text-accent transition-colors">
{{ 'compose.cc'|trans }}
</button>
<button type="button"
data-compose--compose-target="bccBtn"
data-action="click->compose--compose#showBcc"
title="{{ 'compose.add_bcc'|trans }}"
class="text-xs text-ink-faint hover:text-accent transition-colors">
{{ 'compose.bcc'|trans }}
</button>
</div>
</div>
{# Why a send was refused. Rendered from the form rather than from a
toast so it sits with the field it is about, and so a refusal
that came from the server reads the same as one the window
caught itself. It travels WITH the row: in a compact header the
address block below is folded away, and a refusal nobody can see is
the oldest bug in this window. #}
<div data-compose--compose-target="errors"
class="{{ compact ? '' : 'px-4' }} {{ form.toAddresses.vars.errors|length > 0 ? 'py-1' : 'hidden' }}"
role="alert" aria-live="polite">
<p class="text-xs text-danger">
{%- for error in form.toAddresses.vars.errors %}{{ error.message }}{% endfor -%}
</p>
</div>
</div>
+20 -9
View File
@@ -4,7 +4,7 @@ compose/_undo_too_late.html.twig
The cancel arrived after SendMessageHandler had already claimed the message.
This template exists because the alternative was lying. Every undo used to
answer with _undo_toast.html.twig — "send cancelled", window restored — whether
answer with the dock-undo stream — "send cancelled", window restored — whether
or not the cancel had actually taken, and a cancel clicked in the last fraction
of the hold does not take. The user was told the one thing that was not true
about the one action that cannot be reversed.
@@ -14,10 +14,17 @@ not happen; and no window, because there is no draft to reopen. Restoring the
composer here would put an editable copy of an already-sent mail on screen, and
the next autosave would file it as a draft standing beside the sent copy.
The dock is emptied for the same reason the send toast empties it — whatever
window this cancel came from is describing a message that has gone.
And the page is left exactly as a send that ran its course leaves it, by
including the very same partial: whatever window this cancel came from is
describing a message that has gone, so it closes, and the message takes its
place in the conversation. That used to be a bare "empty the dock", which was
right only because the inline composer had already been replaced by a
countdown bar before this could be reached. It is not any more — the window is
still open, still holding a copy of a mail that is on the wire — so saying
"too late" without also closing it would leave the user an editable draft of
something already sent.
Variables: message, ctx
Variables: message, thread (MessageThread|null), ctx
#}
<turbo-stream action="append" target="toast-region">
<template>
@@ -29,8 +36,12 @@ Variables: message, ctx
</template>
</turbo-stream>
{% if false == ctx.inline %}
<turbo-stream action="update" target="compose_dock">
<template></template>
</turbo-stream>
{% endif %}
{# `notify: false` — the error above IS this send's notification, and a
"Message sent" toast stacked under "too late to stop it" tells the same
news twice with the reassuring half on top. #}
{% include 'compose/_sent.stream.html.twig' with {
message: message,
thread: thread,
ctx: ctx,
notify: false,
} only %}
+32 -67
View File
@@ -19,7 +19,7 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
fallbacks had drifted from the ones the controller used, which is how a
window could render against 'compose_dock' while the server thought it was
inline. It is a value object now: all three renders of this template
(renderWindow, _undo_toast, _inline_undo) construct one, so the fields are
(renderWindow, _dock_undo, _inline_undo) construct one, so the fields are
there by type. #}
{% set inline = ctx.inline %}
{% set frame = ctx.frame %}
@@ -46,7 +46,6 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
saved: 'compose.status.saved'|trans,
saveFailed: 'compose.status.save_failed'|trans,
uploading: 'compose.status.uploading'|trans,
sending: 'compose.sending'|trans,
newMessage: 'compose.new_message'|trans,
attachFailed: 'compose.attach_failed'|trans,
attachRemoveFailed: 'compose.attach_remove_failed'|trans,
@@ -62,7 +61,6 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
tooLarge: 'compose.attach_too_large'|trans({ '%name%': '%s', '%limit%': '%d' }),
imageFailed: 'compose.image_insert_failed'|trans,
noSignature: 'compose.signature_none'|trans,
cancelSend: 'compose.sending_cancel'|trans,
}|json_encode|e('html_attr') }}"
{# The toolbar controller's own vocabulary. Same arrangement and same
reason as compose--compose's above: the link popover writes words
@@ -83,8 +81,14 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
data-compose--compose-inline-value="{{ inline ? 'true' : 'false' }}"
{# 'forward' moves the caret to To, spares the quote-only body the
empty-mail question, and is the case the quote-collapse setting
below is about. See ComposeController::forwardMessage(). #}
data-compose--compose-mode-value="{{ mode|default('new') }}"
below is about. See ComposeController::forwardMessage().
From the CONTEXT, not from a render-time `mode` variable. It was
the latter, passed by the forward action alone, so every other
render of this window — the undo reopen, a server-side refusal —
forgot what it was looking at and started asking a forward whether
it was an empty mail. ctx travels in the URL. #}
data-compose--compose-mode-value="{{ ctx.mode }}"
data-compose--compose-collapse-forward-quote-value="{{ app.user and false == app.user.getSetting(constant('App\\Entity\\User\\User::SETTING_COMPOSE_FORWARD_QUOTE_COLLAPSED'), true) ? 'false' : 'true' }}"
data-compose--compose-min-chars-value="{{ min_chars|default(5) }}"
data-compose--compose-max-attachment-bytes-value="{{ constant('App\\Controller\\Mail\\ComposeAttachmentController::MAX_ATTACHMENT_BYTES') }}"
@@ -144,23 +148,27 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
</button>
{% if inline %}
{# Replying rarely means retargeting: the address and subject
rows stay folded behind this summary. #}
{% set recipients = [] %}
{% for entry in message.toAddresses ?? [] %}
{% set recipients = recipients|merge([entry.name|default('') ?: entry.address]) %}
{% endfor %}
{# A REDUCED header, not a summary of one. What stood here was
a button reading "to Someone" that unfolded the address
block when clicked — fine for a reply, wrong for a forward,
which opens with no recipient and needs exactly one thing
typed before it can go anywhere. The line that said "No
recipients" was a label in front of the field that would
have fixed it. It is that field now. Everything else —
From, Cc, Bcc, Subject — stays folded behind the chevron,
because retargeting the rest really is the rare case.
See compose/_to_row.html.twig; the dock renders the same
partial in its address block. #}
{{ include('compose/_to_row.html.twig', { compact: true }) }}
<button type="button"
data-action="click->compose--compose#toggleFields"
class="flex items-center gap-2 min-w-0 flex-1 md:flex-none text-sm text-ink-soft
hover:text-ink transition-colors cursor-pointer">
<span class="text-xs text-ink-faint shrink-0">{{ 'message.details.to_label'|trans }}</span>
<span class="truncate max-w-[420px]">
{{- recipients is empty ? ('compose.no_recipients'|trans) : recipients|join(', ') -}}
</span>
title="{{ 'compose.more_fields'|trans }}"
aria-label="{{ 'compose.more_fields'|trans }}"
class="{{ hc }}">
<i data-compose--compose-target="fieldsChevron"
class="fa-solid fa-chevron-down text-[10px] text-ink-faint shrink-0 transition-transform duration-150"></i>
class="fa-solid fa-chevron-down text-[10px] text-ink-faint transition-transform duration-150"></i>
</button>
<span class="hidden" data-compose--compose-target="title"></span>
{% else %}
@@ -342,55 +350,12 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
</div>
{# ── To ───────────────────────────────────────────────── #}
{# A real <label for>, not a <span>: it is what gives the recipient
combobox an accessible name. Tom Select hides the <select> and
builds its own text input, and copies the name across only when
there is a label bound to the select's id — so the label is
load-bearing rather than decoration. The controller mirrors it
onto the generated input as well, for the same reason a belt
goes with braces. #}
<div data-compose--compose-target="toField"
class="flex items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
<label for="{{ form.toAddresses.vars.id }}"
id="{{ form.toAddresses.vars.id }}_label"
class="text-xs font-medium text-ink-faint w-10 shrink-0 text-right">{{ 'compose.to'|trans }}</label>
<div class="flex-1 min-w-0">
{{ form_widget(form.toAddresses) }}
</div>
<div class="flex gap-2 shrink-0">
<button type="button"
data-compose--compose-target="ccBtn"
data-action="click->compose--compose#showCc"
{# title, not aria-label: the button already has an
accessible name — the word "Cc" on its face — and
an aria-label would replace it rather than add to
it, leaving the visible label and the spoken one
saying different things (WCAG 2.5.3). #}
title="{{ 'compose.add_cc'|trans }}"
class="text-xs text-ink-faint hover:text-accent transition-colors">
{{ 'compose.cc'|trans }}
</button>
<button type="button"
data-compose--compose-target="bccBtn"
data-action="click->compose--compose#showBcc"
title="{{ 'compose.add_bcc'|trans }}"
class="text-xs text-ink-faint hover:text-accent transition-colors">
{{ 'compose.bcc'|trans }}
</button>
</div>
</div>
{# Why a send was refused. Rendered from the form rather than from a
toast so it sits with the field it is about, and so a refusal
that came from the server reads the same as one the window
caught itself. #}
<div data-compose--compose-target="errors"
class="px-4 {{ form.toAddresses.vars.errors|length > 0 ? 'py-1' : 'hidden' }}"
role="alert" aria-live="polite">
<p class="text-xs text-danger">
{%- for error in form.toAddresses.vars.errors %}{{ error.message }}{% endfor -%}
</p>
</div>
{# The dock only. An inline window renders the same partial in its
header — one To row per window, wherever it sits, because every
target the controller reaches for is on it. #}
{% if not inline %}
{{ include('compose/_to_row.html.twig', { compact: false }) }}
{% endif %}
{# ── Cc ───────────────────────────────────────────────── #}
<div data-compose--compose-target="ccField"