Files
pl_mail/templates/_partials/_account_list.html.twig
T
Paul LütznerandClaude Opus 5.5 994d0a9c0d fix(sidebar): the open row no longer fades in from black on every load
Every sidebar row went out with no colour class, and ui--sidebar gave
the open one bg-accent-soft and text-accent (and every other row
text-ink-muted) on connect. By then the browser had already styled the
page, and the rows carry transition-colors for their hover, so on every
full load and every Turbo visit the open row faded in over 150ms from
the inherited black and a transparent pill, and the rest faded from
black to their muted ink. Measured in headless Chromium against the
test stack: app.css loaded at ~155ms, first paint at ~200ms with the
rows black, the controller's pass at ~250ms, transitionrun on every
visible row in the next frame. The stylesheet order was never the
cause, and neither was the per-frame sampler: plain transitionrun
listeners see the same thing.

The server now renders the state. SidebarRowStateExtension restates
ui--sidebar#_matches against the main request, and every link row in
the sidebar, the account list and an expanded account's folders takes
its classes from sidebar_row_state(). The controller's pass is then a
no-op on everything the server drew, the first style the browser
resolves is the final one, and the hover transitions are untouched
(still ~130ms in and out). The pass stays for frame navigations and
for rows that arrive later.

After: the open row's first sample is already its final colour, and no
sidebar row runs a transition on load, on a Turbo visit or in the
mobile drawer. Two transitions remain at load and neither is a sidebar
row: the Mercure dot, which is neutral until the stream reports, by
design, and the topbar calendar toggle, which is still rendered as
"mail" and corrected on connect.

SidebarRowStateTest pins the rendered state, the matching rule, and
that the class lists agree with the controller's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:37:33 +02:00

124 lines
7.3 KiB
Twig

{% set _drawerClose = _drawerClose is defined ? _drawerClose : '' %}
{# '' for the desktop rail, '-drawer' for the mobile one. Both render this
partial into the same document, so every id below has to carry it. #}
{% set _idSuffix = _idSuffix is defined ? _idSuffix : '' %}
{% for account in accounts %}
{% set expanded = app.user.expandedAccountId == account.id %}
{% set frameId = 'account-folders-' ~ account.id ~ _idSuffix %}
{# The frame id travels with the request too. Turbo matches the response
against the id of the frame that asked, so a drawer frame fetching a
reply that says `account-folders-20` finds nothing to swap and the
folders never appear. #}
<div data-controller="settings--account-expand"
data-settings--account-expand-folders-url-value="{{ path('app_mail_account_folders', { account: account.id, frame: frameId }) }}"
data-settings--account-expand-persist-url-value="{{ path('app_mail_sidebar_account_expanded') }}"
data-settings--account-expand-account-value="{{ account.id }}"
data-settings--account-expand-active-value="{{ expanded ? 'true' : 'false' }}">
{# Two targets, not one. The name opens the account — everything in it,
whatever it is labelled — and the chevron opens its label list. The
whole row used to toggle, which left no way to ask for the account
itself, and expanding was the only thing clicking an account could
do. #}
{# The row is the .nav-item, so an account gets the same pill as every
other sidebar row — a rounded-lg box on this wrapper was the last
square highlight left in the sidebar. It has to be the wrapper and
not the anchor: on the anchor the pill stops before the chevron and
cuts the row in half.
Open or closed is rendered, not applied on connect — see
App\Twig\SidebarRowStateExtension. #}
{% set _href = path('app_mail_account', { account: account.id }) %}
<div class="nav-item flex items-center transition-colors {{ sidebar_row_state(_href) }}">
{# Named explicitly rather than by the address inside it: that span
is `truncate`, and a name a screen reader reads as "kontakt@…"
because CSS ran out of room is not a name. #}
<a
href="{{ _href }}"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
aria-label="{{ 'sidebar.accounts.open'|trans({ '%account%': account.displayAddress }) }}"
title="{{ account.displayAddress }}"
data-nav-tier="row"
class="flex items-center gap-2 flex-1 min-w-0"
>
{# The account's IDENTITY colour, the same tone its rows wear in
the unified lists — so a coloured dot on a message and this
dot in the sidebar are the same account. It used to be a
green/grey status light, which made every connected account
identical and told the list dots apart from nothing. Inactive
accounts dim rather than change hue: still recognisably the
same account, visibly asleep.
Two things were wrong with it. It read `sortOrder`, so
reordering the accounts swapped their colours and the mark
stopped identifying anything; that is `colorIndex` now, which
is assigned once and never moves. And it had no name of any
kind, so an orange circle beside one address and a green one
beside another could not be interpreted at all — the obvious
reading being a status light, which it is not and never was.
Account health is where status is reported; this says which
account, and now says so out loud. The one piece of state it
does carry — switched off — is named too, and agrees with the
toggle in Settings that put it there. #}
<span class="w-2 h-2 rounded-full flex-shrink-0 {{ account.colorIndex|account_color }}{{ account.isActive ? '' : ' opacity-40' }}"
role="img"
title="{{ account.isActive
? 'sidebar.accounts.colour'|trans({ '%account%': account.displayAddress })
: 'sidebar.accounts.colour_disabled'|trans({ '%account%': account.displayAddress }) }}"
aria-label="{{ account.isActive
? 'sidebar.accounts.colour'|trans({ '%account%': account.displayAddress })
: 'sidebar.accounts.colour_disabled'|trans({ '%account%': account.displayAddress }) }}"></span>
<span class="truncate text-sm">{{ account.displayAddress }}</span>
</a>
{# aria-expanded, because this is a disclosure button and which way
it will go is not visible to a screen reader — nor, since the
open account moved into the user's settings, to anything else
that has to know the state before pressing it. The controller
keeps it in step.
The account's address is IN the label. Every account renders one
of these, so "Toggle account folders" three times over is three
controls a screen-reader user cannot tell apart — the list of
buttons on the page reads the same word three times and none of
them says which mailbox it opens. #}
<button
type="button"
aria-label="{{ 'sidebar.accounts.toggle'|trans({ '%account%': account.displayAddress }) }}"
title="{{ 'sidebar.accounts.toggle'|trans({ '%account%': account.displayAddress }) }}"
aria-expanded="{{ expanded ? 'true' : 'false' }}"
aria-controls="{{ frameId }}"
data-settings--account-expand-target="toggle"
data-nav-tier="row"
class="px-2 rounded-lg text-gray-400 cursor-pointer hover:text-ink"
data-action="click->settings--account-expand#toggle"
>
<i data-settings--account-expand-target="chevron"
class="fa-solid fa-chevron-right text-xs shrink-0 transition-transform duration-150
{{ expanded ? 'rotate-90' : '' }}"></i>
</button>
</div>
{# The expanded account's folders are rendered here, not fetched after
the fact: the sidebar re-renders on every visit, and a list that
arrives one request later is a list that visibly blinks on each
navigation. Any other account still loads its frame on demand. #}
<turbo-frame
id="{{ frameId }}"
data-settings--account-expand-target="frame"
class="{{ expanded ? '' : 'hidden' }}"
>
{% if expanded %}
{% include 'mail/_account_folder_rows.html.twig' with {
account: account,
labels: sidebarCounts.labelsForAccount(account),
} only %}
{% endif %}
</turbo-frame>
</div>
{% else %}
<p class="px-3 text-sm text-ink-faint italic" data-nav-tier="row">{{ 'sidebar.accounts.none'|trans }}</p>
{% endfor %}