feat(ui): every select is a Tom Select, themed from the palette tokens

`select-field` got the closed control right — appearance:none, the app's fill,
the app's chevron — and could never get the popup, because the popup is the one
part of a form control CSS does not reach. color-scheme was the last thing left
to try and it only picks light or dark: the list is still the platform's font,
the platform's row height and the platform's highlight, sitting in front of a
themed app in five of the six themes.

Compose already had the answer for its address fields. This is that same widget
on every other single-select in the app, so there is one dropdown here instead
of two.

- assets/controllers/ui/select_controller.js progressively enhances a native
  <select>. The element stays in the DOM as the source of truth, so forms post
  identically, `onchange="…requestSubmit()"` and `data-action="change->…"` keep
  firing, and no-JS renders the native control the stylesheet still styles.
- The panel is parented to <body>: every call site is inside something that
  clips. It flips above the control when there is no room below and caps itself
  to the room there is, which Tom Select does not do on its own, and closes on
  scroll rather than being left behind by the pane it was anchored to.
- Short lists lose the search box entirely, so a five-item picker behaves like
  the native control it replaced instead of blinking a text caret.
- tom-select.css now skins .single as the twin of `select-field` — same fill,
  border, radius, chevron and height, the last one measured per call site so
  h-8 rows stay h-8. No per-theme rules: the six themes differ only in what the
  tokens resolve to.
- The rule editor builds five selects in JavaScript and was missed by the last
  sweep; it stamps the attributes itself now.
- Compose's font pickers are converted too, frameless so the format bar keeps
  its character. Their labels were the lookup keys, which is why they were
  English in a German UI; the values carry the keys now.
- "No results found" arrives from Twig |trans (EN/DE/en_PI).

Left native, deliberately: compose's multi-select address fields, which have a
richer Tom Select of their own; the provider preset, enhanced by UX Autocomplete
because it fetches its choices; and compose's sr-only account select, which is
never shown. PageRendersTest now sweeps every page for a select that is none of
those and carries no widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 committed 2026-08-09 15:33:09 +02:00
1 parent 06fd104799
commit e453cc9516
22 files changed
+658 -43

No files matched your search

+27 -8
View File
@@ -1,5 +1,6 @@
{# templates/compose/_window.html.twig #}
{% form_theme form '_layout/form/compose_theme.html.twig' %}
{% import '_partials/_select.html.twig' as select %}
{#
Renders in two places:
@@ -152,7 +153,11 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
<div data-compose--compose-target="collapsible"
class="flex flex-col flex-1 min-h-0">
{# ── Hidden account select (submitted with form) ───────── #}
{# ── Hidden account select (submitted with form) ─────────
Deliberately not a Tom Select. It is never shown: the "From"
line is a custom menu built from these options, and this is only
the value the form posts. A widget here would be one the user
cannot see and the screen reader would announce twice. #}
<select id="{{ form.account.vars.id }}"
name="{{ form.account.vars.full_name }}"
data-compose--compose-target="accountSelect"
@@ -321,27 +326,41 @@ Variables: form, message, ctx (inline, frame, thread, urlParams)
bg-sunken
rounded-full px-2 py-1">
{# Bare rather than framed: this is a strip of icon buttons,
and two boxed fields in the middle of it would read as a
different toolbar. Only the popup changes — which is the
point, since the format bar scrolls horizontally on
phones and a native popup was the one thing in it the
theme never reached.
The option labels used to be the lookup keys, which is
why they were English in a German UI. The values carry
the key now and the labels are free to be translated. #}
<select data-compose--compose-toolbar-target="fontFamily"
data-action="change->compose--compose-toolbar#changeFontFamily"
aria-label="{{ 'compose.font_family.label'|trans }}"
{{ select.enhance(bare = true) }}
class="shrink-0 bg-transparent border-none outline-none text-xs cursor-pointer px-1 py-0.5 rounded
text-ink-muted
hover:bg-hover transition-colors">
<option>Sans Serif</option>
<option>Serif</option>
<option>Monospace</option>
<option value="sans">{{ 'compose.font_family.sans'|trans }}</option>
<option value="serif">{{ 'compose.font_family.serif'|trans }}</option>
<option value="monospace">{{ 'compose.font_family.monospace'|trans }}</option>
</select>
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>
<select data-compose--compose-toolbar-target="fontSize"
data-action="change->compose--compose-toolbar#changeFontSize"
aria-label="{{ 'compose.font_size.label'|trans }}"
{{ select.enhance(bare = true) }}
class="shrink-0 bg-transparent border-none outline-none text-xs cursor-pointer px-1 py-0.5 rounded
text-ink-muted
hover:bg-hover transition-colors">
<option>Small</option>
<option selected>Normal</option>
<option>Large</option>
<option>Huge</option>
<option value="small">{{ 'compose.font_size.small'|trans }}</option>
<option value="normal" selected>{{ 'compose.font_size.normal'|trans }}</option>
<option value="large">{{ 'compose.font_size.large'|trans }}</option>
<option value="huge">{{ 'compose.font_size.huge'|trans }}</option>
</select>
<div class="w-px h-4 bg-line mx-1 shrink-0"></div>