Skip to main content
astrocraft-ui/ components · 101

Advanced Select

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/advanced-select

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/advanced-select --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/advanced-select --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

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

Tailwind theme, re-pointed onto Lumos tokens

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

Live demo

Advanced Select

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
`AdvancedSelect.astro``advanced-select` `advanced-select-check` `advanced-select-empty` `advanced-select-label` `advanced-select-native` `advanced-select-option` `advanced-select-option-label` `advanced-select-panel` `advanced-select-search` `advanced-select-trigger` `chevron``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
`data-empty``[aria-disabled]` `[aria-expanded]` `[aria-selected]` `[disabled]` `[hidden]`

Source

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

src/components/ui/forms/advanced-select/AdvancedSelect.astro
---
// src/components/ui/forms/advanced-select/AdvancedSelect.astro — headless primitive (see ../../README.md).
// A searchable custom select (single or multiple) backed by a real, visually-hidden native <select>
// so it still submits with a form. The trigger carries the shared `data-size` / `data-state` field
// surface; the panel is a role="listbox" of options with an optional sticky search box, checkmarks,
// and full keyboard support (↑/↓/Home/End/Enter/Esc). Pass `options` (value/label/disabled/selected);
// the native select stays the single source of truth for the value.
//
// structure.css visually hides that native <select>. That rule is NOT cosmetic: the element is
// `aria-hidden` and out of the tab order, so if it renders visibly a mouse user can change it
// directly, behind the script's back, and the trigger's label goes stale.
//
// The label reports emptiness as `data-empty="true|false"` (not `data-placeholder` — that name is
// taken; it holds the placeholder TEXT) so a theme can dim the placeholder state.
//
// ponytail: this one needs JS — without it you get an inert trigger over a hidden <select>. That's
// the deliberate trade for a styled/searchable control. Zero-JS callers should use the native
// `<Select>` primitive instead. Options are static and client-filtered (substring match);
// remote/async loading is out of scope — fetch + render server-side per project if needed.
import "../../../../styles/structure.css";

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

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

interface Option {
  value: string;
  label: string;
  disabled?: boolean;
  selected?: boolean;
}

type Props = HTMLAttributes<"button"> & {
  options: Option[];
  name?: string;
  multiple?: boolean;
  search?: boolean;
  placeholder?: string;
  searchPlaceholder?: string;
  /** For `multiple`: show "N <countText>" instead of listing labels, e.g. "3 selected". */
  countText?: string;
  emptyText?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
};

const {
  options,
  name,
  multiple = false,
  search = false,
  placeholder = "Select…",
  searchPlaceholder = "Search…",
  countText,
  emptyText = "No results",
  size = "md",
  state = "default",
  class: className,
  ...rest
} = Astro.props;

const panelId = `advanced-select-panel-${crypto.randomUUID().slice(0, 8)}`;
const chosen = options.filter((o) => o.selected);
const initialLabel =
  chosen.length === 0
    ? placeholder
    : multiple && countText
      ? `${chosen.length} ${countText}`
      : chosen.map((o) => o.label).join(", ");
---

<div data-slot="advanced-select" data-placeholder={placeholder} data-count-text={countText}>
  <select
    name={name}
    multiple={multiple || undefined}
    aria-hidden="true"
    tabindex="-1"
    data-slot="advanced-select-native"
  >
    {
      /* `|| undefined` guarantees Astro OMITS the boolean attr when false — passing `false` emits
        `multiple="false"` / `selected="false"`, which browsers read as present. */
    }
    {!multiple && <option value="" selected={chosen.length === 0 || undefined} />}
    {
      options.map((o) => (
        <option
          value={o.value}
          selected={o.selected || undefined}
          disabled={o.disabled || undefined}
        >
          {o.label}
        </option>
      ))
    }
  </select>

  <button
    type="button"
    role="combobox"
    aria-haspopup="listbox"
    aria-expanded="false"
    aria-controls={panelId}
    data-slot="advanced-select-trigger"
    data-size={size}
    data-state={state}
    class={className}
    {...rest}
  >
    <span data-slot="advanced-select-label" data-empty={String(chosen.length === 0)}
      >{initialLabel}</span
    >
    <slot name="chevron"><Chevron /></slot>
  </button>

  <div
    id={panelId}
    role="listbox"
    aria-multiselectable={multiple ? "true" : undefined}
    tabindex="-1"
    data-slot="advanced-select-panel"
    hidden
  >
    {
      search && (
        <input
          type="text"
          autocomplete="off"
          placeholder={searchPlaceholder}
          data-slot="advanced-select-search"
        />
      )
    }
    {
      options.map((o) => (
        <button
          type="button"
          role="option"
          tabindex="-1"
          aria-selected={o.selected ? "true" : "false"}
          aria-disabled={o.disabled ? "true" : undefined}
          data-slot="advanced-select-option"
          data-value={o.value}
        >
          <span data-slot="advanced-select-option-label">{o.label}</span>
          <slot name="indicator">
            <svg
              data-slot="advanced-select-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" />
            </svg>
          </slot>
        </button>
      ))
    }
    <p data-slot="advanced-select-empty" hidden>{emptyText}</p>
  </div>
</div>

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

  function wire(root: HTMLElement) {
    const select = root.querySelector<HTMLSelectElement>('[data-slot="advanced-select-native"]');
    const trigger = root.querySelector<HTMLButtonElement>('[data-slot="advanced-select-trigger"]');
    const panel = root.querySelector<HTMLElement>('[data-slot="advanced-select-panel"]');
    const labelEl = root.querySelector<HTMLElement>('[data-slot="advanced-select-label"]');
    const searchEl = root.querySelector<HTMLInputElement>('[data-slot="advanced-select-search"]');
    const empty = root.querySelector<HTMLElement>('[data-slot="advanced-select-empty"]');
    if (!select || !trigger || !panel || !labelEl) return;

    const options = [
      ...panel.querySelectorAll<HTMLElement>('[data-slot="advanced-select-option"]'),
    ];
    const multiple = select.multiple;
    const placeholder = root.dataset.placeholder ?? "";
    const countText = root.dataset.countText ?? "";

    const visible = () => options.filter((o) => !o.hidden);

    const syncVisual = () => {
      const chosen = [...select.selectedOptions].filter((o) => o.value !== "");
      const values = new Set(chosen.map((o) => o.value));
      options.forEach((o) =>
        o.setAttribute("aria-selected", String(values.has(o.dataset.value ?? ""))),
      );
      const isEmpty = chosen.length === 0;
      labelEl.dataset.empty = String(isEmpty);
      labelEl.textContent = isEmpty
        ? placeholder
        : multiple && countText
          ? `${chosen.length} ${countText}`
          : chosen.map((o) => o.text).join(", ");
    };

    const choose = (option: HTMLElement) => {
      const value = option.dataset.value ?? "";
      const nativeOpts = [...select.options];
      if (multiple) {
        const match = nativeOpts.find((o) => o.value === value);
        if (match) match.selected = !match.selected;
      } else {
        nativeOpts.forEach((o) => {
          o.selected = o.value === value;
        });
      }
      select.dispatchEvent(new Event("change", { bubbles: true }));
      syncVisual();
      if (!multiple) close(true);
    };

    const filter = () => filterByText(options, searchEl?.value ?? "", empty);

    const open = () => {
      panel.hidden = false;
      trigger.setAttribute("aria-expanded", "true");
      (searchEl ?? visible()[0])?.focus();
    };
    const close = (focusTrigger = false) => {
      panel.hidden = true;
      trigger.setAttribute("aria-expanded", "false");
      if (searchEl) searchEl.value = "";
      filter();
      if (focusTrigger) trigger.focus();
    };

    const focusBy = (dir: 1 | -1, from: HTMLElement) => {
      const vis = visible();
      const i = nextIndex(vis.length, vis.indexOf(from), dir);
      if (i >= 0) vis[i]?.focus();
    };

    trigger.addEventListener("click", () => {
      if (trigger.getAttribute("aria-expanded") === "true") close(true);
      else open();
    });
    trigger.addEventListener("keydown", (event) => {
      if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
        event.preventDefault();
        open();
      }
    });

    searchEl?.addEventListener("input", filter);
    searchEl?.addEventListener("keydown", (event) => {
      if (event.key === "ArrowDown") {
        event.preventDefault();
        visible()[0]?.focus();
      } else if (event.key === "Escape") {
        event.preventDefault();
        close(true);
      }
    });

    for (const o of options) {
      o.addEventListener("click", () => choose(o));
      o.addEventListener("keydown", (event) => {
        switch (event.key) {
          case "ArrowDown":
            event.preventDefault();
            focusBy(1, o);
            break;
          case "ArrowUp":
            event.preventDefault();
            focusBy(-1, o);
            break;
          case "Home":
            event.preventDefault();
            visible()[0]?.focus();
            break;
          case "End": {
            event.preventDefault();
            const vis = visible();
            vis[vis.length - 1]?.focus();
            break;
          }
          case "Enter":
          case " ":
            event.preventDefault();
            choose(o);
            break;
          case "Escape":
            event.preventDefault();
            close(true);
            break;
        }
      });
    }

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

    syncVisual();
  }

  onReadyOnce('[data-slot="advanced-select"]', wire);
</script>
src/components/ui/forms/advanced-select/index.ts
import AdvancedSelect from "./AdvancedSelect.astro";

export { AdvancedSelect };
export default AdvancedSelect;

What you get

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