Quicksearch
Summary
A combobox-style search field with an anchored results panel. Soma owns the mechanics: open/close, keyboard, combobox ARIA. The consumer owns the results: listen for the search event and render items into the results element, and the panel shows while the query is non-empty. This docs site's navbar search is the component dogfooded.
When to use
| Situation | Guidance |
|---|---|
| Type-ahead over a known set | The home turf: pages, projects, people; navbar global search, sidebar filters. |
| Picking a value into a form | Use Select. It syncs a native <select> so submission works. |
| Server-backed results | Fine — the search event fires per keystroke; debounce and fetch in your handler, then render (snippet below). |
Examples
The contract — you render the results
The component never touches the results element's content. On
every input it dispatches the search event with the trimmed
query (including '' when the field is cleared, so
a handler can reset stale markup), then opens the panel while
the query is non-empty and closes it when it isn't. Your
handler renders the children from exactly three building
blocks:
.soma-quicksearch-item: a result row (usually an<a>; Enter clicks it, so links navigate). The keyboard highlight walks these..soma-quicksearch-section: an optional small-caps heading between item groups; skipped by the keyboard..soma-quicksearch-empty: the "no matches" row when nothing matched.
Re-render on every event — the component re-reads the item list
on each keystroke, so replacing innerHTML wholesale
is the intended model. If result strings come from users or a
server, escape them before interpolating into markup.
Keyboard and focus
| Key / interaction | Action |
|---|---|
| Typing | Fires the search event per keystroke; a non-empty query opens the panel, an empty one closes it. |
| ↓ / ↑ | Move the highlight over the items, wrapping at either end. Nothing is highlighted until the first press. |
| Enter | Activates the highlighted item — it is clicked, so links navigate and click handlers run. Without a highlight, Enter is left alone (a wrapping form submits normally). |
| Esc | Closes via the layer manager, as does a click outside the component. Clicks inside (the input, an item) never dismiss. |
| Refocus | Focusing the input with a non-empty query reopens the panel over the previous results. |
Focus stays in the input throughout. The highlight is conveyed
to assistive technology via aria-activedescendant,
painted with .soma-active, and kept in view with
scrollIntoView (the panel caps at 400px and
scrolls).
HTML
The container you author — a labelled field wrapping icon + input, and an empty results element:
<div class="soma-quicksearch">
<label class="soma-quicksearch-field">
<span class="soma-icon soma-icon-search"></span>
<input type="search" placeholder="Search…" aria-label="Search pages" />
</label>
<div class="soma-quicksearch-results" aria-hidden="true"></div>
</div>
The children you render into the results element on each search event:
<span class="soma-quicksearch-section">Pages</span>
<a class="soma-quicksearch-item" href="/dashboard">
<span class="soma-icon soma-icon-apps"></span>Dashboard</a>
<a class="soma-quicksearch-item" href="/settings">Settings</a>
<!-- or, when nothing matched: -->
<div class="soma-quicksearch-empty">No matches.</div>
The component adds the combobox ARIA itself: the input becomes
role="combobox" with aria-expanded,
aria-controls, aria-autocomplete="list"
and aria-activedescendant; the results element
becomes role="listbox" (an id is generated for it
when missing, and items get ids as they're highlighted). Don't
hand-author those — do keep a visible label or
aria-label on the input; the icon doesn't name it.
CSS classes
| Class | Effect |
|---|---|
.soma-quicksearch | Container and auto-init hook; -full stretches the field to its container's width (default 320px). |
.soma-quicksearch-field | The <label> wrapping icon + input. |
.soma-quicksearch-results | The anchored panel (aria-hidden toggled by the component; max-height 400px, scrolls). |
.soma-quicksearch-section | A group heading inside the results, rendered by you. |
.soma-quicksearch-item | A result row, rendered by you; .soma-active marks the keyboard highlight. |
.soma-quicksearch-empty | The "no matches" row, rendered by you. |
JavaScript
Constructor
| Member | Description |
|---|---|
Soma.quicksearch(elOrSelector) | Get or create the singleton for a .soma-quicksearch container. No options. Behaviour is fixed; content is yours. |
| Throws | When nothing matches the selector, when the element lacks the soma-quicksearch class, or when the container is missing its <input> or .soma-quicksearch-results. |
| Auto-init | Every .soma-quicksearch is bound once at DOMContentLoaded. Markup rendered later needs one explicit call (snippet below). |
Instance methods
| Member | Description |
|---|---|
.open() | Show the panel (whatever the results element currently holds) and register with the layer manager. No-op while already open. The component itself opens whenever the query is non-empty — including on refocus. |
.close() | Hide the panel, clear the keyboard highlight and aria-activedescendant. No-op while closed. The component itself closes on an emptied query, Esc, or an outside click. |
.on(event, fn) / .off(event, fn) | Subscribe/unsubscribe: 'search', 'open', 'close' (without the soma-quicksearch- prefix). |
.isOpen | Boolean: whether the panel is currently shown. |
All methods return the instance, so calls chain
(qs.on('search', render).open()).
Events
| Event | Fires | detail |
|---|---|---|
soma-quicksearch-search | On every input, including when the field is cleared (query: ''). | { query } (trimmed) |
soma-quicksearch-open | When the panel shows. | — |
soma-quicksearch-close | When the panel hides. | — |
All three are bubbling CustomEvents dispatched on the
container, so they also work delegated with plain
addEventListener:
document.addEventListener('soma-quicksearch-search', (e) => {
console.log(e.target.id, 'searched for', e.detail.query);
});
Server-backed results — debounce, fetch, and guard against a stale response landing after a newer query:
const qs = Soma.quicksearch('#global-search');
const results = document.querySelector('#global-search .soma-quicksearch-results');
let timer = 0;
let latest = 0;
qs.on('search', (e) => {
const query = e.detail.query;
clearTimeout(timer);
if (!query) return; // panel already closed — skip the fetch
timer = setTimeout(async () => {
const seq = ++latest;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const items = await res.json();
if (seq !== latest) return; // superseded by a newer query
results.innerHTML = items.length
? items.map((i) => `<a class="soma-quicksearch-item" href="${i.url}">${esc(i.title)}</a>`).join('')
: '<div class="soma-quicksearch-empty">No matches.</div>';
}, 200);
});
Bind markup rendered after page load (auto-init runs once, at
DOMContentLoaded; the call is a get-or-create
singleton):
panel.insertAdjacentHTML('beforeend', quicksearchMarkup);
Soma.quicksearch(panel.querySelector('.soma-quicksearch'))
.on('search', renderResults);
Imperative control (e.g. restoring a saved query on page load):
const qs = Soma.quicksearch('#qs-demo');
const input = document.querySelector('#qs-demo input');
input.value = savedQuery;
renderResults(savedQuery); // fill the results element yourself…
qs.open(); // …then show the panel; qs.close() hides it
The live examples above, exactly as this page wires them:
// Live filter: you render the results on every search event.
const PAGES = [
{ title: 'Dashboard', icon: 'apps' },
{ title: 'Inbox', icon: 'mail' },
{ title: 'Notifications', icon: 'bell' },
{ title: 'Profile', icon: 'user' },
{ title: 'Settings', icon: 'settings' },
{ title: 'Help', icon: 'help' },
];
const qs = Soma.quicksearch('#qs-demo');
const results = document.querySelector('#qs-demo .soma-quicksearch-results');
qs.on('search', (e) => {
const q = e.detail.query.toLowerCase();
const hits = PAGES.filter((p) => p.title.toLowerCase().includes(q));
results.innerHTML =
'<span class="soma-quicksearch-section">Pages</span>' +
(hits.length
? hits.map((p) => `
<a class="soma-quicksearch-item" href="#">
<span class="soma-icon soma-icon-${p.icon}"></span>${p.title}</a>`).join('')
: '<div class="soma-quicksearch-empty">No matches.</div>');
});
// Full-width demo: a flat action list, no sections.
const ACTIONS = ['Restart service', 'Rotate credentials', 'Run backup', 'Scale replicas', 'Ban token'];
const qsFull = Soma.quicksearch('#qs-full');
const fullResults = document.querySelector('#qs-full .soma-quicksearch-results');
qsFull.on('search', (e) => {
const q = e.detail.query.toLowerCase();
const hits = ACTIONS.filter((a) => a.toLowerCase().includes(q));
fullResults.innerHTML = hits.length
? hits.map((a) => `<a class="soma-quicksearch-item" href="#">${a}</a>`).join('')
: '<div class="soma-quicksearch-empty">No matches.</div>';
});
// Grouped demo: a section heading per group, empty state when nothing
// matches.
const GROUPED = [
{ group: 'Pages', title: 'Notifications' },
{ group: 'Pages', title: 'Nodes' },
{ group: 'People', title: 'Anna Nelson' },
{ group: 'People', title: 'Marcus Delgado' },
];
const qs2 = Soma.quicksearch('#qs-grouped');
const results2 = document.querySelector('#qs-grouped .soma-quicksearch-results');
qs2.on('search', (e) => {
const q = e.detail.query.toLowerCase();
const hits = GROUPED.filter((x) => x.title.toLowerCase().includes(q));
if (!hits.length) {
results2.innerHTML = '<div class="soma-quicksearch-empty">No matches.</div>';
return;
}
const groups = [...new Set(hits.map((h) => h.group))];
results2.innerHTML = groups.map((g) => `
<span class="soma-quicksearch-section">${g}</span>
${hits.filter((h) => h.group === g).map((h) => `
<a class="soma-quicksearch-item" href="#">${h.title}</a>`).join('')}`).join('');
});
// Empty-state demo: the dataset is empty, so every query misses.
const qsEmpty = Soma.quicksearch('#qs-empty');
const emptyResults = document.querySelector('#qs-empty .soma-quicksearch-results');
qsEmpty.on('search', () => {
emptyResults.innerHTML =
'<div class="soma-quicksearch-empty">No matches — refine the query.</div>';
});