feat: a sidebar that folds away, and remembers it wherever you sign in

The LABELS and ACCOUNTS headings are disclosures now. Collapsing one is
remembered against the user rather than the browser, so it follows you to
your phone, and it is rendered into the first paint rather than applied
afterwards, so nothing opens and then snaps shut on the way past.

The unit of collapse is the SECTION, decided from the data: user label
trees are flat in practice — 24 custom labels in the development mailbox
and not one of them nested — so per-label collapse had nothing to fold.
What actually pushes the accounts off the bottom of the nav is the wall of
labels itself, and that is what now folds. The per-label trees keep working
for anyone who does nest, out of the same stored set.

Reuses the idiom already in the file rather than adding a second one beside
it. Both headings are <details>/<summary>, like the "More" block above them,
so the chevron, the keyboard activation, the accessible name and the
expanded state a screen reader hears are all the ones this sidebar already
had. The only difference from More is where "should this be open?" comes
from: More asks the route, because the honest answer there is "are you in
it?", and these ask the settings bag, because the honest answer here is
"how did you leave it".

It also retires the localStorage the label trees used to live in. That was
per browser and was re-applied on connect, so a collapsed tree was rendered
open and shut a frame later, on every single navigation. One key space now
— section:labels, section:accounts, label:<full name> — one endpoint, one
handler, stored as COLLAPSED so a label created tomorrow shows up expanded
without backfilling anybody's settings.

The collapsed LABELS heading carries the unread that is hidden under it,
because a section you cannot see is a section whose mail you cannot see
either. It is not the sum of the per-label counts: a conversation filed
under two labels would be counted twice and the heading would promise more
than expanding it could show, so it is a distinct-thread query with tests
built to contain exactly that case. ACCOUNTS gets no such number, because
there is no honest one — those rows count within an account and only the
expanded one is ever loaded.

Badges inside a shut section are hidden rather than omitted, so
refreshCounts() still patches them by data-count-key and a reopened section
is never showing numbers from three syncs ago. The new roll-up rides the
same counts payload.

No migration: it is a key in the settings bag. It joins the config backup,
unlike its sidebar.expanded_account neighbour, because it holds section
names and label full names rather than a row id that means someone else on
the target host.

Storage is separate from SETTING_SIDEBAR_ACCOUNT on purpose — that answers
which account is expanded, this answers whether the accounts are on screen
at all — and neither writes the other, so collapsing and reopening the
section brings the same account back still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-13 16:34:54 +02:00
1 parent 744c051988
commit a18be77a51
10 files changed
+1197 -99

No files matched your search

