Patterns Medium
Search Filter
Lightweight search plus sidebar filter pattern with URL query synchronization and live result updates.
Open in Lab
MCP
vanilla-js css react vue svelte
Targets: TS JS HTML React Vue Svelte
Code
* {
box-sizing: border-box;
}
:root {
--bg: #0b1020;
--panel: #121933;
--muted: #9fb0cc;
--text: #dbe8ff;
--border: rgba(255, 255, 255, 0.14);
--accent: #60a5fa;
}
body {
margin: 0;
font-family: "Sora", system-ui, sans-serif;
background: linear-gradient(145deg, #0e1730 0%, #090e1c 60%);
color: var(--text);
}
.layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: 100vh;
}
.filters {
border-right: 1px solid var(--border);
padding: 1.25rem;
background: rgba(255, 255, 255, 0.02);
}
.filters h1 {
margin: 0 0 1rem;
font-size: 1.2rem;
}
.field {
display: grid;
gap: 0.4rem;
margin-bottom: 1rem;
color: var(--muted);
font-size: 0.85rem;
}
input[type="search"] {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
color: var(--text);
padding: 0.6rem;
}
fieldset {
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.8rem;
display: grid;
gap: 0.5rem;
}
legend {
color: var(--muted);
font-size: 0.8rem;
padding: 0 0.35rem;
}
.hint {
color: var(--muted);
font-size: 0.75rem;
margin-top: 1rem;
line-height: 1.45;
}
.results {
padding: 1.25rem;
}
.results header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.8rem;
}
.results h2 {
margin: 0;
}
#count {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
}
.list {
display: grid;
gap: 0.75rem;
}
.card {
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem;
background: var(--panel);
}
.card h3 {
margin: 0;
font-size: 1rem;
}
.meta {
margin-top: 0.35rem;
color: var(--muted);
font-size: 0.85rem;
display: flex;
gap: 0.5rem;
}
.badge {
border: 1px solid var(--accent);
color: #bfdbfe;
border-radius: 999px;
padding: 0.15rem 0.5rem;
text-transform: capitalize;
}
@media (max-width: 820px) {
.layout {
grid-template-columns: 1fr;
}
.filters {
border-right: 0;
border-bottom: 1px solid var(--border);
}
}(() => {
const DATA = [
{ id: 1, title: "Payment retries", status: "active", type: "workflow" },
{ id: 2, title: "Onboarding copy", status: "pending", type: "content" },
{ id: 3, title: "API docs refresh", status: "active", type: "docs" },
{ id: 4, title: "Quarterly report", status: "archived", type: "analytics" },
{ id: 5, title: "Token rotation", status: "pending", type: "security" },
{ id: 6, title: "Usage dashboard", status: "active", type: "analytics" },
{ id: 7, title: "Support macros", status: "archived", type: "ops" },
];
const searchInput = document.getElementById("search");
const statusInputs = Array.from(document.querySelectorAll("input[name='status']"));
const list = document.getElementById("list");
const count = document.getElementById("count");
const state = {
q: "",
status: new Set(),
};
const canSyncHistory = !window.location.href.startsWith("about:srcdoc");
const applyStateFromUrl = () => {
const params = new URLSearchParams(window.location.search);
state.q = params.get("q") ?? "";
state.status = new Set((params.get("status") ?? "").split(",").filter(Boolean));
searchInput.value = state.q;
for (const input of statusInputs) {
input.checked = state.status.has(input.value);
}
};
const syncUrl = () => {
if (!canSyncHistory) return;
const params = new URLSearchParams();
const query = state.q.trim();
if (query) params.set("q", query);
if (state.status.size > 0) {
params.set("status", Array.from(state.status).join(","));
}
const next = params.toString();
const url = next ? `${window.location.pathname}?${next}` : window.location.pathname;
try {
history.replaceState({}, "", url);
} catch {
// ignore History API restrictions inside sandboxed srcdoc iframes
}
};
const render = () => {
const query = state.q.trim().toLowerCase();
const filtered = DATA.filter((item) => {
const queryMatch =
query.length === 0 || `${item.title} ${item.type}`.toLowerCase().includes(query);
const statusMatch = state.status.size === 0 || state.status.has(item.status);
return queryMatch && statusMatch;
});
list.innerHTML = "";
for (const item of filtered) {
const card = document.createElement("article");
card.className = "card";
card.innerHTML = `
<h3>${item.title}</h3>
<p class="meta">
<span>${item.type}</span>
<span class="badge">${item.status}</span>
</p>
`;
list.appendChild(card);
}
count.textContent = `${filtered.length} result${filtered.length === 1 ? "" : "s"}`;
};
searchInput.addEventListener("input", () => {
state.q = searchInput.value;
syncUrl();
render();
});
for (const input of statusInputs) {
input.addEventListener("change", () => {
if (input.checked) {
state.status.add(input.value);
} else {
state.status.delete(input.value);
}
syncUrl();
render();
});
}
applyStateFromUrl();
render();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Search Filter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="layout">
<aside class="filters" aria-label="Filters">
<h1>Search + Filter</h1>
<label class="field">
Search
<input id="search" type="search" placeholder="Find resources" />
</label>
<fieldset>
<legend>Status</legend>
<label><input type="checkbox" name="status" value="active" /> Active</label>
<label><input type="checkbox" name="status" value="pending" /> Pending</label>
<label><input type="checkbox" name="status" value="archived" /> Archived</label>
</fieldset>
<p class="hint">Query params sync in standalone mode: <code>?q=&status=active,pending</code></p>
</aside>
<section class="results" aria-live="polite">
<header>
<h2>Results</h2>
<p id="count"></p>
</header>
<div id="list" class="list"></div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useMemo, useState } from "react";
type Item = {
id: number;
title: string;
status: "active" | "pending" | "archived";
type: string;
};
const STATUS_OPTIONS: Item["status"][] = ["active", "pending", "archived"];
const DATA: Item[] = [
{ id: 1, title: "Payment retries", status: "active", type: "workflow" },
{ id: 2, title: "Onboarding copy", status: "pending", type: "content" },
{ id: 3, title: "API docs refresh", status: "active", type: "docs" },
{ id: 4, title: "Quarterly report", status: "archived", type: "analytics" },
{ id: 5, title: "Token rotation", status: "pending", type: "security" },
{ id: 6, title: "Usage dashboard", status: "active", type: "analytics" },
{ id: 7, title: "Support macros", status: "archived", type: "ops" },
];
export default function SearchFilterPattern() {
const [q, setQ] = useState("");
const [statuses, setStatuses] = useState<Item["status"][]>([]);
const canSyncHistory =
typeof window !== "undefined" && !window.location.href.startsWith("about:srcdoc");
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const initialQuery = params.get("q") ?? "";
const parsedStatuses = (params.get("status") ?? "")
.split(",")
.map((value) => value.trim())
.filter((value): value is Item["status"] => STATUS_OPTIONS.includes(value as Item["status"]));
setQ(initialQuery);
setStatuses(parsedStatuses);
}, []);
useEffect(() => {
if (!canSyncHistory) return;
const params = new URLSearchParams();
const trimmed = q.trim();
if (trimmed) params.set("q", trimmed);
if (statuses.length > 0) params.set("status", statuses.join(","));
const next = params.toString();
const nextUrl = next ? `${window.location.pathname}?${next}` : window.location.pathname;
try {
window.history.replaceState({}, "", nextUrl);
} catch {
// ignore History API restrictions inside sandboxed srcdoc iframes
}
}, [q, statuses, canSyncHistory]);
const filtered = useMemo(() => {
const query = q.trim().toLowerCase();
return DATA.filter((item) => {
const queryMatch =
query.length === 0 || `${item.title} ${item.type}`.toLowerCase().includes(query);
const statusMatch = statuses.length === 0 || statuses.includes(item.status);
return queryMatch && statusMatch;
});
}, [q, statuses]);
const toggleStatus = (status: Item["status"]) => {
setStatuses((prev) =>
prev.includes(status) ? prev.filter((value) => value !== status) : [...prev, status]
);
};
const clearFilters = () => {
setQ("");
setStatuses([]);
};
return (
<section className="min-h-screen bg-[#0d1117] px-4 py-6 text-[#e6edf3]">
<div className="mx-auto grid max-w-6xl gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="space-y-4 rounded-2xl border border-[#30363d] bg-[#161b22] p-4">
<div>
<p className="text-xs font-bold uppercase tracking-wide text-[#8b949e]">Pattern</p>
<h1 className="mt-1 text-lg font-bold">Search + Filter</h1>
<p className="mt-1 text-sm text-[#8b949e]">
Filter items by text query and status facets.
</p>
</div>
<label className="grid gap-1.5 text-xs font-semibold text-[#8b949e]">
Search
<input
value={q}
placeholder="Find resources"
onChange={(event) => setQ(event.target.value)}
className="rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm font-normal text-[#e6edf3] placeholder-[#6b7280] outline-none transition-colors focus:border-[#58a6ff]"
/>
</label>
<fieldset className="rounded-lg border border-[#30363d] p-3">
<legend className="px-1 text-xs font-semibold text-[#8b949e]">Status</legend>
<div className="mt-1 grid gap-2">
{STATUS_OPTIONS.map((status) => {
const selected = statuses.includes(status);
return (
<label
key={status}
className={`flex cursor-pointer items-center justify-between rounded-md border px-2.5 py-2 text-sm capitalize transition-colors ${
selected
? "border-sky-400/40 bg-sky-500/10 text-sky-200"
: "border-[#30363d] bg-[#0d1117] text-[#c9d1d9] hover:border-[#58a6ff]/45"
}`}
>
<span>{status}</span>
<input
type="checkbox"
checked={selected}
onChange={() => toggleStatus(status)}
className="h-3.5 w-3.5 accent-sky-400"
/>
</label>
);
})}
</div>
</fieldset>
<button
type="button"
onClick={clearFilters}
className="w-full rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-xs font-semibold text-[#c9d1d9] transition-colors hover:bg-[#1a2230]"
>
Clear filters
</button>
<p className="text-xs text-[#6e7681]">
URL sync works outside `srcdoc`:{" "}
<code className="text-[#9fb3c8]">?q=&status=active</code>
</p>
</aside>
<section className="rounded-2xl border border-[#30363d] bg-[#161b22] p-4">
<header className="mb-3 flex items-end justify-between gap-4 border-b border-[#21262d] pb-3">
<div>
<h2 className="text-base font-bold">Results</h2>
<p className="text-xs text-[#8b949e]">
Query: <span className="text-[#c9d1d9]">{q.trim() || "none"}</span>
</p>
</div>
<p className="text-xs font-semibold text-[#8b949e]">{filtered.length} results</p>
</header>
<ul className="grid gap-2">
{filtered.map((item) => (
<li
key={item.id}
className="rounded-xl border border-[#30363d] bg-[#0d1117]/50 px-3 py-2.5"
>
<p className="text-sm font-semibold text-[#e6edf3]">{item.title}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-[#8b949e]">
<span className="rounded-full border border-[#30363d] px-2 py-0.5 capitalize text-[#9fb3c8]">
{item.type}
</span>
<span
className={`rounded-full border px-2 py-0.5 font-semibold capitalize ${
item.status === "active"
? "border-emerald-400/30 bg-emerald-500/10 text-emerald-300"
: item.status === "pending"
? "border-amber-400/30 bg-amber-500/10 text-amber-300"
: "border-slate-400/30 bg-slate-500/10 text-slate-300"
}`}
>
{item.status}
</span>
</div>
</li>
))}
{filtered.length === 0 && (
<li className="rounded-xl border border-dashed border-[#30363d] bg-[#0d1117]/45 px-4 py-6 text-center text-sm text-[#8b949e]">
No resources match this filter set.
</li>
)}
</ul>
</section>
</div>
</section>
);
}<script setup>
import { ref, computed, onMounted, watch } from "vue";
const STATUS_OPTIONS = ["active", "pending", "archived"];
const DATA = [
{ id: 1, title: "Payment retries", status: "active", type: "workflow" },
{ id: 2, title: "Onboarding copy", status: "pending", type: "content" },
{ id: 3, title: "API docs refresh", status: "active", type: "docs" },
{ id: 4, title: "Quarterly report", status: "archived", type: "analytics" },
{ id: 5, title: "Token rotation", status: "pending", type: "security" },
{ id: 6, title: "Usage dashboard", status: "active", type: "analytics" },
{ id: 7, title: "Support macros", status: "archived", type: "ops" },
];
const q = ref("");
const statuses = ref([]);
const canSyncHistory = ref(false);
onMounted(() => {
canSyncHistory.value =
typeof window !== "undefined" && !window.location.href.startsWith("about:srcdoc");
const params = new URLSearchParams(window.location.search);
q.value = params.get("q") ?? "";
const parsedStatuses = (params.get("status") ?? "")
.split(",")
.map((v) => v.trim())
.filter((v) => STATUS_OPTIONS.includes(v));
statuses.value = parsedStatuses;
});
watch(
[q, statuses],
() => {
if (!canSyncHistory.value) return;
const params = new URLSearchParams();
const trimmed = q.value.trim();
if (trimmed) params.set("q", trimmed);
if (statuses.value.length > 0) params.set("status", statuses.value.join(","));
const next = params.toString();
const nextUrl = next ? `${window.location.pathname}?${next}` : window.location.pathname;
try {
window.history.replaceState({}, "", nextUrl);
} catch {
// ignore
}
},
{ deep: true }
);
const filtered = computed(() => {
const query = q.value.trim().toLowerCase();
return DATA.filter((item) => {
const queryMatch =
query.length === 0 || `${item.title} ${item.type}`.toLowerCase().includes(query);
const statusMatch = statuses.value.length === 0 || statuses.value.includes(item.status);
return queryMatch && statusMatch;
});
});
function toggleStatus(status) {
if (statuses.value.includes(status)) {
statuses.value = statuses.value.filter((v) => v !== status);
} else {
statuses.value = [...statuses.value, status];
}
}
function clearFilters() {
q.value = "";
statuses.value = [];
}
function statusBadgeClass(status) {
if (status === "active")
return {
borderColor: "rgba(52,211,153,0.3)",
background: "rgba(16,185,129,0.1)",
color: "#6ee7b7",
};
if (status === "pending")
return {
borderColor: "rgba(251,191,36,0.3)",
background: "rgba(245,158,11,0.1)",
color: "#fcd34d",
};
return {
borderColor: "rgba(148,163,184,0.3)",
background: "rgba(100,116,139,0.1)",
color: "#cbd5e1",
};
}
function filterLabelStyle(selected) {
return {
display: "flex",
cursor: "pointer",
alignItems: "center",
justifyContent: "space-between",
borderRadius: "0.375rem",
border: `1px solid ${selected ? "rgba(56,189,248,0.4)" : "#30363d"}`,
padding: "0.5rem 0.625rem",
fontSize: "0.875rem",
textTransform: "capitalize",
background: selected ? "rgba(14,165,233,0.1)" : "#0d1117",
color: selected ? "#bae6fd" : "#c9d1d9",
};
}
</script>
<template>
<section style="min-height: 100vh; background: #0d1117; padding: 1rem 1rem; color: #e6edf3; font-family: system-ui, -apple-system, sans-serif;">
<div style="margin: 0 auto; max-width: 72rem; display: grid; gap: 1rem; grid-template-columns: 280px minmax(0, 1fr);">
<aside style="display: flex; flex-direction: column; gap: 1rem; border-radius: 1rem; border: 1px solid #30363d; background: #161b22; padding: 1rem;">
<div>
<p style="font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e;">Pattern</p>
<h1 style="margin-top: 0.25rem; font-size: 1.125rem; font-weight: 700;">Search + Filter</h1>
<p style="margin-top: 0.25rem; font-size: 0.875rem; color: #8b949e;">Filter items by text query and status facets.</p>
</div>
<label style="display: grid; gap: 0.375rem; font-size: 0.75rem; font-weight: 600; color: #8b949e;">
Search
<input
v-model="q"
placeholder="Find resources"
style="border-radius: 0.5rem; border: 1px solid #30363d; background: #0d1117; padding: 0.5rem 0.75rem; font-size: 0.875rem; color: #e6edf3; outline: none;"
/>
</label>
<fieldset style="border-radius: 0.5rem; border: 1px solid #30363d; padding: 0.75rem;">
<legend style="padding: 0 0.25rem; font-size: 0.75rem; font-weight: 600; color: #8b949e;">Status</legend>
<div style="margin-top: 0.25rem; display: grid; gap: 0.5rem;">
<label
v-for="status in STATUS_OPTIONS"
:key="status"
:style="filterLabelStyle(statuses.includes(status))"
>
<span>{{ status }}</span>
<input
type="checkbox"
:checked="statuses.includes(status)"
@change="toggleStatus(status)"
style="width: 14px; height: 14px; accent-color: #38bdf8;"
/>
</label>
</div>
</fieldset>
<button
@click="clearFilters"
style="width: 100%; border-radius: 0.5rem; border: 1px solid #30363d; background: #0d1117; padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 600; color: #c9d1d9; cursor: pointer;"
>
Clear filters
</button>
<p style="font-size: 0.75rem; color: #6e7681;">
URL sync works outside <code style="color: #9fb3c8;">srcdoc</code>: <code style="color: #9fb3c8;">?q=&status=active</code>
</p>
</aside>
<section style="border-radius: 1rem; border: 1px solid #30363d; background: #161b22; padding: 1rem;">
<header style="margin-bottom: 0.75rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border-bottom: 1px solid #21262d; padding-bottom: 0.75rem;">
<div>
<h2 style="font-size: 1rem; font-weight: 700;">Results</h2>
<p style="font-size: 0.75rem; color: #8b949e;">
Query: <span style="color: #c9d1d9;">{{ q.trim() || "none" }}</span>
</p>
</div>
<p style="font-size: 0.75rem; font-weight: 600; color: #8b949e;">{{ filtered.length }} results</p>
</header>
<ul style="display: grid; gap: 0.5rem; list-style: none; padding: 0; margin: 0;">
<li
v-for="item in filtered"
:key="item.id"
style="border-radius: 0.75rem; border: 1px solid #30363d; background: rgba(13,17,23,0.5); padding: 0.625rem 0.75rem;"
>
<p style="font-size: 0.875rem; font-weight: 600; color: #e6edf3;">{{ item.title }}</p>
<div style="margin-top: 0.25rem; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #8b949e;">
<span style="border-radius: 999px; border: 1px solid #30363d; padding: 0.125rem 0.5rem; text-transform: capitalize; color: #9fb3c8;">
{{ item.type }}
</span>
<span
:style="{
borderRadius: '999px',
border: '1px solid',
padding: '0.125rem 0.5rem',
fontWeight: 600,
textTransform: 'capitalize',
...statusBadgeClass(item.status),
}"
>
{{ item.status }}
</span>
</div>
</li>
<li
v-if="filtered.length === 0"
style="border-radius: 0.75rem; border: 1px dashed #30363d; background: rgba(13,17,23,0.45); padding: 1.5rem 1rem; text-align: center; font-size: 0.875rem; color: #8b949e;"
>
No resources match this filter set.
</li>
</ul>
</section>
</div>
</section>
</template><script>
import { onMount } from "svelte";
const STATUS_OPTIONS = ["active", "pending", "archived"];
const DATA = [
{ id: 1, title: "Payment retries", status: "active", type: "workflow" },
{ id: 2, title: "Onboarding copy", status: "pending", type: "content" },
{ id: 3, title: "API docs refresh", status: "active", type: "docs" },
{ id: 4, title: "Quarterly report", status: "archived", type: "analytics" },
{ id: 5, title: "Token rotation", status: "pending", type: "security" },
{ id: 6, title: "Usage dashboard", status: "active", type: "analytics" },
{ id: 7, title: "Support macros", status: "archived", type: "ops" },
];
let q = "";
let statuses = [];
let canSyncHistory = false;
onMount(() => {
canSyncHistory =
typeof window !== "undefined" && !window.location.href.startsWith("about:srcdoc");
const params = new URLSearchParams(window.location.search);
q = params.get("q") ?? "";
const parsedStatuses = (params.get("status") ?? "")
.split(",")
.map((v) => v.trim())
.filter((v) => STATUS_OPTIONS.includes(v));
statuses = parsedStatuses;
});
$: {
if (canSyncHistory) {
const params = new URLSearchParams();
const trimmed = q.trim();
if (trimmed) params.set("q", trimmed);
if (statuses.length > 0) params.set("status", statuses.join(","));
const next = params.toString();
const nextUrl = next ? `${window.location.pathname}?${next}` : window.location.pathname;
try {
window.history.replaceState({}, "", nextUrl);
} catch {
// ignore
}
}
}
$: filtered = DATA.filter((item) => {
const query = q.trim().toLowerCase();
const queryMatch =
query.length === 0 || `${item.title} ${item.type}`.toLowerCase().includes(query);
const statusMatch = statuses.length === 0 || statuses.includes(item.status);
return queryMatch && statusMatch;
});
function toggleStatus(status) {
if (statuses.includes(status)) {
statuses = statuses.filter((v) => v !== status);
} else {
statuses = [...statuses, status];
}
}
function clearFilters() {
q = "";
statuses = [];
}
function statusBadgeStyle(status) {
if (status === "active")
return "border-color: rgba(52,211,153,0.3); background: rgba(16,185,129,0.1); color: #6ee7b7;";
if (status === "pending")
return "border-color: rgba(251,191,36,0.3); background: rgba(245,158,11,0.1); color: #fcd34d;";
return "border-color: rgba(148,163,184,0.3); background: rgba(100,116,139,0.1); color: #cbd5e1;";
}
</script>
<section style="min-height: 100vh; background: #0d1117; padding: 1rem 1rem; color: #e6edf3; font-family: system-ui, -apple-system, sans-serif;">
<div style="margin: 0 auto; max-width: 72rem; display: grid; gap: 1rem; grid-template-columns: 280px minmax(0, 1fr);">
<aside style="display: flex; flex-direction: column; gap: 1rem; border-radius: 1rem; border: 1px solid #30363d; background: #161b22; padding: 1rem;">
<div>
<p style="font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #8b949e;">Pattern</p>
<h1 style="margin-top: 0.25rem; font-size: 1.125rem; font-weight: 700;">Search + Filter</h1>
<p style="margin-top: 0.25rem; font-size: 0.875rem; color: #8b949e;">Filter items by text query and status facets.</p>
</div>
<label style="display: grid; gap: 0.375rem; font-size: 0.75rem; font-weight: 600; color: #8b949e;">
Search
<input
bind:value={q}
placeholder="Find resources"
style="border-radius: 0.5rem; border: 1px solid #30363d; background: #0d1117; padding: 0.5rem 0.75rem; font-size: 0.875rem; color: #e6edf3; outline: none;"
/>
</label>
<fieldset style="border-radius: 0.5rem; border: 1px solid #30363d; padding: 0.75rem;">
<legend style="padding: 0 0.25rem; font-size: 0.75rem; font-weight: 600; color: #8b949e;">Status</legend>
<div style="margin-top: 0.25rem; display: grid; gap: 0.5rem;">
{#each STATUS_OPTIONS as status}
{@const selected = statuses.includes(status)}
<label
style="display: flex; cursor: pointer; align-items: center; justify-content: space-between; border-radius: 0.375rem; border: 1px solid {selected ? 'rgba(56,189,248,0.4)' : '#30363d'}; padding: 0.5rem 0.625rem; font-size: 0.875rem; text-transform: capitalize; background: {selected ? 'rgba(14,165,233,0.1)' : '#0d1117'}; color: {selected ? '#bae6fd' : '#c9d1d9'};"
>
<span>{status}</span>
<input
type="checkbox"
checked={selected}
on:change={() => toggleStatus(status)}
style="width: 14px; height: 14px; accent-color: #38bdf8;"
/>
</label>
{/each}
</div>
</fieldset>
<button
on:click={clearFilters}
style="width: 100%; border-radius: 0.5rem; border: 1px solid #30363d; background: #0d1117; padding: 0.5rem 0.75rem; font-size: 0.75rem; font-weight: 600; color: #c9d1d9; cursor: pointer;"
>
Clear filters
</button>
<p style="font-size: 0.75rem; color: #6e7681;">
URL sync works outside <code style="color: #9fb3c8;">srcdoc</code>: <code style="color: #9fb3c8;">?q=&status=active</code>
</p>
</aside>
<section style="border-radius: 1rem; border: 1px solid #30363d; background: #161b22; padding: 1rem;">
<header style="margin-bottom: 0.75rem; display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; border-bottom: 1px solid #21262d; padding-bottom: 0.75rem;">
<div>
<h2 style="font-size: 1rem; font-weight: 700;">Results</h2>
<p style="font-size: 0.75rem; color: #8b949e;">
Query: <span style="color: #c9d1d9;">{q.trim() || "none"}</span>
</p>
</div>
<p style="font-size: 0.75rem; font-weight: 600; color: #8b949e;">{filtered.length} results</p>
</header>
<ul style="display: grid; gap: 0.5rem; list-style: none; padding: 0; margin: 0;">
{#each filtered as item (item.id)}
<li style="border-radius: 0.75rem; border: 1px solid #30363d; background: rgba(13,17,23,0.5); padding: 0.625rem 0.75rem;">
<p style="font-size: 0.875rem; font-weight: 600; color: #e6edf3;">{item.title}</p>
<div style="margin-top: 0.25rem; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: #8b949e;">
<span style="border-radius: 999px; border: 1px solid #30363d; padding: 0.125rem 0.5rem; text-transform: capitalize; color: #9fb3c8;">
{item.type}
</span>
<span style="border-radius: 999px; border: 1px solid; padding: 0.125rem 0.5rem; font-weight: 600; text-transform: capitalize; {statusBadgeStyle(item.status)}">
{item.status}
</span>
</div>
</li>
{/each}
{#if filtered.length === 0}
<li style="border-radius: 0.75rem; border: 1px dashed #30363d; background: rgba(13,17,23,0.45); padding: 1.5rem 1rem; text-align: center; font-size: 0.875rem; color: #8b949e;">
No resources match this filter set.
</li>
{/if}
</ul>
</section>
</div>
</section>Search Filter
A simple integration pattern for catalog views where users combine free-text search and faceted filtering.
Features
- Search input + status filters
- URL query sync with
history.replaceState - Shareable filtered URLs
- Live result counting
Notes
This is intentionally lighter than advanced-filters: no preset saving and no complex filter schema.