Skip to main content
astrocraft-ui/ components · 101

Combobox

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/combobox

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/combobox --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/combobox --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/combobox --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/combobox --theme tailwind --bridge lumos

Live demo

ComboBox (Autocomplete)

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`ComboBox.astro``chevron` `combobox` `combobox-empty` `combobox-input` `combobox-list` `combobox-toggle` `combobox-value``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
`data-selected``[aria-expanded]` `[hidden]`
`ComboBoxOption.astro``combobox-check` `combobox-option` `combobox-option-label`——`[aria-selected]`

Source

What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/combobox/ComboBox.astro
---
// src/components/ui/forms/combobox/ComboBox.astro — headless primitive (see ../../README.md).
// Single-select autocomplete: a role="combobox" text input over a role="listbox" of <ComboBoxOption>s
// that filters as you type. Keyboard: ↑/↓ move the active option (via aria-activedescendant, focus
// stays in the input), Enter commits, Esc closes, Home/End jump. The visible input shows the label;
// when `name` is set, a hidden input carries the selected option's value so the form submits the
// value, not the label. The input carries the shared `data-size` / `data-state` field surface.
//
// The list is shown and hidden with the `hidden` ATTRIBUTE — never a class. That only wins over a
// theme's own `display` rule because structure.css declares `[hidden] { display: none !important }`;
// see the note in that file before you style the list's display.
//
// ponytail: client-side, static options only — the filter is a substring match on each option's text,
// there's no async/remote loading and no grouping. Upgrade path: fetch + render options server-side
// per page, or add a remote-source branch to the script below; the markup contract stays the same.
// The listbox is positioned inside the root wrapper, so an ancestor with `overflow: hidden` can clip
// it — lift it to the Popover API (like Dropdown) if a layout needs that.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

import Chevron from "../../_Chevron.astro";

type Props = HTMLAttributes<"input"> & {
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
  emptyText?: string;
};

const {
  size = "md",
  state = "default",
  name,
  placeholder = "Search…",
  emptyText = "No results",
  class: className,
  ...rest
} = Astro.props;
const listId = `combobox-list-${crypto.randomUUID().slice(0, 8)}`;
---

<div data-slot="combobox">
  <input
    type="text"
    role="combobox"
    aria-expanded="false"
    aria-controls={listId}
    aria-autocomplete="list"
    autocomplete="off"
    placeholder={placeholder}
    data-slot="combobox-input"
    data-size={size}
    data-state={state}
    class={className}
    {...rest}
  />
  {
    /* Carries the committed option's value for form submission — the visible input holds the label. */
  }
  {name && <input type="hidden" name={name} data-slot="combobox-value" />}
  <button
    type="button"
    tabindex="-1"
    aria-label="Toggle options"
    aria-expanded="false"
    data-slot="combobox-toggle"
  >
    <slot name="chevron"><Chevron /></slot>
  </button>
  <ul id={listId} role="listbox" data-slot="combobox-list" hidden>
    <slot />
    <li data-slot="combobox-empty" role="presentation" hidden>{emptyText}</li>
  </ul>
</div>

<script>
  import { createActiveDescendant, filterByText } from "../../_listbox";
  import { onReadyOnce } from "../../_once";

  let uid = 0;

  function wire(root: HTMLElement) {
    const input = root.querySelector<HTMLInputElement>('[data-slot="combobox-input"]');
    const toggle = root.querySelector<HTMLButtonElement>('[data-slot="combobox-toggle"]');
    const list = root.querySelector<HTMLElement>('[data-slot="combobox-list"]');
    const empty = root.querySelector<HTMLElement>('[data-slot="combobox-empty"]');
    const hidden = root.querySelector<HTMLInputElement>('[data-slot="combobox-value"]');
    if (!input || !list) return;
    const options = [...list.querySelectorAll<HTMLElement>('[data-slot="combobox-option"]')];
    options.forEach((o) => (o.id ||= `combobox-opt-${(uid += 1)}`));

    const rover = createActiveDescendant(input, options);

    const open = () => {
      list.hidden = false;
      input.setAttribute("aria-expanded", "true");
      toggle?.setAttribute("aria-expanded", "true");
    };
    const close = () => {
      list.hidden = true;
      input.setAttribute("aria-expanded", "false");
      toggle?.setAttribute("aria-expanded", "false");
      rover.setActive(null);
    };

    // Typing filters by the query; opening (focus / toggle) shows every option so a committed value
    // can still be changed — filtering on open would leave only the one already-selected label visible.
    const filter = () => {
      filterByText(options, input.value, empty);
      if (rover.active()?.hidden) rover.setActive(null);
    };
    const showAll = () => filterByText(options, "", empty);

    const commit = (option: HTMLElement) => {
      input.value = option.dataset.label ?? (option.textContent ?? "").trim();
      if (hidden) hidden.value = option.dataset.value ?? input.value;
      options.forEach((o) => (o.dataset.selected = String(o === option)));
      close();
      // `change` = value committed (what form frameworks listen for). Don't dispatch a synthetic
      // `input` too — that's the user-typing signal and would re-enter the input handler below.
      input.dispatchEvent(new Event("change", { bubbles: true }));
      input.focus();
    };

    input.addEventListener("focus", () => {
      input.select(); // select the committed label so the next keystroke replaces it
      showAll();
      open();
    });
    input.addEventListener("input", () => {
      open();
      filter();
      rover.setActive(rover.visible()[0] ?? null);
    });
    input.addEventListener("keydown", (event) => {
      const vis = rover.visible();
      switch (event.key) {
        case "ArrowDown":
          event.preventDefault();
          if (input.getAttribute("aria-expanded") !== "true") open();
          rover.move(1);
          break;
        case "ArrowUp":
          event.preventDefault();
          rover.move(-1);
          break;
        case "Home":
          if (vis.length) {
            event.preventDefault();
            rover.setActive(vis[0]);
          }
          break;
        case "End":
          if (vis.length) {
            event.preventDefault();
            rover.setActive(vis[vis.length - 1]);
          }
          break;
        case "Enter": {
          const a = rover.active();
          if (a) {
            event.preventDefault();
            commit(a);
          }
          break;
        }
        case "Escape":
          close();
          break;
      }
    });

    toggle?.addEventListener("click", () => {
      if (input.getAttribute("aria-expanded") === "true") {
        close();
      } else {
        showAll();
        open();
        input.focus();
      }
    });

    for (const o of options) {
      // mousedown fires before the input's blur, so preventDefault keeps focus in the input.
      o.addEventListener("mousedown", (event) => event.preventDefault());
      o.addEventListener("click", () => commit(o));
      o.addEventListener("mousemove", () => rover.setActive(o));
    }

    root.addEventListener("focusout", (event) => {
      if (!root.contains(event.relatedTarget as Node)) close();
    });
  }

  onReadyOnce('[data-slot="combobox"]', wire);
</script>
src/components/ui/forms/combobox/ComboBoxOption.astro
---
// src/components/ui/forms/combobox/ComboBoxOption.astro — ComboBox compound part (see ../../README.md).
// One role="option". The slotted content is both the label and what the filter matches on; pass
// `value` for a distinct stored value and `label` to override the text written into the input.
// The ComboBox script marks the committed option with `data-selected="true"` and the active one with
// `aria-selected="true"` — a theme reveals the check glyph off whichever it wants to signal.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"li"> & { value?: string; label?: string };

const { value, label, class: className, ...rest } = Astro.props;
---

<li
  role="option"
  aria-selected="false"
  data-slot="combobox-option"
  data-value={value}
  data-label={label}
  class={className}
  {...rest}
>
  <span data-slot="combobox-option-label"><slot /></span>
  <slot name="indicator"
    ><svg
      data-slot="combobox-check"
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2.5"
      stroke-linecap="round"
      stroke-linejoin="round"
      aria-hidden="true"
    >
      <path d="M20 6 9 17l-5-5"></path>
    </svg></slot
  >
</li>
src/components/ui/forms/combobox/index.ts
import ComboBox from "./ComboBox.astro";
import ComboBoxOption from "./ComboBoxOption.astro";

export { ComboBox, ComboBoxOption };
export default ComboBox;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/combobox — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.