mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged. Takes over #115. Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
488 lines
29 KiB
Twig
488 lines
29 KiB
Twig
{# A refresh of the list gets the list, a navigation of the list frame gets the
|
||
document with only the list in its body, and an ordinary visit gets the whole
|
||
page. All three render the same `list_frame` block below, so the answers
|
||
cannot drift apart — see App\Twig\ListFragmentGlobal for what each caller
|
||
reads, and for why the middle one keeps its head. #}
|
||
{% extends listFragment.poll ? '_layout/_list_fragment.html.twig' : '_layout/app.html.twig' %}
|
||
|
||
{# The list/reading pane switch belongs to these pages alone: its targets are
|
||
further down this file, and it throws on connect wherever they are not.
|
||
Spelled out rather than `parent() ~ mail--mail-pane`, because the parent is
|
||
conditional above and the fragment layout has no such block to inherit. #}
|
||
{#
|
||
A mail list is never restored from Turbo's snapshot cache.
|
||
|
||
Opening a conversation is an ordinary Drive visit, so Turbo snapshots the list
|
||
as it leaves — and it leaves BEFORE the conversation is marked read, because
|
||
mail--thread-read waits two seconds and then posts. The stream that post renders
|
||
back targets a row that is not on the thread page, so nothing updates the
|
||
snapshot either. Pressing the browser's back button then restored it: the row
|
||
still bold, the unread count still counting it, and no request made that could
|
||
have corrected either.
|
||
|
||
A poll does correct it, a few seconds later, which is what made this "sometimes"
|
||
— and what makes it easy to write a test that passes against the bug. The list
|
||
is stale at the moment it is shown, and that is the whole of the complaint.
|
||
|
||
`no-cache` means back re-fetches. That costs one request on a screen whose only
|
||
purpose is to show current state, and whose contents may have changed for a
|
||
dozen other reasons while the conversation was open.
|
||
#}
|
||
{% block turbo_cache_control %}
|
||
<meta name="turbo-cache-control" content="no-cache">
|
||
{% endblock %}
|
||
|
||
{# The base layout puts the Inbox's unread count in front of every title, which
|
||
is right for settings or the calendar and wrong here: a mail folder says its
|
||
own number or none (the bin counts a total, Sent and Archive count nothing),
|
||
and the Inbox says its own in its own title block. Off by default for all of
|
||
them, and each page that does count names its key again, as before. #}
|
||
{% block title_prefix %}{% endblock %}
|
||
{% block title_count_key %}{% endblock %}
|
||
|
||
{# mail--dnd is here rather than in the base layout for the same reason
|
||
mail--mail-pane is: it is about a mail LIST. It listens on <body> because
|
||
the two ends of a drag — the rows in the list frame and the folders in the
|
||
sidebar, of which there are two copies — have no closer common ancestor. #}
|
||
{# `parent()`, and not the base list written out again. Written out, it was
|
||
one controller short: the base layout mounts `mail--jobs` beside
|
||
`core--mercure`, this block replaced both names with its own three, and the
|
||
topbar's background-work indicator was never told to look again on any mail
|
||
page — which is every page a bulk action is started from. It showed what the
|
||
server rendered at page load and then sat there, for as long as it existed
|
||
(#42 is where it was finally noticed). Whatever the base layout mounts, the
|
||
mailbox mounts too. #}
|
||
{% block body_controllers %}{{ parent() }} mail--mail-pane mail--dnd{% endblock %}
|
||
|
||
{% block body_values %}
|
||
{# The list to draw beside a message that was opened by its own address.
|
||
A thread page has no list behind it — the route knows a conversation, not
|
||
the folder and page it was opened from — so with the reading pane beside
|
||
the list, a reload or a bookmark would otherwise leave an empty column
|
||
next to the message. The Inbox is the one list that always exists. #}
|
||
data-mail--mail-pane-inbox-url-value="{{ path('app_mail_inbox') }}"
|
||
{# Two whole URLs rather than a base the controller appends an action to.
|
||
A route is the router's to spell, and `path()` cannot be asked for a
|
||
prefix — building one by trimming the end off a generated path is a
|
||
string operation that keeps working right up until the route changes. #}
|
||
data-mail--dnd-move-url-value="{{ path('app_status_bulk_run', { action: 'move' }) }}"
|
||
data-mail--dnd-label-url-value="{{ path('app_status_bulk_run', { action: 'label' }) }}"
|
||
data-mail--dnd-category-url-value="{{ path('app_status_bulk_run', { action: 'category' }) }}"
|
||
data-mail--dnd-read-url-value="{{ path('app_status_bulk_run', { action: 'read' }) }}"
|
||
data-mail--dnd-star-url-value="{{ path('app_status_bulk_run', { action: 'star' }) }}"
|
||
{# The placeholder is left in deliberately — the controller substitutes it.
|
||
Same shape as the calendar grid's pending/reverted announcements. Always
|
||
plural because the pill it fills is only drawn for two or more; a single
|
||
conversation drags a picture of its own row. #}
|
||
data-mail--dnd-dragging-value="{{ 'dnd.dragging'|trans }}"
|
||
{% endblock %}
|
||
|
||
{# The one part of a view that varies within its scope: the category tab for the
|
||
inbox, the label id for a label view. Empty for every list where the scope is
|
||
the whole answer. Defined here so `block('list_scope_value')` resolves for
|
||
every list rather than only the two that care. #}
|
||
{% block list_scope_value %}{% endblock %}
|
||
|
||
{% 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>
|
||
list_scope — space-separated special-use roles this view shows, so a
|
||
Mercure mailbox.synced update knows whether to refresh
|
||
("inbox", "sent", … or "*" for every mailbox; default "*")
|
||
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
|
||
#}
|
||
|
||
{#
|
||
Which of the two documents this is.
|
||
|
||
A frame navigation of the list — a folder in the sidebar, a category tab, the
|
||
pager, the sort menu, a search — has Turbo take `inbox-list-frame` out of the
|
||
response and drop everything else in the body on the floor. Rendering the
|
||
sidebar, the topbar and the calendar pane for it is work whose output is
|
||
measured, parsed and discarded — 21–24 database statements on a fifty-row
|
||
list against the 9–13 the frame alone costs, spent on a navigation that cannot
|
||
have changed any of it.
|
||
|
||
The HEAD is not dropped, which is why this is a fork inside `body` rather than
|
||
a second parent layout. Turbo reads that head to decide whether the navigation
|
||
renders anything at all, and then merges it into the live document, removing
|
||
what the response leaves out — so it has to stay exactly what app.html.twig
|
||
renders for a full visit. Cut it down to the <title> and the tab stops
|
||
following the folder you clicked; cut it down further and the page loses its
|
||
csrf token. Both measured: see App\Twig\ListFragmentGlobal.
|
||
#}
|
||
{% block body %}{% if listFragment.navigation %}{{ block('list_frame') }}{% else %}{{ block('mailbox_page') }}{% endif %}{% endblock %}
|
||
|
||
{% block mailbox_page %}
|
||
{#
|
||
sidebar-drawer wraps the entire page so the controller can manage
|
||
the drawer state from any trigger (burger button in the topbar, backdrop).
|
||
#}
|
||
<div
|
||
class="h-full overflow-hidden app-bg"
|
||
data-controller="ui--sidebar-drawer"
|
||
>
|
||
{# ── Mobile backdrop ──────────────────────────────────────────────── #}
|
||
<div
|
||
data-ui--sidebar-drawer-target="backdrop"
|
||
data-action="click->ui--sidebar-drawer#backdropClick"
|
||
class="md:hidden fixed inset-0 z-30 bg-black/40 backdrop-blur-sm
|
||
opacity-0 pointer-events-none transition-opacity duration-200"
|
||
aria-hidden="true"
|
||
></div>
|
||
|
||
{# ── Mobile sidebar drawer ────────────────────────────────────────── #}
|
||
{#
|
||
On mobile: fixed, full-height, slides in from the left.
|
||
On md+: hidden (the normal sidebar is in the flex row below).
|
||
#}
|
||
{# `data-surface` — see app.css. Each of the three marks a wrapper that
|
||
re-points the density tokens at that surface's own resolved pair, so
|
||
a dense list can sit beside a comfortable reading pane. On the
|
||
wrappers rather than inside the partials because the cascade reaches
|
||
the same elements either way and _sidebar.html.twig is a file this
|
||
change deliberately leaves alone. Both sidebar copies carry it: the
|
||
drawer is the same sidebar on a phone, and a setting that applied to
|
||
one and not the other would be a bug nobody could describe. #}
|
||
<div
|
||
data-ui--sidebar-drawer-target="drawer" id="sidebar-drawer" inert
|
||
data-surface="sidebar"
|
||
class="md:hidden fixed inset-y-0 left-0 z-40 w-64 p-3
|
||
-translate-x-full transition-transform duration-200 ease-out"
|
||
aria-label="{{ 'sidebar.nav.inbox'|trans }}"
|
||
>
|
||
{% include '_partials/_sidebar.html.twig' with { _drawerMode: true } %}
|
||
</div>
|
||
|
||
{# ui--split spans the topbar as well as the panes: the calendar
|
||
toggle lives in the topbar, and a Stimulus action only reaches a
|
||
controller mounted on an ancestor. #}
|
||
<div class="shell-frame flex h-full flex-col p-gutter gap-gutter"
|
||
{% if app.user %}
|
||
style="--calendar-pane-width: {{ app.user.calendarPaneWidth }}px"
|
||
data-controller="ui--split"
|
||
data-ui--split-state-url-value="{{ path('app_calendar_pane_state') }}"
|
||
data-ui--split-token-value="{{ csrf_token('calendar_pane_state') }}"
|
||
data-ui--split-min-value="{{ constant('App\\Entity\\User\\User::CALENDAR_PANE_MIN_WIDTH') }}"
|
||
data-ui--split-max-value="{{ constant('App\\Entity\\User\\User::CALENDAR_PANE_MAX_WIDTH') }}"
|
||
data-ui--split-mode-value="{{ app.user.calendarPaneMode.value }}"
|
||
{# Settings → Calendar → Behaviour. Which position the switch skips
|
||
(none, split or calendar); the label in the topbar is drawn from
|
||
the same setting. #}
|
||
data-ui--split-skip-value="{{ app.user.calendarSwitchSkip.value }}"
|
||
{# The attribute drives every layout rule below, and it is rendered
|
||
as the STORED mode. It used to be rendered as `mail` and
|
||
corrected by the controller on connect — which painted every
|
||
reload as mail first and the remembered view second, a flash on
|
||
every refresh. The one reader the stored mode can be wrong for
|
||
is a phone, which cannot hold split or calendar; the controller
|
||
still demotes there on connect, so the correction flash is
|
||
confined to the case that actually needs correcting. #}
|
||
data-calendar-mode="{{ app.user.calendarPaneMode.value }}"
|
||
{# …and marked as remembered rather than chosen in this window, which
|
||
is what keeps the phone's first paint clean. Below lg every
|
||
remembered position opens on the mail, and the pane is already
|
||
`hidden` there, so until the controller has confirmed the mode
|
||
the mail must not step aside for a calendar that is not drawn
|
||
(see [data-calendar-restored] in app.css). Split is the default
|
||
now, so without this every phone would open on a blank row and
|
||
wait for JavaScript to put the mail back. connect() removes it. #}
|
||
data-calendar-restored
|
||
{# The pane reloads its body through a Turbo Frame, and the view it
|
||
lands on decides how much room it wants — see
|
||
CalendarView::minimumPaneWidth(). turbo:frame-load bubbles, so
|
||
the boundary's own controller can hear it from up here. #}
|
||
data-action="turbo:frame-load->ui--split#paneLoaded"
|
||
{% endif %}
|
||
>
|
||
|
||
{# Full-width topbar. `calendarRestored`, because its calendar
|
||
switch has to be drawn in the same remembered position as the
|
||
layout above — and, like the layout, as `mail` below lg. See
|
||
"The first paint" in the topbar. #}
|
||
{% include '_partials/_topbar.html.twig' with { calendarRestored: true } %}
|
||
|
||
{# Sidebar + content #}
|
||
<div class="flex flex-1 gap-gutter overflow-hidden">
|
||
|
||
{# Sidebar — desktop only (hidden on mobile, shown in drawer) #}
|
||
<div class="hidden md:flex" data-surface="sidebar">
|
||
{% include '_partials/_sidebar.html.twig' %}
|
||
</div>
|
||
|
||
{# ── The mail column: the insight card, then the mail card ──
|
||
Two cards, not one card with a band inside it. The strip is a
|
||
SIBLING of .main-pane here rather than a child of the list
|
||
pane, which is the only way it can carry its own border and
|
||
sit on the app background the way the sidebar and the
|
||
calendar pane do — a child can only ever be a box drawn on
|
||
the mail card's surface.
|
||
|
||
`data-mail-column` exists for the two rules in app.css that
|
||
hide the mail side outright: calendar mode, and split below
|
||
lg. They select .main-pane, and a wrapper around it would
|
||
survive both and leave an insight card floating beside a
|
||
calendar. The wrapper answers to the same two rules.
|
||
|
||
The strip is still LAZY and still renders nothing when there
|
||
is nothing to say — being its own card costs the mailbox no
|
||
more than being a band did. #}
|
||
{# No `gap-gutter` here, and that is load-bearing. A gap is
|
||
paid between CHILDREN, and the frame below is a child even
|
||
with nothing in it — before its lazy fetch lands, on a day
|
||
with no insight, and after the strip is dismissed. The mail
|
||
pane then sat a gutter lower than the sidebar beside it, for
|
||
a strip that was not on screen. The separation is the
|
||
STRIP's margin instead (see insight/_pane.html.twig), so it
|
||
exists exactly when the strip does. #}
|
||
<div data-mail-column
|
||
class="flex flex-col flex-1 min-w-0 overflow-hidden">
|
||
|
||
<turbo-frame id="insight_pane"
|
||
class="shrink-0"
|
||
src="{{ path('app_insight_pane') }}"
|
||
loading="lazy"></turbo-frame>
|
||
|
||
{# Main content. Targeted by ui--split so the drag can enforce a
|
||
minimum on this side too — the pane knows how much room it is
|
||
taking only by measuring what is left. #}
|
||
{#
|
||
The reading pane position, drawn from the first paint.
|
||
|
||
`data-reading-pane` is the STORED mode and nothing the browser has
|
||
measured: whether the message actually goes beside the list is a
|
||
question about this card's width, answered in app.css by a
|
||
container query, so a window the calendar has squeezed falls back
|
||
to one pane at a time without a script having to notice. It is
|
||
rendered here rather than applied on connect for the reason the
|
||
calendar mode is — a reload that painted one pane and then
|
||
rearranged itself would flash on every visit.
|
||
|
||
`data-reading-open` is the one thing that varies within a mode: a
|
||
message is open. The thread page renders it directly; everywhere
|
||
else mail--mail-pane sets and clears it as messages open and
|
||
close, so the placeholder and the message never both show.
|
||
#}
|
||
{% set readingPane = app.user ? app.user.readingPaneMode : enum('App\\Domain\\Enum\\Mail\\ReadingPaneMode').Off %}
|
||
<div class="flex flex-1 overflow-hidden relative main-pane"
|
||
data-reading-pane="{{ readingPane.value }}"
|
||
{% if 'true' == block('pane_open')|trim %}data-reading-open{% endif %}
|
||
{% if app.user %}
|
||
data-ui--split-target="main"
|
||
data-controller="mail--reading-split"
|
||
style="--reading-pane-pct: {{ app.user.readingPaneWidthPct }}%"
|
||
data-mail--reading-split-state-url-value="{{ path('app_appearance_reading_pane_state') }}"
|
||
data-mail--reading-split-token-value="{{ csrf_token('reading_pane_state') }}"
|
||
data-mail--reading-split-min-value="{{ constant('App\\Entity\\User\\User::READING_PANE_MIN_PCT') }}"
|
||
data-mail--reading-split-max-value="{{ constant('App\\Entity\\User\\User::READING_PANE_MAX_PCT') }}"
|
||
data-mail--reading-split-default-value="{{ constant('App\\Entity\\User\\User::READING_PANE_DEFAULT_PCT') }}"
|
||
data-mail--reading-split-width-value="{{ app.user.readingPaneWidthPct }}"
|
||
{% endif %}>
|
||
|
||
{# List pane #}
|
||
<div
|
||
id="message-list"
|
||
data-surface="list"
|
||
data-mail--mail-pane-target="list"
|
||
data-mail--reading-split-target="list"
|
||
{# Confirms to the server that the rows below reached the
|
||
screen. Mounted on the container rather than on each
|
||
row so fifty rows are one report, and here rather than
|
||
inside the frame so it survives a fragment swap and
|
||
watches for one. #}
|
||
{# One data-controller attribute: a second one is dropped by the parser. The
|
||
pull gesture is outside the frame, so a refresh cannot take it away, and
|
||
it reaches the topbar's sync button by lookup, not by DOM position. #}
|
||
{% if app.user %}
|
||
data-controller="mail--new-marker mail--pull-to-refresh"
|
||
data-mail--new-marker-url-value="{{ path('app_mail_threads_listed') }}"
|
||
{% else %}
|
||
data-controller="mail--pull-to-refresh"
|
||
{% endif %}
|
||
{# @container, so the rows below switch to their stacked
|
||
layout on the LIST's width. They used to switch on the
|
||
viewport's, which is a different number entirely once
|
||
the calendar pane is docked: a 1440px window with a
|
||
wide pane leaves the list narrower than a phone, and
|
||
every row stayed on one line and truncated to nothing. #}
|
||
class="@container relative flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
|
||
>
|
||
{# The pull to refresh indicator. Here, beside the frame and not inside it, so a
|
||
refresh never replaces it. Its words are the visual states only: the sync
|
||
toast is what announces the run, so this stays out of the accessibility tree. #}
|
||
<div data-mail--pull-to-refresh-target="indicator" data-state="idle" aria-hidden="true" class="mail-pull-indicator">
|
||
<i class="fa-solid fa-rotate" data-mail--pull-to-refresh-target="icon" aria-hidden="true"></i>
|
||
<span data-label-for="pulling">{{ 'pull_to_refresh.pull'|trans }}</span>
|
||
<span data-label-for="release">{{ 'pull_to_refresh.release'|trans }}</span>
|
||
</div>
|
||
{{ block('list_frame') }}
|
||
</div>
|
||
|
||
{#
|
||
The divider between list and message. Drawn only when the
|
||
reading pane is beside the list (see app.css), which is also
|
||
how mail--mail-pane tells that it is: a handle that is
|
||
`display: none` means one pane at a time.
|
||
|
||
A separator rather than a button, because it is a window
|
||
splitter in the ARIA sense: it has a value, a range and arrow
|
||
keys, and a screen reader announces it as such. The value is
|
||
the MESSAGE's share of the card, in percent, which is what is
|
||
stored and what the keys step.
|
||
#}
|
||
{% if app.user %}
|
||
<div
|
||
data-reading-handle
|
||
data-mail--reading-split-target="handle"
|
||
data-action="pointerdown->mail--reading-split#start keydown->mail--reading-split#nudge dblclick->mail--reading-split#reset"
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-valuemin="{{ constant('App\\Entity\\User\\User::READING_PANE_MIN_PCT') }}"
|
||
aria-valuemax="{{ constant('App\\Entity\\User\\User::READING_PANE_MAX_PCT') }}"
|
||
aria-valuenow="{{ app.user.readingPaneWidthPct }}"
|
||
aria-label="{{ 'reading_pane.handle'|trans }}"
|
||
tabindex="0"
|
||
></div>
|
||
{% endif %}
|
||
|
||
{# Reading pane #}
|
||
<div
|
||
data-mail--mail-pane-target="reading"
|
||
data-mail--reading-split-target="reading"
|
||
data-surface="reading"
|
||
class="flex flex-col flex-1 overflow-hidden {% block reading_hidden %}hidden{% endblock %}"
|
||
>
|
||
{% block reading_pane %}
|
||
{% if selectedMessage is not null %}
|
||
{% include 'mail/_message_content.html.twig' with { message: selectedMessage } %}
|
||
{% endif %}
|
||
{% endblock %}
|
||
</div>
|
||
|
||
{#
|
||
What the right-hand pane says while no message is open. A
|
||
sibling of the reading pane and not content inside it,
|
||
because mail--mail-pane REPLACES the reading pane's
|
||
innerHTML with each message it loads — a hint placed in it
|
||
would be wiped by the first click and never come back.
|
||
Hidden everywhere the reading pane is not beside the list.
|
||
#}
|
||
<div data-reading-placeholder aria-hidden="true">
|
||
<i class="fa-regular fa-envelope-open" aria-hidden="true"></i>
|
||
<p>{{ 'reading_pane.placeholder'|trans }}</p>
|
||
</div>
|
||
|
||
</div>
|
||
</div>{# /mail column #}
|
||
|
||
{# Calendar — a sibling of .main-pane, so the handle between
|
||
them splits the row's width rather than shrinking one into
|
||
the other. Below lg it is absent and the sidebar link goes
|
||
to the full page instead. #}
|
||
{% if app.user %}
|
||
{% include '_partials/_calendar_pane.html.twig' %}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{#
|
||
The list itself, in one place.
|
||
|
||
Its own block because it is rendered two ways: inside the page above on a
|
||
visit, and alone as the whole response when the poll refreshes it. The page
|
||
templates fill `message_list`, `inbox_tabs` and the toolbar without caring
|
||
which of the two is happening.
|
||
#}
|
||
{% block list_frame %}
|
||
{# Whether this page actually put a list in the frame. The thread page does
|
||
not — it has no list to render — and the pane controller reads this to
|
||
know that going Back has to fetch one rather than uncover an empty
|
||
frame. See mail_pane_controller#_listNeedsRendering. #}
|
||
{# `data-sync-scope` is ON THE FRAME, which is a change of address rather
|
||
than of meaning: it used to sit on #message-list, one element further
|
||
out. That element is outside the frame, so it survives a frame
|
||
navigation — and a scope that survives the navigation is a scope that
|
||
describes the folder you just left. Switch from the inbox to Sent and
|
||
the list went on refreshing itself for inbox syncs and ignoring sent
|
||
ones, which shows up as new mail not appearing until something else
|
||
redraws. The scope belongs where the list it describes is rendered.
|
||
Read by mail--mail-pane#_affectsCurrentView and by compose--compose
|
||
when it discards a draft. #}
|
||
<turbo-frame id="inbox-list-frame" data-turbo-action="advance" class="contents"
|
||
data-list-rendered="{% block list_rendered %}1{% endblock %}"
|
||
data-sync-scope="{% block list_scope %}*{% endblock %}"
|
||
data-list-scope-value="{{ block('list_scope_value') }}">
|
||
|
||
{# ── Gmail-style toolbar ──────────────────────────────────────────── #}
|
||
{% block inbox_toolbar %}
|
||
{# list_scope is the same block the sync scope uses, so a view
|
||
names itself once. list_scope_value carries the one thing that
|
||
varies within a scope: the category tab for the inbox, the label
|
||
id for a label view. Both go to the server's closed set — see
|
||
App\Service\Mail\ListViewResolver. #}
|
||
{% include '_partials/_list_toolbar.html.twig' with {
|
||
page: page ?? 1,
|
||
total: total ?? 0,
|
||
per_page: per_page ?? 50,
|
||
list_scope: block('list_scope'),
|
||
list_scope_value: block('list_scope_value'),
|
||
unread_only: unread_only ?? false,
|
||
} %}
|
||
{% endblock %}
|
||
|
||
{# Optional tabs row (inbox only).
|
||
|
||
The wrapper is always here even when the row is not, because it is
|
||
what a refresh writes into: `data-list-region` marks the parts of
|
||
this frame a background refresh is allowed to replace, and a region
|
||
that disappears when it is empty cannot be filled again. Only the
|
||
inbox renders tabs, and only when more than one category has mail —
|
||
see mail/inbox.html.twig — so the empty case is the common one.
|
||
|
||
`contents`, so the wrapper adds no box of its own and the tabs stay
|
||
a direct child of the frame's flex column. #}
|
||
<div data-list-region="tabs" class="contents">{% block inbox_tabs %}{% endblock %}</div>
|
||
|
||
{# The rows are a region too. A refresh brings them up to date by
|
||
morphing rather than replacing — see mail--mail-pane#_morphRows.
|
||
|
||
`data-enter-children`, not `data-enter`: the whole-list gesture is
|
||
the ROWS arriving, not this element. A list that fades as one block
|
||
tells you a grey rectangle changed; fifty rows dropping in, each one
|
||
a frame behind the one above it, tells you what changed. motion.js
|
||
gives each row the entrance named here and marks it as spoken for, so
|
||
the entrance the row carries for itself — the long drop that means
|
||
one new mail, see _partials/_thread_row.html.twig — cannot fire on
|
||
top. Same direction as that one, and nothing else in common: this is
|
||
30ms a row, and what is seen is the cascade rather than the fall.
|
||
|
||
This element is only ever FILLED by a refresh, never replaced, so it
|
||
fires on exactly the thing it should mean: a genuinely different list
|
||
arriving, from the sidebar, a category tab, the next page or a search.
|
||
|
||
The stagger is capped in CSS at eight rows and that cap is doing real
|
||
work here. Fifty rows at 16ms would be four fifths of a second before
|
||
the last one was legible; capped, a list of fifty and a list of six
|
||
both finish in about 150ms and read as one cascade. #}
|
||
<ul data-list-region="rows"
|
||
data-enter-children="slide-down"
|
||
data-enter-stagger
|
||
class="flex-1 overflow-y-auto divide-y divide-line">
|
||
{% block message_list %}{% endblock %}
|
||
</ul>
|
||
|
||
</turbo-frame>
|
||
{% endblock %}
|