Advanced Select
Free · MITHeadless, 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
npx astrocraft-ui add forms/advanced-selectPlain-CSS theme — no build step
npx astrocraft-ui add forms/advanced-select --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/advanced-select --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/advanced-select --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/advanced-select --theme tailwind --bridge lumosLive demo
Advanced Select
No results
No results
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.
| Component | Slots | Variants | Runtime state | Native 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 — 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>
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.