mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
toolbar
This commit is contained in:
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 #}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user