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>
171 lines
5.1 KiB
Text
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>
|