# Search

> Command search with live filtering and an empty state.

- **Slug:** `search`
- **Kind:** component · **Category:** Navigation
- **Source:** ported from [Beautiful UI](https://github.com/slev12397/beautiful-ui/blob/main/components/primitives/SearchList.tsx) (MIT)
- **Docs page:** https://viewership.ai/ui/components/search
- **All files in one bundle:** https://viewership.ai/ui/components/search.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/search.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/search/`:

   - `SearchList.astro` (raw: https://viewership.ai/ui/raw/search/SearchList.astro.txt)

3. Copy its registry dependencies, each into its own sibling folder under `src/components/viewership/`:

   - `glide-menu`: `GlideMenu.astro`, `glide-menu.ts` (docs: https://viewership.ai/ui/components/glide-menu.md)

npm packages: none.

## Usage

```astro
---
import SearchList from "../components/viewership/search/SearchList.astro";
---

<SearchList />
```

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
import SearchList from "./SearchList.astro";

const items = [
  "Build the weekly GA4 report",
  "Find new Reddit threads",
  "Compare ChatGPT citations",
  "Draft the spring launch email",
  "Check AI Overviews mentions",
  "Audit Google Ads spend",
  "Pause low-ROAS campaigns",
];
---

<div class="stage">
  <SearchList
    items={items}
    labels={{ placeholder: "Search tasks…", ariaLabel: "Search tasks", listLabel: "Marketing tasks" }}
  />
</div>

<style>
  .stage {
    display: flex;
    width: 100%;
    justify-content: center;
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `string[]` |  | Items. Case-insensitive substring match. |
| `limit` | `number` | `5` | Suggestions shown for an empty query. |
| `value` | `string` | `""` | Initial query. Works without JS. |
| `labels` | `Partial<SearchListLabels>` |  | placeholder, ariaLabel, listLabel, emptyTitle, emptyHint, clear, results ("{n} results"), oneResult. |
| `vs-search-select` | `CustomEvent<{ value: string; index: number }>` |  | Fires on pick. index points into items. |
| `vs-search-input` | `CustomEvent<{ query: string; count: number }>` |  | Fires on each query change. |

## Source

### `src/components/viewership/search/SearchList.astro`

```astro
---
/**
 * SearchList: command search with live filtering and an empty state.
 *
 * Ported from Beautiful UI `components/primitives/SearchList.tsx`
 * (MIT, © 2026 Shane Levine).
 *
 * Accessibility: WAI-ARIA combobox with an inline listbox. Focus stays in
 * the field; ↑/↓ move the active option, Enter picks it, Escape clears.
 * Result counts are announced politely.
 *
 * Events (bubbling, on the root):
 *   vs-search-select  detail: { value, index }  (index into `items`)
 *   vs-search-input   detail: { query, count }
 *
 * @example
 * <SearchList items={["Build the weekly report", "Check AI citations"]} />
 */
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";

export interface SearchListLabels {
  placeholder: string;
  /** Accessible name of the field. */
  ariaLabel: string;
  /** Accessible name of the results list. */
  listLabel: string;
  emptyTitle: string;
  emptyHint: string;
  clear: string;
  /** Announced result count; {n} is replaced. */
  results: string;
  oneResult: string;
}

type Props = HTMLAttributes<"div"> & {
  items: string[];
  labels?: Partial<SearchListLabels>;
  /** How many items show while the query is empty. */
  limit?: number;
  /** Initial query. */
  value?: string;
};

const { items = [], labels, limit = 5, value = "", class: className, ...rest } = Astro.props;

const l: SearchListLabels = {
  placeholder: "Search…",
  ariaLabel: "Search",
  listLabel: "Results",
  emptyTitle: "No results found",
  emptyHint: "Adjust your search to try again",
  clear: "Clear search",
  results: "{n} results",
  oneResult: "1 result",
  ...labels,
};

const uid = `vs-search-${Math.random().toString(36).slice(2, 8)}`;
const q = value.toLowerCase();
const shown = items.map((item, i) => (q ? item.toLowerCase().includes(q) : i < limit));
const empty = value.length > 2 && !shown.some(Boolean);
const searchIcon = '<circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" />';
---

<div
  class:list={["vs-search", className]}
  data-limit={limit}
  data-labels={JSON.stringify({ results: l.results, oneResult: l.oneResult, emptyTitle: l.emptyTitle })}
  {...rest}
>
  <div class="card">
    <div class="field">
      <svg class="field-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" set:html={searchIcon} />
      <input
        class="input"
        type="text"
        role="combobox"
        aria-label={l.ariaLabel}
        aria-autocomplete="list"
        aria-controls={`${uid}-list`}
        aria-expanded={shown.some(Boolean) ? "true" : "false"}
        autocomplete="off"
        spellcheck="false"
        placeholder={l.placeholder}
        value={value}
        data-input
      />
      <button type="button" class="clear" aria-label={l.clear} hidden={!value} data-clear>
        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
          <path d="M18 6L6 18M6 6l12 12"></path>
        </svg>
      </button>
    </div>

    <div class="empty" hidden={!empty} data-empty>
      <span class="empty-icon" aria-hidden="true">
        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" set:html={searchIcon} />
      </span>
      <span class="empty-title">{l.emptyTitle}</span>
      <span class="empty-hint">{l.emptyHint}</span>
    </div>

    <div class="results" hidden={empty} data-results>
      <GlideMenu class="list" role="listbox" id={`${uid}-list`} aria-label={l.listLabel} highlightRadius="6px">
        {items.map((item, i) => (
          <div class="option" role="option" id={`${uid}-opt-${i}`} aria-selected="false" data-menu-row data-index={i} hidden={!shown[i]}>
            {item}
          </div>
        ))}
      </GlideMenu>
    </div>
  </div>
  <span class="vh" aria-live="polite" data-live></span>
</div>

<script>
  function init(root: HTMLElement) {
    const input = root.querySelector<HTMLInputElement>("[data-input]")!;
    const clear = root.querySelector<HTMLButtonElement>("[data-clear]")!;
    const empty = root.querySelector<HTMLElement>("[data-empty]")!;
    const results = root.querySelector<HTMLElement>("[data-results]")!;
    const list = root.querySelector<HTMLElement>("[role=listbox]")!;
    const options = [...root.querySelectorAll<HTMLElement>("[role=option]")];
    const live = root.querySelector<HTMLElement>("[data-live]")!;
    const labels = JSON.parse(root.dataset.labels || "{}") as { results: string; oneResult: string; emptyTitle: string };
    const limit = Number(root.dataset.limit ?? 5);

    let visible: HTMLElement[] = options.filter((o) => !o.hidden);
    let active = -1;
    let announceTimer: number | undefined;

    const setActive = (index: number, fromPointer = false) => {
      active = index;
      options.forEach((o) => {
        const on = o === visible[index];
        o.setAttribute("aria-selected", String(on));
        o.toggleAttribute("data-active", on);
      });
      const option = visible[index];
      if (option) {
        input.setAttribute("aria-activedescendant", option.id);
        if (!fromPointer) {
          // Move the glide highlight with the keyboard (it follows mouseover).
          option.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
          option.scrollIntoView({ block: "nearest" });
        }
      } else {
        input.removeAttribute("aria-activedescendant");
        list.dispatchEvent(new MouseEvent("mouseleave"));
      }
    };

    const update = () => {
      const query = input.value;
      const q = query.toLowerCase();
      const before = new Set(visible);
      visible = [];
      options.forEach((o, i) => {
        const show = q ? (o.textContent ?? "").trim().toLowerCase().includes(q) : i < limit;
        o.hidden = !show;
        if (!show) return;
        visible.push(o);
        // Rows that just appeared fade in.
        if (!before.has(o)) {
          o.removeAttribute("data-enter");
          void o.offsetWidth;
          o.setAttribute("data-enter", "");
        }
      });
      const isEmpty = query.length > 2 && visible.length === 0;
      empty.hidden = !isEmpty;
      results.hidden = isEmpty;
      clear.hidden = !query;
      input.setAttribute("aria-expanded", String(visible.length > 0));
      setActive(-1);

      window.clearTimeout(announceTimer);
      announceTimer = window.setTimeout(() => {
        live.textContent =
          visible.length === 0 ? labels.emptyTitle : visible.length === 1 ? labels.oneResult : labels.results.replace("{n}", String(visible.length));
      }, 500);
      root.dispatchEvent(new CustomEvent("vs-search-input", { bubbles: true, detail: { query, count: visible.length } }));
    };

    const choose = (option: HTMLElement) => {
      const value = (option.textContent ?? "").trim();
      input.value = value;
      update();
      input.focus();
      root.dispatchEvent(new CustomEvent("vs-search-select", { bubbles: true, detail: { value, index: Number(option.dataset.index) } }));
    };

    input.addEventListener("input", update);

    input.addEventListener("keydown", (event) => {
      if (event.altKey || event.ctrlKey || event.metaKey) return;
      const n = visible.length;
      if (event.key === "ArrowDown" && n) {
        event.preventDefault();
        setActive(active < 0 ? 0 : (active + 1) % n);
      } else if (event.key === "ArrowUp" && n) {
        event.preventDefault();
        setActive(active <= 0 ? n - 1 : active - 1);
      } else if (event.key === "Enter" && visible[active]) {
        event.preventDefault();
        choose(visible[active]!);
      } else if (event.key === "Escape" && input.value) {
        event.preventDefault();
        input.value = "";
        update();
      }
    });

    // Keep focus in the field when picking with the pointer.
    list.addEventListener("mousedown", (event) => event.preventDefault());
    list.addEventListener("click", (event) => {
      const option = (event.target as Element).closest<HTMLElement>("[role=option]");
      if (option) choose(option);
    });
    list.addEventListener("mouseover", (event) => {
      if (!event.isTrusted) return;
      const option = (event.target as Element).closest<HTMLElement>("[role=option]");
      if (option) setActive(visible.indexOf(option), true);
    });

    clear.addEventListener("click", () => {
      input.value = "";
      update();
      input.focus();
    });

    document.addEventListener("astro:before-swap", () => window.clearTimeout(announceTimer), { once: true });
  }

  const boot = () =>
    document.querySelectorAll<HTMLElement>(".vs-search:not([data-vs-ready])").forEach((root) => {
      root.dataset.vsReady = "";
      init(root);
    });
  boot();
  document.addEventListener("astro:page-load", boot);
</script>

<style>
  .vs-search {
    display: flex;
    width: 100%;
    max-width: 288px;
    min-height: 248px;
    flex-direction: column;
    align-items: stretch;
  }
  .vh {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
  }
  .card {
    width: 100%;
    align-self: flex-start;
    overflow: hidden;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-raised);
    transition: box-shadow var(--vs-duration-fast) ease-out;
  }
  .card:has(.input:focus-visible) {
    box-shadow:
      0 0 0 1px color-mix(in srgb, var(--vs-accent) 60%, transparent),
      var(--vs-shadow-md);
  }

  /* ── field ── */
  .field {
    display: flex;
    height: 40px;
    align-items: center;
    gap: 8px;
    padding: 0 12px;
    border-bottom: 1px solid var(--vs-line);
    transition: background-color 100ms ease-out;
  }
  .field:hover {
    background: var(--vs-hover);
  }
  .field-icon {
    flex-shrink: 0;
    color: var(--vs-ink-3);
  }
  .input {
    min-width: 0;
    flex: 1;
    padding: 0;
    border: none;
    background: transparent;
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    outline: none;
  }
  .input::placeholder {
    color: var(--vs-ink-3);
  }
  .clear {
    display: flex;
    width: 24px;
    height: 24px;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: none;
    border-radius: var(--vs-radius-pill);
    background: transparent;
    color: var(--vs-ink-3);
    cursor: pointer;
    animation: vs-fade-in 150ms ease-out both;
    transition:
      background-color 100ms ease-out,
      color 100ms ease-out;
  }
  .clear:hover {
    background: color-mix(in srgb, var(--vs-line) 70%, transparent);
    color: var(--vs-ink);
  }
  .clear:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .clear[hidden] {
    display: none;
  }

  /* ── results ── */
  .results {
    padding: 4px;
  }
  .results[hidden] {
    display: none;
  }
  .list {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .option {
    position: relative;
    z-index: 1;
    display: flex;
    width: 100%;
    height: 32px;
    align-items: center;
    padding: 0 8px;
    border-radius: 6px;
    color: var(--vs-ink);
    font-size: 13px;
    text-align: left;
    cursor: pointer;
    user-select: none;
  }
  .option[hidden] {
    display: none;
  }
  .option[data-enter] {
    animation: vs-fade-in 200ms ease-out both;
  }
  /* Keyboard-active row keeps a fill whenever the glide highlight is not on it. */
  .list:not([data-glide-active]) .option[data-active] {
    background: var(--vs-hover);
  }

  /* ── empty state ── */
  .empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 4px;
    padding: 32px 16px;
    animation: vs-fade-in 250ms ease-out both;
  }
  .empty[hidden] {
    display: none;
  }
  .empty-icon {
    display: flex;
    width: 32px;
    height: 32px;
    align-items: center;
    justify-content: center;
    margin-bottom: 6px;
    border-radius: var(--vs-radius-control);
    background: var(--vs-inset);
    box-shadow: var(--vs-shadow-hairline);
    color: var(--vs-ink-3);
  }
  .empty-title {
    font-size: 13px;
    font-weight: 500;
    color: var(--vs-ink);
  }
  .empty-hint {
    font-size: 12px;
    color: var(--vs-ink-3);
  }

  @media (forced-colors: active) {
    .option[data-active] {
      outline: 2px solid Highlight;
    }
  }
</style>
```
