jb-website/src/components/molecules/BlogSearch.astro
Josh Bairstow 75e7859146 chore(blog): guard keydown for IME composition and <select>
Code review forward-proofing:
- Early-return on event.isComposing so the / shortcut doesn't disrupt
  IME composition (multi-keystroke input methods).
- Treat <select> as editable so / doesn't steal focus while a dropdown
  is open and the user is typing to seek.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 01:35:53 +10:00

171 lines
5.1 KiB
Text

---
// Light client-side filter for the blog archive.
// Reads [data-search] attributes on rows + the featured article, hides
// non-matches, collapses empty year heads, toggles a no-results message.
// Filter script is added in Task 5; this file ships markup + styles only.
---
<div class="bsearch">
<input
type="search"
class="binput"
aria-label="Search posts"
placeholder="Search the journal…"
autocomplete="off"
spellcheck="false"
/>
<span class="bhint" aria-hidden="true">Esc to clear</span>
</div>
<style>
/* No border-bottom: the adjacent .feature article carries a border-top
that already separates this row from the page below. */
.bsearch {
display: flex;
align-items: baseline;
gap: 14px;
padding: clamp(14px, 2.4vh, 22px) 0;
border-top: 1px solid var(--hairline-on-dark);
}
:global(body:not(.dark)) .bsearch {
border-top-color: var(--hairline);
}
.binput {
flex: 1;
max-width: 28ch;
background: transparent;
border: 0;
border-bottom: 1px solid transparent;
padding: 6px 0;
font-family: var(--font-mono);
font-size: 13px;
color: var(--fg-on-dark-1);
letter-spacing: 0.02em;
outline: none;
transition: border-color var(--dur-base) var(--ease-out);
}
:global(body:not(.dark)) .binput {
color: var(--fg-1);
}
.binput::placeholder {
color: var(--fg-on-dark-3);
}
:global(body:not(.dark)) .binput::placeholder {
color: var(--fg-3);
}
.binput:focus {
border-bottom-color: var(--accent);
}
.bhint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-on-dark-3);
letter-spacing: 0.06em;
opacity: 0;
transition: opacity var(--dur-base) var(--ease-out);
}
:global(body:not(.dark)) .bhint {
color: var(--fg-3);
}
.bsearch:focus-within .bhint {
opacity: 1;
}
@media (max-width: 800px) {
.binput {
max-width: none;
}
.bhint {
display: none;
}
}
</style>
<script>
// Lives on /blog. No-ops gracefully on any other page (selectors return empty).
const input = document.querySelector<HTMLInputElement>(".binput");
if (input) {
const rows = Array.from(
document.querySelectorAll<HTMLElement>("[data-search]")
);
const featured = document.querySelector<HTMLElement>("[data-featured]");
const yearHeads = Array.from(
document.querySelectorAll<HTMLElement>("[data-year-head]")
);
const empty = document.querySelector<HTMLElement>("[data-empty]");
const emptyQuery = empty?.querySelector<HTMLElement>("[data-empty-query]");
function applyFilter() {
const raw = input!.value.toLowerCase().trim();
const tokens = raw.length ? raw.split(/\s+/) : [];
const isFiltering = tokens.length > 0;
let visibleCount = 0;
for (const row of rows) {
const haystack = row.dataset.search ?? "";
const isFeatured = row === featured;
let visible: boolean;
if (!isFiltering) {
visible = true;
} else if (isFeatured) {
// Featured hides whenever there's a query, regardless of match.
visible = false;
} else {
visible = tokens.every((t) => haystack.includes(t));
}
row.classList.toggle("is-hidden", !visible);
if (visible && !isFeatured) visibleCount += 1;
}
// Hide year heads whose following rows are all hidden.
for (const head of yearHeads) {
let anyVisible = false;
let node: Element | null = head.nextElementSibling;
while (node && !node.matches("[data-year-head]")) {
if (node.matches("[data-search]") && !node.classList.contains("is-hidden")) {
anyVisible = true;
break;
}
node = node.nextElementSibling;
}
head.classList.toggle("is-hidden", !anyVisible);
}
// Toggle empty-state message.
if (empty) {
const showEmpty = isFiltering && visibleCount === 0;
empty.hidden = !showEmpty;
if (showEmpty && emptyQuery) emptyQuery.textContent = input!.value.trim();
}
}
input.addEventListener("input", applyFilter);
// `/` from anywhere on the page focuses the search input — unless the user
// is already typing somewhere editable (text input, textarea, or any
// contenteditable element). `Esc` while the input is focused clears the
// query, blurs, and restores the full archive.
document.addEventListener("keydown", (event) => {
if (event.isComposing) return;
if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey) {
const target = event.target as HTMLElement | null;
const tag = target?.tagName;
const editable =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target?.isContentEditable === true;
if (!editable) {
event.preventDefault();
input!.focus();
}
} else if (event.key === "Escape" && document.activeElement === input) {
if (input!.value !== "") {
input!.value = "";
applyFilter();
}
input!.blur();
}
});
}
</script>