fix(mobile): tapping the search box zoomed the page, and the box was too small to use

Two reports about the same control on a phone.

── The zoom ────────────────────────────────────────────────────────────────
iOS zooms the page into any focused control whose COMPUTED font-size is under
16px, and does not zoom back out. There is one way to stop it — make the
computed size 16px — because the other, `maximum-scale=1, user-scalable=no`,
takes pinch-zoom away from everyone who needs it and was rejected on that
ground long ago (see the viewport meta in app.html.twig).

The rule to do it has been in app.css all along and had never once fired. It
sat in `@layer base`, where Tailwind's utilities outrank it, and `text-sm` is
on essentially every field in the app. Measured at 390px before moving it:
the search box 14px, the login email and password 14px, the compose subject
14px, the compose editor 14px, the formatting bar's selects 12px. Every one
of them zoomed.

It is unlayered now, and `[contenteditable="true"]` joins the selector list —
the compose body is a div, not a textarea, and Safari treats it identically.

`max(16px, 0.875rem)` rather than a bare `16px`, which would be a pin and not
a floor: an absolute value REPLACES the utility instead of raising it, so
below md nothing would answer to `--app-font-scale` any more, and above about
1.14 fields would come out smaller than they were before the rule started
firing — and smaller than their own labels. The slider runs to 1.25.

The compose editor keeps its own pin, and gets a second absolute value below
md rather than an exemption. What that pin exists to escape is the REM
cascade, so that the appearance scale cannot move the editor under a sender
judging their own message by eye; a second px value, chosen per width,
escapes it exactly as well. The cost is that a message written on a phone is
composed two pixels larger than one written at a desk.

── The box ─────────────────────────────────────────────────────────────────
Below md the field had about 120px of topbar between the logo and the
calendar button — too narrow to read a query back in, and the first thing a
thumb met reaching for anything else. It is a magnifying glass now, and the
field drops out from under the topbar when the button asks for it.

ONE field, moved by CSS, not a second one for phones. The box is a combobox:
it owns `aria-controls`, an id'd listbox, a live region and the recent-search
list. A duplicate would have meant two elements claiming `#search-listbox`.

Absolute rather than a second flex row, so the topbar keeps its height —
`--pane-header-h` is a published knob and the shell is laid out against it.
`focus()` is called synchronously inside the click, because iOS raises the
keyboard only for a focus in the gesture that asked for it.

── Four things a review of the first draft found ───────────────────────────
  • EVERY BOUNDARY IN THIS FEATURE HAS TO BE 48rem. The button's visibility,
    the shell's positioning context and the row's alignment are Tailwind `md:`
    variants, compiled to `(width < 48rem)`; the panel rule was written in px.
    rem in a media query answers to the browser's default-font-size
    PREFERENCE, not to `html { font-size }`, so the two drift apart for anyone
    who has changed it. At Chrome's "Small", `md:` lands at 576px and mail
    search was simply UNREACHABLE from 576 to 768 — no button, no panel, and
    no other way in. At 20px it lands at 960px and both rendered at once, with
    `md:relative` unmatched, so the suggestions dropdown reparented to
    <header> and painted across the whole topbar. `html` is pinned to 16px by
    the appearance scale, so the page looked entirely normal while it happened.

  • Escape died once the focus left the input. Both rungs it defers to are
    spent BY the field, and `aria-expanded` is written from handlers bound to
    the input — so a Tab into the open dropdown left it "true" for good and
    every later Escape returned early. The gates now apply only while the
    field holds the focus.

  • Turning an iPhone upright mid-query hid the field being typed in: landscape
    is 812–932px, above md, so the crossing handed the focused element
    `display: none` and the keyboard dropped with nothing on screen to explain
    it. A crossing that finds the field in use opens the panel instead.

  • The 16px pin, above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-28 12:00:14 +02:00
1 parent 9a9871c8c9
commit c8846783b4
7 files changed
+753 -18

No files matched your search

