Skip to main content
astrocraft-ui/ components · 101

Combobox Multi

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-multi

Plain-CSS theme — no build step

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

Tailwind theme — needs Tailwind v4

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

Plain-CSS theme, re-pointed onto Lumos tokens

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

Tailwind theme, re-pointed onto Lumos tokens

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

Live demo

ComboBoxMulti

Multi-select autocomplete over the same ComboBoxOptions the single-select ComboBox uses, with TagsInput's chips for what you have chosen.Enter toggles and clears the query, Backspace on an empty box removes the last chip. The active option is marked data-active so thataria-selected can keep meaning "chosen" — see the file header on why that costs twelve lines rather than a change to a frozen module.

    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
    `ComboBoxMulti.astro``combobox-empty` `combobox-multi` `combobox-multi-input` `combobox-multi-list` `combobox-multi-status` `combobox-multi-tags` `combobox-multi-template``data-size`: `sm` · `md` · `lg`
    `data-state`: `default` · `error` · `success`
    `data-active` `data-value` `data-option``[aria-expanded]` `[hidden]`

    Source

    What the command copies — 2 files, alias-free so the copy lands standing alone. This is the whole component, shown without its comments; the files you install keep them, with the reasoning behind every rule.

    src/components/ui/forms/combobox-multi/ComboBoxMulti.astro
    ---
    import type { HTMLAttributes } from "astro/types";
    
    import { TagsInputTag } from "../../forms/tags-input";
    
    type Props = HTMLAttributes<"input"> & {
      name?: string;
      label?: string;
      placeholder?: string;
      emptyText?: string;
      removeLabel?: string;
      max?: number;
      size?: "sm" | "md" | "lg";
      state?: "default" | "error" | "success";
    };
    
    const {
      name,
      label = "Choose options",
      placeholder = "Search…",
      emptyText = "No results",
      removeLabel = "Remove",
      max,
      size = "md",
      state = "default",
      class: className,
      ...rest
    } = Astro.props;
    
    const listId = `combobox-multi-${crypto.randomUUID().slice(0, 8)}`;
    ---
    
    <div data-slot="combobox-multi" data-max={max}>
      <ul data-slot="combobox-multi-tags"></ul>
      <input
        type="text"
        role="combobox"
        aria-expanded="false"
        aria-controls={listId}
        aria-autocomplete="list"
        aria-label={label}
        autocomplete="off"
        placeholder={placeholder}
        data-slot="combobox-multi-input"
        data-size={size}
        data-state={state}
        class={className}
        {...rest}
      />
      <ul
        id={listId}
        role="listbox"
        aria-multiselectable="true"
        aria-label={label}
        data-slot="combobox-multi-list"
        hidden
      >
        <slot />
        <li data-slot="combobox-empty" role="presentation" hidden>{emptyText}</li>
      </ul>
      <template data-slot="combobox-multi-template">
        <TagsInputTag name={name} removeLabel={removeLabel} />
      </template>
      <p role="status" aria-live="polite" data-slot="combobox-multi-status"></p>
    </div>
    
    <script>
      import { filterByText, nextIndex } from "../../_listbox";
      import { onReadyOnce } from "../../_once";
    
      let uid = 0;
    
      function wire(root: HTMLElement) {
        const input = root.querySelector<HTMLInputElement>('[data-slot="combobox-multi-input"]');
        const list = root.querySelector<HTMLElement>('[data-slot="combobox-multi-list"]');
        const tags = root.querySelector<HTMLElement>('[data-slot="combobox-multi-tags"]');
        const template = root.querySelector<HTMLTemplateElement>(
          '[data-slot="combobox-multi-template"]',
        );
        const status = root.querySelector<HTMLElement>('[data-slot="combobox-multi-status"]');
        const empty = root.querySelector<HTMLElement>('[data-slot="combobox-empty"]');
        if (!input || !list || !tags || !template) return;
    
        const options = [...list.querySelectorAll<HTMLElement>('[data-slot="combobox-option"]')];
        options.forEach((option) => (option.id ||= `combobox-multi-opt-${(uid += 1)}`));
        const max = Number(root.dataset.max);
    
        const active = () => options.find((option) => option.dataset.active === "true") ?? null;
        const setActive = (option: HTMLElement | null) => {
          for (const o of options) {
            if (o === option) o.dataset.active = "true";
            else delete o.dataset.active;
          }
          if (option) {
            input.setAttribute("aria-activedescendant", option.id);
            option.scrollIntoView({ block: "nearest" });
          } else {
            input.removeAttribute("aria-activedescendant");
          }
        };
        const move = (dir: 1 | -1) => {
          const visible = options.filter((option) => !option.hidden);
          const current = active();
          const i = nextIndex(visible.length, current ? visible.indexOf(current) : -1, dir);
          if (i >= 0) setActive(visible[i]);
        };
    
        const chosen = () =>
          options.filter((option) => option.getAttribute("aria-selected") === "true");
    
        const open = () => {
          list.hidden = false;
          input.setAttribute("aria-expanded", "true");
        };
        const close = () => {
          list.hidden = true;
          input.setAttribute("aria-expanded", "false");
          setActive(null);
        };
    
        const publish = () => {
          const selected = chosen();
          root.dataset.value = selected.map((option) => option.dataset.value ?? "").join(",");
          if (status) status.textContent = String(selected.length);
          root.dispatchEvent(new Event("change", { bubbles: true }));
        };
    
        const labelOf = (option: HTMLElement) =>
          option.dataset.label ?? (option.textContent ?? "").trim();
    
        const renderTags = () => {
          const row = template.content.querySelector('[data-slot="tags-input-tag"]');
          if (!row) return;
          tags.replaceChildren();
          for (const option of chosen()) {
            const value = option.dataset.value ?? labelOf(option);
            const chip = row.cloneNode(true) as HTMLElement;
            chip.dataset.value = value;
            chip.dataset.option = option.id;
            const text = chip.querySelector('[data-slot="tags-input-tag-label"]');
            const hidden = chip.querySelector<HTMLInputElement>('[data-slot="tags-input-value"]');
            const remove = chip.querySelector('[data-slot="tags-input-remove"]');
            if (text) text.textContent = labelOf(option);
            if (hidden) hidden.value = value;
            remove?.setAttribute(
              "aria-label",
              `${remove.getAttribute("data-label") ?? "Remove"} ${labelOf(option)}`,
            );
            tags.append(chip);
          }
        };
    
        const toggle = (option: HTMLElement) => {
          const isChosen = option.getAttribute("aria-selected") === "true";
          if (!isChosen && Number.isFinite(max) && chosen().length >= max) return;
          option.setAttribute("aria-selected", String(!isChosen));
          renderTags();
          publish();
        };
    
        input.addEventListener("focus", () => {
          filterByText(options, "", empty);
          open();
        });
        input.addEventListener("input", () => {
          open();
          filterByText(options, input.value, empty);
          setActive(options.find((option) => !option.hidden) ?? null);
        });
    
        input.addEventListener("keydown", (event) => {
          switch (event.key) {
            case "ArrowDown":
              event.preventDefault();
              if (list.hidden) open();
              move(1);
              break;
            case "ArrowUp":
              event.preventDefault();
              move(-1);
              break;
            case "Enter": {
              const option = active();
              if (!option) return;
              event.preventDefault();
              toggle(option);
              input.value = "";
              filterByText(options, "", empty);
              break;
            }
            case "Backspace": {
              if (input.value !== "") return;
              const last = chosen().at(-1);
              if (last) toggle(last);
              break;
            }
            case "Escape":
              close();
              break;
          }
        });
    
        for (const option of options) {
          option.addEventListener("mousedown", (event) => event.preventDefault());
          option.addEventListener("click", () => toggle(option));
          option.addEventListener("mousemove", () => setActive(option));
        }
    
        tags.addEventListener("click", (event) => {
          const target = event.target;
          if (!(target instanceof Element)) return;
          if (!target.closest('[data-slot="tags-input-remove"]')) return;
          const id = target.closest<HTMLElement>('[data-slot="tags-input-tag"]')?.dataset.option;
          const option = options.find((o) => o.id === id);
          if (option) toggle(option);
          input.focus();
        });
    
        root.addEventListener("focusout", (event) => {
          if (!root.contains(event.relatedTarget as Node)) close();
        });
    
        renderTags();
        publish();
      }
    
      onReadyOnce('[data-slot="combobox-multi"]', wire);
    </script>
    
    src/components/ui/forms/combobox-multi/index.ts
    import ComboBoxMulti from "./ComboBoxMulti.astro";
    
    export { ComboBoxMulti };
    export default ComboBoxMulti;
    

    What you get

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