Combobox

data-combobox is a custom plugin defined in this app (src/datastar/plugins/combobox.ts). It wires an input to a server-rendered listbox following the APG combobox pattern: ArrowUp/ArrowDown move the active option via aria-activedescendant, Enter activates it, Escape and click-outside close. Options come from @get('/api/suggest') on each (debounced) keystroke, so the plugin looks them up live rather than capturing once.

Town lookup

<div data-combobox>
  <form>
    <input data-part="input" data-bind:term
           data-on:input__debounce.200ms="@get('/api/suggest')" />
  </form>
  <div data-part="popup" hidden>
    <div id="town-suggestions">
      <!-- server renders: -->
      <button data-part="option" data-on:click="$term = 'Bristol'">Bristol</button>
    </div>
  </div>
</div>

The plugin

src/datastar/plugins/combobox.ts
import { attribute } from '@wrux/astro-datastar/engine';

/**
 * `data-combobox` wires a text input to a suggestions listbox following the
 * APG combobox pattern (list autocomplete). The option list is expected to
 * be server-rendered and swapped via Datastar fragment merges, so options
 * are looked up live rather than captured once. Expected structure:
 *
 * ```html
 * <div data-combobox>
 *   <form>
 *     <input data-part="input" data-bind:q data-on:input="@get(...)" />
 *   </form>
 *   <div data-part="popup" hidden>
 *     <div id="my-suggestions">
 *       <a data-part="option" href="...">Suggestion</a>
 *       <button data-part="option" data-on:click="...">Suggestion</button>
 *     </div>
 *   </div>
 * </div>
 * ```
 *
 * Behavior: typing opens the popup (it stays hidden while the option list
 * is empty); ArrowUp/ArrowDown move the active option (managed via
 * `aria-activedescendant`, focus stays in the input); Enter activates the
 * active option by clicking it (so anchors navigate and `data-on:click`
 * expressions run); Escape, click-outside, focus-out, option activation,
 * and submitting the surrounding form all close the popup. Active options
 * get a `data-active` attribute for styling.
 */
