mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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:
1 parent
08886538a3
commit
a6f8181c0d
4 files changed
+118
-15
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user