@@ -0,0 +1,193 @@
import { Controller } from '@hotwired/stimulus';
/**
* The search box on a phone: a button in the topbar, and the field itself as a
* row that drops out from under it.
*
* Below md the field had roughly 120px of row to live in, between the logo and
* the calendar button — narrow enough that a typed query scrolled out of its
* own box, and wide enough to be the first thing a thumb landed on reaching for
* anything else. So it leaves the row and comes back on request.
*
* This controller owns only the disclosure: whether the panel is showing, where
* the focus goes, and what the button reports about itself. Everything the
* search *is* — the suggestions, the recents, the listbox, the arrow keys —
* stays in mail--search on the panel itself, and there is exactly one of it at
* every width. The two shapes are the same field, moved; not two fields, which
* would have meant two elements claiming `#search-listbox` and a screen reader
* following whichever it found first.
*
* Above md none of this runs: the button is `md:hidden`, and the geometry is a
* media query in app.css rather than anything written here. There is nothing to
* undo on a rotation into landscape — the panel simply becomes the topbar field
* again, which is why this deliberately does not listen for a breakpoint.
*/
export default class extends Controller {
static targets = ['toggle', 'panel', 'field'];
static values = { openLabel: String, closeLabel: String };
/**
* Where the field stops being a topbar field and becomes a panel. Tailwind's
* own `max-md:`, character for character: the boundary is 48 REM, and rem in
* a media query answers to the browser's default-font-size preference, so a
* px spelling of "the same thing" is only the same thing at 16px.
*/
static MOBILE_QUERY = '(width < 48rem)';
connect() {
// The SAME query Tailwind compiles `md:` to — see `.search-shell` in
// app.css for why every boundary in this feature has to be the one
// written in rem.
this._mq = window.matchMedia(this.constructor.MOBILE_QUERY);
this._boundBreakpoint = this._applyBreakpoint.bind(this);
this._mq.addEventListener('change', this._boundBreakpoint);
// On the document rather than on this element: a tap lands on the mail
// list behind the panel, which is not a descendant of anything here.
this._boundOutside = this._closeOnOutside.bind(this);
// Capture, and the reason is the Escape ladder mail--search already
// has: its first Escape takes the suggestion list down and its second
// clears the box. Closing the panel is the third rung, so this has to
// read `aria-expanded` BEFORE that handler runs and changes it —
// otherwise one press collapses all three and a half-typed query
// disappears with the list.
this._boundKeydown = this._closeOnEscape.bind(this);
document.addEventListener('click', this._boundOutside);
document.addEventListener('keydown', this._boundKeydown, true);
}
disconnect() {
this._mq?.removeEventListener('change', this._boundBreakpoint);
document.removeEventListener('click', this._boundOutside);
document.removeEventListener('keydown', this._boundKeydown, true);
}
/**
* Carry the field across the breakpoint rather than dropping it.
*
* An iPhone in landscape is 812 to 932 CSS pixels — above md, so the search
* is the topbar field there — and turning it upright crosses to the panel
* layout. Mid-query that meant the element being typed into was given
* `display: none`: the focus fell to <body>, the keyboard dropped, and
* nothing on screen said why. The text survived and the magnifier brought
* it back, which is exactly the sort of thing nobody works out while it is
* happening.
*
* So a crossing that finds the field in use opens the panel instead, and a
* crossing the other way puts the flag back, so the next one starts from
* the state the server renders.
*/
_applyBreakpoint(event) {
if (false === event.matches) {
this._setOpen(false);
return;
}
if (false === this.hasFieldTarget) {
return;
}
const inUse = this.fieldTarget === document.activeElement
|| '' !== this.fieldTarget.value;
if (true === inUse) {
this._setOpen(true);
}
}
toggle() {
this._setOpen(false === this._isOpen());
}
_isOpen() {
return this.hasPanelTarget && 'true' === this.panelTarget.dataset.open;
}
_setOpen(open) {
if (false === this.hasPanelTarget) {
return;
}
// An attribute rather than a class, so the closed state is the one the
// server renders and a phone whose JavaScript never arrived gets the
// button and no panel — rather than a panel wedged open under the
// topbar with nothing able to close it.
this.panelTarget.dataset.open = true === open ? 'true' : 'false';
if (this.hasToggleTarget) {
this.toggleTarget.setAttribute('aria-expanded', true === open ? 'true' : 'false');
// The name changes with the state, because "Search" on a button
// that closes the search is a lie the icon cannot correct.
const label = true === open ? this.closeLabelValue : this.openLabelValue;
if ('' !== label) {
this.toggleTarget.setAttribute('aria-label', label);
this.toggleTarget.setAttribute('title', label);
}
}
if (false === this.hasFieldTarget) {
return;
}
if (true === open) {
// Synchronously, inside the click. iOS only raises the keyboard for
// a focus() that happens in the gesture that asked for it; deferred
// to a frame or a transition callback it opens the field and leaves
// the user to tap it a second time.
this.fieldTarget.focus();
return;
}
// Give the focus back rather than dropping it on <body>, which is where
// a keyboard user would otherwise have to start again from.
if (this.fieldTarget === document.activeElement && this.hasToggleTarget) {
this.toggleTarget.focus();
}
}
_closeOnOutside(event) {
if (false === this._isOpen() || this.element.contains(event.target)) {
return;
}
this._setOpen(false);
}
_closeOnEscape(event) {
if ('Escape' !== event.key || false === this._isOpen()) {
return;
}
// mail--search's own two rungs first: while the suggestion list is up
// this Escape belongs to it, and while there is still a query in the
// box the next one belongs to clearing that. Only an Escape with
// nothing left to dismiss puts the panel away.
//
// Both rungs are spent BY THE FIELD, so both are deferred to only while
// the field holds the focus. Without that condition the gates read
// state that nothing can then clear: Tab out of the box into the open
// dropdown and `aria-expanded` stays "true" for good — mail--search
// writes it from handlers bound to the input, and the input no longer
// has the key. Escape stopped working from that moment on, permanently,
// and the panel sat over the top of the message list until a pointer
// put it away. The `value !== ''` gate was the easier of the two to
// reach: type, press Escape once, Tab away, and rung two can never run
// either.
if (this.hasFieldTarget && this.fieldTarget === document.activeElement) {
if ('true' === this.fieldTarget.getAttribute('aria-expanded')) {
return;
}
if ('' !== this.fieldTarget.value) {
return;
}
}
this._setOpen(false);
}
}
+133 -14
View File
@@ -2698,20 +2698,6 @@
opacity: 0.5;
}
/* ── Mobile viewport ───────────────────────────────────────────────────
The meta tag pins the scale, but iOS ignores user-scalable and zooms
in whenever a focused control renders below 16px — which every
text-sm/text-xs field here does. Setting the *computed* size to 16px
on small screens is the only thing that actually suppresses it.
────────────────────────────────────────────────────────────────────── */
@media (max-width: 767px) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
select,
textarea {
font-size: 16px;
}
}
html {
/* Pinch-zoom and rubber-band scrolling both let the fixed-height
shell drift away from the viewport, leaving the app half off
@@ -3216,6 +3202,118 @@
}
}
/* ═══ iOS zooms into anything under 16px ═══════════════════════════════════
Tap a control whose COMPUTED font-size is below 16px on an iPhone and Safari
zooms the page in to meet it — and does not zoom back out. There is one
documented way to stop that and it is to make the computed size 16px;
`maximum-scale=1, user-scalable=no` also works and is not available to us,
because it takes pinch-zoom away from everyone who needs it (WCAG 1.4.4, and
see the viewport meta in app.html.twig where that was decided).
THIS RULE ALREADY EXISTED, IN `@layer base`, AND HAD NEVER ONCE FIRED.
Tailwind's utilities live in a later layer, so `text-sm` on the element beat
it every time — and `text-sm` is on essentially every field in the app.
Measured at 390px before moving it: the search box 14px, the login email and
password 14px, the compose subject 14px, the compose editor 14px, the
formatting bar's selects 12px. Every one of them zoomed. Unlayered, it wins,
which is the whole reason the rule is here rather than back where it was.
`[contenteditable]` belongs in the list: the compose body is a div, not a
textarea, and Safari zooms into it on exactly the same rule. The three input
types excluded have no text to render and are sized by the UA.
Below md only. On a desktop 16px everywhere would be a redesign, and no
pointer-driven browser zooms on focus.
═══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 767px) {
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
select,
textarea,
[contenteditable="true"] {
/* A FLOOR, not a pin. A bare `16px` replaces the utility instead of
raising it, which cancels `--app-font-scale` for every field below
md — and above about 1.14 it makes fields SMALLER than they were
before this rule started firing, and smaller than their own labels.
The appearance slider runs to 1.25, so that is five stops of it, and
the people standing on those stops are the ones the WCAG argument
above is about.
0.875rem is `text-sm`, which is what nearly every field here carries.
CSS cannot say "this element's own size, floored at 16px", so the
approximation is to floor the common one: the `text-xs` controls in
the compose toolbar come out a little larger than drawn at high
scale, which is not a defect for someone who asked for larger text. */
font-size: max(16px, 0.875rem);
}
}
/* ═══ The search box on a phone ════════════════════════════════════════════
Above md the search box is what it always was: a field in the topbar row,
and none of this applies.
Below md that field had about 120px between the logo and the calendar
button — too narrow to read a query back in, and the first thing a thumb
hit on the way to anything else. It becomes a magnifying-glass button that
opens the field as a full-width row hanging under the topbar, which is what
a phone expects and what gives the query somewhere to be read.
ONE field, moved, rather than a second one for phones. The box is a
combobox: it owns `aria-controls`, an id'd listbox, a live region and the
recent-search list. A duplicate would have meant duplicate ids — two
elements claiming `#search-listbox` — and a screen reader following the
wrong one.
Absolute rather than a second flex row, so the topbar keeps its height:
`--pane-header-h` is a published knob and the shell is laid out against it.
The containing block is the <header>, which is already `relative` for the
dropdown's sake. The dropdown stays inside this element and so keeps
positioning against it.
`(width < 48rem)` — Tailwind's own `max-md:` query, copied verbatim — and
NOT a px equivalent. This element's visibility (`md:hidden` on the button),
its positioning context (`md:relative`) and its row alignment
(`max-md:justify-end`) are all Tailwind variants compiled against 48rem, and
rem in a media query resolves against the browser's default-font-size
PREFERENCE rather than against `html { font-size }`. So a px boundary here
moves independently of those three, and the two disagree for anyone who has
changed that preference:
• Chrome set to "Small" (12px) puts `md:` at 576px. A px panel rule would
still be waiting for 768px, so between the two the button is gone AND
the panel is hidden — and there is no other way into mail search. It is
simply missing, on a page that otherwise renders at a normal size.
• Set to 20px, `md:` lands at 960px: the button and the inline field both
render, the button becomes a no-op that still reports `aria-expanded`,
and `md:relative` does not match — so the shell computes `static` and
the suggestions dropdown anchors to <header> and paints across the whole
topbar.
The two `max-width: 767px` font rules elsewhere in this file stay in px on
purpose: they exist to clear a 16px threshold that iOS measures in device
pixels, and expressing them in rem would push 16px fields onto a desktop
whose owner has merely asked for larger text.
═══════════════════════════════════════════════════════════════════════════ */
@media (width < 48rem) {
.search-shell {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 30;
padding: 0.25rem 1rem 0.75rem;
background-color: rgb(var(--rgb-surface));
border-bottom: 1px solid rgb(var(--rgb-line) / var(--line-alpha));
}
/* Closed is the server-rendered state, so a phone that never runs the
controller gets the button and no panel rather than a panel stuck open
under the topbar. */
.search-shell[data-open="false"] {
display: none;
}
}
/* ═══ The virtual keyboard ═════════════════════════════════════════════════
`--keyboard-inset` is how much of the layout viewport the on-screen keyboard
has taken. The compose controller publishes it on <html> from
@@ -3993,6 +4091,27 @@ html {
line-height: 1.625;
}
/*
16px of it on a phone, and the pin above is not weakened by saying so: what
that rule exists to escape is the REM cascade, so that `--app-font-scale`
cannot move the editor under a sender who is judging their own message by
eye. A second absolute px value, chosen per width, escapes it exactly as
well.
The reason for the second value is the one in the iOS block near the top of
this file: Safari zooms the whole page into any focused control under 16px
and does not zoom back out, and the editor is a `contenteditable`, which it
treats like any other. 14px meant that every tap into the message body
zoomed the app. The cost is that a message written on a phone is composed
two pixels larger than one written at a desk; the alternative was a composer
you had to pinch your way out of after every tap.
*/
@media (max-width: 767px) {
[data-compose--compose-toolbar-target="editor"] {
font-size: 16px;
}
}
/* ── A visually-hidden input must not escape its own label ─────────────────
`sr-only` is `position: absolute`, and a <label> is `position: static` by
default — so the containing block of every `peer sr-only` radio was not its
+44 -4
View File
@@ -45,17 +45,56 @@
></span>
</a>
{# ── Search + calendar ─────────────────────────────────────────────── #}
<div class="flex-1 max-w-2xl mx-auto flex items-center gap-2 min-w-0">
{# ── Search + calendar ───────────────────────────────────────────────
`justify-end` below md, because the search box leaves the row there:
it becomes the button below and a panel under the topbar (see
`.search-shell` in app.css), so what is left in this group is two icons
that belong beside the account controls rather than adrift in the
middle of an empty row. #}
<div class="flex-1 max-w-2xl mx-auto flex items-center gap-2 min-w-0 max-md:justify-end"
data-controller="ui--mobile-search"
{# On the controller element, where Stimulus reads values from — not on
the button, which is only a target. Both labels are rendered here
rather than spelled in the JavaScript, the same arrangement the
compose window and ui--select use, so the button does not go back to
English in a German topbar. #}
data-ui--mobile-search-open-label-value="{{ 'header.search.open'|trans }}"
data-ui--mobile-search-close-label-value="{{ 'header.search.close'|trans }}">
{# The way in on a phone, and nothing at all above md. It is a
disclosure, so it says whether it is open — an icon that toggles a
panel and never reports its state is a button a screen reader
announces identically in both. #}
<button
type="button"
class="md:hidden flex items-center justify-center w-9 h-9 rounded-lg
text-ink-muted hover:bg-hover transition-colors flex-shrink-0"
aria-expanded="false"
aria-controls="search-shell"
aria-label="{{ 'header.search.open'|trans }}"
title="{{ 'header.search.open'|trans }}"
data-ui--mobile-search-target="toggle"
data-action="click->ui--mobile-search#toggle"
>
<i class="fa-solid fa-magnifying-glass text-base" aria-hidden="true"></i>
</button>
{# The search box is a combobox in the ARIA sense — a text field with a
popup list attached — and the parts of that live in three places: the
input carries the state, the listbox carries the options, and the
controller keeps aria-activedescendant pointing at whichever one the
arrow keys landed on. The section titles are values rather than strings
in the JavaScript because they are shown to people and therefore
translated. #}
translated.
`md:relative` rather than a plain `relative`: below md `.search-shell`
makes this element `absolute`, and two position utilities on one element
leave which one wins to stylesheet order. Each width names its own. #}
<div
class="flex-1 relative min-w-0"
id="search-shell"
class="search-shell flex-1 md:relative min-w-0"
data-open="false"
data-ui--mobile-search-target="panel"
data-controller="mail--search"
data-mail--search-suggest-url-value="{{ path('app_mail_search_suggest') }}"
data-mail--search-recents-title-value="{{ 'header.search.recents.title'|trans }}"
@@ -84,6 +123,7 @@
aria-autocomplete="list"
aria-label="{{ 'header.search.placeholder'|trans }}"
data-mail--search-target="input"
data-ui--mobile-search-target="field"
data-action="keydown->mail--search#onKeydown focus->mail--search#onFocus input->mail--search#onInput"
class="w-full pl-10 pr-4 py-2 text-sm
bg-sunken
+377
View File
@@ -0,0 +1,377 @@
import { test, expect, type Page } from "./support/test";
import { seed } from "./support/config";
/**
* The search box on a phone, and the zoom that used to come with tapping it.
*
* Two faults, reported together, and they meet in this file because they met on
* the same control:
*
* • The field had about 120px of topbar to live in below md — too narrow to
* read a query back in, and the first thing a thumb met on the way to
* anything else. It is a button now, and the field drops out from under the
* topbar when the button asks for it.
*
* • Tapping it zoomed the whole page in, and did not zoom back out. iOS does
* that to any focused control whose COMPUTED font-size is under 16px. A
* rule meant to prevent it had been in the stylesheet all along and had
* never once fired: it sat in `@layer base`, where every Tailwind `text-sm`
* outranked it.
*
* The second is why the sweep at the bottom is a sweep and not one assertion.
* The rule is invisible when it works and invisible when it does not, so the
* only thing that keeps it honest is measuring every control a thumb can land
* on.
*/
const toggle = '[data-ui--mobile-search-target="toggle"]';
const shell = "#search-shell";
const field = 'input[name="q"]';
test.beforeAll(() => {
seed("seed-mail");
});
/** Every control iOS would zoom into, with the size it actually computes. */
async function undersizedControls(page: Page): Promise<string[]> {
return page.evaluate(() =>
Array.from(
document.querySelectorAll(
'input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]),' +
'textarea, select, [contenteditable="true"]',
),
)
.filter((el) => (el as HTMLElement).getClientRects().length > 0)
.map((el) => {
const e = el as HTMLElement;
const size = parseFloat(getComputedStyle(e).fontSize);
const name =
(e as HTMLInputElement).name ||
e.getAttribute("data-mail--search-target") ||
e.getAttribute("data-compose--compose-target") ||
e.tagName.toLowerCase();
return { name, size };
})
.filter((c) => c.size < 16)
.map((c) => `${c.name}=${c.size}px`),
);
}
test.describe("the search box on a phone", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("is a button, with the field off the row until it is asked for", async ({ page }) => {
await page.goto("/mail/inbox");
await expect(page.locator(toggle)).toBeVisible();
await expect(page.locator(shell)).toBeHidden();
});
/**
* Focused in the same gesture that opened it. iOS raises the keyboard only
* for a focus() inside the gesture that asked for it — deferred to a frame
* or a transition callback, the field opens and waits to be tapped again.
*/
test("opens the field and puts the cursor in it", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator(toggle).click();
await expect(page.locator(shell)).toBeVisible();
await expect(page.locator(field)).toBeFocused();
});
test("says whether it is open, by state and by name", async ({ page }) => {
await page.goto("/mail/inbox");
const button = page.locator(toggle);
await expect(button).toHaveAttribute("aria-expanded", "false");
await expect(button).toHaveAccessibleName("Search mail");
await button.click();
await expect(button).toHaveAttribute("aria-expanded", "true");
// The name follows: "Search" on a button that closes the search is a
// lie the icon cannot correct.
await expect(button).toHaveAccessibleName("Close search");
});
/**
* The header is laid out against `--pane-header-h` and the shell is built
* on it, so the panel hangs UNDER the topbar rather than growing it. If
* this ever fails, the whole app has shifted down by 58px.
*/
test("hangs under the topbar without making it taller", async ({ page }) => {
await page.goto("/mail/inbox");
const header = page.locator("header").first();
const before = (await header.boundingBox())!;
await page.locator(toggle).click();
const after = (await header.boundingBox())!;
const panel = (await page.locator(shell).boundingBox())!;
expect(after.height).toBe(before.height);
expect(panel.y).toBeGreaterThanOrEqual(after.y + after.height - 1);
// Full width of the topbar, not a corner of it.
expect(panel.width).toBeGreaterThan(after.width - 4);
});
test("closes on a tap outside it", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator(toggle).click();
await expect(page.locator(shell)).toBeVisible();
// Well below the panel, which overlays the top of the list — a tap
// inside it is not "outside".
await page.locator("#message-list").click({ position: { x: 20, y: 260 } });
await expect(page.locator(shell)).toBeHidden();
});
/**
* mail--search already spends two Escapes: the first takes the suggestion
* list down, the second clears the box. Putting the panel away is the third
* rung, and it must not swallow the two below it — a half-typed query is
* not worth losing to a press aimed at the list on top of it.
*/
test("spends Escape one rung at a time", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator(toggle).click();
await page.locator(field).pressSequentially("E2E", { delay: 50 });
const dropdown = page.locator('[data-mail--search-target="dropdown"]');
await expect(dropdown).toBeVisible();
// One: the list.
await page.locator(field).press("Escape");
await expect(dropdown).toBeHidden();
await expect(page.locator(shell)).toBeVisible();
await expect(page.locator(field)).toHaveValue("E2E");
// Two: the query.
await page.locator(field).press("Escape");
await expect(page.locator(field)).toHaveValue("");
await expect(page.locator(shell)).toBeVisible();
// Three: the panel.
await page.keyboard.press("Escape");
await expect(page.locator(shell)).toBeHidden();
});
/**
* The Escape ladder defers to mail--search only while the FIELD holds the
* focus, because both of the rungs it defers to are spent by the field.
* Tab into the open dropdown and `aria-expanded` stays "true" for good —
* mail--search writes it from handlers bound to the input, which no longer
* has the key — so a gate that read it unconditionally killed Escape from
* that moment on and left the panel over the message list.
*/
test("still closes on Escape after the focus has moved into the list", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator(toggle).click();
await page.locator(field).pressSequentially("E2E", { delay: 50 });
await expect(page.locator('[data-mail--search-target="dropdown"]')).toBeVisible();
await page.locator(field).press("Tab");
await expect(page.locator(field)).not.toBeFocused();
await page.keyboard.press("Escape");
await expect(page.locator(shell)).toBeHidden();
});
test("still searches", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator(toggle).click();
await page.locator(field).fill("E2E Star Me");
await page.locator(field).press("Enter");
await expect(page).toHaveURL(/[?&]q=/);
});
});
/**
* Every boundary in this feature is 48rem, and that is load-bearing rather than
* tidy. The button's visibility, the shell's positioning context and the row's
* alignment are Tailwind `md:` variants — compiled to `(width < 48rem)` — while
* the panel's geometry is a hand-written rule in app.css. rem in a media query
* answers to the browser's default-font-size PREFERENCE, not to
* `html { font-size }`, so a px spelling of the panel rule drifts away from the
* other three the moment somebody changes that preference. And `html` is pinned
* to 16px by the appearance scale, so the page still renders at a normal size:
* there is no visual cue that a font preference is what broke it.
*/
test.describe("the search survives a browser font preference", () => {
/** Chrome's own setting, which moves every rem media query in the app. */
async function setDefaultFontSize(page: Page, px: number) {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Page.setFontSizes", { fontSizes: { standard: px, fixed: px } });
}
/** How many ways into search are on screen: the button, the field, or both. */
async function entryPoints(page: Page) {
return {
button: await page.locator(toggle).isVisible(),
field: await page.locator(field).isVisible(),
shellPosition: await page
.locator(shell)
.evaluate((el) => getComputedStyle(el).position),
};
}
/**
* 12px puts `md:` at 576px. With the panel rule in px it was still waiting
* for 768px, so from 576 to 768 the button was hidden AND the panel was
* hidden — and there is no other way to reach mail search in the app. It
* was simply gone.
*/
test("small default font: there is still a way in at 600px", async ({ page }) => {
await page.setViewportSize({ width: 600, height: 800 });
await setDefaultFontSize(page, 12);
await page.goto("/mail/inbox");
const seen = await entryPoints(page);
expect(seen.button || seen.field).toBe(true);
});
/**
* 20px puts `md:` at 960px. At 800px the button and the inline field both
* rendered, the button was a no-op that still announced itself expanded,
* and `md:relative` did not match — so the shell computed `static` and the
* suggestions dropdown anchored to <header> and painted across the topbar.
*/
test("large default font: exactly one way in at 800px, and the dropdown stays anchored", async ({ page }) => {
await page.setViewportSize({ width: 800, height: 800 });
await setDefaultFontSize(page, 20);
await page.goto("/mail/inbox");
const seen = await entryPoints(page);
expect(seen.button && seen.field).toBe(false);
expect(seen.button || seen.field).toBe(true);
// Whichever shape it is in, the dropdown has something to hang off
// other than the whole topbar.
expect(seen.shellPosition).not.toBe("static");
});
});
test.describe("the search box on a desktop", () => {
test.use({ viewport: { width: 1280, height: 800 } });
test("is still a field in the topbar row, with no button in the way", async ({ page }) => {
await page.goto("/mail/inbox");
await expect(page.locator(shell)).toBeVisible();
await expect(page.locator(field)).toBeVisible();
await expect(page.locator(toggle)).toBeHidden();
// In the row, not hanging under it.
const header = (await page.locator("header").first().boundingBox())!;
const box = (await page.locator(shell).boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(header.y);
expect(box.y + box.height).toBeLessThanOrEqual(header.y + header.height);
});
});
/**
* Nothing a thumb can land on may compute under 16px, or iOS zooms the page in
* on the tap and leaves it there. The rule that prevents it is in app.css and
* has to stay UNLAYERED — moved back into `@layer base`, every one of these
* goes to 14px again and the suite is the only thing that would notice.
*/
test.describe("nothing on a phone is small enough to zoom into", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("the mailbox, search box open", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator(toggle).click();
expect(await undersizedControls(page)).toEqual([]);
});
test("the compose window, editor and toolbar included", async ({ page }) => {
await page.goto("/mail/inbox");
await page.getByRole("button", { name: "Show or hide the sidebar" }).click();
await page.getByRole("link", { name: "Compose" }).click();
await expect(page.locator(".compose-window")).toBeVisible();
// The editor is a contenteditable div rather than a textarea, and
// Safari zooms into one on exactly the same rule — which is why the
// selector list in app.css names it.
expect(await undersizedControls(page)).toEqual([]);
});
test("the login form", async ({ page, context }) => {
await context.clearCookies();
await page.goto("/login");
expect(await undersizedControls(page)).toEqual([]);
});
});
/**
* An iPhone in landscape is 812 to 932 CSS pixels — above md, so the search
* there is the topbar field, and turning the phone upright crosses into the
* panel layout. Mid-query that used to mean the element being typed into was
* handed `display: none`: focus fell to <body>, the keyboard dropped, and
* nothing said why.
*/
test.describe("turning the phone upright mid-search", () => {
test.use({ viewport: { width: 844, height: 390 } });
test("carries the query into the panel instead of hiding it", async ({ page }) => {
await page.goto("/mail/inbox");
// Landscape is above md: the field is in the row, no button.
await expect(page.locator(field)).toBeVisible();
await page.locator(field).fill("E2E Star");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator(shell)).toBeVisible();
await expect(page.locator(field)).toHaveValue("E2E Star");
await expect(page.locator(toggle)).toHaveAttribute("aria-expanded", "true");
});
test("leaves the panel shut when nothing was being typed", async ({ page }) => {
await page.goto("/mail/inbox");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator(shell)).toBeHidden();
await expect(page.locator(toggle)).toHaveAttribute("aria-expanded", "false");
});
});
/**
* `font-size: 16px` would be a PIN: it replaces the utility rather than raising
* it, so below md nothing would answer to the appearance scale any more — and
* above about 1.14 the fields would come out SMALLER than they were before the
* rule started firing, and smaller than their own labels. The slider runs to
* 1.25, so that is five stops of it, standing on exactly the people the rule's
* accessibility argument is about.
*/
test.describe("the 16px is a floor and not a ceiling", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("fields still grow with the appearance scale", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator(toggle).click();
const size = async () =>
page.locator(field).evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
expect(await size()).toBe(16);
await page.evaluate(() =>
document.documentElement.style.setProperty("--app-font-scale", "1.25"),
);
// 0.875rem of a 20px root is 17.5px — bigger than the floor, so the
// floor gets out of the way rather than clamping it back down.
expect(await size()).toBeGreaterThan(16);
});
});
+2
View File
@@ -1656,11 +1656,13 @@ header:
heading: Labels
none: Noch keine Labels
search:
close: Suche schließen
live:
status: '%count% passende Unterhaltungen'
status_none: Keine passende Mail
status_one: 1 passende Unterhaltung
title: Mail
open: Mails durchsuchen
placeholder: E-Mails durchsuchen…
recents:
clear: Leeren
+2
View File
@@ -1650,11 +1650,13 @@ header:
heading: Labels
none: No labels yet
search:
close: Close search
live:
status: '%count% matching conversations'
status_none: No matching mail
status_one: 1 matching conversation
title: Mail
open: Search mail
placeholder: Search mail…
recents:
clear: Clear
+2
View File
@@ -1357,11 +1357,13 @@ header:
heading: Flags
none: No flags yet
search:
close: Batten the hunt
live:
status: "%count% matchin' parleys"
status_none: Nary a matchin' letter
status_one: 1 matchin' parley
title: Post
open: Search the hold
placeholder: Search the hold…
recents:
clear: Scuttle