This commit is contained in:
Paul Lützner committed 2026-07-08 12:39:31 +02:00
1 parent 9a82fd7332
commit ecd5deb0a1
7 files changed
+575 -9

No files matched your search

@@ -0,0 +1,260 @@
// assets/controllers/list_toolbar_controller.js
//
// Drives the Gmail-style toolbar above the message list.
//
// The master "checkbox" is a <button role="checkbox"> whose visual state
// (border colour, fill, checkmark/dash SVG) is set entirely here in JS.
// We avoid <input type="checkbox"> + CSS peer-* because the checkmark SVG
// lives *inside* the label, not as a peer sibling, so peer-checked: cannot
// reach it.
import { Controller } from "@hotwired/stimulus";
// Classes applied to the checkbox button in each state
const CB_BASE = "border-gray-400 dark:border-gray-500 bg-white dark:bg-gray-800";
const CB_ACTIVE = "border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500";
export default class extends Controller {
static targets = [
"checkboxBtn", // the <button role="checkbox"> that acts as master checkbox
"checkmark", // the ✓ SVG inside checkboxBtn
"indeterminate", // the — SVG inside checkboxBtn
"selectMenu", // the dropdown
"selectMenuBtn",
"refreshSlot", // wrapper div around the Refresh button
"actions", // wrapper div around bulk-action buttons
"selectionCount",
];
static values = { total: Number };
// ── Lifecycle ─────────────────────────────────────────────────────────
connect() {
// Listen for the custom event fired by message_row_controller when
// a row checkbox changes.
this._onRowChange = this._syncFromRows.bind(this);
this._onClickOutside = this._closeSelectMenu.bind(this);
this.element.addEventListener("list-toolbar:row-changed", this._onRowChange);
document.addEventListener("click", this._onClickOutside, { capture: true });
this._syncFromRows();
}
disconnect() {
this.element.removeEventListener("list-toolbar:row-changed", this._onRowChange);
document.removeEventListener("click", this._onClickOutside, { capture: true });
}
// ── Master checkbox (click handler) ───────────────────────────────────
toggleAll() {
// If anything is checked (all or some), uncheck everything.
// If nothing is checked, check everything.
const checkedCount = this._checkedRows().length;
const targetState = checkedCount === 0;
this._setAllRows(targetState);
this._syncFromRows();
}
// ── Select-type dropdown ──────────────────────────────────────────────
toggleSelectMenu(event) {
event.stopPropagation();
this.selectMenuTarget.classList.toggle("hidden");
}
selectAll(event) {
event?.preventDefault();
this._setAllRows(true);
this._syncFromRows();
this._closeSelectMenu();
}
selectNone(event) {
event?.preventDefault();
this._setAllRows(false);
this._syncFromRows();
this._closeSelectMenu();
}
selectRead(event) {
event?.preventDefault();
this._selectBy((li) => li.dataset.unread !== "true");
this._closeSelectMenu();
}
selectUnread(event) {
event?.preventDefault();
this._selectBy((li) => li.dataset.unread === "true");
this._closeSelectMenu();
}
selectStarred(event) {
event?.preventDefault();
this._selectBy((li) => li.dataset.starred === "true");
this._closeSelectMenu();
}
// ── Refresh ───────────────────────────────────────────────────────────
refresh() {
const frame = document.getElementById("inbox-list-frame");
const icon = this.refreshSlotTarget.querySelector("i");
if (icon) { icon.classList.add("fa-spin"); }
if (frame) {
frame.addEventListener(
"turbo:frame-load",
() => { if (icon) { icon.classList.remove("fa-spin"); } },
{ once: true },
);
frame.reload();
} else {
Turbo.visit(window.location.href, { action: "replace" });
}
}
// ── Bulk action stubs ─────────────────────────────────────────────────
archiveSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
console.log("[list-toolbar] archive", ids);
// TODO: POST /threads/bulk-archive { ids }
}
deleteSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
console.log("[list-toolbar] delete", ids);
// TODO: POST /threads/bulk-delete { ids }
}
markReadSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
console.log("[list-toolbar] mark-read", ids);
// TODO: POST /threads/bulk-mark-read { ids }
}
markUnreadSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
console.log("[list-toolbar] mark-unread", ids);
// TODO: POST /threads/bulk-mark-unread { ids }
}
snoozeSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
console.log("[list-toolbar] snooze", ids);
// TODO: open snooze picker then POST /threads/bulk-snooze { ids, until }
}
// ── Private ───────────────────────────────────────────────────────────
/** All row checkboxes in the visible list */
_rowCheckboxes() {
return Array.from(
document.querySelectorAll(
"#message-list [data-controller~='message-row'] input[type='checkbox']",
),
);
}
_checkedRows() {
return this._rowCheckboxes().filter((cb) => cb.checked);
}
_selectedIds() {
return this._checkedRows().map((cb) => {
const li = cb.closest("[data-message-row-id-value]");
return li ? parseInt(li.dataset.messageRowIdValue, 10) : null;
}).filter(Boolean);
}
_setAllRows(checked) {
this._rowCheckboxes().forEach((cb) => { cb.checked = checked; });
}
_selectBy(predicate) {
this._rowCheckboxes().forEach((cb) => {
const li = cb.closest("li");
cb.checked = li ? predicate(li) : false;
});
this._syncFromRows();
}
/**
* Single source of truth for all visual state.
* Called after any change to row checkboxes or the master button.
*/
_syncFromRows() {
const all = this._rowCheckboxes();
const checkedCount = all.filter((cb) => cb.checked).length;
const allChecked = all.length > 0 && checkedCount === all.length;
const someChecked = checkedCount > 0 && checkedCount < all.length;
const hasSelection = checkedCount > 0;
// ── Master checkbox button visual state ──────────────────────────
this._setCheckboxState(allChecked, someChecked);
// ── Refresh ↔ Actions slot swap ──────────────────────────────────
if (this.hasRefreshSlotTarget) {
this.refreshSlotTarget.classList.toggle("hidden", hasSelection);
this.refreshSlotTarget.classList.toggle("flex", !hasSelection);
}
if (this.hasActionsTarget) {
this.actionsTarget.classList.toggle("hidden", !hasSelection);
this.actionsTarget.classList.toggle("flex", hasSelection);
this.actionsTarget.setAttribute("aria-hidden", String(!hasSelection));
}
// ── Count label ──────────────────────────────────────────────────
if (this.hasSelectionCountTarget) {
this.selectionCountTarget.textContent =
checkedCount > 0 ? String(checkedCount) : "";
}
}
/**
* Sets the visual state of the master checkbox button.
*
* States:
* unchecked — plain bordered box, no icon
* indeterminate — blue fill, dash icon
* checked — blue fill, checkmark icon
*/
_setCheckboxState(checked, indeterminate) {
if (!this.hasCheckboxBtnTarget) { return; }
const btn = this.checkboxBtnTarget;
// aria
btn.setAttribute("aria-checked", indeterminate ? "mixed" : String(checked));
// background / border
const isActive = checked || indeterminate;
btn.classList.remove(...CB_BASE.split(" "), ...CB_ACTIVE.split(" "));
btn.classList.add(...(isActive ? CB_ACTIVE : CB_BASE).split(" "));
// icons
if (this.hasCheckmarkTarget) {
this.checkmarkTarget.classList.toggle("hidden", !checked || indeterminate);
}
if (this.hasIndeterminateTarget) {
this.indeterminateTarget.classList.toggle("hidden", !indeterminate);
}
}
_closeSelectMenu() {
if (this.hasSelectMenuTarget) {
this.selectMenuTarget.classList.add("hidden");
}
}
}
+11 -5
View File
@@ -9,7 +9,17 @@ export default class extends Controller {
toggleSelect(event) {
event.stopPropagation();
// TODO: bulk selection
// Notify the toolbar controller so it can sync the master checkbox
// and show/hide the action bar. We bubble up through the DOM until
// we find the element that hosts list-toolbar, then dispatch there.
const toolbar = document.querySelector("[data-controller~='list-toolbar']");
if (toolbar) {
toolbar.dispatchEvent(
new CustomEvent("list-toolbar:row-changed", { bubbles: false }),
);
}
}
async toggleStar(event) {
@@ -29,8 +39,6 @@ export default class extends Controller {
async snooze(event, until = null) {
event.stopPropagation();
// Call el.dispatchEvent or pass `until` from a date picker before calling.
// e.g. this.snooze(event, "2026-07-10T08:00:00Z")
await this.#post(this.#url("snooze"), { until });
}
@@ -60,8 +68,6 @@ export default class extends Controller {
return;
}
// Turbo handles the stream response automatically when the
// Content-Type is text/vnd.turbo-stream.html.
const html = await response.text();
Turbo.renderStreamMessage(html);
}
+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
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* 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
* 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.
+28
View File
@@ -2,6 +2,20 @@
{% set selectedMessage = selectedMessage|default(null) %}
{#
Subclasses can override these blocks:
inbox_tabs — tabs / heading row above the toolbar (renders before toolbar)
inbox_toolbar — override the entire toolbar (rarely needed)
message_list — the <li> items inside the <ul>
reading_pane — the reading pane content
pane_open — "true"/"false" string (default false)
list_hidden — extra CSS classes to hide list on direct-load pages
reading_hidden — extra CSS classes (default: "hidden")
Pagination variables expected from the controller (all optional, fallback to 0/1):
page, total, per_page
#}
{% block body %}
<div class="h-screen overflow-hidden bg-gradient-to-br from-blue-50 via-slate-100 to-indigo-100 dark:from-slate-950 dark:via-slate-900 dark:to-indigo-950">
<div class="flex h-full flex-col p-3 gap-3">
@@ -26,11 +40,25 @@
class="flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
>
<turbo-frame id="inbox-list-frame" data-turbo-action="advance" class="contents">
{# ── Gmail-style toolbar ──────────────────────────── #}
{% block inbox_toolbar %}
{% include '_partials/_list_toolbar.html.twig' with {
page: page ?? 1,
total: total ?? 0,
per_page: per_page ?? 50,
} %}
{% endblock %}
{# Optional tabs row (inbox only) #}
{% block inbox_tabs %}{% endblock %}
<ul class="flex-1 overflow-y-auto divide-y divide-gray-100/70 dark:divide-gray-800/70">
{% block message_list %}{% endblock %}
</ul>
</turbo-frame>
</div>
+244
View File
@@ -0,0 +1,244 @@
{#
_list_toolbar.html.twig
─────────────────────────────────────────────────────────────────────────────
Gmail-style toolbar above the message list, inside #inbox-list-frame.
Variables (passed via `with` from _mailbox.html.twig):
page int current page (1-based)
total int total thread count
per_page int items per page
#}
{% set total = total ?? 0 %}
{% set per_page = per_page ?? 50 %}
{% set page = page ?? 1 %}
{% set last_page = max(1, (total / per_page)|round(0, 'ceil')) %}
{% set page_start = total > 0 ? (page - 1) * per_page + 1 : 0 %}
{% set page_end = min(page * per_page, total) %}
{% set has_prev = page > 1 %}
{% set has_next = page < last_page %}
{% set base_params = app.request.query.all %}
{#
tbtn macro — icon button with tooltip.
Tooltip is a sibling <span> inside the relative-positioned <button>.
Uses plain `group` + `group-hover:` (no named variant) so it works
with any Tailwind v4 setup regardless of CLI version.
The button itself carries `group` and `relative`.
#}
{% macro tbtn(action, icon, label, extra_classes) %}
<button
type="button"
data-action="{{ action }}"
aria-label="{{ label }}"
class="group relative flex items-center justify-center
w-8 h-8 rounded-full
text-gray-500 dark:text-gray-400
hover:text-gray-800 dark:hover:text-gray-100
hover:bg-gray-200 dark:hover:bg-white/10
transition-colors duration-100 cursor-pointer {{ extra_classes ?? '' }}"
>
<i class="{{ icon }} text-[15px]" aria-hidden="true"></i>
<span class="
pointer-events-none select-none
absolute top-full left-1/2 -translate-x-1/2 mt-2
px-2 py-1 rounded-md
text-[11px] font-medium leading-none whitespace-nowrap
bg-gray-700 dark:bg-gray-200
text-white dark:text-gray-900
opacity-0 group-hover:opacity-100
transition-opacity duration-100 delay-500
z-50
">{{ label }}</span>
</button>
{% endmacro %}
{# Same treatment for pagination nav links (they're <a> not <button>) #}
{% macro pgnav(href, icon, label, disabled) %}
{% if disabled %}
<span class="flex items-center justify-center w-8 h-8 rounded-full
text-gray-300 dark:text-gray-600 cursor-not-allowed"
aria-disabled="true">
<i class="{{ icon }} text-[13px]" aria-hidden="true"></i>
</span>
{% else %}
<a
href="{{ href }}"
data-turbo-frame="inbox-list-frame"
data-turbo-action="advance"
aria-label="{{ label }}"
class="group relative flex items-center justify-center w-8 h-8 rounded-full
text-gray-500 dark:text-gray-400
hover:text-gray-800 dark:hover:text-gray-100
hover:bg-gray-200 dark:hover:bg-white/10
transition-colors duration-100"
>
<i class="{{ icon }} text-[13px]" aria-hidden="true"></i>
<span class="
pointer-events-none select-none
absolute top-full left-1/2 -translate-x-1/2 mt-2
px-2 py-1 rounded-md
text-[11px] font-medium leading-none whitespace-nowrap
bg-gray-700 dark:bg-gray-200
text-white dark:text-gray-900
opacity-0 group-hover:opacity-100
transition-opacity duration-100 delay-500
z-50
">{{ label }}</span>
</a>
{% endif %}
{% endmacro %}
{% import _self as m %}
<div
class="flex items-center px-3 py-1
border-b border-gray-100/70 dark:border-gray-800/70
bg-white/40 dark:bg-white/[0.02]
shrink-0 min-h-[40px]"
data-controller="list-toolbar"
data-list-toolbar-total-value="{{ total }}"
>
{# ── Checkbox + chevron ───────────────────────────────────────────────── #}
<div class="flex items-center shrink-0 mr-2">
{# The visible checkbox box — styled and toggled entirely by JS.
We do NOT use a real <input type="checkbox"> with peer-* because
the checkmark SVG is a child of the label, not a peer sibling,
so peer-checked: cannot reach it. JS drives everything. #}
<button
type="button"
role="checkbox"
aria-checked="false"
data-list-toolbar-target="checkboxBtn"
data-action="click->list-toolbar#toggleAll"
class="flex items-center justify-center w-4 h-4 rounded
border border-gray-400 dark:border-gray-500
bg-white dark:bg-gray-800
cursor-pointer transition-colors duration-100
hover:border-blue-500 focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="{{ 'toolbar.select_all'|trans }}"
>
{# Checkmark — shown when all selected #}
<svg data-list-toolbar-target="checkmark"
class="hidden w-2.5 h-2.5 text-white pointer-events-none"
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2.2">
<polyline points="1.5,5 3.8,7.5 8.5,2.5"/>
</svg>
{# Dash — shown when some (but not all) selected #}
<svg data-list-toolbar-target="indeterminate"
class="hidden w-2.5 h-2.5 text-white pointer-events-none"
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2.2">
<line x1="2" y1="5" x2="8" y2="5"/>
</svg>
</button>
{# Chevron — opens select-type dropdown #}
<div class="relative">
<button
type="button"
data-action="click->list-toolbar#toggleSelectMenu"
data-list-toolbar-target="selectMenuBtn"
aria-label="{{ 'toolbar.select_menu'|trans }}"
aria-haspopup="true"
class="ml-0.5 p-0.5 rounded
text-gray-400 hover:text-gray-600 dark:hover:text-gray-300
transition-colors cursor-pointer"
>
<svg class="w-2.5 h-2.5" viewBox="0 0 10 10" fill="none"
stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="2,3.5 5,6.5 8,3.5"/>
</svg>
</button>
{# Dropdown #}
<div
data-list-toolbar-target="selectMenu"
class="hidden absolute left-0 top-full mt-1 z-30 w-36
bg-white dark:bg-gray-900
border border-gray-200 dark:border-gray-700
rounded-xl shadow-lg overflow-hidden py-1 text-sm"
>
{% for key, label in {
'selectAll': 'toolbar.select.all',
'selectNone': 'toolbar.select.none',
'selectRead': 'toolbar.select.read',
'selectUnread': 'toolbar.select.unread',
'selectStarred': 'toolbar.select.starred',
} %}
<button type="button"
data-action="click->list-toolbar#{{ key }}"
class="w-full text-left px-3 py-1.5
text-gray-700 dark:text-gray-200
hover:bg-gray-100 dark:hover:bg-gray-800
transition-colors cursor-pointer">
{{ label|trans }}
</button>
{% endfor %}
</div>
</div>
</div>
{# ════════════════════════════════════════════════════════════════════════
Left action slot:
• refreshSlot — visible when nothing is selected
• actions — visible when ≥1 row is selected
JS swaps hidden/flex between the two.
════════════════════════════════════════════════════════════════════════ #}
{# Refresh — default visible state #}
<div data-list-toolbar-target="refreshSlot" class="flex items-center">
{{ m.tbtn('click->list-toolbar#refresh', 'fa-solid fa-rotate-right', 'toolbar.refresh'|trans) }}
</div>
{# Bulk actions — hidden until ≥1 selected #}
<div
data-list-toolbar-target="actions"
class="hidden items-center gap-2"
aria-hidden="true"
>
{{ m.tbtn('click->list-toolbar#archiveSelected', 'fa-solid fa-box-archive', 'toolbar.actions.archive'|trans) }}
{{ m.tbtn('click->list-toolbar#deleteSelected', 'fa-solid fa-trash', 'toolbar.actions.delete'|trans, 'hover:text-red-600 dark:hover:text-red-400') }}
{{ m.tbtn('click->list-toolbar#markReadSelected', 'fa-solid fa-envelope-open', 'toolbar.actions.mark_read'|trans) }}
{{ m.tbtn('click->list-toolbar#markUnreadSelected', 'fa-solid fa-envelope', 'toolbar.actions.mark_unread'|trans) }}
{{ m.tbtn('click->list-toolbar#snoozeSelected', 'fa-solid fa-clock', 'toolbar.actions.snooze'|trans) }}
<span
data-list-toolbar-target="selectionCount"
class="ml-1 text-xs text-gray-500 dark:text-gray-400 tabular-nums whitespace-nowrap"
></span>
</div>
{# Spacer #}
<div class="flex-1"></div>
{# ── Pagination ───────────────────────────────────────────────────────── #}
{% if total > 0 %}
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 shrink-0 pr-1">
<span class="tabular-nums mr-1">
{{ page_start }}–{{ page_end }} {{ 'toolbar.pagination.of'|trans }} {{ total }}
</span>
{% if has_prev %}
{% set prev_params = base_params|merge({page: page - 1}) %}
{{ m.pgnav(path(app.request.attributes.get('_route'), prev_params), 'fa-solid fa-chevron-left', 'toolbar.pagination.prev'|trans, false) }}
{% else %}
{{ m.pgnav('', 'fa-solid fa-chevron-left', 'toolbar.pagination.prev'|trans, true) }}
{% endif %}
{% if has_next %}
{% set next_params = base_params|merge({page: page + 1}) %}
{{ m.pgnav(path(app.request.attributes.get('_route'), next_params), 'fa-solid fa-chevron-right', 'toolbar.pagination.next'|trans, false) }}
{% else %}
{{ m.pgnav('', 'fa-solid fa-chevron-right', 'toolbar.pagination.next'|trans, true) }}
{% endif %}
</div>
{% endif %}
</div>
+6 -2
View File
@@ -27,11 +27,14 @@
{% set href = path('app_mail_message', { id: item.id }) %}
{% set rowId = item.id %}
{% endif %}
<li
id="thread_{{ rowId }}"
class="group relative hover:shadow-sm"
data-controller="message-row"
data-message-row-id-value="{{ rowId }}"
data-unread="{{ unread ? 'true' : 'false' }}"
data-starred="{{ starred ? 'true' : 'false' }}"
>
{# Full-row clickable overlay #}
<a
@@ -53,11 +56,12 @@
{{ unread ? 'bg-white/40 dark:bg-white/[0.03]' : 'bg-transparent' }}
hover:bg-white/60 dark:hover:bg-white/[0.06]"
>
{# Checkbox #}
{# Checkbox — fires custom event so the toolbar controller can react #}
<input
type="checkbox"
data-action="click->message-row#toggleSelect"
data-action="change->message-row#toggleSelect"
class="z-10 w-4 h-4 rounded border-gray-300 dark:border-gray-700 text-blue-600 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer flex-shrink-0"
aria-label="{{ 'thread_row.select'|trans({'%subject%': subject}) }}"
>
{# Star toggle #}
+24
View File
@@ -128,3 +128,27 @@ mailbox:
errors:
access_denied: You do not have access to this resource.
not_found: The requested resource could not be found.
toolbar:
refresh: Refresh
select_all: Select all conversations
select_menu: More selection options
select:
all: All
none: None
read: Read
unread: Unread
starred: Starred
actions:
archive: Archive
delete: Delete
mark_read: Mark as read
mark_unread: Mark as unread
snooze: Snooze
pagination:
of: of
prev: Newer
next: Older