<script>
window.basecoat = window.basecoat || {};
window.basecoat.registerSelect = function (Alpine) {
if (Alpine.components && Alpine.components.select) return;
Alpine.data("select", (name = null, initialValue = null) => ({
open: false,
name: null,
options: [],
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: "",
init() {
this.$nextTick(() => {
if (name) this.name = name;
this.options = Array.from(this.$el.querySelectorAll("[role=option]:not([aria-disabled])"));
this.disabledOptions = Array.from(this.$el.querySelectorAll("[role=option][aria-disabled=true]"));
if (this.options.length === 0) return;
if (initialValue) {
const option = this.options.find((opt) => opt.getAttribute("data-value") === initialValue);
this.selectOption(option, false);
this.focusedIndex = this.options.indexOf(option);
} else {
this.selectOption(this.options[0], false);
}
});
},
focusOption() {
if (this.options.length === 0) return;
if (this.focusedIndex >= this.options.length) {
this.focusedIndex = this.options.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.options.forEach((opt) => opt.removeAttribute("data-focus"));
const option = this.options[this.focusedIndex];
option.setAttribute("data-focus", "");
option.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
focusOnSelectedOption() {
if (this.options.length === 0 || this.selectedValue === null) return;
const option = this.options.find((opt) => opt.getAttribute("data-value") === this.selectedValue);
if (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
if (!this.open) {
this.open = true;
this.focusOnSelectedOption();
} else {
this.focusedIndex = this.focusedIndex === null ? 0 : this.focusedIndex + delta;
}
this.focusOption();
},
handleOptionClick(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.selectOption(option);
this.open = false;
this.$nextTick(() => this.$refs.trigger.focus());
}
},
handleOptionMousemove(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
}
},
handleOptionEnter(event) {
this.selectOption(this.options[this.focusedIndex]);
this.open = !this.open;
},
selectOption(option, dispatch = true) {
if (this.options.length === 0 || !option || option.disabled || option.getAttribute("data-value") == null) {
return;
}
this.options.forEach((opt) => {
opt.setAttribute("aria-selected", opt === option);
});
this.selectedLabel = option.innerHTML;
this.selectedValue = option.getAttribute("data-value");
if (dispatch) {
this.$dispatch("select:change", {
value: this.selectedValue,
label: this.selectedLabel,
});
this.$dispatch("change");
}
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach((opt) => {
opt.setAttribute("aria-hidden", "true");
});
} else {
this.disabledOptions.forEach((opt) => {
opt.removeAttribute("aria-hidden");
});
}
this.options.forEach((opt) => {
opt.removeAttribute("aria-hidden");
if (opt.getAttribute("data-value") != null && !opt.innerHTML.toLowerCase().includes(query.toLowerCase())) {
opt.setAttribute("aria-hidden", "true");
}
});
},
$trigger: {
"@click"() {
this.open = !this.open;
this.focusOnSelectedOption();
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
if (this.open) this.handleOptionEnter();
else this.open = true;
},
":aria-expanded"() {
return this.open;
},
"x-ref": "trigger",
},
$content: {
"@click"(e) {
this.handleOptionClick(e);
},
"@mouseover"(e) {
this.handleOptionMousemove(e);
},
":aria-hidden"() {
return !this.open;
},
"x-cloak": "",
},
$filter: {
"@input"(e) {
this.filterOptions(e.target.value);
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
this.handleOptionEnter();
},
},
}));
};
document.addEventListener("alpine:init", () => {
window.basecoat.registerSelect(Alpine);
});
</script>
<div class="popover" x-data="select('', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal w-[200px]">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0">
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content">
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input type="text" value="" placeholder="Search framework..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="-content" aria-labelledby="-trigger" x-model="query" x-bind="$filter" />
</header>
<div role="listbox" aria-orientation="vertical" data-empty="No framework found.">
<div role="option" data-value="Next.js">Next.js</div>
<div role="option" data-value="SvelteKit">SvelteKit</div>
<div role="option" data-value="Nuxt.js">Nuxt.js</div>
<div role="option" data-value="Remix">Remix</div>
<div role="option" data-value="Astro">Astro</div>
</div>
</div>
</div>
Usage
HTML + Javascript
This component requires Javascript.
Combobox uses the same Alpine.js code and markup as the Select component, the only difference being the search box at the top of the listbox.
You can set up a few additional options on the <div data-popover>
element:
data-side
can be set totop
,bottom
,left
, orright
to change the side of the popover.data-align
can be set tostart
,center
, orend
to change the alignment of the popover.data-empty
to set up the message when the search is empty.
You can include the Javascript code provided below, load it as an individual file or use the CLI. Some Alpine.js properties are also required on certain elements (e.g. x-bind
, x-data
, @click
).
<div class="popover" x-data="select('', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal w-[200px]">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0">
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content">
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input type="text" value="" placeholder="Search framework..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="-content" aria-labelledby="-trigger" x-model="query" x-bind="$filter" />
</header>
<div role="listbox" aria-orientation="vertical" data-empty="No framework found.">
<div role="option" data-value="Next.js">Next.js</div>
<div role="option" data-value="SvelteKit">SvelteKit</div>
<div role="option" data-value="Nuxt.js">Nuxt.js</div>
<div role="option" data-value="Remix">Remix</div>
<div role="option" data-value="Astro">Astro</div>
</div>
</div>
</div>
<script>
window.basecoat = window.basecoat || {};
window.basecoat.registerSelect = function (Alpine) {
if (Alpine.components && Alpine.components.select) return;
Alpine.data("select", (name = null, initialValue = null) => ({
open: false,
name: null,
options: [],
disabledOptions: [],
focusedIndex: null,
selectedLabel: null,
selectedValue: null,
query: "",
init() {
this.$nextTick(() => {
if (name) this.name = name;
this.options = Array.from(this.$el.querySelectorAll("[role=option]:not([aria-disabled])"));
this.disabledOptions = Array.from(this.$el.querySelectorAll("[role=option][aria-disabled=true]"));
if (this.options.length === 0) return;
if (initialValue) {
const option = this.options.find((opt) => opt.getAttribute("data-value") === initialValue);
this.selectOption(option, false);
this.focusedIndex = this.options.indexOf(option);
} else {
this.selectOption(this.options[0], false);
}
});
},
focusOption() {
if (this.options.length === 0) return;
if (this.focusedIndex >= this.options.length) {
this.focusedIndex = this.options.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.options.forEach((opt) => opt.removeAttribute("data-focus"));
const option = this.options[this.focusedIndex];
option.setAttribute("data-focus", "");
option.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
focusOnSelectedOption() {
if (this.options.length === 0 || this.selectedValue === null) return;
const option = this.options.find((opt) => opt.getAttribute("data-value") === this.selectedValue);
if (!option) return;
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
},
moveOptionFocus(delta) {
if (this.options.length === 0) return;
if (!this.open) {
this.open = true;
this.focusOnSelectedOption();
} else {
this.focusedIndex = this.focusedIndex === null ? 0 : this.focusedIndex + delta;
}
this.focusOption();
},
handleOptionClick(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.selectOption(option);
this.open = false;
this.$nextTick(() => this.$refs.trigger.focus());
}
},
handleOptionMousemove(event) {
const option = event.target.closest("[role=option]");
if (option && option.getAttribute("aria-disabled") !== "true") {
this.focusedIndex = this.options.indexOf(option);
this.focusOption();
}
},
handleOptionEnter(event) {
this.selectOption(this.options[this.focusedIndex]);
this.open = !this.open;
},
selectOption(option, dispatch = true) {
if (this.options.length === 0 || !option || option.disabled || option.getAttribute("data-value") == null) {
return;
}
this.options.forEach((opt) => {
opt.setAttribute("aria-selected", opt === option);
});
this.selectedLabel = option.innerHTML;
this.selectedValue = option.getAttribute("data-value");
if (dispatch) {
this.$dispatch("select:change", {
value: this.selectedValue,
label: this.selectedLabel,
});
this.$dispatch("change");
}
},
filterOptions(query) {
if (query.length > 0) {
this.disabledOptions.forEach((opt) => {
opt.setAttribute("aria-hidden", "true");
});
} else {
this.disabledOptions.forEach((opt) => {
opt.removeAttribute("aria-hidden");
});
}
this.options.forEach((opt) => {
opt.removeAttribute("aria-hidden");
if (opt.getAttribute("data-value") != null && !opt.innerHTML.toLowerCase().includes(query.toLowerCase())) {
opt.setAttribute("aria-hidden", "true");
}
});
},
$trigger: {
"@click"() {
this.open = !this.open;
this.focusOnSelectedOption();
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
if (this.open) this.handleOptionEnter();
else this.open = true;
},
":aria-expanded"() {
return this.open;
},
"x-ref": "trigger",
},
$content: {
"@click"(e) {
this.handleOptionClick(e);
},
"@mouseover"(e) {
this.handleOptionMousemove(e);
},
":aria-hidden"() {
return !this.open;
},
"x-cloak": "",
},
$filter: {
"@input"(e) {
this.filterOptions(e.target.value);
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveOptionFocus(+1);
},
"@keydown.up.prevent"() {
this.moveOptionFocus(-1);
},
"@keydown.home.prevent"() {
this.focusOption(0);
},
"@keydown.end.prevent"() {
this.focusOption(this.options.length - 1);
},
"@keydown.enter.prevent"() {
this.handleOptionEnter();
},
},
}));
};
document.addEventListener("alpine:init", () => {
window.basecoat.registerSelect(Alpine);
});
</script>
Jinja and Nunjucks
You can use the select()
Nunjucks or Jinja macro for this component. If you use one of the macros, make sure to set is_combobox
to True
(or true
for Nunjucks).
{% call select(
listbox_attrs={"data-empty": "No framework found."},
is_combobox=true
) %}
<div role="option" data-value="nextjs">Next.js</div>
<div role="option" data-value="sveltekit">SvelteKit</div>
<div role="option" data-value="nuxtjs">Nuxt.js</div>
<div role="option" data-value="remix">Remix</div>
<div role="option" data-value="astro">Astro</div>
{% endcall %}
Examples
Scrollable
<div class="popover" x-data="select('', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal w-[200px]">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0">
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content" class="w-72">
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input type="text" value="" placeholder="Search timezone..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="-content" aria-labelledby="-trigger" x-model="query" x-bind="$filter" />
</header>
<div role="listbox" aria-orientation="vertical" data-empty="No timezone found.">
<div class="max-h-64 overflow-y-auto scrollbar">
<div role="group" aria-labelledby="demo-combobox-timezones-group-0">
<span id="demo-combobox-timezones-group-0" role="heading">Americas</span>
<div role="option" data-value="America/New_York">(GMT-5) New York</div>
<div role="option" data-value="America/Los_Angeles">(GMT-8) Los Angeles</div>
<div role="option" data-value="America/Chicago">(GMT-6) Chicago</div>
<div role="option" data-value="America/Toronto">(GMT-5) Toronto</div>
<div role="option" data-value="America/Vancouver">(GMT-8) Vancouver</div>
<div role="option" data-value="America/Sao_Paulo">(GMT-3) São Paulo</div>
</div>
<div role="group" aria-labelledby="demo-combobox-timezones-group-1">
<span id="demo-combobox-timezones-group-1" role="heading">Europe</span>
<div role="option" data-value="Europe/London">(GMT+0) London</div>
<div role="option" data-value="Europe/Paris">(GMT+1) Paris</div>
<div role="option" data-value="Europe/Berlin">(GMT+1) Berlin</div>
<div role="option" data-value="Europe/Rome">(GMT+1) Rome</div>
<div role="option" data-value="Europe/Madrid">(GMT+1) Madrid</div>
<div role="option" data-value="Europe/Amsterdam">(GMT+1) Amsterdam</div>
</div>
<div role="group" aria-labelledby="demo-combobox-timezones-group-2">
<span id="demo-combobox-timezones-group-2" role="heading">Asia/Pacific</span>
<div role="option" data-value="Asia/Tokyo">(GMT+9) Tokyo</div>
<div role="option" data-value="Asia/Shanghai">(GMT+8) Shanghai</div>
<div role="option" data-value="Asia/Singapore">(GMT+8) Singapore</div>
<div role="option" data-value="Asia/Dubai">(GMT+4) Dubai</div>
<div role="option" data-value="Australia/Sydney">(GMT+11) Sydney</div>
<div role="option" data-value="Asia/Seoul">(GMT+9) Seoul</div>
</div>
</div>
<hr role="separator" />
<div role="option">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M8 12h8" />
<path d="M12 8v8" />
</svg>
Create timezone
</div>
</div>
</div>
</div>
Top side
<div class="popover" x-data="select('', '')" @click.away="open = false">
<button type="button" aria-haspopup="listbox" aria-expanded="false" x-bind="$trigger" class="btn-outline justify-between font-normal w-[200px]">
<div x-html="selectedLabel" class="flex items-center gap-x-2"></div>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0">
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</button>
<div data-popover aria-hidden="true" x-bind="$content" data-side="top">
<header>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search-icon lucide-search">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input type="text" value="" placeholder="Search framework..." autocomplete="off" autocorrect="off" spellcheck="false" aria-autocomplete="list" role="combobox" aria-expanded="true" aria-controls="-content" aria-labelledby="-trigger" x-model="query" x-bind="$filter" />
</header>
<div role="listbox" aria-orientation="vertical" data-empty="No framework found.">
<div role="option" data-value="Next.js">Next.js</div>
<div role="option" data-value="SvelteKit">SvelteKit</div>
<div role="option" data-value="Nuxt.js">Nuxt.js</div>
<div role="option" data-value="Remix">Remix</div>
<div role="option" data-value="Astro">Astro</div>
</div>
</div>
</div>