What's new: one X on a full-size screenshot (#197)

The picture brought its own X into the corner of the dialog body, just
below the modal's X. It is gone; while a picture is up, the modal's X is
relabelled "Close the screenshot" and its first press puts the picture
back, the second closes What's new, as Escape already did.

Closes #187

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 authored and GitHub committed 2026-10-16 18:21:53 +02:00
1 parent 08886538a3
commit a6f8181c0d
4 files changed
+118 -15

No files matched your search

+7
View File
@@ -6,6 +6,13 @@ so anything that changes the schema irreversibly is called out explicitly.
The published image tags: `latest` follows the most recent release below,
`main` follows the tip of the default branch, and `sha-…` pins one commit.
## Unreleased
**One X on a What's new screenshot.** A screenshot opened full size brought its own X into the
corner of the dialog, just below the dialog's X. Now there is only the dialog's: pressed once it
puts the screenshot back, pressed again it closes What's new, the same as Escape.
[#187](https://github.com/karatektus/pl_mail/issues/187).
## v0.3.6 — 2026-10-16
**Several Gmail accounts import without failing on a duplicate key.** With more than one Gmail
@@ -17,6 +17,19 @@ import { openLayer, closeLayer } from "../../history_layer.js"
* the notes are read, and the modal's own header, and its X, stay where they
* are. (The first version was a <dialog> in the top layer, over everything.)
*
* ## One X, pressed twice
*
* The X is the modal's own. While a picture is up, its first press shrinks the
* picture and the second closes What's new, the way Escape already worked. The
* picture used to bring an X of its own into the corner of the dialog body, a
* little below the modal's, and two X's one above the other read as a mistake
* (#187). The modal's X is relabelled for as long as the picture is up, so a
* screen reader hears what the next press does.
*
* Taken off the modal in the capture phase on window, like the keys below:
* the X's own action is a listener on the button, which a capture listener
* further out runs before.
*
* ## Escape and Tab belong to the picture while it is up
*
* The shared modal listens for keydown on document — Escape closes it, Tab is
@@ -34,6 +47,9 @@ import { openLayer, closeLayer } from "../../history_layer.js"
*/
let current = null
/** The modal header's X; the footer's Done is `ui--modal#close` without the event. */
const MODAL_X = '[data-action~="click->ui--modal#close"]'
window.addEventListener("keydown", (event) => {
if (null === current) return
@@ -44,12 +60,25 @@ window.addEventListener("keydown", (event) => {
} else if ("Tab" === event.key) {
event.preventDefault()
event.stopImmediatePropagation()
current.closeTarget.focus()
current._modalX()?.focus()
}
}, true)
window.addEventListener("click", (event) => {
if (null === current) return
const x = current._modalX()
if (!x || !x.contains(event.target)) return
event.preventDefault()
event.stopImmediatePropagation()
current.close()
}, true)
export default class extends Controller {
static targets = ["overlay", "backdrop", "image", "close"]
static targets = ["overlay", "backdrop", "image"]
static values = { closeLabel: String }
connect() {
// The modal's own X, with the picture still up.
@@ -70,9 +99,11 @@ export default class extends Controller {
// Replaced while open: a release picked a moment before the thumbnail
// was clicked lands after it, and the frame is drawn again under the
// picture. Its history entry goes with it, or the next Back would be
// spent on nothing.
// spent on nothing. The modal and its X outlive the frame, so the X
// gets its own label back.
if (this === current) {
current = null
this._restoreModalX()
closeLayer()
}
}
@@ -111,7 +142,8 @@ export default class extends Controller {
// Opacity rather than visibility: a hidden thumbnail would leave the
// accessibility tree, and it is where the keyboard goes back to.
this._thumbnail.style.opacity = "0"
this.closeTarget.focus()
this._relabelModalX()
this._modalX()?.focus()
this._animate(false)
}
@@ -141,10 +173,11 @@ export default class extends Controller {
// The shrink holds its last frame (fill: forwards) so the picture does
// not flash full size before it is hidden; let go of it here, or the
// next open would start invisible.
for (const target of [this.imageTarget, this.backdropTarget, this.closeTarget]) {
for (const target of [this.imageTarget, this.backdropTarget]) {
target.getAnimations().forEach((animation) => animation.cancel())
}
this._restoreModalX()
this.overlayTarget.hidden = true
this.imageTarget.removeAttribute("src")
@@ -183,11 +216,39 @@ export default class extends Controller {
const grow = image.animate([{ transform: small }, { transform: "none" }], options)
this.backdropTarget.animate([{ opacity: 0 }, { opacity: 1 }], options)
this.closeTarget.animate([{ opacity: 0 }, { opacity: 1 }], options)
return grow.finished.catch(() => {})
}
/** The X in the header of the modal this dialog is drawn in. */
_modalX() {
return this.element.closest("#modal-backdrop")?.querySelector(MODAL_X) ?? null
}
/** "Close the screenshot" while its next press does that, not "Close". */
_relabelModalX() {
const x = this._modalX()
if (!x) return
this._modalXLabel = x.getAttribute("aria-label")
x.setAttribute("aria-label", this.closeLabelValue)
}
_restoreModalX() {
const x = this._modalX()
if (!x || undefined === this._modalXLabel) return
if (null === this._modalXLabel) {
x.removeAttribute("aria-label")
} else {
x.setAttribute("aria-label", this._modalXLabel)
}
this._modalXLabel = undefined
}
/** "180ms" or "0.18s" → 180; anything else, including "0s", → 0. */
_ms(value) {
const number = parseFloat(value)
+6 -8
View File
@@ -48,7 +48,8 @@ Accepts:
<template data-ui--modal-frame-title>{{ 'whats_new.title'|trans }}</template>
<div class="relative flex h-[min(640px,80vh)] min-h-0 flex-col border-t border-line" data-whats-new
data-controller="whats-new--zoom">
data-controller="whats-new--zoom"
data-whats-new--zoom-close-label-value="{{ 'whats_new.screenshot_close'|trans }}">
<div class="flex min-h-0 flex-1">
{# ── Releases ─────────────────────────────────────────────────── #}
@@ -238,19 +239,16 @@ Accepts:
{# ── A screenshot, full size ──────────────────────────────────────────
Over everything above, below the modal's own header; grown out of its
thumbnail by the controller, and the X, Escape or Back put it back.
thumbnail by the controller, and the modal's X, Escape or Back put it
back. No X of its own (#187), so not a modal dialog either: the one
control that closes it is the header's, outside it.
Layout in app.css under .whats-new-shot. #}
<div data-whats-new--zoom-target="overlay" data-whats-new-screenshot hidden
role="dialog" aria-modal="true" aria-label="{{ 'whats_new.screenshot'|trans }}"
role="group" aria-label="{{ 'whats_new.screenshot'|trans }}"
class="whats-new-shot">
<div data-whats-new--zoom-target="backdrop" class="whats-new-shot-backdrop"></div>
<img data-whats-new--zoom-target="image" alt=""
class="relative block max-h-full min-h-0 max-w-full min-w-0 rounded-lg border border-line object-contain">
<button type="button" data-whats-new--zoom-target="close" data-action="whats-new--zoom#close"
aria-label="{{ 'whats_new.screenshot_close'|trans }}"
class="absolute right-3 top-3 inline-flex size-11 items-center justify-center rounded-full bg-raised text-ink shadow-float transition-colors hover:bg-hover focus-visible:outline-2 focus-visible:outline-accent md:size-9">
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
</button>
</div>
</div>
</turbo-frame>
+38 -1
View File
@@ -92,7 +92,10 @@ test.describe("What's new", () => {
const url = page.url();
const thumbnail = dialog.getByRole("link", { name: "Open the screenshot full size" }).first();
const shot = dialog.locator("[data-whats-new-screenshot]");
const close = shot.getByRole("button", { name: "Close the screenshot" });
// The modal's own X, relabelled while the picture is up: the picture
// has no X of its own, so there is one X on screen, not two (#187).
const close = dialog.getByRole("button", { name: "Close the screenshot" });
const modalX = dialog.getByRole("button", { name: "Close", exact: true });
const open = async () => {
await thumbnail.click();
@@ -109,6 +112,8 @@ test.describe("What's new", () => {
await open();
expect(context.pages(), "no new tab").toHaveLength(pages);
await expect(shot.getByRole("button"), "the picture brings no X of its own").toHaveCount(0);
await expect(modalX, "the modal's X is the one that shrinks it").toHaveCount(0);
await expect(shot.locator("img")).toHaveAttribute("src", (await thumbnail.getAttribute("href")) ?? "");
// It fills the body of the dialog — within the body's 1px top rule —
@@ -124,6 +129,7 @@ test.describe("What's new", () => {
await close.click();
await putBack("the X");
await expect(modalX, "and the X is What's new's again").toBeVisible();
await open();
await page.keyboard.press("Escape");
@@ -138,6 +144,37 @@ test.describe("What's new", () => {
await expect(dialog).toBeHidden();
});
/**
* The X pressed twice: once to put the picture back, once more to close
* What's new (#187).
*/
test("the modal's X puts a screenshot back first, then closes What's new", async ({ page }) => {
await page.goto("/settings?section=general");
await page.locator("[data-whats-new-menu] > button").click();
await page.getByRole("menuitem", { name: "What's new" }).click();
const dialog = page.locator("#modal-backdrop");
await expect(dialog.locator("[data-whats-new-notes]")).toBeVisible();
await dialog.locator("[data-whats-new-notes]").evaluate((notes) => { notes.dataset.stale = ""; });
await dialog.locator("[data-whats-new-history] summary").click();
await dialog.locator('[data-whats-new-release="v0.3.5"]').first().click();
await expect(dialog.locator('[data-whats-new-notes="v0.3.5"]:not([data-stale])')).toBeVisible();
const shot = dialog.locator("[data-whats-new-screenshot]");
await dialog.getByRole("link", { name: "Open the screenshot full size" }).first().click();
await expect(shot).toBeVisible();
await dialog.getByRole("button", { name: "Close the screenshot" }).click();
await expect(shot).toBeHidden();
await expect(dialog, "the first press only puts the picture back").toBeVisible();
await dialog.getByRole("button", { name: "Close", exact: true }).click();
await expect(dialog).toBeHidden();
});
/**
* Over the calendar, whose day headings painted on top of the menu while
* it was an ordinary dropdown inside the header's stacking context. Asked