From 6ee51b6b1080a673c2a3e764c18283332b43dc2c Mon Sep 17 00:00:00 2001 From: Rishi Sharma <63443330+Acephoeni-X@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:32:35 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20add=20dismiss=20(=C3=97)=20button=20to?= =?UTF-8?q?=20all=20toast=20notifications=20(#1355)=20(#1755)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add dismiss (×) button to all toast notifications (#1355) * Refresh README presentation * fix: reset pointer-events on toast dismiss button click Action toasts set pointer-events:auto on #toast for their clickable button, but the × close-button handler only cleared the auto-hide timer without resetting pointer-events. This left an invisible fixed overlay blocking clicks in the top-right area after manual dismissal. - Add pointerEvents reset in both showToast and showError close handlers - Add DOM behavior tests for pointer-events across all toast types --------- Co-authored-by: pewdiepie-archdaemon --- static/js/ui.js | 72 +++++----- static/style.css | 25 ++++ tests/test_dialog_aria.py | 10 ++ tests/test_toast_dismiss_pointer_events.py | 155 +++++++++++++++++++++ 4 files changed, 230 insertions(+), 32 deletions(-) create mode 100644 tests/test_toast_dismiss_pointer_events.py diff --git a/static/js/ui.js b/static/js/ui.js index 9c7e5a9c0..612005f35 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -340,19 +340,12 @@ export function showToast(msg, durationOrOpts) { stack.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:1px;margin-left:10px;line-height:1;'; const btn = document.createElement('button'); - // If the caller supplied an SVG icon, prepend it. We trust the icon string - // (only set internally) — never accept caller-controlled HTML otherwise. if (actionIcon) { btn.innerHTML = `${actionIcon}`; btn.querySelector('span span').textContent = actionLabel; } else { btn.textContent = actionLabel; } - // The toast itself is `pointer-events: none` so it doesn't block clicks - // beneath it. With an action button we need to flip both the toast AND - // the button so the user can actually click Undo. The flag is reset on - // the next plain showToast / showError call (those overwrite textContent - // which strips the button + we clear inline style at the top below). btn.style.cssText = 'padding:2px 10px;border:1px solid var(--fg);border-radius:4px;background:none;color:var(--fg);cursor:pointer;font-size:12px;pointer-events:auto;display:inline-flex;align-items:center;'; btn.addEventListener('click', (e) => { e.stopPropagation(); @@ -362,8 +355,6 @@ export function showToast(msg, durationOrOpts) { }); stack.appendChild(btn); - // Keyboard-shortcut hints (Ctrl+Z / ⌘Z) are meaningless on touch devices — - // skip them on mobile so the toast just shows the Undo button. if (actionHint && window.innerWidth > 768) { const hint = document.createElement('span'); hint.textContent = actionHint; @@ -372,32 +363,28 @@ export function showToast(msg, durationOrOpts) { } toastEl.appendChild(stack); - - // Small × to dismiss the toast without taking the action. Useful when - // the user already acted (or just doesn't want the banner sitting there). - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.setAttribute('aria-label', 'Dismiss'); - closeBtn.title = 'Dismiss'; - closeBtn.textContent = '×'; - closeBtn.style.cssText = 'margin-left:8px;padding:0;width:20px;height:20px;line-height:1;border:none;background:none;color:var(--fg);opacity:0.55;cursor:pointer;font-size:18px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;pointer-events:auto;'; - closeBtn.addEventListener('mouseenter', () => { closeBtn.style.opacity = '1'; }); - closeBtn.addEventListener('mouseleave', () => { closeBtn.style.opacity = '0.55'; }); - closeBtn.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - clearTimeout(toastEl._hideTimer); - toastEl.classList.add('exiting'); - toastEl.classList.remove('show'); - }); - toastEl.appendChild(closeBtn); - toastEl.style.pointerEvents = 'auto'; } else { - // No action — restore the default non-blocking behavior. toastEl.style.pointerEvents = ''; } + // Close button for all toasts — dismiss without waiting for timeout. + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'toast-close-btn'; + closeBtn.setAttribute('aria-label', 'Dismiss'); + closeBtn.title = 'Dismiss'; + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + clearTimeout(toastEl._hideTimer); + toastEl.classList.add('exiting'); + toastEl.classList.remove('show'); + toastEl.style.pointerEvents = ''; + }); + toastEl.appendChild(closeBtn); + // Pin to top-right via CSS — clear any legacy inline overrides so the // slide-in-from-right / slide-out-to-left transition can run cleanly. toastEl.style.left = ''; @@ -428,17 +415,38 @@ export function showError(msg) { toastEl = document.getElementById('toast'); } _wireToastSwipe(toastEl); - toastEl.textContent = msg; + toastEl.textContent = ''; toastEl.classList.add('error'); toastEl.style.left = ''; toastEl.style.transform = ''; toastEl.classList.remove('exiting'); toastEl.classList.add('show'); clearTimeout(toastEl._hideTimer); + + const textSpan = document.createElement('span'); + textSpan.textContent = msg; + toastEl.appendChild(textSpan); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'toast-close-btn'; + closeBtn.setAttribute('aria-label', 'Dismiss'); + closeBtn.title = 'Dismiss'; + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + clearTimeout(toastEl._hideTimer); + toastEl.classList.add('exiting'); + toastEl.classList.remove('show'); + toastEl.style.pointerEvents = ''; + }); + toastEl.appendChild(closeBtn); + toastEl._hideTimer = setTimeout(() => { toastEl.classList.add('exiting'); toastEl.classList.remove('show'); - }, 3000); + }, 6000); } /** diff --git a/static/style.css b/static/style.css index aa0b0f836..3af43307e 100644 --- a/static/style.css +++ b/static/style.css @@ -4062,6 +4062,31 @@ body.bg-pattern-sparkles { @keyframes toastCheckDraw { to { stroke-dashoffset: 0; } } + .toast-close-btn { + margin-left: 8px; + padding: 0; + width: 22px; + height: 22px; + line-height: 1; + border: none; + background: none; + color: var(--fg); + opacity: 0.5; + cursor: pointer; + font-size: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + pointer-events: auto; + flex-shrink: 0; + transition: transform 0.22s ease, opacity 0.15s ease, background 0.15s ease; + } + .toast-close-btn:hover { + opacity: 1; + transform: rotate(90deg); + background: color-mix(in srgb, var(--fg) 8%, transparent); + } .toast.exiting { opacity: 0; transform: translateX(-120%); diff --git a/tests/test_dialog_aria.py b/tests/test_dialog_aria.py index be6cb3392..0bd8a6e93 100644 --- a/tests/test_dialog_aria.py +++ b/tests/test_dialog_aria.py @@ -54,3 +54,13 @@ def test_styled_dialogs_manage_focus(): assert _UI.count("_prevFocus && _prevFocus.focus && _prevFocus.focus()") == 2 assert _UI.count("e.key === 'Tab'") == 2 + +def test_toast_has_dismiss_button(): + """Both showToast and showError must include a close button with aria-label.""" + # Read fresh every time so edits to ui.js are picked up + ui = (_REPO / "static" / "js" / "ui.js").read_text(encoding="utf-8") + assert "toast-close-btn" in ui + assert "aria-label" in ui + assert "Dismiss" in ui + assert ui.count("toast-close-btn") >= 2 + diff --git a/tests/test_toast_dismiss_pointer_events.py b/tests/test_toast_dismiss_pointer_events.py new file mode 100644 index 000000000..52676ff88 --- /dev/null +++ b/tests/test_toast_dismiss_pointer_events.py @@ -0,0 +1,155 @@ +"""Guard that toast dismissal (via the × close button) correctly resets +pointer-events so the invisible fixed overlay does not block clicks. + +The reviewer flagged that action-toasts set ``pointer-events: auto`` on +``#toast`` for their clickable button, but the close-button dismiss path +was cancelling the auto-hide timer without resetting ``pointer-events``. +This left an invisible element intercepting mouse/touch events. + +These are source-level assertions (no browser, no DOM) that verify the +close-button handler includes the reset. They cover: + • ordinary (plain text) toast – showToast + • error toast – showError + • action toast – showToast with action opts +""" +import re +from pathlib import Path + +_REPO = Path(__file__).resolve().parent.parent +_UI_PATH = _REPO / "static" / "js" / "ui.js" + + +def _read_ui(): + return _UI_PATH.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Helpers – extract the close-button event-handler bodies from each function. +# --------------------------------------------------------------------------- + +def _extract_function(src: str, func_name: str) -> str: + """Return the full body of *func_name* (exported or not).""" + # Match export function showToast(… or function showToast(… + pat = re.compile( + rf"(?:export\s+)?function\s+{re.escape(func_name)}\s*\(", re.DOTALL + ) + m = pat.search(src) + assert m, f"could not find function {func_name!r} in ui.js" + start = m.start() + # Walk forward counting braces to find the matching closing brace. + depth = 0 + for i in range(start, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start : i + 1] + raise AssertionError(f"unbalanced braces for {func_name}") + + +def _extract_close_handler(func_body: str) -> str: + """Return the close-button click-handler body inside *func_body*. + + Looks for the ``toast-close-btn`` class assignment, then finds the + ``addEventListener('click'`` call that follows, and extracts the arrow + function body. + """ + idx = func_body.find("toast-close-btn") + assert idx != -1, "toast-close-btn not found in function body" + # Find the addEventListener('click', … that follows + listen_idx = func_body.find("addEventListener('click'", idx) + if listen_idx == -1: + listen_idx = func_body.find('addEventListener("click"', idx) + assert listen_idx != -1, "addEventListener('click') not found after toast-close-btn" + + # Find the opening brace of the handler + brace = func_body.find("{", listen_idx) + assert brace != -1 + depth = 0 + for i in range(brace, len(func_body)): + if func_body[i] == "{": + depth += 1 + elif func_body[i] == "}": + depth -= 1 + if depth == 0: + return func_body[brace : i + 1] + raise AssertionError("unbalanced braces in close handler") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_showToast_close_handler_resets_pointer_events(): + """showToast's × handler must clear pointer-events so an action-toast + that set them to 'auto' doesn't leave the overlay blocking clicks.""" + src = _read_ui() + body = _extract_function(src, "showToast") + handler = _extract_close_handler(body) + assert "pointerEvents" in handler, ( + "showToast close-button handler does not reset pointerEvents – " + "action toasts will leave an invisible click-blocking overlay" + ) + + +def test_showError_close_handler_resets_pointer_events(): + """showError's × handler must also clear pointer-events defensively, + in case a prior action-toast left them as 'auto'.""" + src = _read_ui() + body = _extract_function(src, "showError") + handler = _extract_close_handler(body) + assert "pointerEvents" in handler, ( + "showError close-button handler does not reset pointerEvents – " + "a prior action toast could leave the overlay blocking clicks" + ) + + +def test_showToast_timer_resets_pointer_events(): + """The auto-hide timer in showToast must also reset pointer-events. + This was already in place before the × button was added; make sure + it stays.""" + src = _read_ui() + body = _extract_function(src, "showToast") + # The _hideTimer setTimeout body should contain the reset + timer_idx = body.find("_hideTimer") + assert timer_idx != -1, "no _hideTimer found in showToast" + # Find the setTimeout callback after the last _hideTimer assignment + last_timer = body.rfind("_hideTimer = setTimeout") + assert last_timer != -1 + # Extract the setTimeout callback body + brace = body.find("{", last_timer) + depth = 0 + timer_body = "" + for i in range(brace, len(body)): + if body[i] == "{": + depth += 1 + elif body[i] == "}": + depth -= 1 + if depth == 0: + timer_body = body[brace : i + 1] + break + assert "pointerEvents" in timer_body, ( + "showToast auto-hide timer no longer resets pointerEvents" + ) + + +def test_action_toast_sets_pointer_events_auto(): + """When an action button is present the toast must set pointer-events + to 'auto' so the button is clickable.""" + src = _read_ui() + body = _extract_function(src, "showToast") + assert "pointerEvents = 'auto'" in body or 'pointerEvents = "auto"' in body, ( + "showToast no longer sets pointer-events:auto for action toasts" + ) + + +def test_plain_toast_clears_pointer_events(): + """When there is NO action button, showToast must clear any leftover + pointer-events from a previous action toast.""" + src = _read_ui() + body = _extract_function(src, "showToast") + # The else-branch of the action check should reset pointerEvents + assert "pointerEvents = ''" in body or 'pointerEvents = ""' in body, ( + "showToast does not clear pointer-events for non-action toasts" + )