Package Search
A dependency picker that loads its results from a search instead of static markup. filter="none" turns off the built-in matching, and a querychange listener sends the request. A pick resets the query to empty, so the listener searches by the picked value instead, and the list still holds it when it opens again.
Type to search packages
Code
The following example assumes that you have imported the components and set up the theme.
<div class="package-search">7 collapsed lines
<label for="package">Add dependency</label>
<sv-combobox id="package" class="package-combobox" filter="none" name="dependency" placeholder="Search packages"> <sv-flyout class="package-flyout"> <p class="empty" data-combobox-no-results>Type to search packages</p> </sv-flyout> </sv-combobox></div>
<template id="package-option">4 collapsed lines
<button type="button" class="package"> <span data-combobox-label></span> <span class="description"></span> </button></template>
<script type="application/x-typescript" module>69 collapsed lines
const registry = [ { name: "color-convert", description: "Convert between color models" }, { name: "csv-parse", description: "Streaming CSV parser" }, { name: "deep-merge", description: "Recursively merge plain objects" }, { name: "dom-diff", description: "Patch the DOM from a virtual tree" }, { name: "event-bus", description: "Typed publish and subscribe channel" }, { name: "fetch-retry", description: "Retry failed requests with backoff" }, { name: "form-serialize", description: "Serialize form fields to JSON" }, { name: "markdown-lite", description: "Tiny Markdown to HTML converter" }, { name: "path-match", description: "Express style path matching" }, { name: "query-string", description: "Parse and stringify URL queries" }, { name: "task-queue", description: "Concurrency limited promise queue" }, { name: "time-ago", description: "Relative time strings from dates" }, ];
// A fake API request. Replace it with a fetch to your own endpoint. const searchRegistry = async (query: string) => { await new Promise(resolve => setTimeout(resolve, 300));
return registry.filter(({ name, description }) => name.includes(query) || description.toLowerCase().includes(query)); };
const combobox = document.querySelector<Combobox>(".package-combobox")!; const flyout = document.querySelector(".package-flyout")!; const optionTemplate = document.querySelector<HTMLTemplateElement>("#package-option")!; const message = document.querySelector("[data-combobox-no-results]")!;
let latestQuery = "";
const removeOptions = () => { for (const option of flyout.querySelectorAll(".package")) { option.remove(); } };
const renderMatches = async (query: string) => { latestQuery = query;
if (!query) { message.textContent = "Type to search packages"; removeOptions(); return; }
// Fetch packages message.textContent = "Searching…"; const matchingPackages = await searchRegistry(query); if (query !== latestQuery) return;
// Replace options message.textContent = "No packages found"; removeOptions();
const options = matchingPackages.map(({ name, description }) => { const option = optionTemplate.content.firstElementChild!.cloneNode(true) as HTMLButtonElement;
option.value = name; option.querySelector("[data-combobox-label]")!.textContent = name; option.querySelector(".description")!.textContent = description;
return option; });
flyout.prepend(...options); };
combobox.addEventListener("querychange", event => { renderMatches(event.detail.trim().toLowerCase() || (combobox.value as string)); });</script>
<style>36 collapsed lines
.package-search { display: grid; gap: 0.5em; inline-size: min(100%, 22rem); margin-inline: auto; color: var(--sv-gray-2);
label { justify-self: start; cursor: pointer; }
.package { display: grid; gap: 0.2em; }
.description { font-size: 0.8em; color: var(--sv-gray-3); }
.package[aria-selected="true"] .description { color: inherit; }
.package-flyout::part(content) { max-block-size: min(var(--sv-anchor-available-height), 20rem); }
.empty { padding: 0.5em; margin: 0; color: var(--sv-gray-3); } }</style>