update compose window

This commit is contained in:
Paul Lützner committed 2026-07-16 15:43:32 +02:00
1 parent 70dc5c998c
commit 27f1f6bdb4
6 files changed
+184 -77

No files matched your search

+112 -43
View File
@@ -1,10 +1,8 @@
/*
* tom-select.css
* Overrides the UX Autocomplete / Tom Select default skin to match
* the plMail compose window aesthetic.
*
* Loaded via @import in app.css. Set tom-select.default.css autoimport
* to false in controllers.json so this is the only skin.
* Custom skin for Symfony UX Autocomplete / Tom Select used in the
* plMail compose window. All default Tom Select CSS bundles are disabled
* in controllers.json — this is the sole skin.
*/
/* ── Base reset ──────────────────────────────────────────────────────── */
@@ -24,12 +22,18 @@
min-width: 0;
}
/* UX Autocomplete wraps the <select> in an extra div — flatten it */
.ts-wrapper.multi.has-items .ts-control,
.ts-wrapper.multi .ts-control {
padding-bottom: 2px;
}
/* ── Control (the visible input area) ───────────────────────────────── */
.ts-control {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 3px;
gap: 4px;
padding: 2px 0;
background: transparent !important;
border: none !important;
@@ -40,7 +44,7 @@
border-radius: 0;
}
/* The actual text input Tom Select injects */
/* The text input Tom Select injects */
.ts-control input {
flex: 1 1 80px;
min-width: 80px;
@@ -67,7 +71,7 @@
color: rgb(107 114 128); /* gray-500 */
}
/* Hide the default dropdown caret Tom Select appends */
/* Hide the dropdown caret Tom Select appends to the wrapper */
.ts-wrapper .ts-control::after,
.ts-wrapper.single .ts-control::after,
.ts-wrapper.multi .ts-control::after {
@@ -78,8 +82,8 @@
.ts-control .item {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 4px 1px 8px;
gap: 4px;
padding: 2px 6px 2px 9px;
border-radius: 9999px;
background-color: rgb(219 234 254); /* blue-100 */
color: rgb(29 78 216); /* blue-700 */
@@ -99,7 +103,7 @@
color: rgb(147 197 253); /* blue-300 */
}
/* × remove button */
/* × remove button — per chip */
.ts-control .item .remove {
display: inline-flex;
align-items: center;
@@ -107,17 +111,17 @@
width: 14px;
height: 14px;
border-radius: 9999px;
font-size: 11px;
font-size: 13px;
font-style: normal;
line-height: 1;
opacity: 0.55;
opacity: 0.5;
cursor: pointer;
color: inherit;
border: none;
background: transparent;
padding: 0;
margin-left: 1px;
flex-shrink: 0;
text-decoration: none;
transition: opacity 100ms;
}
@@ -125,12 +129,18 @@
opacity: 1;
}
/* Hide Tom Select's global clear button — we use per-chip removes */
.ts-wrapper .clear-button,
.ts-wrapper > .clear-button {
display: none !important;
}
/* ── Dropdown panel ──────────────────────────────────────────────────── */
.ts-dropdown {
position: absolute;
left: -4rem;
right: -1rem;
top: calc(100% + 2px);
top: calc(100% + 4px);
z-index: 40;
border-radius: 0.75rem;
overflow: hidden;
@@ -165,31 +175,38 @@
padding: 7px 14px;
cursor: pointer;
transition: background-color 80ms;
color: rgb(55 65 81);
color: rgb(55 65 81); /* gray-700 */
}
.dark .ts-dropdown .option {
color: rgb(229 231 235);
color: rgb(229 231 235); /* gray-200 */
}
.ts-dropdown .option.active,
.ts-dropdown .option:hover {
background-color: rgb(239 246 255);
color: rgb(29 78 216);
background-color: rgb(239 246 255); /* blue-50 */
color: rgb(29 78 216); /* blue-700 */
}
.dark .ts-dropdown .option.active,
.dark .ts-dropdown .option:hover {
background-color: rgba(37 99 235 / 0.15);
color: rgb(147 197 253);
color: rgb(147 197 253); /* blue-300 */
}
.ts-dropdown .option.selected {
background-color: transparent;
opacity: 0.5;
opacity: 0.45;
}
/* ── Option inner layout ─────────────────────────────────────────────── */
.ts-option-row {
display: flex;
align-items: center;
gap: 10px;
padding: 1px 0;
}
/* ── Option inner layout: avatar + text block ────────────────────────── */
.ts-option-avatar {
display: inline-flex;
align-items: center;
@@ -232,50 +249,72 @@
}
/* ── "Add …" create row ──────────────────────────────────────────────── */
.ts-dropdown .create {
.ts-option-create {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
font-size: 0.8125rem;
color: rgb(107 114 128);
color: rgb(107 114 128); /* gray-500 */
}
.dark .ts-option-create {
color: rgb(156 163 175); /* gray-400 */
}
.ts-option-create-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 9999px;
background-color: rgb(219 234 254); /* blue-100 */
color: rgb(37 99 235); /* blue-600 */
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.dark .ts-option-create-icon {
background-color: rgba(37 99 235 / 0.2);
color: rgb(147 197 253);
}
.ts-dropdown .create {
padding: 7px 14px;
cursor: pointer;
transition: background-color 80ms;
border-top: 1px solid rgba(209 213 219 / 0.5);
}
.dark .ts-dropdown .create {
color: rgb(156 163 175);
border-top-color: rgba(255 255 255 / 0.08);
}
.ts-dropdown .create:hover,
.ts-dropdown .create.active {
background-color: rgb(249 250 251);
color: rgb(31 41 55);
}
.dark .ts-dropdown .create:hover,
.dark .ts-dropdown .create.active {
background-color: rgba(255 255 255 / 0.05);
color: rgb(229 231 235);
}
/* ── No results ──────────────────────────────────────────────────────── */
.ts-dropdown .no-results,
.ts-dropdown .no_results {
.ts-no-results {
padding: 10px 14px;
font-size: 0.8125rem;
color: rgb(156 163 175);
color: rgb(156 163 175); /* gray-400 */
text-align: center;
}
/* ── Spinner ─────────────────────────────────────────────────────────── */
/* ── Spinner — hidden, UX Autocomplete handles loading state ─────────── */
.ts-wrapper .spinner {
display: none;
display: none !important;
}
/* ── Focus / open state ──────────────────────────────────────────────── */
/* ── Focus / open state — keep frameless ─────────────────────────────── */
.ts-wrapper.focus .ts-control,
.ts-wrapper.dropdown-active .ts-control {
outline: none !important;
@@ -290,19 +329,49 @@
}
/* ── Thin scrollbar ──────────────────────────────────────────────────── */
.ts-dropdown-content::-webkit-scrollbar {
width: 4px;
}
.ts-dropdown-content::-webkit-scrollbar-track {
background: transparent;
}
.ts-dropdown-content::-webkit-scrollbar { width: 4px; }
.ts-dropdown-content::-webkit-scrollbar-track { background: transparent; }
.ts-dropdown-content::-webkit-scrollbar-thumb {
background-color: rgba(156 163 175 / 0.45);
border-radius: 9999px;
}
.dark .ts-dropdown-content::-webkit-scrollbar-thumb {
background-color: rgba(75 85 99 / 0.5);
}
/* Hide the original <select> that Tom Select moves off-screen */
.ts-hidden-accessible {
position: absolute !important;
left: -10000px !important;
width: 1px !important;
height: 1px !important;
overflow: hidden !important;
clip: rect(0,0,0,0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* remove_button plugin — × button appended to each chip by Tom Select */
.ts-control .item .remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 9999px;
font-size: 13px;
line-height: 1;
opacity: 0.5;
cursor: pointer;
color: inherit;
border: none;
background: transparent;
padding: 0;
margin-left: 2px;
flex-shrink: 0;
text-decoration: none;
transition: opacity 100ms;
}
.ts-control .item .remove:hover {
opacity: 1;
}
+2 -2
View File
@@ -1508,8 +1508,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }
* @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
* url?: scalar|Param|null, // URL of the hub's publish endpoint
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* jwt?: string|array{ // JSON Web Token configuration.
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
+41 -24
View File
@@ -14,43 +14,60 @@ class ContactAutocompleteField extends AbstractType
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'class' => Contact::class,
'placeholder' => '',
'multiple' => true,
'autocomplete' => true,
'searchable_fields' => ['email', 'displayName'],
'choice_label' => fn(Contact $c) => $c->getDisplayName()
'class' => Contact::class,
'placeholder' => '',
'multiple' => true,
'autocomplete' => true,
'allow_options_create' => true,
'searchable_fields' => ['email', 'displayName'],
'choice_label' => fn(Contact $c) => $c->getDisplayName()
? sprintf('%s <%s>', $c->getDisplayName(), $c->getEmail())
: $c->getEmail(),
// Tom Select option + item renderers injected as JS strings.
// UX Autocomplete passes these through to Tom Select's `render` config.
'tom_select_options' => [
'plugins' => ['remove_button'],
'persist' => false,
'closeAfterSelect' => false,
'openOnFocus' => false,
'hideSelected' => true,
'render' => [
// Suggestion row in the dropdown
// Dropdown suggestion row: avatar initial + name + email
'option' => "function(data, escape) {
var name = data.text ? data.text.replace(/<[^>]+>/g, '') : '';
var email = data.value || '';
// If choice_label formatted it as 'Name <email>', extract parts
var match = name.match(/^(.+?) <([^>]+)>$/);
var displayName = match ? escape(match[1]) : '';
var displayEmail = match ? escape(match[2]) : escape(email);
var initials = displayName
var raw = data.text ? data.text.replace(/<[^>]+>/g, '') : (data.value || '');
var match = raw.match(/^(.+?) <([^>]+)>$/);
var displayName = match ? escape(match[1]) : '';
var displayEmail = match ? escape(match[2]) : escape(raw);
var initial = displayName
? displayName.replace(/<[^>]*>/g, '').trim().split(/\\s+/).map(function(w){ return w[0]; }).slice(0,2).join('').toUpperCase()
: displayEmail[0].toUpperCase();
return '<div class=\"flex items-center gap-2.5 py-0.5\">'
+ '<span class=\"ts-option-avatar\">' + initials + '</span>'
: (displayEmail[0] || '?').toUpperCase();
return '<div class=\"ts-option-row\">'
+ '<span class=\"ts-option-avatar\">' + initial + '</span>'
+ '<span class=\"ts-option-text\">'
+ (displayName ? '<span class=\"ts-option-name\">' + displayName + '</span>' : '')
+ '<span class=\"ts-option-email\">' + displayEmail + '</span>'
+ '</span></div>';
}",
// Selected chip label
// Selected chip: name (or email) + × remove button
'item' => "function(data, escape) {
var name = data.text ? data.text.replace(/<[^>]+>/g, '') : data.value;
var match = name.match(/^(.+?) <[^>]+>$/);
var label = match ? escape(match[1]) : escape(name);
return '<div>' + label + '</div>';
var raw = data.text ? data.text.replace(/<[^>]+>/g, '') : (data.value || '');
var match = raw.match(/^(.+?) <[^>]+>$/);
var label = match ? escape(match[1]) : escape(raw);
return '<div>' + label + '</div>';
}",
// "Add <typed>" create row
'option_create' => "function(data, escape) {
return '<div class=\"ts-option-create\">'
+ '<span class=\"ts-option-create-icon\">+</span>'
+ 'Add <strong>' + escape(data.input) + '</strong>'
+ '</div>';
}",
// No results
'no_results' => "function(data, escape) {
return '<div class=\"ts-no-results\">No contacts found</div>';
}",
],
],
+2 -2
View File
@@ -2,8 +2,8 @@
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}{{ 'app.name'|trans }}{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect width=%2232%22 height=%2232%22 rx=%226%22 fill=%22%232563eb%22/><path d=%22M4 10 L16 19 L28 10 M4 10 L4 24 L28 24 L28 10 Z%22 stroke=%22white%22 stroke-width=%222.2%22 fill=%22none%22 stroke-linejoin=%22round%22/></svg>">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><text y=%221em%22 font-size=%2224%22>✉️</text></svg>">
{# Must be first thing in <head> to avoid flash of wrong theme #}
<script>
+25 -4
View File
@@ -1,13 +1,34 @@
{# templates/form/compose_theme.html.twig #}
{# templates/_layout/form/compose_theme.html.twig #}
{#
address_entry_widget / address_entry_row are no longer used —
the compose window uses ts-address fields directly.
Kept here as no-ops so Symfony doesn't throw if they're referenced.
Form theme for the compose window only.
- Strips ALL styling from <select> elements so Tom Select copies no classes
onto its .ts-wrapper div.
- Removes the native-select chevron wrapper that modal_form_theme adds.
- address_entry_widget / address_entry_row kept as no-ops.
#}
{% block address_entry_widget %}{% endblock %}
{% block address_entry_row %}{% endblock %}
{# ── Bare select — Tom Select clones this element's classes onto .ts-wrapper ── #}
{% block choice_widget_collapsed %}
<select {{ block('widget_attributes') }} {% if multiple %}multiple="multiple"{% endif %}>
{% if placeholder is not none %}
<option value=""{% if not value %} selected{% endif %}>{{ placeholder|trans({}, translation_domain) }}</option>
{% endif %}
{% if preferred_choices|length > 0 %}
{% set options = preferred_choices %}
{{ block('choice_widget_options') }}
{% if choices|length > 0 and separator is not none %}
<option disabled>{{ separator }}</option>
{% endif %}
{% endif %}
{% set options = choices %}
{{ block('choice_widget_options') }}
</select>
{% endblock %}
{% block email_widget %}
<input
type="text"
+2 -2
View File
@@ -142,7 +142,7 @@
{# ── Cc ───────────────────────────────────────────────── #}
<div data-compose-target="ccField"
class="hidden items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
class="flex hidden items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
<span class="text-xs font-medium text-gray-400 dark:text-gray-500 w-10 shrink-0 text-right">Cc</span>
<div class="flex-1 min-w-0">
{{ form_widget(form.ccAddresses) }}
@@ -151,7 +151,7 @@
{# ── Bcc ──────────────────────────────────────────────── #}
<div data-compose-target="bccField"
class="hidden items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
class="flex hidden items-center gap-3 px-4 py-1.5 shrink-0 min-h-[36px]">
<span class="text-xs font-medium text-gray-400 dark:text-gray-500 w-10 shrink-0 text-right">Bcc</span>
<div class="flex-1 min-w-0">
{{ form_widget(form.bccAddresses) }}