fix: light dismiss for header menu/search overlays (outside tap closes, never activates)

This commit is contained in:
ctao
2026-07-25 12:33:09 +02:00
parent 4fa35e5317
commit ffe54bc766
3 changed files with 68 additions and 16 deletions
+14
View File
@@ -104,6 +104,20 @@
const a = e.target.closest && e.target.closest('a');
remember((a && a.dataset.q) || input.value);
});
// Dropdown dismiss (opt-in — /search keeps its results list). A suggestion
// list is a NON-MODAL combobox popup (ARIA combobox pattern): interacting
// outside the field+list closes it but the interaction is NOT swallowed —
// unlike the modal header panels, which scrim-dismiss in Base.astro.
if (opts.dismiss) {
const box = input.closest('form') || input.parentElement;
const hide = () => { clearTimeout(timer); list.innerHTML = ''; status.textContent = ''; };
document.addEventListener('pointerdown', (e) => { if (list.firstChild && !box.contains(e.target)) hide(); });
// relatedTarget may be null mid-click on our own links — pointerdown covers that path
box.addEventListener('focusout', (e) => { if (e.relatedTarget && !box.contains(e.relatedTarget)) hide(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && list.firstChild) { hide(); e.stopPropagation(); } // panel Escape stays a second step
});
}
return run; // callers may run() immediately (e.g. /search?q=…)
};
})();
+28 -4
View File
@@ -110,11 +110,35 @@ const links = [
<script is:inline>
// Header search suggestions (desktop field + mobile reveal); the forms
// are plain GET /search, so everything below is progressive enhancement.
newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion' });
newsSearch({ input: 'pop-q', list: 'pop-suggest', status: 'pop-suggest-status', limit: 6, unit: 'suggestion' });
newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
newsSearch({ input: 'pop-q', list: 'pop-suggest', status: 'pop-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
var pop = document.querySelector('.search-pop');
pop.addEventListener('toggle', function () {
if (pop.open) document.getElementById('pop-q').focus();
if (pop) pop.addEventListener('toggle', function () {
var q = document.getElementById('pop-q');
if (pop.open && q) q.focus();
});
// Light dismiss for the header panels (menu + search reveal) — scrim
// convention (Material/iOS, NN/g error prevention): a tap outside an
// open panel ONLY closes it and must never activate what's underneath.
// <details> has no native light dismiss; the Popover API was evaluated
// and rejected here — its light dismiss deliberately lets the outside
// click THROUGH (::backdrop is spec-forced pointer-events: none), the
// exact behavior research ruled out. Canceling pointerdown doesn't
// cancel the later click (Pointer Events spec), hence the click guard.
var swallow = false;
document.addEventListener('pointerdown', function (e) {
var open = document.querySelector('details[name="header-panel"][open]');
swallow = !!(open && !open.contains(e.target));
if (swallow) { open.open = false; e.preventDefault(); }
}, true);
document.addEventListener('click', function (e) {
if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); }
}, true);
// Escape closes the open panel and returns focus to its trigger button
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
var open = document.querySelector('details[name="header-panel"][open]');
if (open) { open.open = false; open.querySelector('summary').focus(); }
});
</script>
<main id="main">
+26 -12
View File
@@ -22,7 +22,8 @@
--on-galaxy-muted: #c7cde9;
--tint-cherenkov: color-mix(in srgb, var(--cherenkov) 16%, transparent); /* <mark> highlight only */
--cherenkov-hover: color-mix(in oklab, var(--cherenkov), var(--galaxy) 12%); /* CTA hover state */
--ico-ext: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M7 17 17 7M9 7h8v8" fill="none" stroke="black" stroke-width="2.5"/%3E%3C/svg%3E');
/* stroke 3: at ~0.72em rendering this matches the .ico family's ~1.5px optical weight */
--ico-ext: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M7 17 17 7M9 7h8v8" fill="none" stroke="black" stroke-width="3"/%3E%3C/svg%3E');
/* Type — fonts (BRAND D.3.1/D.3.2) and the ONLY font sizes in this codebase */
--font-body: "Inter", "Inter Fallback", Arial, system-ui, sans-serif;
--font-display: "Space Grotesk", var(--font-body); /* headlines only */
@@ -213,7 +214,8 @@ img { max-width: 100%; height: auto; display: block; }
spec-required search). Chrome stays whisper-quiet: hairline on navy.
Flexes 140-200px (only it and the spacer flex — one-row invariant). */
.nav-search { position: relative; display: flex; align-items: center; flex: 0 1 200px; min-width: 140px; margin-left: 6px; }
.nav-search .search-ico { left: 11px; width: 14px; height: 14px; color: rgba(255, 255, 255, 0.7); }
/* stroke 2.6 ≈ the family's 1.5px rendered weight at 14px (icons must not thin out when small) */
.nav-search .search-ico { left: 11px; width: 14px; height: 14px; color: rgba(255, 255, 255, 0.7); stroke-width: 2.6; }
.nav-search input {
width: 100%; min-width: 0; font: inherit; font-size: var(--fs-s); color: #fff;
background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.45); /* 3.55:1 vs Galaxy — WCAG 1.4.11 */
@@ -345,6 +347,7 @@ a.btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: no
}
.suggest:empty { display: none; }
.suggest li { display: flex; align-items: flex-start; gap: var(--space-s); padding: 0 12px; border-radius: calc(var(--radius) - 8px); }
.suggest li + li { margin-top: 2px; } /* rows breathe — hover fills read as discrete items */
.suggest li:hover, .suggest li:focus-within { background: var(--moon); }
/* Focused-empty state labels ("Recent" / "Latest news") — quiet, non-interactive */
li.suggest-label {
@@ -352,16 +355,20 @@ li.suggest-label {
text-transform: uppercase; letter-spacing: 0.08em; padding-top: 8px;
}
.suggest li.suggest-label:hover { background: none; }
/* Each later group opens with a hairline — label + its rows read as one unit */
.suggest li.suggest-label:not(:first-child) {
margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); border-radius: 0;
}
.search-results li.suggest-label { background: none; border: 0; box-shadow: none; padding: 8px 0 0; }
/* The link holds <mark>-split text nodes — it must lay out as flowing text,
never as a flex row (flex would turn every fragment into its own item and
stop titles from wrapping). ≥44px target comes from the block padding. */
.suggest a {
flex: 1; min-width: 0; display: block; padding-block: 10px;
flex: 1; min-width: 0; display: block; padding-block: 12px; /* row ≥44px — real tap target */
color: var(--text); font-weight: 500; font-size: var(--fs-s); text-decoration: none;
overflow-wrap: break-word;
}
.suggest small { margin-top: 12px; } /* date tracks the first title line */
.suggest small { margin-top: 14px; } /* date tracks the first title line */
.suggest::after {
content: "Press Enter for all results"; display: block; margin-top: 4px;
padding: 8px 12px 2px; border-top: 1px solid var(--border);
@@ -494,7 +501,7 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
/* Inline rendering (< 1200px): light disclosure row with a chevron */
.toc--inline { border: 1px solid var(--border); border-radius: var(--radius); padding: 4px var(--space-m); margin-bottom: var(--space-l); }
.toc--inline summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-s); min-height: 40px; }
.toc--inline summary .ico { width: 14px; height: 14px; }
.toc--inline summary .ico { width: 14px; height: 14px; stroke-width: 2.6; } /* optical weight match at 14px */
.toc--inline[open] summary .ico { transform: rotate(180deg); }
.toc--inline ol { padding-bottom: var(--space-s); }
.toc--rail { display: none; }
@@ -576,17 +583,24 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
.notice { color: var(--muted); font-size: var(--fs-s); margin-top: 10px; }
/* Search results (list built by the inline script on /search) — card-like rows;
same single-signal chrome as cards (borderless on Moon, shadow hover) */
.search-results { list-style: none; margin: var(--space-m) 0 0; padding: 0; max-width: 720px; display: grid; gap: var(--space-s); }
same single-signal chrome as cards (borderless on Moon, shadow hover).
Margins, not grid gap: idle-state group labels must hug their rows. */
.search-results { list-style: none; margin: var(--space-m) 0 0; padding: 0; max-width: 720px; }
.search-results li + li { margin-top: var(--space-m); } /* each article reads as its own card */
.search-results li.suggest-label + li { margin-top: var(--space-s); } /* rows hug their group label */
.search-results li {
display: flex; align-items: flex-start; gap: var(--space-s);
background: #fff; border-radius: var(--radius);
padding: 12px 18px; box-shadow: var(--shadow-ambient);
padding: var(--space-m); box-shadow: var(--shadow-ambient);
}
.search-results li:hover { box-shadow: var(--shadow-lifted); }
/* Title flows as inline text (never display:flex — see .suggest a note) */
.search-results a { flex: 1; min-width: 0; overflow-wrap: break-word; text-decoration: none; font-weight: 500; }
.search-results a:hover { text-decoration: underline; }
/* Title flows as inline text (never display:flex — see .suggest a note);
card-title treatment (fs-l Galaxy) so titles outweigh the muted meta */
.search-results a {
flex: 1; min-width: 0; overflow-wrap: break-word; text-decoration: none;
font-weight: 600; font-size: var(--fs-l); line-height: 1.3; color: var(--galaxy);
}
.search-results a:hover { color: var(--link); text-decoration: underline; }
/* Dates on results and suggestions — plain muted text, no chip chrome */
.search-results small, .suggest small { color: var(--muted); white-space: nowrap; font-size: var(--fs-xs); }
.search-results small { margin-top: 5px; } /* optical align with the first title line */
@@ -709,7 +723,7 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.card, .search-results li { transition: box-shadow var(--dur) var(--ease), transform var(--dur) var(--ease); }
.card:hover { transform: translateY(-2px); } /* the one elevate gesture (no cover zoom) */
.card:hover, .search-results li:hover { transform: translateY(-2px); } /* the one elevate gesture (no cover zoom) */
}
@keyframes nebula { /* inert unless applied inside the block above */
from { transform: translate3d(-8%, -5%, 0) rotate(-6deg) scale(1); }