attribute({
  name: 'combobox',
  requirement: { key: 'denied', value: 'denied' },
  apply({ el }) {
    const root = el as HTMLElement;
    const input = root.querySelector<HTMLInputElement>('[data-part="input"]');
    const popup = root.querySelector<HTMLElement>('[data-part="popup"]');
    if (!input || !popup) return;

    const listboxId =
      popup.id || `combobox-listbox-${Math.random().toString(36).slice(2, 8)}`;
    popup.id = listboxId;
    popup.setAttribute('role', 'listbox');
    input.setAttribute('role', 'combobox');
    input.setAttribute('aria-controls', listboxId);
    input.setAttribute('aria-expanded', 'false');
    input.setAttribute('aria-autocomplete', 'list');

    let open = false;
    let activeIndex = -1;

    const options = () =>
      Array.from(popup.querySelectorAll<HTMLElement>('[data-part="option"]'));

    const setActive = (index: number) => {
      const opts = options();
      activeIndex = opts.length === 0 ? -1 : index;
      let activeId = '';
      opts.forEach((option, i) => {
        if (!option.id) option.id = `${listboxId}-option-${i}`;
        option.setAttribute('role', 'option');
        const isActive = i === activeIndex;
        option.setAttribute('aria-selected', String(isActive));
        if (isActive) {
          option.setAttribute('data-active', '');
          activeId = option.id;
        } else {
          option.removeAttribute('data-active');
        }
      });
      if (activeId) {
        input.setAttribute('aria-activedescendant', activeId);
        opts[activeIndex]?.scrollIntoView({ block: 'nearest' });
      } else {
        input.removeAttribute('aria-activedescendant');
      }
    };

    /** The popup is only ever visible while it has options to show. */
    const sync = () => {
      const show = open && options().length > 0;
      popup.hidden = !show;
      input.setAttribute('aria-expanded', String(show));
      if (!show && activeIndex !== -1) setActive(-1);
    };

    const close = () => {
      open = false;
      sync();
    };

    // Fragment merges replace the option list under the popup; reset the
    // active option and re-evaluate visibility whenever that happens.
    const observer = new MutationObserver(() => {
      setActive(-1);
      sync();
    });
    observer.observe(popup, { childList: true, subtree: true });

    const onInput = () => {
      open = true;
      sync();
    };

    const onKeydown = (event: KeyboardEvent) => {
      const opts = options();
      switch (event.key) {
        case 'ArrowDown':
        case 'ArrowUp': {
          if (popup.hidden && opts.length > 0) {
            open = true;
            sync();
          }
          if (popup.hidden) return;
          event.preventDefault();
          const down = event.key === 'ArrowDown';
          const count = options().length;
          setActive(
            activeIndex === -1
              ? down
                ? 0
                : count - 1
              : (activeIndex + (down ? 1 : -1) + count) % count,
          );
          break;
        }
        case 'Enter': {
          if (popup.hidden || activeIndex === -1) return;
          event.preventDefault();
          options()[activeIndex]?.click();
          close();
          break;
        }
        case 'Escape': {
          if (popup.hidden) return;
          event.preventDefault();
          close();
          break;
        }
        case 'Tab':
          close();
          break;
      }
    };

    // Runs after the option's own handlers (anchor navigation, Datastar
    // `data-on:click`) since it listens on the bubbling popup.
    const onPopupClick = (event: MouseEvent) => {
      const option = (event.target as HTMLElement).closest<HTMLElement>(
        '[data-part="option"]',
      );
      if (option) close();
    };

    const onOutsidePointer = (event: PointerEvent) => {
      if (open && !root.contains(event.target as Node)) close();
    };

    const onFocusOut = (event: FocusEvent) => {
      const next = event.relatedTarget as Node | null;
      if (open && (!next || !root.contains(next))) close();
    };

    const form = input.closest('form') ?? root.querySelector('form');
    const onSubmit = () => close();

    input.addEventListener('input', onInput);
    input.addEventListener('keydown', onKeydown);
    popup.addEventListener('click', onPopupClick);
    root.addEventListener('focusout', onFocusOut);
    document.addEventListener('pointerdown', onOutsidePointer);
    form?.addEventListener('submit', onSubmit);

    return () => {
      observer.disconnect();
      input.removeEventListener('input', onInput);
      input.removeEventListener('keydown', onKeydown);
      popup.removeEventListener('click', onPopupClick);
      root.removeEventListener('focusout', onFocusOut);
      document.removeEventListener('pointerdown', onOutsidePointer);
      form?.removeEventListener('submit', onSubmit);
    };
  },
});

The shared component

src/components/SuggestionOptions.astro
---
/** Combobox option list shared by the page (empty) and /api/suggest. */
interface Props {
  matches?: string[];
}

const { matches = [] } = Astro.props;
---

<div id="town-suggestions">
  {
    matches.map((town) => (
      <button
        type="button"
        data-part="option"
        data-on:click={`$term = '${town.replace(/'/g, "\\'")}'`}
      >
        {town}
      </button>
    ))
  }
</div>

The endpoint

src/pages/api/suggest.ts
import {
  html,
  readSignals,
  SignalsValidationError,
} from '@wrux/astro-datastar/server';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { z } from 'astro/zod';
import SuggestionOptions from '../../components/SuggestionOptions.astro';
import { towns } from '../../data/towns';

export const prerender = false;

const signals = z.object({
  term: z.string().max(100).catch(''),
});

export const GET: APIRoute = async ({ request }) => {
  let term: string;
  try {
    ({ term } = await readSignals(request, signals));
  } catch (err) {
    if (err instanceof SignalsValidationError) return err.response();
    throw err;
  }

  const matches = term
    ? towns.filter((t) => t.toLowerCase().startsWith(term.toLowerCase()))
    : [];

  const container = await AstroContainer.create();
  return html(
    await container.renderToString(SuggestionOptions, { props: { matches } }),
  );
};