mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off. A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again. The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load. The choice lives in the user's settings bag and a config backup carries it. No migration. Continues #56. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
543 lines
29 KiB
Twig
543 lines
29 KiB
Twig
{#
|
||
_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
|
||
|
||
Inherited from the rendering context rather than passed (the include above is
|
||
not `only`):
|
||
|
||
sort SearchSortOrder|null set by the search page alone; its presence is
|
||
what puts the order switch on the toolbar
|
||
#}
|
||
|
||
{% 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.attributes.get('_route_params')|default({})|merge(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 }}"
|
||
title="{{ label }}"
|
||
class="flex items-center justify-center
|
||
w-8 h-8 rounded-full
|
||
text-ink-muted
|
||
hover:text-ink
|
||
hover:bg-hover
|
||
transition-colors duration-100 cursor-pointer {{ extra_classes ?? '' }}"
|
||
>
|
||
<i class="{{ icon }} text-[15px]" aria-hidden="true"></i>
|
||
</button>
|
||
{% endmacro %}
|
||
|
||
{#
|
||
mrow macro — one row of the "More" menu above a selection.
|
||
|
||
The same `data-action` a toolbar button would carry, so a shortcut finds the
|
||
control by what it does and not by where it is drawn, followed by the one that
|
||
closes the menu: ui--dropdown closes on a click OUTSIDE it, and a row is inside.
|
||
`flyout` names the panel a row hands over to — see openFlyout in the controller.
|
||
#}
|
||
{% macro mrow(action, icon, label, extra_classes, flyout) %}
|
||
<button
|
||
type="button"
|
||
role="menuitem"
|
||
data-action="{{ action }} click->ui--dropdown#close"
|
||
{% if flyout ?? false %}data-mail--list-toolbar-flyout-param="{{ flyout }}"{% endif %}
|
||
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-sm text-left whitespace-nowrap
|
||
text-ink-soft hover:bg-hover transition-colors cursor-pointer {{ extra_classes ?? '' }}"
|
||
>
|
||
<i class="{{ icon }} w-4 text-center text-[13px] text-ink-muted" aria-hidden="true"></i>
|
||
{{ label }}
|
||
</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-ink-faint 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 }}"
|
||
title="{{ label }}"
|
||
class="flex items-center justify-center w-8 h-8 rounded-full
|
||
text-ink-muted
|
||
hover:text-ink
|
||
hover:bg-hover
|
||
transition-colors duration-100"
|
||
>
|
||
<i class="{{ icon }} text-[13px]" aria-hidden="true"></i>
|
||
</a>
|
||
{% endif %}
|
||
{% endmacro %}
|
||
|
||
{% import _self as m %}
|
||
|
||
<div
|
||
{# A COLUMN, and the controls are the row inside it.
|
||
The select-all-in-view banner used to be `absolute top-full` against this
|
||
bar, which put it over whatever came next in the frame — on the inbox
|
||
that is the category tabs, so the offer to select 18,000 conversations
|
||
landed on top of Primary / Social / Promotions / Updates and hid them.
|
||
In flow it takes its own height and the tabs move down for it, which is
|
||
what a banner should do. #}
|
||
class="flex flex-col px-3 py-1
|
||
border-b border-line
|
||
bg-raised
|
||
shrink-0"
|
||
data-controller="mail--list-toolbar"
|
||
data-mail--list-toolbar-total-value="{{ total }}"
|
||
{# What this list is showing, in the vocabulary the list template already
|
||
uses for itself. The server resolves it against a closed set — see
|
||
App\Service\Mail\ListViewResolver, which explains why the view is named
|
||
rather than posted as the current URL. #}
|
||
data-mail--list-toolbar-view-scope-value="{{ list_scope|default('') }}"
|
||
data-mail--list-toolbar-view-value-value="{{ list_scope_value|default('') }}"
|
||
data-mail--list-toolbar-unread-only-value="{{ unread_only|default(false) ? 'true' : 'false' }}"
|
||
{# The list, page number aside. A selection survives "next page" and does
|
||
not survive going to a different list, and this is how the two are told
|
||
apart — see assets/mail_selection.js. Route and parameters rather than
|
||
the scope above, because a search has no scope and is told from another
|
||
search only by its query. #}
|
||
data-mail--list-toolbar-view-key-value="{{ app.request.attributes.get('_route') }}?{{ base_params|filter((v, k) => k != 'page')|url_encode }}"
|
||
data-mail--list-toolbar-i18n-value="{{ {
|
||
selectAll: 'toolbar.view_select.offer'|trans,
|
||
selectAllAction: 'toolbar.view_select.offer_action'|trans,
|
||
allSelected: 'toolbar.view_select.selected'|trans,
|
||
clear: 'toolbar.view_select.clear'|trans,
|
||
purgeConfirmOne: 'toolbar.purge_confirm.one'|trans,
|
||
purgeConfirmMany: 'toolbar.purge_confirm.many'|trans,
|
||
}|json_encode }}"
|
||
>
|
||
|
||
{# The row the toolbar has always been. Wrapped now only so the banner
|
||
below can be a sibling of it rather than an overlay on the tabs. #}
|
||
<div data-toolbar-row class="flex items-center min-h-[var(--pane-header-h)]">
|
||
|
||
{# ── 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-mail--list-toolbar-target="checkboxBtn"
|
||
data-action="click->mail--list-toolbar#toggleAll"
|
||
{# border-field-border, not border-field: the latter is the FILL
|
||
token — white at 0.6 alpha — so this box was drawing a white
|
||
border on a white surface and reading as a smudge next to the
|
||
row checkboxes below it. #}
|
||
class="flex items-center justify-center w-4 h-4 rounded
|
||
border border-field-border
|
||
bg-field
|
||
cursor-pointer transition-colors duration-100
|
||
hover:border-accent focus-visible:outline-none
|
||
focus-visible:ring-2 focus-visible:ring-accent"
|
||
aria-label="{{ 'toolbar.select_all'|trans }}"
|
||
>
|
||
{# Checkmark — shown when all selected #}
|
||
<svg data-mail--list-toolbar-target="checkmark"
|
||
class="hidden w-2.5 h-2.5 text-accent-ink 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-mail--list-toolbar-target="indeterminate"
|
||
class="hidden w-2.5 h-2.5 text-accent-ink 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->mail--list-toolbar#toggleSelectMenu"
|
||
data-mail--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-ink-soft
|
||
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 #}
|
||
{# The same entrance as every other menu in the app — spelt out
|
||
because this one is opened by the toolbar controller rather than
|
||
by ui--dropdown, and a menu that behaves differently from the ten
|
||
beside it for no reason is worse than one that does not move at
|
||
all. Safe on first-paint markup: it is `hidden`, and a
|
||
display:none element has no running animation. #}
|
||
<div
|
||
data-mail--list-toolbar-target="selectMenu"
|
||
data-enter="slide-down"
|
||
class="hidden absolute left-0 top-full mt-1 z-30 w-max min-w-36
|
||
bg-surface
|
||
border border-line
|
||
rounded-pane 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->mail--list-toolbar#{{ key }}"
|
||
{# `block`: the menu is as wide as its widest
|
||
entry now, and five inline-block buttons in a
|
||
max-content box sit side by side to work that
|
||
width out. #}
|
||
class="block w-full text-left px-3 py-1.5
|
||
text-ink-soft
|
||
hover:bg-hover
|
||
transition-colors cursor-pointer
|
||
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent">
|
||
{{ label|trans }}
|
||
</button>
|
||
{% endfor %}
|
||
|
||
{# The whole view, from the menu where selecting is done (#37).
|
||
It was only ever offered by the banner below, and the banner
|
||
only appears once every row on the page is ticked — so the
|
||
one way to select a folder was hidden behind having already
|
||
selected a page of it, and was reported as missing.
|
||
|
||
Only where it differs from "All": on a list that fits on one
|
||
page the two are the same selection, and offering both would
|
||
be asking somebody to work out that they are.
|
||
|
||
The wrapper is the pagination region's sibling in spirit:
|
||
`total` moves when mail arrives or a bulk action runs, and
|
||
the controller redraws the label from its own total value
|
||
rather than trusting the number rendered here. #}
|
||
<button type="button"
|
||
data-mail--list-toolbar-target="wholeView"
|
||
data-action="click->mail--list-toolbar#selectWholeView"
|
||
data-label="{{ 'toolbar.select.whole_view'|trans }}"
|
||
class="{{ total > per_page ? 'block' : 'hidden' }} w-full text-left px-3 py-1.5
|
||
border-t border-line mt-1 pt-2
|
||
text-ink-soft whitespace-nowrap
|
||
hover:bg-hover
|
||
transition-colors cursor-pointer
|
||
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent">
|
||
{{ 'toolbar.select.whole_view'|trans({ '%count%': total }) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{# ════════════════════════════════════════════════════════════════════════
|
||
Left action slot: bulk actions, visible when ≥1 row is selected. There
|
||
used to be a Refresh button holding this spot in the unselected state —
|
||
removed, because the topbar's sync button is the one that refreshes and
|
||
two spinners answering one question always had one of them lying.
|
||
════════════════════════════════════════════════════════════════════════ #}
|
||
|
||
{# Bulk actions — hidden until ≥1 selected #}
|
||
<div
|
||
data-mail--list-toolbar-target="actions"
|
||
class="hidden items-center gap-2"
|
||
aria-hidden="true"
|
||
>
|
||
{{ m.tbtn('click->mail--list-toolbar#archiveSelected', 'fa-solid fa-box-archive', 'toolbar.actions.archive'|trans) }}
|
||
{# Delete, or Delete forever where the selection is already thrown away.
|
||
|
||
Delete forever is the user's choice (Settings → Mail → Disposal), off
|
||
until they make it, and is the one button here that cannot be undone.
|
||
With it on, Spam and the bin offer it in place of Delete, as the rows
|
||
there do: in Spam the trash would only be a second place to delete the
|
||
same mail from, and in the bin it trashes mail that already carries
|
||
Trash, which changes nothing on the server while the answer still
|
||
takes the rows out of the list, so mail looked deleted and was back on
|
||
the next load.
|
||
|
||
With it off, Spam keeps Delete, and the bin has no Delete at all, for
|
||
the second reason: the only way out of it is Move to inbox. What asks
|
||
first before a delete forever is mail--list-toolbar#purgeSelected. #}
|
||
{% if list_scope|default('') in ['spam', 'trash'] and app.user and app.user.deleteNowButtons %}
|
||
{{ m.tbtn('click->mail--list-toolbar#purgeSelected', 'fa-solid fa-fire', 'toolbar.actions.purge'|trans, 'hover:text-danger') }}
|
||
{% elseif 'trash' != list_scope|default('') %}
|
||
{{ m.tbtn('click->mail--list-toolbar#deleteSelected', 'fa-solid fa-trash', 'toolbar.actions.delete'|trans, 'hover:text-danger') }}
|
||
{% endif %}
|
||
{# Beside archive and delete because it is the third way a selection
|
||
LEAVES this list, and those three are what a selection is nearly
|
||
always made for. #}
|
||
{% include '_partials/_move_menu.html.twig' with {
|
||
labels: sidebarCounts.flattenedUserLabels,
|
||
} only %}
|
||
|
||
{# ── More ──────────────────────────────────────────────────────────
|
||
Everything else a selection can have done to it. Nine buttons in a
|
||
row were wider than the list whenever it shared its card — with a
|
||
docked calendar, or with the reading pane beside it — and the row
|
||
was cut off at the pane's edge. Three stay out; six go in here.
|
||
|
||
Snooze and Label are menus themselves, and a menu inside a menu is
|
||
two things to dismiss. So their rows close this one and open theirs
|
||
in its place: the two panels live in this wrapper, under the same
|
||
button, each behind a trigger nobody sees. That trigger is also what
|
||
`b` and `l` press, so the keys and the rows go through one door. #}
|
||
<div class="relative" data-controller="ui--dropdown" data-toolbar-more>
|
||
<button
|
||
type="button"
|
||
data-action="click->ui--dropdown#toggle"
|
||
aria-haspopup="menu"
|
||
aria-label="{{ 'toolbar.actions.more'|trans }}"
|
||
title="{{ 'toolbar.actions.more'|trans }}"
|
||
class="flex items-center justify-center w-8 h-8 rounded-full
|
||
text-ink-muted hover:text-ink hover:bg-hover
|
||
transition-colors duration-100 cursor-pointer"
|
||
>
|
||
<i class="fa-solid fa-ellipsis-vertical text-[15px]" aria-hidden="true"></i>
|
||
</button>
|
||
|
||
<div data-ui--dropdown-target="menu" hidden role="menu"
|
||
aria-label="{{ 'toolbar.actions.more'|trans }}"
|
||
class="absolute left-0 top-full mt-1 z-30 w-max min-w-48 popover py-1">
|
||
{# Star stars; it does not toggle — a selection is a mix of both,
|
||
and the server's bulk star is the one a drop on Starred
|
||
already uses. It is here for `s` as much as for the pointer:
|
||
a key presses the control that exists. #}
|
||
{{ m.mrow('click->mail--list-toolbar#starSelected', 'fa-solid fa-star', 'toolbar.actions.star'|trans) }}
|
||
{{ m.mrow('click->mail--list-toolbar#markReadSelected', 'fa-solid fa-envelope-open', 'toolbar.actions.mark_read'|trans) }}
|
||
{{ m.mrow('click->mail--list-toolbar#markUnreadSelected', 'fa-solid fa-envelope', 'toolbar.actions.mark_unread'|trans) }}
|
||
{{ m.mrow('click->mail--list-toolbar#openFlyout', 'fa-solid fa-clock', 'toolbar.actions.snooze'|trans, '', 'snooze') }}
|
||
{{ m.mrow('click->mail--list-toolbar#openFlyout', 'fa-solid fa-tag', 'toolbar.actions.label'|trans, '', 'label') }}
|
||
{# Not where the selection is already thrown away. In the bin the move
|
||
would pull mail back out of it and into Spam; in Spam it is nothing. #}
|
||
{% if list_scope|default('') not in ['spam', 'trash'] %}
|
||
<div class="my-1 border-t border-line"></div>
|
||
{{ m.mrow('click->mail--list-toolbar#spamSelected', 'fa-solid fa-circle-exclamation', 'toolbar.actions.spam'|trans, 'hover:text-danger') }}
|
||
{% endif %}
|
||
</div>
|
||
|
||
{# The two panels the rows above hand over to. Laid exactly over
|
||
the button and deaf to the pointer, so the button under it still
|
||
takes its own clicks; the panels switch the pointer back on for
|
||
themselves. Over the button, not beside it, because that is
|
||
where a panel has to hang from — the label panel measures its
|
||
trigger to place itself, and a trigger with no box measures as
|
||
the corner of the window. #}
|
||
<div class="absolute inset-0 pointer-events-none *:pointer-events-none [&_[data-ui--dropdown-target=menu]]:pointer-events-auto">
|
||
{# A menu, not a direct action: this used to hardcode "tomorrow
|
||
8am" with a comment asking for a picker. #}
|
||
<div class="absolute inset-0" data-controller="ui--dropdown">
|
||
<button type="button" tabindex="-1" aria-hidden="true"
|
||
data-toolbar-flyout="snooze"
|
||
data-action="click->ui--dropdown#toggle"
|
||
class="block w-full h-full invisible"
|
||
aria-label="{{ 'toolbar.actions.snooze'|trans }}"></button>
|
||
{% include '_partials/_snooze_menu.html.twig' with {
|
||
controller: 'mail--list-toolbar',
|
||
method: 'snoozeSelected',
|
||
snoozed: false,
|
||
align: 'left',
|
||
} only %}
|
||
</div>
|
||
{% include '_partials/_label_menu.html.twig' with {
|
||
labels: sidebarCounts.flattenedUserLabels,
|
||
targetType: 'thread',
|
||
trigger: false,
|
||
align: 'left',
|
||
} %}
|
||
</div>
|
||
</div>
|
||
<span
|
||
data-mail--list-toolbar-target="selectionCount"
|
||
class="ml-1 text-xs text-ink-muted tabular-nums whitespace-nowrap"
|
||
></span>
|
||
</div>
|
||
|
||
{# ── "You are looking at unread only" ──────────────────────────────────
|
||
|
||
Said out loud, and with a way back out — the same rule the label view's
|
||
account narrowing follows, and for the same reason: a filtered list and
|
||
a genuinely quiet one look identical, so a filter that does not announce
|
||
itself reads as mail having gone missing.
|
||
|
||
Here rather than in each view's header because six lists can be narrowed
|
||
this way — the inbox, Starred, Archive, Spam, Snoozed and any label,
|
||
under an account or across all of them — and they have six templates
|
||
between them. The toolbar is the one part every one of them already
|
||
shares.
|
||
|
||
The way out is this route again without `unread`, rebuilt from the
|
||
request rather than named: the views reached like this carry route
|
||
parameters (a label id) AND query parameters (?account=, ?tab=, ?sort=),
|
||
and dropping either on the way back would answer "show all" with a
|
||
different list than the one being filtered. Only `unread` and `page` are
|
||
removed — page, because page four of the unread threads is not a page
|
||
that need exist once the filter is gone. #}
|
||
{% if unread_only|default(false) %}
|
||
{% set _back = path(
|
||
app.request.attributes.get('_route'),
|
||
app.request.attributes.get('_route_params')|default({})|merge(
|
||
app.request.query.all|filter((v, k) => k not in ['unread', 'page'])
|
||
)
|
||
) %}
|
||
<span class="flex items-center gap-1.5 text-xs shrink-0">
|
||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full
|
||
bg-accent-soft text-accent font-medium">
|
||
<i class="fa-solid fa-envelope text-[9px]" aria-hidden="true"></i>
|
||
{{ 'toolbar.filter.unread_only'|trans }}
|
||
</span>
|
||
<a href="{{ _back }}" class="text-ink-muted hover:text-ink hover:underline">
|
||
{{ 'toolbar.filter.show_all'|trans }}
|
||
</a>
|
||
</span>
|
||
{% endif %}
|
||
|
||
{# Spacer #}
|
||
<div class="flex-1"></div>
|
||
|
||
{# The list of keyboard shortcuts, for somebody who has them. `?` opens the
|
||
same dialog, and a key nobody has told you about is not a way in — this
|
||
is the visible one, on the bar the keys act on. Absent with the
|
||
shortcuts switched off, where it would advertise keys that do nothing;
|
||
the list is still in Settings → General for deciding to switch them on.
|
||
|
||
Before the sort menu and the pager so those two keep the far end, where
|
||
they have always been. #}
|
||
{% if app.user and app.user.keyboardShortcuts %}
|
||
<button type="button"
|
||
data-controller="ui--modal"
|
||
data-ui--modal-src-value="{{ path('app_shortcuts_help') }}"
|
||
data-ui--modal-title-value="{{ 'shortcuts.title'|trans }}"
|
||
data-action="click->ui--modal#open"
|
||
data-shortcuts-button
|
||
aria-label="{{ 'shortcuts.title'|trans }}"
|
||
title="{{ 'shortcuts.title'|trans }} (?)"
|
||
class="hidden md:flex items-center justify-center shrink-0
|
||
w-8 h-8 mr-1 rounded-full
|
||
text-ink-faint hover:text-ink hover:bg-hover
|
||
transition-colors duration-100 cursor-pointer">
|
||
<i class="fa-regular fa-keyboard text-[15px]" aria-hidden="true"></i>
|
||
</button>
|
||
{% endif %}
|
||
|
||
{# ── Sort order ────────────────────────────────────────────────────────
|
||
Sits before the pagination because switching it resets the pagination,
|
||
and a control that changes the one beside it belongs upstream of it.
|
||
|
||
Two sources, one control. `sort` is the search page's SearchSortOrder,
|
||
whose second position is relevance; `list_sort` is a folder's
|
||
ListSortOrder, whose second position is oldest-first. Relevance has no
|
||
meaning without a query, so the two enums stay separate and the menu
|
||
renders whichever it is handed. #}
|
||
{% if sort is defined and sort is not null and total > 0 %}
|
||
{% include '_partials/_sort_menu.html.twig' with {
|
||
current: sort,
|
||
options: enum('App\\Domain\\Enum\\Mail\\SearchSortOrder').cases,
|
||
route: 'app_mail_search',
|
||
} only %}
|
||
{% elseif list_sort is defined and list_sort is not null and total > 0 %}
|
||
{% include '_partials/_sort_menu.html.twig' with {
|
||
current: list_sort,
|
||
options: enum('App\\Domain\\Enum\\Mail\\ListSortOrder').cases,
|
||
route: app.request.attributes.get('_route'),
|
||
} only %}
|
||
{% endif %}
|
||
|
||
{# ── Pagination ─────────────────────────────────────────────────────────
|
||
|
||
The one part of this toolbar a background refresh replaces. The range
|
||
and the prev/next state are derived from a total the server owns, so
|
||
they go stale after a bulk action the way the category tabs do — and
|
||
unlike the buttons to the left, nothing here is the target of the click
|
||
that triggered the refresh. Everything else in this toolbar is left
|
||
exactly where it is: see mail--mail-pane#_swapRegions for why.
|
||
|
||
The wrapper renders whether or not there is a page to describe, because
|
||
a region that vanishes at zero cannot be filled again. `contents`, so it
|
||
adds no box to the toolbar's flex row. #}
|
||
<div data-list-region="pagination" class="contents">
|
||
{% if total > 0 %}
|
||
<div class="flex items-center gap-1 text-xs text-ink-muted shrink-0 pr-1">
|
||
{# `data-page-range`: what app.css takes out of a narrow toolbar
|
||
while rows are ticked — see "A selection in a narrow list". #}
|
||
<span class="tabular-nums mr-1" data-page-range>
|
||
{{ 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>
|
||
|
||
</div>{# ── end of the controls row ──────────────────────────────────── #}
|
||
|
||
{# The offer to select past the page. Hidden until every row on screen is
|
||
ticked AND the view holds more than fits — before that it would be a
|
||
control that does nothing. Filled in by the controller, which is the only
|
||
thing that knows both numbers.
|
||
|
||
`-mx-3` so the tinted strip runs the full width of the bar while the
|
||
toolbar keeps its own horizontal padding, and `mt-1` to clear the row
|
||
above rather than sit against it. #}
|
||
<div data-mail--list-toolbar-target="viewBanner"
|
||
class="hidden -mx-3 mt-1 flex items-center justify-center gap-2
|
||
px-3 py-1.5 text-xs bg-accent-soft text-ink border-t border-line">
|
||
<span data-mail--list-toolbar-target="viewBannerText"></span>
|
||
<button type="button"
|
||
data-mail--list-toolbar-target="viewBannerAction"
|
||
data-action="click->mail--list-toolbar#selectAllInView"
|
||
class="font-semibold text-accent underline underline-offset-2 hover:opacity-70 cursor-pointer">
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|