<script>
window.basecoat = window.basecoat || {};
window.basecoat.registerDropdownMenu = function (Alpine) {
if (Alpine.components && Alpine.components.dropdownMenu) return;
Alpine.data("dropdownMenu", () => ({
open: false,
focusedIndex: null,
menuItems: [],
init() {
this.$nextTick(() => {
this.menuItems = Array.from(this.$el.querySelectorAll("[role=menuitem]:not([disabled]),[role=menuitemcheckbox]:not([disabled]),[role=menuitemradio]:not([disabled])"));
});
},
focusMenuitem() {
if (this.menuItems.length === 0) return;
if (this.focusedIndex >= this.menuItems.length) {
this.focusedIndex = this.menuItems.length - 1;
} else if (this.focusedIndex < 0 || this.focusedIndex === null) {
this.focusedIndex = 0;
}
this.menuItems.forEach((item) => item.blur());
this.menuItems[this.focusedIndex].focus();
},
moveMenuitemFocus(delta) {
if (this.menuItems.length === 0) return;
let wasOpen = this.open;
if (!this.open) {
this.open = true;
this.focusedIndex = 0;
} else {
this.focusedIndex = this.focusedIndex === null ? 0 : this.focusedIndex + delta;
}
if (wasOpen) {
this.focusMenuitem();
} else {
setTimeout(() => this.focusMenuitem(), 50);
}
},
handleMenuitemClick(event) {
const menuitem = event.target.closest("[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]");
if (menuitem && menuitem.getAttribute("aria-disabled") !== "true" && !menuitem.disabled) {
this.$nextTick(() => {
this.$refs.trigger.focus();
this.open = false;
});
}
},
handleMenuitemMousemove(event) {
const menuitem = event.target.closest("[role=menuitem],[role=menuitemcheckbox],[role=menuitemradio]");
if (menuitem && menuitem.getAttribute("aria-disabled") !== "true" && !menuitem.disabled) {
this.focusedIndex = this.menuItems.indexOf(menuitem);
this.focusMenuitem();
}
},
$trigger: {
"@click"() {
this.open = !this.open;
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveMenuitemFocus(+1);
},
"@keydown.up.prevent"() {
this.moveMenuitemFocus(-1);
},
"@keydown.home.prevent"() {
this.focusMenuitem(0);
},
"@keydown.end.prevent"() {
this.focusMenuitem(this.menuItems.length - 1);
},
"@keydown.enter.prevent"() {
this.open = !this.open;
},
":aria-expanded"() {
return this.open;
},
"x-ref": "trigger",
},
$content: {
"@click"(e) {
this.handleMenuitemClick(e);
},
"@keydown.escape.prevent"() {
this.open = false;
this.$refs.trigger.focus();
},
"@keydown.down.prevent"() {
this.moveMenuitemFocus(+1);
},
"@keydown.up.prevent"() {
this.moveMenuitemFocus(-1);
},
"@keydown.home.prevent"() {
this.focusMenuitem(0);
},
"@keydown.end.prevent"() {
this.focusMenuitem(this.menuItems.length - 1);
},
"@mouseover"(e) {
this.handleMenuitemMousemove(e);
},
":aria-hidden"() {
return !this.open;
},
"x-cloak": "",
},
}));
};
document.addEventListener("alpine:init", () => {
window.basecoat.registerDropdownMenu(Alpine);
});
</script>
<div class="popover" x-data="dropdownMenu" @click.away="open = false" id="dropdown-menu">
<button type="button" aria-haspopup="menu" aria-expanded="false" x-bind="$trigger" id="dropdown-menu-trigger" aria-controls="dropdown-menu-menu" class="btn-outline">Open</button>
<div data-popover aria-hidden="true" x-bind="$content" id="dropdown-menu-menu" class="min-w-56">
<nav role="menu">
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">My Account</span>
<button type="button" role="menuitem">
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
<button type="button" role="menuitem">
Keyboard shortcuts
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘K</span>
</button>
</div>
<hr role="separator" />
<button type="button" role="menuitem">GitHub</button>
<button type="button" role="menuitem">Support</button>
<button type="button" role="menuitem" disabled>API</button>
<hr role="separator" />
<button type="button" role="menuitem">
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
</nav>
</div>
</div>
Usage
HTML + Javascript
This component requires Javascript.
The component is structured as such:
- A
<div class="popover">
which wraps around the entire component and holds it state (e.g. open/close). - A
<button>
that acts as the trigger to open or close the popover. - A
<div data-popover>
that holds the content of the popover. - Inside of the
<div data-popover>
is a<nav role="menu">
that contains a combination of:<button role="menuitem">
for menu items.<button role="menuitemcheckbox">
for menu items with checkboxes.<button role="menuitemradio">
for menu items with radio buttons.<hr role="separator">
to display a horizontal separator.<div role="group">
to group options.<span role="heading">
for group headings.
As with the Popover component, 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.
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
).
Jinja and Nunjucks
You can use the dropdown_menu()
Nunjucks or Jinja macro for this component.
{% call dropdown_menu(
id="dropdown-menu",
trigger="Open",
trigger_attrs={"class": "btn-outline"},
content_attrs={"class": "min-w-56"},
register_on=["alpine:init", "htmx:afterSwap"]
) %}
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">My Account</span>
<button type="button" role="menuitem">
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
<button type="button" role="menuitem">
Keyboard shortcuts
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘K</span>
</button>
</div>
<hr role="separator">
<button type="button" role="menuitem">
GitHub
</button>
<button type="button" role="menuitem">
Support
</button>
<button type="button" role="menuitem" disabled>
API
</button>
<hr role="separator">
<button type="button" role="menuitem">
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
{% endcall %}
Examples
Checkboxes
<div class="popover" x-data="dropdownMenu" @click.away="open = false" id="dropdown-menu-checkboxes">
<button type="button" aria-haspopup="menu" aria-expanded="false" x-bind="$trigger" id="dropdown-menu-checkboxes-trigger" aria-controls="dropdown-menu-checkboxes-menu" class="btn-outline">Open</button>
<div data-popover aria-hidden="true" x-bind="$content" id="dropdown-menu-checkboxes-menu" class="min-w-56">
<nav role="menu">
<div role="group" aria-labelledby="account-options">
<span id="account-options" role="heading">Account Options</span>
<button type="button" role="menuitem">
<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">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Profile
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitem">
<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">
<rect width="20" height="14" x="2" y="5" rx="2" />
<line x1="2" x2="22" y1="10" y2="10" />
</svg>
Billing
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitem">
<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">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
Settings
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
<hr role="separator" />
<div role="group" aria-labelledby="appearance-options">
<span id="appearance-options" role="heading">Appearance</span>
<button type="button" role="menuitemcheckbox" aria-checked="true" class="group" x-data="{ checked: true }" @click="checked = !checked" :aria-checked="checked">
<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="invisible group-aria-checked:visible" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5" /></svg>
Status Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitemcheckbox" aria-checked="false" class="group" disabled x-data="{ checked: false }" @click="checked = !checked" :aria-checked="checked">
<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="invisible group-aria-checked:visible" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5" /></svg>
Activity Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitemcheckbox" aria-checked="false" class="group" x-data="{ checked: false }" @click="checked = !checked" :aria-checked="checked">
<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="invisible group-aria-checked:visible" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5" /></svg>
Panel
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
<hr role="separator" />
<button type="button" role="menuitem">
<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">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" x2="9" y1="12" y2="12" />
</svg>
Logout
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
</nav>
</div>
</div>
Radio Group
<div class="popover" x-data="dropdownMenu" @click.away="open = false" id="dropdown-menu-radio-group">
<button type="button" aria-haspopup="menu" aria-expanded="false" x-bind="$trigger" id="dropdown-menu-radio-group-trigger" aria-controls="dropdown-menu-radio-group-menu" class="btn-outline">Open</button>
<div data-popover aria-hidden="true" x-bind="$content" id="dropdown-menu-radio-group-menu" class="min-w-56">
<nav role="menu">
<div role="group" aria-labelledby="position-options" x-data="{ checked: 'bottom' }">
<span id="position-options" role="heading">Panel Position</span>
<hr role="separator" />
<button type="button" role="menuitemradio" aria-checked="false" class="group" @click="checked = 'top'" :aria-checked="checked == 'top'">
<div class="size-4 flex items-center justify-center">
<div class="size-2 rounded-full bg-foreground invisible group-aria-checked:visible" aria-hidden="true" , focusable="false"></div>
</div>
Status Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⇧⌘P</span>
</button>
<button type="button" role="menuitemradio" aria-checked="false" class="group" @click="checked = 'bottom'" :aria-checked="checked == 'bottom'">
<div class="size-4 flex items-center justify-center">
<div class="size-2 rounded-full bg-foreground invisible group-aria-checked:visible" aria-hidden="true" , focusable="false"></div>
</div>
Activity Bar
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘B</span>
</button>
<button type="button" role="menuitemradio" aria-checked="false" class="group" @click="checked = 'right'" :aria-checked="checked == 'right'">
<div class="size-4 flex items-center justify-center">
<div class="size-2 rounded-full bg-foreground invisible group-aria-checked:visible" aria-hidden="true" , focusable="false"></div>
</div>
Panel
<span class="text-muted-foreground ml-auto text-xs tracking-widest">⌘S</span>
</button>
</div>
</nav>
</div>
</div>