Search
The input answers at once, the results catch up.
The input writes the query signal on every key. The result list reads a deferred copy of it, which updates once typing pauses, so a slow list never blocks the input. Arrow keys move a highlight that touches only two rows.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Search - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<style>
.results li[data-active="true"] {
border-radius: var(--i-border-radius);
background-color: var(--i-color-subtle-border);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const {
Component, Signal, Computed, application, html, For, Show,
createDeferred, createSelector, useTransition,
} = Signy;
const ADJECTIVES = ["Amber", "Brisk", "Calm", "Daring", "Eager", "Fuzzy", "Gentle", "Hidden", "Ivory", "Jolly"];
const NOUNS = ["Falcon", "River", "Lantern", "Meadow", "Comet", "Harbor", "Willow", "Summit", "Ember", "Pebble"];
const CATEGORIES = ["Books", "Music", "Games", "Tools"];
// 5,000 made up products
const ITEMS = Array.from({ length: 5000 }, (_, i) => ({
id: i + 1,
name: `${ADJECTIVES[i % 10]} ${NOUNS[Math.floor(i / 10) % 10]} ${i + 1}`,
category: CATEGORIES[(i * 7) % 4],
}));
const LIMIT = 100;
class Search extends Component {
query = new Signal("");
category = new Signal("All");
// The list follows the query once typing pauses for 150ms
deferredQuery = createDeferred(() => this.query.value, { timeoutMs: 150 });
transition = useTransition();
active = new Signal(null);
isActive = createSelector(() => this.active.value);
chosen = new Signal(null);
matches = new Computed(() => {
const query = this.deferredQuery().toLowerCase();
const category = this.category.value;
return ITEMS.filter(
(item) => (category === "All" || item.category === category) && item.name.toLowerCase().includes(query),
);
});
shown = new Computed(() => this.matches.value.slice(0, LIMIT));
stale = new Computed(() => this.query.value !== this.deferredQuery() || this.transition[0].value);
pickCategory(category) {
const [, start] = this.transition;
start(() => (this.category.value = category));
}
// Arrow keys move the highlight. Only the row that loses it and the row that gets it update.
onKey(event) {
const shown = this.shown.value;
const index = shown.findIndex((item) => item.id === this.active.value);
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const next = event.key === "ArrowDown" ? Math.min(index + 1, shown.length - 1) : Math.max(index - 1, 0);
this.active.value = shown[next]?.id ?? null;
} else if (event.key === "Enter" && index !== -1) {
event.preventDefault();
this.chosen.value = shown[index];
}
}
render() {
return html`
<h1>Search</h1>
<input type="search" aria-label="Search 5,000 products" placeholder="Try amber or comet 12"
onInput=${(e) => (this.query.value = e.currentTarget.value)}
onKeyDown=${(e) => this.onKey(e)} />
<div class="cluster" role="group" aria-label="Category">
${["All", ...CATEGORIES].map((category) => html`
<button class="small" aria-pressed=${() => String(this.category.value === category)}
onClick=${() => this.pickCategory(category)}>${category}</button>
`)}
</div>
${Show({
when: () => this.chosen.value,
children: (item) => html`<p class="alert success" role="status">You picked ${item.name}.</p>`,
})}
<p aria-live="polite" aria-busy=${() => String(this.stale.value)}>
${() => this.matches.value.length} matches${() => (this.matches.value.length > LIMIT ? `, showing ${LIMIT}` : "")}
</p>
<ul class="results">
${For({
each: () => this.shown.value,
keyBy: "id",
fallback: html`<li>Nothing matches.</li>`,
children: (item) => html`
<li data-active=${() => String(this.isActive(item.id).value)}>
${item.name} <small>${item.category}</small>
</li>
`,
})}
</ul>
`;
}
}
application(Search).mount("#app");
</script>
</body>
</html>
What it uses
- createDeferred() and useTransition()
- createSelector() for the highlighted row
- For with
keyBy