+99 -43
View File
@@ -25,15 +25,15 @@ const SYNC_EVENTS = ["core--mercure:mailbox-synced", "core--mercure:account
*/
const WRITTEN_EVENT = "mail--list-toolbar:written";
/**
* Where the nav was scrolled to, and which label trees were open.
* Where the nav was scrolled to.
*
* Session storage for the scroll — it belongs to this run of the app, and
* restoring yesterday's scroll would be a surprise; local for the trees, which
* are a preference, and are the same shape as the collapsed admin panels the
* server stores per user.
* Session storage, because it belongs to this run of the app and restoring
* yesterday's scroll would be a surprise. Which sections and label trees are
* collapsed used to live beside it in localStorage and does not any more: that
* is a preference rather than a position, so it is stored per user on the
* server and rendered into `open` before the first paint. See rememberCollapse.
*/
const SCROLL_KEY = "sidebar_scroll";
const TREES_KEY = "sidebar_closed_trees";
/**
* One counts request, however many sidebars are listening.
@@ -128,14 +128,17 @@ function patchNewDots(counts) {
}
export default class extends Controller {
static targets = ["link", "badge", "scroller", "tree"];
static values = { countsUrl: String, i18n: Object };
static targets = ["link", "badge", "scroller", "collapseSummary"];
static values = { countsUrl: String, collapseUrl: String, i18n: Object };
connect() {
// Both before the first paint of this element, so nothing is seen in
// the wrong place: the sidebar re-renders on every visit now, and a
// scroll jump would be exactly the flicker turbo-permanent avoided.
this._restoreTrees();
// Before the first paint of this element, so nothing is seen in the
// wrong place: the sidebar re-renders on every visit now, and a scroll
// jump would be exactly the flicker turbo-permanent avoided.
//
// There is no _restoreTrees() beside it any more, and its absence is
// the feature. Which sections are collapsed arrives already applied, in
// the HTML, so there is nothing to put back after the fact.
this._restoreScroll();
this._updateActive();
@@ -173,21 +176,84 @@ export default class extends Controller {
document.removeEventListener(WRITTEN_EVENT, this._onWritten);
}
/** A tree was opened or closed. Closed is what is stored, so a label added
* later shows up expanded rather than needing a backfill of everyone's
* preferences — the same call the admin panels make. */
rememberTree(event) {
const key = event.target.dataset.treeKey;
/**
* A section heading or a label tree was opened or closed.
*
* One handler for both, because they are one <details> idiom with one key
* space — `section:labels`, `section:accounts`, `label:<full name>`. COLLAPSED
* is what is sent, so a label created later shows up expanded rather than
* needing a backfill of everyone's preferences: the same rule the admin
* panels follow.
*
* The sidebar is on the page twice (drawer and desktop column) and both
* copies carry the same keys, so toggling one leaves the other showing the
* opposite until it next re-renders. Mirrored here rather than left to the
* next navigation — opening the drawer to find the section you just
* collapsed still expanded reads as the preference not having saved.
*/
rememberCollapse(event) {
const details = event.target;
const key = details.dataset.collapseKey;
if (!key) {
return;
}
const closed = new Set(this._closedTrees());
this._announce(details);
this._mirror(key, details.open);
this._persistCollapse(key, false === details.open);
}
event.target.open ? closed.delete(key) : closed.add(key);
/**
* Keep the summary's aria-expanded in step with the disclosure.
*
* <details>/<summary> already exposes its state natively, so this is belt
* and braces — but the attribute is rendered server-side with the stored
* state, and an aria-expanded frozen at its initial value is worse than one
* that was never there.
*/
_announce(details) {
details
.querySelector(":scope > summary[aria-expanded]")
?.setAttribute("aria-expanded", details.open ? "true" : "false");
}
localStorage.setItem(TREES_KEY, JSON.stringify([...closed]));
/** The other copy of this sidebar, if it is on the page. */
_mirror(key, open) {
document
.querySelectorAll(`details[data-collapse-key="${CSS.escape(key)}"]`)
.forEach((other) => {
if (other.open === open) {
return;
}
// Assigning fires `toggle` on the other one too, which lands
// back here — the guard above makes that second pass a no-op
// rather than a loop, since by then the two already agree.
other.open = open;
});
}
/**
* Fire and forget, with keepalive, exactly as the account expander does:
* the click that collapses a section is often the click that navigates, and
* a preference that fails to save is not worth interrupting anyone over —
* the section is shut either way, it just will not be next time.
*/
_persistCollapse(key, collapsed) {
if (false === this.hasCollapseUrlValue) {
return;
}
fetch(this.collapseUrlValue, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content ?? "",
},
body: JSON.stringify({ key, collapsed }),
keepalive: true,
}).catch(() => {});
}
rememberScroll() {
@@ -344,30 +410,8 @@ export default class extends Controller {
}
/**
* The trail down to an open row.
*
* Every nested label lives inside its parent's <details>, so the ancestry
* is already in the DOM and needs no ids to walk — which matters for the
* account lists, where the href is an id and carries no hint of the tree.
* Where the nav was left scrolled to. Applied on connect, before paint.
*/
_closedTrees() {
try {
const stored = JSON.parse(localStorage.getItem(TREES_KEY) ?? "[]");
return Array.isArray(stored) ? stored : [];
} catch {
return [];
}
}
_restoreTrees() {
const closed = new Set(this._closedTrees());
this.treeTargets.forEach((tree) => {
tree.open = false === closed.has(tree.dataset.treeKey);
});
}
_restoreScroll() {
if (false === this.hasScrollerTarget) {
return;
@@ -380,6 +424,18 @@ export default class extends Controller {
}
}
/**
* The trail down to an open row.
*
* Every nested label lives inside its parent's <details>, so the ancestry
* is already in the DOM and needs no ids to walk — which matters for the
* account lists, where the href is an id and carries no hint of the tree.
*
* `summary.nav-item` is what makes this stop at the label trees rather than
* running on up into the LABELS and ACCOUNTS section headings, which are
* <details> too now. Those are furniture, not a row you can be "inside", and
* tinting them would read as a second selection.
*/
_markAncestors(row) {
let details = row.closest("details")?.parentElement?.closest("details");
+54
View File
@@ -544,6 +544,54 @@ final class MailController extends AbstractController
return $this->json(['ok' => true]);
}
/**
* Remembers which sidebar sections and label trees are collapsed.
*
* The same shape as sidebarAccountExpanded above, and per user rather than
* per browser for the same two reasons: the state is rendered into the
* first paint, so nothing collapses after it is already on screen, and a
* preference about your own nav should follow you to your other devices.
*
* The key is not validated against anything, and deliberately: label trees
* are keyed by full name, so validating would mean a lookup per toggle to
* learn nothing — an unknown key collapses nothing, because the only thing
* that ever reads this bag is a template asking "is MY key in here?". It is
* bounded instead, so the bag cannot be used as free storage.
*/
#[Route('/sidebar/section-collapsed', name: 'sidebar_section_collapsed', methods: ['POST'])]
public function sidebarSectionCollapsed(Request $request, EntityManagerInterface $em): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
if (false === $this->isCsrfTokenValid('ajax', (string) $request->headers->get('X-CSRF-Token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
/** @var User $user */
$user = $this->getUser();
$payload = $request->toArray();
$key = $payload['key'] ?? null;
$collapsed = $payload['collapsed'] ?? null;
if (false === is_string($key) || '' === $key || 256 < strlen($key) || false === is_bool($collapsed)) {
return $this->json(['ok' => false], Response::HTTP_BAD_REQUEST);
}
// Collapsing is always allowed; it is only the growth of the list that
// needs a ceiling, and a user with more than this many collapsed things
// has a nav problem this preference will not fix.
if (true === $collapsed && 500 <= count($user->collapsedSidebarSections)) {
return $this->json(['ok' => false], Response::HTTP_BAD_REQUEST);
}
$user->setSidebarSectionCollapsed($key, $collapsed);
$em->flush();
return $this->json(['ok' => true]);
}
/**
* Unread badge counts for the sidebar, keyed the same way the badges are
* (see the unread_badge macro in _partials/_sidebar.html.twig).
@@ -560,6 +608,12 @@ final class MailController extends AbstractController
$payload = [
'starred' => $counts->forStarred(),
NewMailMarkers::STARRED_KEY => $newMail->forStarred(),
// The collapsed LABELS heading's roll-up. Emitted whether or not
// the section is currently collapsed, because the badge is
// rendered either way — hidden by the disclosure rather than
// omitted, which is the same rule every other badge here follows
// and the reason a collapsed section's numbers do not go stale.
'labels:unread' => $counts->unreadInLabels(),
];
// The new-mail dots ride on this same payload rather than on an
+70
View File
@@ -392,6 +392,34 @@ class User extends UserEntityModel implements UserInterface, PasswordAuthenticat
*/
public const string SETTING_SIDEBAR_ACCOUNT = 'sidebar.expanded_account';
/**
* The sidebar sections and label trees the user has collapsed, as a list of
* keys — `section:labels`, `section:accounts`, `label:<full name>`.
*
* COLLAPSED is stored rather than expanded, for the reason
* SETTING_ADMIN_COLLAPSED_PANELS is: a label created tomorrow, or a section
* added in a later version, then shows up open without anyone having to
* backfill every user's bag. The absent state is the useful default.
*
* One key space for both, because they are one question asked at different
* depths — "is this part of the nav showing?" — and a second mechanism
* beside the first is how two disclosures next to each other end up
* behaving differently. It also replaces the localStorage the label trees
* used to be remembered in: that was per browser, and it was restored after
* the first paint, so every navigation showed the tree open for a frame
* before JavaScript shut it again.
*
* Distinct from SETTING_SIDEBAR_ACCOUNT, which answers a different
* question — *which* account is expanded, not whether the accounts section
* is on screen at all. Collapsing the section hides the expanded account's
* folder list with it and expanding it brings the same account back.
*/
public const string SETTING_SIDEBAR_COLLAPSED = 'sidebar.collapsed_sections';
/** The collapse keys for the two whole-section disclosures. */
public const string SIDEBAR_SECTION_LABELS = 'section:labels';
public const string SIDEBAR_SECTION_ACCOUNTS = 'section:accounts';
/**
* When this admin last had the log browser open, as an ISO 8601 string.
*
@@ -593,6 +621,48 @@ class User extends UserEntityModel implements UserInterface, PasswordAuthenticat
}
}
/**
* Virtual for the same reason as $collapsedAdminPanels — see
* SETTING_SIDEBAR_COLLAPSED.
*
* @var list<string>
*/
public array $collapsedSidebarSections {
get {
$keys = $this->getSetting(self::SETTING_SIDEBAR_COLLAPSED, []);
if (false === is_array($keys)) {
return [];
}
return array_values(array_filter($keys, 'is_string'));
}
}
public function isSidebarSectionCollapsed(string $key): bool
{
return in_array($key, $this->collapsedSidebarSections, true);
}
/** The sidebar's half of setAdminPanelCollapsed(), and the same shape. */
public function setSidebarSectionCollapsed(string $key, bool $collapsed): static
{
$keys = $this->collapsedSidebarSections;
if (true === $collapsed) {
if (false === in_array($key, $keys, true)) {
$keys[] = $key;
}
} else {
$keys = array_values(array_filter(
$keys,
static fn (string $existing): bool => $existing !== $key,
));
}
return $this->setSetting(self::SETTING_SIDEBAR_COLLAPSED, $keys);
}
/**
* Collapsed is stored rather than expanded so a panel added later shows up
* open by default, without having to backfill every user's settings.
@@ -523,6 +523,42 @@ class MessageThreadRepository extends ServiceEntityRepository
return $counts;
}
/**
* One number for everything filed under a custom label — what the LABELS
* heading shows while the section is collapsed.
*
* NOT a sum of countUnreadPerUserLabel(), and that is the whole reason it
* is a query of its own. Those counts are per label and a thread may carry
* several, so adding them up reports one conversation two or three times:
* the section would claim more unread than expanding it could show, which
* is the specific way a rolled-up number loses people's trust.
*
* Stated as "threads carrying at least one custom label" via a subquery
* instead, so each is counted once however many labels are on it.
*/
public function countUnreadInUserLabels(UserInterface $user): int
{
$qb = $this->createQueryBuilder('t')
->select('COALESCE(SUM(t.unreadCount), 0)')
->join('t.account', 'a')
->where('a.usr = :user')
->andWhere('a.isActive = true')
->andWhere('t.unreadCount > 0')
->andWhere(
't.id IN ('
. 'SELECT labelled.id FROM ' . MessageThread::class . ' labelled'
. ' JOIN labelled.labels userLabel'
. ' WHERE userLabel.role IS NULL)',
)
->setParameter('user', $user);
// Same rule the per-label counts follow: a trashed thread stops
// counting, so the heading cannot be louder than the rows under it.
$this->excludeTrashed($qb);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* COUNT DISTINCT over a join to Account — neither expressible through
* count(), which takes field-to-value criteria on this entity alone.
+5
View File
@@ -118,6 +118,11 @@ final readonly class ConfigBackupUsers
User::SETTING_CALENDAR_PANE_MODE,
User::SETTING_SEARCH_SORT,
User::SETTING_ADMIN_COLLAPSED_PANELS,
// Travels, unlike its `sidebar.expanded_account` neighbour, and the
// difference is what these hold: this is section names and label FULL
// NAMES, which the restorer recreates verbatim, not a row id that means
// somebody else on the target.
User::SETTING_SIDEBAR_COLLAPSED,
User::SETTING_ONBOARDING_COMPLETED_AT,
User::SETTING_ONBOARDING_STEP,
User::SETTING_ONBOARDING_SKIPPED,
+36
View File
@@ -7,6 +7,7 @@ namespace App\Twig;
use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Label\Label;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Repository\Label\LabelRepository;
use App\Repository\Mail\MessageThreadRepository;
use Symfony\Bundle\SecurityBundle\Security;
@@ -29,6 +30,7 @@ class SidebarCounts implements ResetInterface
private ?array $labelCounts = null;
private ?int $starredCount = null;
private ?int $snoozedCount = null;
private ?int $labelsUnread = null;
private ?array $userLabelTree = null;
private ?array $visibleLabels = null;
/** @var array<int, list<Label>> account id => its visible labels */
@@ -47,6 +49,7 @@ class SidebarCounts implements ResetInterface
$this->labelCounts = null;
$this->starredCount = null;
$this->snoozedCount = null;
$this->labelsUnread = null;
$this->userLabelTree = null;
$this->visibleLabels = null;
$this->accountLabels = [];
@@ -223,6 +226,39 @@ class SidebarCounts implements ResetInterface
return $this->labelCounts[(int) $label->id] ?? 0;
}
/**
* Whether the user has this sidebar section or label tree collapsed.
*
* Here rather than reached through `app.user` in the template because the
* label tree is rendered by a Twig MACRO, and a macro sees only globals —
* which is already why the counts arrive this way. It also makes the
* anonymous case a plain `false` instead of a null dereference.
*/
public function isCollapsed(string $key): bool
{
$user = $this->security->getUser();
return $user instanceof User && $user->isSidebarSectionCollapsed($key);
}
/**
* The LABELS heading's own number, shown only while that section is
* collapsed — see MessageThreadRepository::countUnreadInUserLabels() for
* why it is not the sum of the rows it stands in for.
*/
public function unreadInLabels(): int
{
if (null === $this->labelsUnread) {
$user = $this->security->getUser();
$this->labelsUnread = null === $user
? 0
: $this->threadRepository->countUnreadInUserLabels($user);
}
return $this->labelsUnread;
}
public function forStarred(): int
{
if (null === $this->starredCount) {
+144 -56
View File
@@ -90,15 +90,25 @@ keeps its accessible name either way.
{% set children = sidebarCounts.visibleChildrenOf(label) %}
{% if children|length > 0 %}
{# Keyed by full name so the controller can put the tree back the way
the user left it after the sidebar re-renders. #}
{# Keyed by full name, in the same stored set as the two section
headings — one key space, one endpoint, one handler, so a nested
label and the section it lives in cannot end up behaving
differently.
`open` now comes from the server rather than from localStorage
applied on connect. That is a fix, not just a move: restoring it
after the first paint meant a collapsed tree was rendered open and
then shut on every navigation, and the state was stuck to one
browser. #}
{% set _treeKey = 'label:' ~ label.fullName %}
<details class="group/tree"
data-ui--sidebar-target="tree"
data-tree-key="{{ label.fullName }}"
data-action="toggle->ui--sidebar#rememberTree"
open>
data-collapse-key="{{ _treeKey }}"
data-action="toggle->ui--sidebar#rememberCollapse"
{{ sidebarCounts.isCollapsed(_treeKey) ? '' : 'open' }}>
<summary class="nav-item group/label flex items-center gap-1 py-1.5 cursor-pointer list-none
transition-colors"
aria-expanded="{{ sidebarCounts.isCollapsed(_treeKey) ? 'false' : 'true' }}"
data-ui--sidebar-target="collapseSummary"
style="--label-depth: {{ depth * 14 }}px">
<i class="fa-solid fa-chevron-right text-[9px] text-ink-faint w-3
transition-transform group-open/tree:rotate-90" aria-hidden="true"></i>
@@ -194,6 +204,7 @@ controller instead — the scroll position and which trees are open.
<aside id="{{ _drawerMode is defined and _drawerMode ? 'sidebar-drawer-inner' : 'sidebar' }}"
data-controller="ui--sidebar"
data-ui--sidebar-counts-url-value="{{ path('app_mail_sidebar_counts') }}"
data-ui--sidebar-collapse-url-value="{{ path('app_mail_sidebar_section_collapsed') }}"
{# The badge names are patched on every sync alongside the numbers, so
the strings have to reach the controller rather than being written in
it. One JSON value, the way every other controller here takes them. #}
@@ -390,59 +401,136 @@ controller instead — the scroll position and which trees are open.
</a>
</details>
{# Labels section #}
<div class="rail-hide pt-4 pb-1 px-3 flex items-center justify-between">
<span class="text-xs font-semibold text-ink-faint uppercase tracking-wider">{{ 'sidebar.labels.heading'|trans }}</span>
<button
data-controller="ui--modal"
data-action="click->ui--modal#open {{ _drawerClose }}"
data-ui--modal-title-value="{{ 'sidebar.labels.add'|trans }}"
data-ui--modal-src-value="{{ path('app_label_new') }}"
class="p-1 rounded hover:bg-hover text-gray-400 hover:text-ink-soft transition-colors cursor-pointer"
aria-label="{{ 'sidebar.labels.add'|trans }}"
>
<i class="fa-solid fa-plus w-3.5 h-3.5" aria-hidden="true"></i>
</button>
</div>
{# ── Labels section ────────────────────────────────────────────────
<div id="{{ _drawerMode is defined and _drawerMode ? 'label-list-drawer' : 'label-list' }}" class="rail-hide space-y-0.5">
{% set labelTree = sidebarCounts.userLabelTree() %}
{% if labelTree|length == 0 %}
<p class="px-3 py-1.5 text-xs text-ink-faint">{{ 'sidebar.labels.none'|trans }}</p>
{% else %}
{% for node in labelTree %}
{{ macros.label_node(node, 0, _drawerClose) }}
{% endfor %}
{% endif %}
</div>
A <details>, which is what the "More" block above already is, and
deliberately the same element rather than a second collapsing
mechanism beside it: the chevron recipe, the summary.nav-item shape,
Enter/Space activation, the accessible name and the expanded state a
screen reader reads out are then all the ones this file already has,
and none of them are re-implemented.
{# Accounts section #}
<div class="rail-hide pt-4 pb-1 px-3 flex items-center justify-between">
<span class="text-xs font-semibold text-ink-faint uppercase tracking-wider">{{ 'sidebar.accounts.heading'|trans }}</span>
<button
data-controller="ui--modal"
data-action="click->ui--modal#open {{ _drawerClose }}"
data-ui--modal-title-value="{{ 'sidebar.accounts.add'|trans }}"
data-ui--modal-src-value="{{ path('app_account_new') }}"
class="p-1 rounded hover:bg-hover text-gray-400 hover:text-ink-soft transition-colors cursor-pointer"
aria-label="{{ 'sidebar.accounts.add'|trans }}"
>
<i class="fa-solid fa-plus w-3.5 h-3.5" aria-hidden="true"></i>
</button>
</div>
What differs from More is only where "should this be open?" comes
from. More asks the ROUTE — the honest answer there is "are you in
it?". Here the honest answer is "how did you leave it", so it comes
from the user's settings bag, and it is rendered into `open` on the
server. That is the whole point of storing it per user rather than in
localStorage: the sidebar re-renders on every visit, and a section
collapsed by JavaScript after the first paint is a section the user
watches snap shut on every single click.
<div id="{{ _drawerMode is defined and _drawerMode ? 'account-list-drawer' : 'account-list' }}" class="rail-hide space-y-0.5">
{# The same suffix the list containers either side of this use. Both
sidebars render this partial on every page, so without it each
account's folder frame and expand toggle existed twice under one
id — invalid HTML, and Turbo resolves a frame target BY id, so
the drawer and the desktop rail were one bad guess apart from
swapping folder lists. #}
{% include '_partials/_account_list.html.twig' with {
_drawerClose: _drawerClose,
_idSuffix: _drawerMode is defined and _drawerMode ? '-drawer' : '',
} %}
</div>
The section, rather than each label, is the unit — see the tree keys
below, which remain for anyone who does nest. A wall of two dozen
flat labels pushing the accounts off the bottom of the nav is the
case this exists for, and per-label collapse does nothing for it. #}
{% set _labelsCollapsed = sidebarCounts.isCollapsed(constant('App\\Entity\\User\\User::SIDEBAR_SECTION_LABELS')) %}
<details class="rail-hide group/labels"
data-action="toggle->ui--sidebar#rememberCollapse"
data-collapse-key="{{ constant('App\\Entity\\User\\User::SIDEBAR_SECTION_LABELS') }}"
{{ _labelsCollapsed ? '' : 'open' }}>
{# Named explicitly, because a summary takes its accessible name
from its contents and this one contains a roll-up badge and the
"Create label" button — unnamed, it read as "Labels 3 unread
Create label". The same string as the visible heading, so a
voice user asking for "Labels" still gets this. #}
<summary class="pt-4 pb-1 px-3 flex items-center gap-2 cursor-pointer list-none rounded-lg transition-colors hover:bg-hover"
aria-label="{{ 'sidebar.labels.heading'|trans }}"
aria-expanded="{{ _labelsCollapsed ? 'false' : 'true' }}"
data-ui--sidebar-target="collapseSummary">
<i class="fa-solid fa-chevron-right w-3 text-[9px] text-ink-faint transition-transform group-open/labels:rotate-90" aria-hidden="true"></i>
<span class="flex-1 text-xs font-semibold text-ink-faint uppercase tracking-wider">{{ 'sidebar.labels.heading'|trans }}</span>
{# The section's own number, and ONLY while it is shut. Open,
the per-label badges under it say it better and this would
be the same mail counted twice on screen — so the disclosure
hides it, via the group-open variant rather than by the
controller, which owns the plain `hidden` class and toggles
it purely on whether the count is zero. The two never fight:
both resolve to display:none, and they answer different
questions.
Rendered whether or not it is visible, so refreshCounts()
patches it by data-count-key like every other badge — a
collapsed section that stopped being patched would show a
number from three syncs ago the moment it was reopened. #}
<span class="group-open/labels:hidden">{{ macros.unread_badge(sidebarCounts.unreadInLabels(), 'labels:unread') }}</span>
<button
data-controller="ui--modal"
data-action="click->ui--modal#open {{ _drawerClose }}"
data-ui--modal-title-value="{{ 'sidebar.labels.add'|trans }}"
data-ui--modal-src-value="{{ path('app_label_new') }}"
class="p-1 rounded hover:bg-hover text-gray-400 hover:text-ink-soft transition-colors cursor-pointer"
aria-label="{{ 'sidebar.labels.add'|trans }}"
>
<i class="fa-solid fa-plus w-3.5 h-3.5" aria-hidden="true"></i>
</button>
</summary>
<div id="{{ _drawerMode is defined and _drawerMode ? 'label-list-drawer' : 'label-list' }}" class="space-y-0.5">
{% set labelTree = sidebarCounts.userLabelTree() %}
{% if labelTree|length == 0 %}
<p class="px-3 py-1.5 text-xs text-ink-faint">{{ 'sidebar.labels.none'|trans }}</p>
{% else %}
{% for node in labelTree %}
{{ macros.label_node(node, 0, _drawerClose) }}
{% endfor %}
{% endif %}
</div>
</details>
{# ── Accounts section ──────────────────────────────────────────────
The same disclosure, for the same reason and out of the same stored
set. It does NOT replace or duplicate SETTING_SIDEBAR_ACCOUNT, which
answers a different question: that one is *which* account is
expanded, this one is whether the accounts are on screen at all.
Collapsing takes the expanded account's folder list down with it and
expanding brings the same account back still open, because neither
setting is written when the other changes.
No roll-up badge here, unlike LABELS, and not for symmetry's sake:
there is no honest single number. Account rows count within one
account, and only the expanded one's folders are ever loaded, so a
heading total would either be a sum over lists that are not on the
page or a count that silently means "the one account you happened to
have open". #}
{% set _accountsCollapsed = sidebarCounts.isCollapsed(constant('App\\Entity\\User\\User::SIDEBAR_SECTION_ACCOUNTS')) %}
<details class="rail-hide group/accounts"
data-action="toggle->ui--sidebar#rememberCollapse"
data-collapse-key="{{ constant('App\\Entity\\User\\User::SIDEBAR_SECTION_ACCOUNTS') }}"
{{ _accountsCollapsed ? '' : 'open' }}>
<summary class="pt-4 pb-1 px-3 flex items-center gap-2 cursor-pointer list-none rounded-lg transition-colors hover:bg-hover"
aria-label="{{ 'sidebar.accounts.heading'|trans }}"
aria-expanded="{{ _accountsCollapsed ? 'false' : 'true' }}"
data-ui--sidebar-target="collapseSummary">
<i class="fa-solid fa-chevron-right w-3 text-[9px] text-ink-faint transition-transform group-open/accounts:rotate-90" aria-hidden="true"></i>
<span class="flex-1 text-xs font-semibold text-ink-faint uppercase tracking-wider">{{ 'sidebar.accounts.heading'|trans }}</span>
<button
data-controller="ui--modal"
data-action="click->ui--modal#open {{ _drawerClose }}"
data-ui--modal-title-value="{{ 'sidebar.accounts.add'|trans }}"
data-ui--modal-src-value="{{ path('app_account_new') }}"
class="p-1 rounded hover:bg-hover text-gray-400 hover:text-ink-soft transition-colors cursor-pointer"
aria-label="{{ 'sidebar.accounts.add'|trans }}"
>
<i class="fa-solid fa-plus w-3.5 h-3.5" aria-hidden="true"></i>
</button>
</summary>
<div id="{{ _drawerMode is defined and _drawerMode ? 'account-list-drawer' : 'account-list' }}" class="space-y-0.5">
{# The same suffix the list containers either side of this use. Both
sidebars render this partial on every page, so without it each
account's folder frame and expand toggle existed twice under one
id — invalid HTML, and Turbo resolves a frame target BY id, so
the drawer and the desktop rail were one bad guess apart from
swapping folder lists. #}
{% include '_partials/_account_list.html.twig' with {
_drawerClose: _drawerClose,
_idSuffix: _drawerMode is defined and _drawerMode ? '-drawer' : '',
} %}
</div>
</details>
</nav>
</aside>
@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
/**
* The sidebar's LABELS and ACCOUNTS sections collapse, and the state is the
* user's rather than the browser's.
*
* The thing actually pinned down here is that the state is RENDERED, not
* applied afterwards. A disclosure that comes back from the server open and is
* shut by JavaScript on connect looks identical in a DOM assertion taken after
* the page settles, and it is the version that makes the sidebar snap closed on
* every navigation — which is the whole reason this preference moved off
* localStorage. So the assertions are made against the raw HTML the controller
* returned, where "no `open` attribute" can only have come from the server.
*/
final class SidebarSectionCollapseTest extends WebTestCase
{
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
private ?Connection $connection = null;
protected function tearDown(): void
{
if (null !== $this->connection && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
$this->connection = null;
parent::tearDown();
}
public function testTheSectionsAreExpandedUntilTheUserCollapsesThem(): void
{
[$client, $user] = $this->signedIn();
$html = $this->inbox($client);
self::assertStringContainsString(
'data-collapse-key="' . User::SIDEBAR_SECTION_LABELS . '"',
$html,
'the labels heading is not a collapsible disclosure at all',
);
self::assertTrue(
$this->isOpen($html, User::SIDEBAR_SECTION_LABELS),
'an untouched sidebar must render its sections open',
);
self::assertTrue($this->isOpen($html, User::SIDEBAR_SECTION_ACCOUNTS));
// Storing COLLAPSED rather than expanded is what makes this the default
// without a backfill: the bag is empty for everyone who has never
// touched it, including every user who existed before this shipped.
self::assertSame([], $user->collapsedSidebarSections);
}
/**
* The point of the feature, and the requirement that is easiest to satisfy
* only halfway: `open` must be absent from the HTML itself.
*/
public function testACollapsedSectionIsRenderedCollapsedRatherThanShutAfterPaint(): void
{
[$client, $user] = $this->signedIn();
$this->begin();
$user->setSidebarSectionCollapsed(User::SIDEBAR_SECTION_LABELS, true);
static::getContainer()->get(EntityManagerInterface::class)->flush();
$html = $this->inbox($client);
self::assertFalse(
$this->isOpen($html, User::SIDEBAR_SECTION_LABELS),
'the collapsed section came back open — it would flash before JavaScript shut it',
);
// Only the one that was collapsed. A section toggle that took its
// neighbour with it would be a shared flag, not a per-section one.
self::assertTrue($this->isOpen($html, User::SIDEBAR_SECTION_ACCOUNTS));
self::assertStringContainsString(
'aria-expanded="false"',
$html,
'the disclosure must say it is collapsed, not merely look it',
);
}
/**
* A collapsed section still renders its badges, and the roll-up on the
* heading is one the counts endpoint knows how to patch.
*
* Hidden rather than omitted is the rule the whole sidebar follows. Drop
* the markup while a section is shut and refreshCounts() has nothing to
* write into, so every number inside goes stale for as long as it is
* closed and is wrong the moment it is reopened.
*/
public function testACollapsedSectionKeepsBadgesThatTheCountsEndpointCanStillPatch(): void
{
[$client, $user] = $this->signedIn();
$this->begin();
$user->setSidebarSectionCollapsed(User::SIDEBAR_SECTION_LABELS, true);
static::getContainer()->get(EntityManagerInterface::class)->flush();
$crawler = $client->request('GET', '/mail/inbox');
$keys = $crawler->filter('[data-ui--sidebar-target="badge"]')->each(
static fn ($node): string => (string) $node->attr('data-count-key'),
);
self::assertContains(
'labels:unread',
$keys,
'the collapsed heading carries no roll-up badge',
);
$client->request('GET', '/mail/sidebar/counts');
$counts = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($counts);
// Every badge on the page, including the ones sealed inside the shut
// section, has to have somewhere to get its next value from.
foreach ($keys as $key) {
self::assertArrayHasKey($key, $counts, sprintf('nothing patches "%s"', $key));
}
}
/**
* The roll-up never claims more than the rows it stands in for.
*
* Summing the per-label counts would have been the obvious implementation,
* and it reports a thread filed under two labels twice — the collapsed
* heading would then promise more unread than expanding it can show, which
* is the specific way a rolled-up number stops being believed.
*
* Asserted as an inequality over whatever the seed happens to hold, in the
* style BadgeSemanticsTest uses for the Trash invariant, so it keeps
* meaning something as the fixture changes: the sum is the ceiling, and a
* de-duplicated count sits at or below it. A SUM implementation exceeds it
* the moment any thread carries two labels.
*/
public function testTheRollUpCountsAConversationOnceHoweverManyLabelsItCarries(): void
{
[$client] = $this->signedIn();
$crawler = $client->request('GET', '/mail/inbox');
$client->request('GET', '/mail/sidebar/counts');
$counts = json_decode((string) $client->getResponse()->getContent(), true);
self::assertIsArray($counts);
self::assertArrayHasKey('labels:unread', $counts);
$perLabelSum = 0;
foreach ($counts as $key => $value) {
// The per-label keys only: "label:<id>", not the account-scoped
// "label:<id>:account:<id>" duplicates of the same mail.
if (1 === preg_match('/^label:\d+$/', (string) $key)) {
$perLabelSum += (int) $value;
}
}
self::assertLessThanOrEqual(
$perLabelSum,
$counts['labels:unread'],
'the roll-up exceeds the sum of the rows under it — it is double counting',
);
// And the badge on the page agrees with the endpoint, or the first
// sync would silently change the number under the user.
$rendered = $crawler->filter('[data-count-key="labels:unread"]')->first();
self::assertSame(1, $rendered->count());
self::assertSame((string) $counts['labels:unread'], trim($rendered->text()));
}
public function testThePersistEndpointRefusesAPostWithoutACsrfToken(): void
{
[$client, $user] = $this->signedIn();
$this->begin();
$client->request(
'POST',
'/mail/sidebar/section-collapsed',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode(['key' => User::SIDEBAR_SECTION_LABELS, 'collapsed' => true]),
);
self::assertSame(403, $client->getResponse()->getStatusCode());
self::assertSame(
[],
$this->reload()->collapsedSidebarSections,
'a tokenless post wrote the preference anyway',
);
}
/**
* Stored on the USER, which is what "synced across devices" reduces to
* here: a second client with its own cookie jar and no shared storage sees
* the state the first one set.
*/
public function testASecondSessionSeesTheStateTheFirstOneSet(): void
{
[$client, $user] = $this->signedIn();
$this->begin();
$user->setSidebarSectionCollapsed(User::SIDEBAR_SECTION_ACCOUNTS, true);
static::getContainer()->get(EntityManagerInterface::class)->flush();
self::assertFalse($this->isOpen($this->inbox($client), User::SIDEBAR_SECTION_ACCOUNTS));
// A second session: the cookie jar emptied, so the session that saw the
// collapse is gone and a new one is issued at login. Nothing client-side
// survives, which is the whole claim — and note the test client runs no
// JavaScript at all, so there was never a localStorage for this to be
// hiding in either.
$client->getCookieJar()->clear();
$client->loginUser($this->reload());
self::assertFalse(
$this->isOpen($this->inbox($client), User::SIDEBAR_SECTION_ACCOUNTS),
'the preference did not follow the user into a new session',
);
}
public function testAnUnknownOrOversizedKeyIsRejectedRatherThanStored(): void
{
[$client, $user] = $this->signedIn();
$this->begin();
foreach ([['key' => '', 'collapsed' => true], ['key' => str_repeat('x', 300), 'collapsed' => true], ['key' => 'section:labels', 'collapsed' => 'yes']] as $payload) {
$client->request(
'POST',
'/mail/sidebar/section-collapsed',
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_CSRF_TOKEN' => $this->token($client),
],
content: json_encode($payload),
);
self::assertSame(400, $client->getResponse()->getStatusCode());
}
self::assertSame([], $this->reload()->collapsedSidebarSections);
}
// ── helpers ──────────────────────────────────────────────────────────
/** @return array{KernelBrowser, User} */
private function signedIn(): array
{
$client = static::createClient();
// The kernel is rebooted between requests by default, which hands each
// one a new connection — and the transaction these tests write their
// preference inside lives on the first one, so the change would be
// invisible to every request after it.
$client->disableReboot();
$user = $this->freshUser($client);
$client->loginUser($user);
return [$client, $user];
}
private function freshUser(KernelBrowser $client): User
{
$user = static::getContainer()->get(UserRepository::class)
->findOneBy(['email' => self::ADMIN_EMAIL]);
if (false === $user instanceof User) {
self::markTestSkipped('run `app:test:seed-user --admin` first');
}
return $user;
}
/**
* The user as the database now has them.
*
* A request boots its own container, so the instance the test is holding is
* detached by the time the response comes back and refresh() on it throws.
* The identity map is cleared first, or this hands back the same stale
* object it was asked to replace.
*/
private function reload(): User
{
$em = static::getContainer()->get(EntityManagerInterface::class);
$em->clear();
$user = static::getContainer()->get(UserRepository::class)
->findOneBy(['email' => self::ADMIN_EMAIL]);
self::assertInstanceOf(User::class, $user);
return $user;
}
private function begin(): void
{
$this->connection = static::getContainer()->get(Connection::class);
$this->connection->beginTransaction();
}
private function inbox(KernelBrowser $client): string
{
$client->request('GET', '/mail/inbox');
return (string) $client->getResponse()->getContent();
}
/**
* Whether the disclosure carrying this key was SENT with `open` on it.
*
* The test client runs no JavaScript, so what the crawler holds is exactly
* the markup the controller returned — an `open` here cannot have been put
* there by a Stimulus connect(), which is the distinction the whole feature
* turns on.
*
* The first match is the desktop rail; the drawer renders the same partial
* a second time with the same keys, and both are asserted below.
*/
private function isOpen(string $html, string $key): bool
{
$nodes = (new Crawler($html))->filter('details[data-collapse-key="' . $key . '"]');
self::assertGreaterThan(0, $nodes->count(), sprintf('no disclosure for "%s" in the page', $key));
$open = $nodes->each(static fn ($node): bool => null !== $node->attr('open'));
// The desktop rail and the mobile drawer are one preference, so they
// must not disagree about it in the same response.
self::assertCount(
1,
array_unique($open),
sprintf('the two sidebars rendered "%s" differently', $key),
);
return $open[0];
}
private function token(KernelBrowser $client): string
{
$crawler = $client->request('GET', '/mail/inbox');
return (string) $crawler->filter('meta[name="csrf-token"]')->attr('content');
}
}
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Repository\Mail\MessageThreadRepository;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The number the collapsed LABELS heading shows.
*
* Worth its own file because the obvious implementation is wrong in a way that
* only shows up on data the default seed does not have. Summing
* countUnreadPerUserLabel() is one line and reads correctly — but those counts
* are per label, a conversation may carry several, and adding them reports it
* once per label. The heading would then promise more unread than expanding the
* section can show, which is the specific way a rolled-up number stops being
* believed.
*
* So the fixtures here are built to contain exactly that: one thread under two
* labels. A SUM implementation passes every other test in the suite and fails
* the first assertion below.
*/
final class UnreadInUserLabelsTest extends KernelTestCase
{
use SeedsMarkerFixtures;
private Connection $connection;
protected function setUp(): void
{
self::bootKernel();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
private function countUnreadInUserLabels(): int
{
return static::getContainer()
->get(MessageThreadRepository::class)
->countUnreadInUserLabels($this->user);
}
public function testAThreadUnderTwoLabelsCountsOnce(): void
{
$work = $this->seedLabel('Work');
$clients = $this->seedLabel('Clients');
$thread = $this->thread('Double filed', unread: 3);
$thread->addLabel($work);
$thread->addLabel($clients);
$this->em->flush();
// The per-label rows say 3 and 3 — six between them, over one
// conversation that has three unread messages in it.
$perLabel = static::getContainer()->get(MessageThreadRepository::class)
->countUnreadPerUserLabel($this->user);
self::assertSame(3, $perLabel[(int) $work->id]);
self::assertSame(3, $perLabel[(int) $clients->id]);
self::assertSame(
3,
$this->countUnreadInUserLabels(),
'the roll-up counted the same conversation once per label on it',
);
}
public function testMailWithNoCustomLabelIsNotCountedAtAll(): void
{
// Inbox only, which is a system label — the section this number belongs
// to lists custom labels, so nothing here is under it.
$this->thread('Just in the inbox', unread: 5);
self::assertSame(0, $this->countUnreadInUserLabels());
}
public function testReadMailUnderALabelAddsNothing(): void
{
$label = $this->seedLabel('Archive pile');
$thread = $this->thread('Already read', unread: 0);
$thread->addLabel($label);
$this->em->flush();
self::assertSame(0, $this->countUnreadInUserLabels());
}
/**
* The same rule the per-label counts follow, so the heading cannot be
* louder than the rows underneath it.
*/
public function testATrashedThreadStopsCounting(): void
{
$label = $this->seedLabel('Work');
$trash = $this->seedLabel('Trash', LabelRole::Trash);
$thread = $this->thread('Binned', unread: 2);
$thread->addLabel($label);
$this->em->flush();
self::assertSame(2, $this->countUnreadInUserLabels());
$thread->addLabel($trash);
$this->em->flush();
self::assertSame(0, $this->countUnreadInUserLabels());
}
public function testSeparateThreadsStillAddUp(): void
{
$work = $this->seedLabel('Work');
$clients = $this->seedLabel('Clients');
$first = $this->thread('One', unread: 2);
$first->addLabel($work);
$second = $this->thread('Two', unread: 4);
$second->addLabel($clients);
$this->em->flush();
// De-duplicating must not turn into under-counting: these are two
// different conversations and both belong in the total.
self::assertSame(6, $this->countUnreadInUserLabels());
}
}
+234
View File
@@ -0,0 +1,234 @@
import { test, expect, type Page } from "./support/test";
/**
* The sidebar's LABELS and ACCOUNTS sections collapse, and the state is the
* user's rather than the browser's.
*
* Modelled on admin-panels.spec.ts, which is the same feature one screen over,
* and the assertion that matters is the same one: after a reload the section
* must come back ALREADY collapsed. A toggle that only works until the next
* navigation is the bug this replaced — the trees used to be remembered in
* localStorage and re-applied on connect, so a collapsed section was rendered
* open and snapped shut a frame later, on every single click.
*
* Runs authenticated as this worker's own user, signed in by the worker fixture
* in support/test.ts.
*/
const DESKTOP = { width: 1280, height: 900 };
const MOBILE = { width: 414, height: 851 };
const LABELS = "section:labels";
const ACCOUNTS = "section:accounts";
/**
* The desktop rail's copy. Both sidebars render the same partial with the same
* keys, so every locator here has to say WHICH one it means — `#sidebar` is the
* inline column, `#sidebar-drawer-inner` the overlay.
*/
const section = (page: Page, key: string, root = "#sidebar") =>
page.locator(`${root} details[data-collapse-key="${key}"]`);
/** Toggle, waiting on the persist rather than racing the next navigation. */
async function toggle(page: Page, key: string, root = "#sidebar") {
const persisted = page.waitForResponse(
(r) =>
r.url().includes("/mail/sidebar/section-collapsed") &&
r.status() === 200,
);
await section(page, key, root).locator("> summary").click();
await persisted;
}
/**
* Put every section back. This spec writes a per-user preference that outlives
* the test, and leaving a worker's user with its labels collapsed would be a
* booby trap for every other spec that looks in the sidebar.
*/
async function expandAll(page: Page) {
for (const key of [LABELS, ACCOUNTS]) {
await page.request.post("/mail/sidebar/section-collapsed", {
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": await csrf(page),
},
data: { key, collapsed: false },
});
}
}
async function csrf(page: Page): Promise<string> {
return page.evaluate(
() =>
document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content") ?? "",
);
}
test.beforeEach(async ({ page }) => {
await page.goto("/mail/inbox");
await expandAll(page);
});
test.afterEach(async ({ page }) => {
await page.goto("/mail/inbox");
await expandAll(page);
});
test.describe("sidebar section collapse (desktop)", () => {
test.use({ viewport: DESKTOP });
test("collapses labels and is still collapsed after a reload", async ({
page,
}) => {
await page.goto("/mail/inbox");
await expect(section(page, LABELS)).toHaveAttribute("open", "");
await toggle(page, LABELS);
await expect(section(page, LABELS)).not.toHaveAttribute("open", "");
// The real assertion: server-rendered collapsed on a fresh load, so
// there is no frame in which the labels are visible.
await page.reload();
await expect(section(page, LABELS)).not.toHaveAttribute("open", "");
// Per section — collapsing labels leaves the accounts alone.
await expect(section(page, ACCOUNTS)).toHaveAttribute("open", "");
// And it round-trips.
await toggle(page, LABELS);
await page.reload();
await expect(section(page, LABELS)).toHaveAttribute("open", "");
});
test("survives a Turbo navigation, not just a reload", async ({ page }) => {
await page.goto("/mail/inbox");
await toggle(page, LABELS);
// A real in-app click rather than page.goto — the sidebar is
// re-rendered per visit, which is exactly when the old localStorage
// version snapped shut after paint.
await page.locator("#sidebar a[href='/mail/sent']").first().click();
await expect(page).toHaveURL(/\/mail\/sent/);
await expect(section(page, LABELS)).not.toHaveAttribute("open", "");
});
test("the collapsed heading carries the unread roll-up, and shows it only when shut", async ({
page,
}) => {
await page.goto("/mail/inbox");
const badge = page.locator(
"#sidebar [data-count-key='labels:unread']",
);
// Rendered either way — hidden rather than omitted, so refreshCounts()
// always has something to patch and a reopened section is never stale.
await expect(badge).toHaveCount(1);
await expect(badge).toBeHidden();
await toggle(page, LABELS);
// Visible now, unless there is simply nothing unread under a label —
// the badge hides itself at zero like every other badge here.
const count = Number((await badge.textContent())?.trim() ?? "0");
if (count > 0) {
await expect(badge).toBeVisible();
}
});
test("keyboard: the summary is focusable and Enter toggles it", async ({
page,
}) => {
await page.goto("/mail/inbox");
const summary = section(page, LABELS).locator("> summary");
await summary.focus();
await expect(summary).toBeFocused();
// Native <details> semantics, which is why this file adds no key
// handling of its own — the accessible name comes from the heading and
// the expanded state from the element.
await expect(summary).toHaveAttribute("aria-label", /labels/i);
await expect(summary).toHaveAttribute("aria-expanded", "true");
const persisted = page.waitForResponse((r) =>
r.url().includes("/mail/sidebar/section-collapsed"),
);
await page.keyboard.press("Enter");
await persisted;
await expect(section(page, LABELS)).not.toHaveAttribute("open", "");
await expect(summary).toHaveAttribute("aria-expanded", "false");
});
});
test.describe("sidebar section collapse (mobile drawer)", () => {
test.use({ viewport: MOBILE });
test("the drawer renders the same state and toggles it too", async ({
page,
}) => {
await page.goto("/mail/inbox");
await page
.getByRole("button", { name: /show or hide the sidebar/i })
.first()
.click();
const drawer = "#sidebar-drawer-inner";
await expect(section(page, LABELS, drawer)).toBeVisible();
await expect(section(page, LABELS, drawer)).toHaveAttribute("open", "");
await toggle(page, LABELS, drawer);
await expect(section(page, LABELS, drawer)).not.toHaveAttribute(
"open",
"",
);
// The same preference, so the desktop copy on the same page agrees
// rather than waiting for the next render to find out.
await expect(section(page, LABELS)).not.toHaveAttribute("open", "");
await page.reload();
await page
.getByRole("button", { name: /show or hide the sidebar/i })
.first()
.click();
await expect(section(page, LABELS, drawer)).not.toHaveAttribute(
"open",
"",
);
});
test("the two sidebars do not share an element id", async ({ page }) => {
await page.goto("/mail/inbox");
// Both copies carry the same collapse KEY on purpose — it is one
// preference — but ids must stay unique, which is why the disclosures
// are keyed by a data attribute and have no id of their own.
const ids = await page.evaluate(() => {
const seen = new Set<string>();
const duplicates: string[] = [];
document.querySelectorAll("[id]").forEach((el) => {
if (seen.has(el.id)) {
duplicates.push(el.id);
}
seen.add(el.id);
});
return duplicates;
});
expect(ids).toEqual([]);
});
});