mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-30 00:22:10 -04:00
* 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 <pewdiepie-archdaemon@users.noreply.github.com>
This commit is contained in:
+30
-22
@@ -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;';
|
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');
|
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) {
|
if (actionIcon) {
|
||||||
btn.innerHTML = `<span style="display:inline-flex;align-items:center;gap:5px;">${actionIcon}<span></span></span>`;
|
btn.innerHTML = `<span style="display:inline-flex;align-items:center;gap:5px;">${actionIcon}<span></span></span>`;
|
||||||
btn.querySelector('span span').textContent = actionLabel;
|
btn.querySelector('span span').textContent = actionLabel;
|
||||||
} else {
|
} else {
|
||||||
btn.textContent = actionLabel;
|
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.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) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -362,8 +355,6 @@ export function showToast(msg, durationOrOpts) {
|
|||||||
});
|
});
|
||||||
stack.appendChild(btn);
|
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) {
|
if (actionHint && window.innerWidth > 768) {
|
||||||
const hint = document.createElement('span');
|
const hint = document.createElement('span');
|
||||||
hint.textContent = actionHint;
|
hint.textContent = actionHint;
|
||||||
@@ -372,32 +363,28 @@ export function showToast(msg, durationOrOpts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toastEl.appendChild(stack);
|
toastEl.appendChild(stack);
|
||||||
|
toastEl.style.pointerEvents = 'auto';
|
||||||
|
} else {
|
||||||
|
toastEl.style.pointerEvents = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Small × to dismiss the toast without taking the action. Useful when
|
// Close button for all toasts — dismiss without waiting for timeout.
|
||||||
// the user already acted (or just doesn't want the banner sitting there).
|
|
||||||
const closeBtn = document.createElement('button');
|
const closeBtn = document.createElement('button');
|
||||||
closeBtn.type = 'button';
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'toast-close-btn';
|
||||||
closeBtn.setAttribute('aria-label', 'Dismiss');
|
closeBtn.setAttribute('aria-label', 'Dismiss');
|
||||||
closeBtn.title = 'Dismiss';
|
closeBtn.title = 'Dismiss';
|
||||||
closeBtn.textContent = '×';
|
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) => {
|
closeBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
clearTimeout(toastEl._hideTimer);
|
clearTimeout(toastEl._hideTimer);
|
||||||
toastEl.classList.add('exiting');
|
toastEl.classList.add('exiting');
|
||||||
toastEl.classList.remove('show');
|
toastEl.classList.remove('show');
|
||||||
|
toastEl.style.pointerEvents = '';
|
||||||
});
|
});
|
||||||
toastEl.appendChild(closeBtn);
|
toastEl.appendChild(closeBtn);
|
||||||
|
|
||||||
toastEl.style.pointerEvents = 'auto';
|
|
||||||
} else {
|
|
||||||
// No action — restore the default non-blocking behavior.
|
|
||||||
toastEl.style.pointerEvents = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin to top-right via CSS — clear any legacy inline overrides so the
|
// 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.
|
// slide-in-from-right / slide-out-to-left transition can run cleanly.
|
||||||
toastEl.style.left = '';
|
toastEl.style.left = '';
|
||||||
@@ -428,17 +415,38 @@ export function showError(msg) {
|
|||||||
toastEl = document.getElementById('toast');
|
toastEl = document.getElementById('toast');
|
||||||
}
|
}
|
||||||
_wireToastSwipe(toastEl);
|
_wireToastSwipe(toastEl);
|
||||||
toastEl.textContent = msg;
|
toastEl.textContent = '';
|
||||||
toastEl.classList.add('error');
|
toastEl.classList.add('error');
|
||||||
toastEl.style.left = '';
|
toastEl.style.left = '';
|
||||||
toastEl.style.transform = '';
|
toastEl.style.transform = '';
|
||||||
toastEl.classList.remove('exiting');
|
toastEl.classList.remove('exiting');
|
||||||
toastEl.classList.add('show');
|
toastEl.classList.add('show');
|
||||||
clearTimeout(toastEl._hideTimer);
|
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._hideTimer = setTimeout(() => {
|
||||||
toastEl.classList.add('exiting');
|
toastEl.classList.add('exiting');
|
||||||
toastEl.classList.remove('show');
|
toastEl.classList.remove('show');
|
||||||
}, 3000);
|
}, 6000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4062,6 +4062,31 @@ body.bg-pattern-sparkles {
|
|||||||
@keyframes toastCheckDraw {
|
@keyframes toastCheckDraw {
|
||||||
to { stroke-dashoffset: 0; }
|
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 {
|
.toast.exiting {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-120%);
|
transform: translateX(-120%);
|
||||||
|
|||||||
@@ -54,3 +54,13 @@ def test_styled_dialogs_manage_focus():
|
|||||||
assert _UI.count("_prevFocus && _prevFocus.focus && _prevFocus.focus()") == 2
|
assert _UI.count("_prevFocus && _prevFocus.focus && _prevFocus.focus()") == 2
|
||||||
assert _UI.count("e.key === 'Tab'") == 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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user