DropdownMenu
composantA menu of actions opened by a button, on the popover API: Escape and a click outside close it and the focus goes back to the button.
Aperçu
Code
<twig:DropdownMenu id="actions">
<twig:DropdownMenu:Trigger>
<twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Actions</twig:Button>
</twig:DropdownMenu:Trigger>
<twig:DropdownMenu:Content>
<twig:DropdownMenu:Item as="a" href="/edit">Edit</twig:DropdownMenu:Item>
<twig:DropdownMenu:Separator />
<twig:DropdownMenu:Item variant="destructive">Delete</twig:DropdownMenu:Item>
</twig:DropdownMenu:Content>
</twig:DropdownMenu>
Code
<twig:DropdownMenu id="actions">
<twig:DropdownMenu:Trigger>
<twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Actions</twig:Button>
</twig:DropdownMenu:Trigger>
<twig:DropdownMenu:Content>
<twig:DropdownMenu:Item as="a" href="/edit">Edit</twig:DropdownMenu:Item>
<twig:DropdownMenu:Separator />
<twig:DropdownMenu:Item variant="destructive">Delete</twig:DropdownMenu:Item>
</twig:DropdownMenu:Content>
</twig:DropdownMenu>
Essayer
Changez les propriétés ; le serveur rend l’exemple de nouveau.
<twig:DropdownMenu id="actions">
<twig:DropdownMenu:Trigger>
<twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Actions</twig:Button>
</twig:DropdownMenu:Trigger>
<twig:DropdownMenu:Content>
<twig:DropdownMenu:Item as="a" href="/edit">Edit</twig:DropdownMenu:Item>
<twig:DropdownMenu:Separator />
<twig:DropdownMenu:Item variant="destructive">Delete</twig:DropdownMenu:Item>
</twig:DropdownMenu:Content>
</twig:DropdownMenu>
<twig:DropdownMenu id="actions">
<twig:DropdownMenu:Trigger>
<twig:Button variant="outline" {{ ...dropdown_menu_trigger_attrs }}>Actions</twig:Button>
</twig:DropdownMenu:Trigger>
<twig:DropdownMenu:Content>
<twig:DropdownMenu:Item as="a" href="/edit">Edit</twig:DropdownMenu:Item>
<twig:DropdownMenu:Separator />
<twig:DropdownMenu:Item variant="destructive">Delete</twig:DropdownMenu:Item>
</twig:DropdownMenu:Content>
</twig:DropdownMenu>
Variantes
open
Whether the menu is open on initial render.
side
Which side of the trigger the menu appears on.
align
Alignment of the menu along the cross axis.
Installation
php bin/console ux:install dropdown-menu --kit=github.com/parler-haut-interagir-librement/ux-kit
Propriétés
| Propriété | Type | Par défaut | Description |
|---|---|---|---|
open |
bool |
false |
Whether the menu is open on initial render. |
side |
'top'|'right'|'bottom'|'left' |
'bottom' |
Which side of the trigger the menu appears on. |
align |
'start'|'center'|'end' |
'start' |
Alignment of the menu along the cross axis. |
Accessibility
The menu of WAI-ARIA: the arrows, Home, End and the first letters move, Enter and Space choose, the right arrow opens a sub-menu and the left arrow closes it.
Fichiers à copier
assets/controllers/dropdown_menu_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* Where a box goes against its trigger: on the side asked for, or on the other one when it does not fit there and the
* other has more room; aligned along the cross axis, `start` and `end` following the reading direction, and pushed
* back inside the viewport. Every value is in viewport pixels, as `getBoundingClientRect()` gives them.
*/
export function placement(trigger, box, viewport, { side = 'bottom', align = 'center', gap = 6, rtl = false } = {}) {
const vertical = side === 'top' || side === 'bottom';
const size = vertical ? box.height : box.width;
const roomBefore = (vertical ? trigger.top : trigger.left) - gap;
const roomAfter = (vertical ? viewport.height - trigger.bottom : viewport.width - trigger.right) - gap;
const wantsBefore = side === 'top' || side === 'left';
const flipped = wantsBefore
? size > roomBefore && roomAfter > roomBefore
: size > roomAfter && roomBefore > roomAfter;
const before = wantsBefore !== flipped;
const main = before
? (vertical ? trigger.top : trigger.left) - gap - size
: (vertical ? trigger.bottom : trigger.right) + gap;
const crossStart = vertical ? trigger.left : trigger.top;
const crossEnd = vertical ? trigger.right : trigger.bottom;
const crossSize = vertical ? box.width : box.height;
const edge = vertical && rtl ? ({ start: 'end', end: 'start' }[align] ?? align) : align;
const cross =
edge === 'start' ? crossStart : edge === 'end' ? crossEnd - crossSize : (crossStart + crossEnd - crossSize) / 2;
const limit = (vertical ? viewport.width : viewport.height) - crossSize;
const clamped = Math.min(Math.max(0, cross), Math.max(0, limit));
const names = vertical ? ['top', 'bottom'] : ['left', 'right'];
return {
top: Math.round(vertical ? main : clamped),
left: Math.round(vertical ? clamped : main),
side: names[before ? 0 : 1],
};
}
/** How long the letters typed in a row add up, in milliseconds. */
export const TYPEAHEAD = 500;
const isDisabled = (item) => item.dataset.disabled === 'true';
/*
* A menu on the popover API, opened by its invoker and closed by the browser on Escape and on a click outside, the focus
* going back to the button. The controller places it, moves the focus between its items and runs their roles.
*/
export default class extends Controller {
static targets = ['trigger', 'content', 'item'];
static values = { open: { type: Boolean, default: false } };
#connected = false;
#focusOnOpen = 'first';
#typed = '';
#typedTimer = null;
#follow = () => this.#place();
connect() {
this.#apply();
this.#connected = true;
}
disconnect() {
this.#connected = false;
this.#stopFollowing();
clearTimeout(this.#typedTimer);
}
itemTargetConnected(item) {
item.tabIndex = -1;
}
toggle(event) {
if (event?.currentTarget?.hasAttribute('popovertarget')) {
return;
}
this.openValue ? this.close() : this.open();
}
open({ focus = 'first' } = {}) {
this.#focusOnOpen = focus;
this.openValue = true;
}
close({ focus = true } = {}) {
this.openValue = false;
if (focus && this.hasTriggerTarget) {
this.triggerTarget.focus();
}
}
sync(event) {
const open = event.newState === 'open';
if (open) {
this.#place();
window.addEventListener('scroll', this.#follow, { passive: true, capture: true });
window.addEventListener('resize', this.#follow, { passive: true });
} else {
this.#stopFollowing();
}
this.openValue = open;
}
openValueChanged() {
if (!this.#connected) {
return;
}
this.#apply();
if (this.openValue && this.#focusOnOpen) {
const items = this.#itemsOf(this.contentTarget);
items[this.#focusOnOpen === 'last' ? items.length - 1 : 0]?.focus();
}
this.#focusOnOpen = 'first';
}
closeFromItem(event) {
const item = event.currentTarget;
if (isDisabled(item)) {
event.preventDefault();
return;
}
this.#closeAfter(item);
}
toggleCheckbox(event) {
const item = event.currentTarget;
if (isDisabled(item)) {
event.preventDefault();
return;
}
const checked = item.getAttribute('aria-checked') !== 'true';
item.setAttribute('aria-checked', String(checked));
this.#indicate(item, '[data-checkbox-indicator]', checked);
this.#closeAfter(item);
}
selectRadio(event) {
const item = event.currentTarget;
if (isDisabled(item)) {
event.preventDefault();
return;
}
const group = item.closest('[data-slot="dropdown-menu-radio-group"]');
if (!group) {
return;
}
group.dataset.value = item.dataset.value;
for (const radio of group.querySelectorAll('[role="menuitemradio"]')) {
radio.setAttribute('aria-checked', String(radio === item));
this.#indicate(radio, '[data-radio-indicator]', radio === item);
}
this.#closeAfter(item);
}
onTriggerKeydown(event) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.open({ focus: event.key === 'ArrowDown' ? 'first' : 'last' });
}
}
onContentKeydown(event) {
const item = event.target.closest('[role^="menuitem"]');
const menu = item ? item.closest('[role="menu"]') : this.contentTarget;
const items = this.#itemsOf(menu);
const index = items.indexOf(item);
const count = items.length;
const rtl = getComputedStyle(menu).direction === 'rtl';
switch (event.key) {
case 'ArrowDown':
this.#move(event, items[(index + 1) % count]);
break;
case 'ArrowUp':
this.#move(event, items[index <= 0 ? count - 1 : index - 1]);
break;
case 'Home':
this.#move(event, items[0]);
break;
case 'End':
this.#move(event, items[count - 1]);
break;
case 'ArrowRight':
case 'ArrowLeft':
if ((event.key === 'ArrowRight') !== rtl) {
this.#openSub(event, item);
} else {
this.#closeSub(event, menu);
}
break;
case 'Escape':
// The browser gives the focus back only when the outermost popover closes.
this.#closeSub(event, menu);
break;
case 'Enter':
case ' ':
if (item && item.tagName !== 'BUTTON') {
event.preventDefault();
item.click();
}
break;
case 'Tab':
this.close({ focus: false });
break;
default:
this.#type(event, items);
}
}
#itemsOf(menu) {
return this.itemTargets.filter((item) => item.closest('[role="menu"]') === menu && !isDisabled(item));
}
#indicate(item, selector, shown) {
const indicator = item.querySelector(selector);
if (indicator) {
indicator.hidden = !shown;
}
}
#closeAfter(item) {
if (item.dataset.closeOnSelect === 'true') {
this.close();
}
}
#move(event, item) {
event.preventDefault();
item?.focus();
}
#openSub(event, item) {
if (item?.getAttribute('aria-haspopup') !== 'menu') {
return;
}
event.preventDefault();
const sub = item.parentElement.querySelector('[role="menu"]');
if (!sub.matches(':popover-open')) {
sub.showPopover();
}
this.#itemsOf(sub)[0]?.focus();
}
#closeSub(event, menu) {
if (menu === this.contentTarget) {
return;
}
event.preventDefault();
menu.hidePopover();
menu.parentElement.querySelector('[aria-haspopup="menu"]')?.focus();
}
#type(event, items) {
if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
clearTimeout(this.#typedTimer);
this.#typed += event.key.toLocaleLowerCase();
this.#typedTimer = setTimeout(() => {
this.#typed = '';
}, TYPEAHEAD);
items.find((item) => item.textContent.trim().toLocaleLowerCase().startsWith(this.#typed))?.focus();
}
#apply() {
const open = this.openValue;
const state = open ? 'open' : 'closed';
this.element.dataset.state = state;
if (this.hasTriggerTarget) {
this.triggerTarget.dataset.state = state;
this.triggerTarget.setAttribute('aria-expanded', String(open));
}
if (this.hasContentTarget) {
this.contentTarget.dataset.state = state;
if (open !== this.contentTarget.matches(':popover-open')) {
open ? this.contentTarget.showPopover() : this.contentTarget.hidePopover();
}
}
}
#place() {
if (!this.hasTriggerTarget) {
return;
}
const content = this.contentTarget;
const { top, left, side } = placement(
this.triggerTarget.getBoundingClientRect(),
{ width: content.offsetWidth, height: content.offsetHeight },
{ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight },
{
side: content.dataset.side,
align: content.dataset.align,
rtl: getComputedStyle(content).direction === 'rtl',
},
);
content.style.inset = `${top}px auto auto ${left}px`;
content.dataset.placed = side;
}
#stopFollowing() {
window.removeEventListener('scroll', this.#follow, { capture: true });
window.removeEventListener('resize', this.#follow);
}
}
assets/controllers/dropdown_menu_sub_controller.js
import { Controller } from '@hotwired/stimulus';
/**
* Where a box goes against its trigger: on the side asked for, or on the other one when it does not fit there and the
* other has more room; aligned along the cross axis, `start` and `end` following the reading direction, and pushed
* back inside the viewport. Every value is in viewport pixels, as `getBoundingClientRect()` gives them.
*/
export function placement(trigger, box, viewport, { side = 'bottom', align = 'center', gap = 6, rtl = false } = {}) {
const vertical = side === 'top' || side === 'bottom';
const size = vertical ? box.height : box.width;
const roomBefore = (vertical ? trigger.top : trigger.left) - gap;
const roomAfter = (vertical ? viewport.height - trigger.bottom : viewport.width - trigger.right) - gap;
const wantsBefore = side === 'top' || side === 'left';
const flipped = wantsBefore
? size > roomBefore && roomAfter > roomBefore
: size > roomAfter && roomBefore > roomAfter;
const before = wantsBefore !== flipped;
const main = before
? (vertical ? trigger.top : trigger.left) - gap - size
: (vertical ? trigger.bottom : trigger.right) + gap;
const crossStart = vertical ? trigger.left : trigger.top;
const crossEnd = vertical ? trigger.right : trigger.bottom;
const crossSize = vertical ? box.width : box.height;
const edge = vertical && rtl ? ({ start: 'end', end: 'start' }[align] ?? align) : align;
const cross =
edge === 'start' ? crossStart : edge === 'end' ? crossEnd - crossSize : (crossStart + crossEnd - crossSize) / 2;
const limit = (vertical ? viewport.width : viewport.height) - crossSize;
const clamped = Math.min(Math.max(0, cross), Math.max(0, limit));
const names = vertical ? ['top', 'bottom'] : ['left', 'right'];
return {
top: Math.round(vertical ? main : clamped),
left: Math.round(vertical ? clamped : main),
side: names[before ? 0 : 1],
};
}
/*
* A sub-menu on a nested popover: the pointer opens and closes it after the delays asked for; the keyboard goes through
* the controller of the menu that holds it.
*/
export default class extends Controller {
static targets = ['trigger', 'content'];
static values = {
openDelay: { type: Number, default: 0 },
closeDelay: { type: Number, default: 0 },
};
#timer = null;
disconnect() {
clearTimeout(this.#timer);
}
show() {
this.#after(this.openDelayValue, () => {
if (!this.contentTarget.matches(':popover-open')) {
this.contentTarget.showPopover();
}
});
}
hide() {
this.#after(this.closeDelayValue, () => {
if (this.contentTarget.matches(':popover-open')) {
this.contentTarget.hidePopover();
}
});
}
sync(event) {
const open = event.newState === 'open';
const state = open ? 'open' : 'closed';
this.contentTarget.dataset.state = state;
this.triggerTarget.dataset.state = state;
this.triggerTarget.setAttribute('aria-expanded', String(open));
if (open) {
this.#place();
}
}
#after(delay, action) {
clearTimeout(this.#timer);
if (delay > 0) {
this.#timer = setTimeout(action, delay);
} else {
action();
}
}
#place() {
const content = this.contentTarget;
const direction = getComputedStyle(content).direction === 'rtl' ? 'left' : 'right';
const { top, left, side } = placement(
this.triggerTarget.getBoundingClientRect(),
{ width: content.offsetWidth, height: content.offsetHeight },
{ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight },
{ side: direction, align: 'start', gap: 2 },
);
content.style.inset = `${top}px auto auto ${left}px`;
content.dataset.placed = side;
}
}
templates/components/DropdownMenu.html.twig
{%- props
## string Unique identifier used to generate the internal DropdownMenu IDs.
id,
## bool Whether the menu is open on initial render.
open = false,
## 'top'|'right'|'bottom'|'left' Which side of the trigger the menu appears on.
side = 'bottom',
## 'start'|'center'|'end' Alignment of the menu along the cross axis.
align = 'start'
-%}
{%- do provide('dropdownMenu.open', open) -%}
{%- do provide('dropdownMenu.side', side) -%}
{%- do provide('dropdownMenu.align', align) -%}
{%- do provide('dropdownMenu.triggerId', 'dropdown-menu-' ~ id ~ '-trigger') -%}
{%- do provide('dropdownMenu.contentId', 'dropdown-menu-' ~ id ~ '-content') -%}
<div data-slot="dropdown-menu" data-state="{{ open ? 'open' : 'closed' }}" data-dropdown-menu-open-value="{{ open ? 'true' : 'false' }}"{{ attributes.defaults({class: 'isDropdown', 'data-controller': 'dropdown-menu'}) }}>
{##- A `DropdownMenu:Trigger` and a `DropdownMenu:Content`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/CheckboxItem.html.twig
{%- props
## bool Whether the checkbox is initially checked.
checked = false,
## bool Whether the item is disabled.
disabled = false,
## bool Whether selecting the item closes the menu.
closeOnSelect = false
-%}
<button type="button" role="menuitemcheckbox" data-slot="dropdown-menu-checkbox-item" data-dropdown-menu-target="item" data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}" data-disabled="{{ disabled ? 'true' : 'false' }}" aria-checked="{{ checked ? 'true' : 'false' }}" aria-disabled="{{ disabled ? 'true' : 'false' }}"{% if disabled %} disabled{% endif %}{{ attributes.defaults({class: 'isDropdownItem', 'data-action': 'click->dropdown-menu#toggleCheckbox'}) }}>
<span class="isDropdownIndicator" aria-hidden="true" data-checkbox-indicator{% if not checked %} hidden{% endif %}>✓</span>
{##- The label text. -#}
{%- block content %}{% endblock -%}
</button>
templates/components/DropdownMenu/Content.html.twig
<div id="{{ inject('dropdownMenu.contentId', null) }}" popover role="menu" tabindex="-1" aria-labelledby="{{ inject('dropdownMenu.triggerId', null) }}" data-slot="dropdown-menu-content" data-dropdown-menu-target="content" data-state="{{ inject('dropdownMenu.open', false) ? 'open' : 'closed' }}" data-side="{{ inject('dropdownMenu.side', 'bottom') }}" data-align="{{ inject('dropdownMenu.align', 'start') }}" data-action="keydown->dropdown-menu#onContentKeydown toggle->dropdown-menu#sync"{{ attributes.defaults({class: 'isDropdownMenu'}) }}>
{##- The menu items, e.g. `DropdownMenu:Item`, `DropdownMenu:Separator`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/Group.html.twig
<div role="group" data-slot="dropdown-menu-group"{{ attributes.defaults({class: 'isDropdownGroup'}) }}>
{##- One or more `DropdownMenu:Item` components. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/Item.html.twig
{%- props
## 'button'|'a'|'div' The HTML tag to render.
as = 'button',
## bool Whether the item is disabled.
disabled = false,
## bool Whether the item is inset with extra start padding.
inset = false,
## 'default'|'destructive' The visual style variant.
variant = 'default',
## bool Whether selecting the item closes the menu.
closeOnSelect = true
-%}
{%- set _tag = as in ['button', 'a', 'div'] ? as : 'button' -%}
<{{ _tag }} role="menuitem" data-slot="dropdown-menu-item" data-variant="{{ variant }}" data-inset="{{ inset ? 'true' : 'false' }}" data-dropdown-menu-target="item" data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}" data-disabled="{{ disabled ? 'true' : 'false' }}" aria-disabled="{{ disabled ? 'true' : 'false' }}"{% if _tag is same as('button') %} type="button"{% if disabled %} disabled{% endif %}{% endif %}{{ attributes.defaults({class: 'isDropdownItem' ~ (variant is same as('destructive') ? ' isDanger' : '') ~ (inset ? ' isInset' : ''), 'data-action': 'click->dropdown-menu#closeFromItem'}) }}>
{##- The item label. -#}
{%- block content %}{% endblock -%}
</{{ _tag }}>
templates/components/DropdownMenu/Label.html.twig
{%- props
## bool Whether the label is inset with extra start padding.
inset = false
-%}
<div data-slot="dropdown-menu-label" data-inset="{{ inset ? 'true' : 'false' }}"{{ attributes.defaults({class: 'isDropdownLabel' ~ (inset ? ' isInset' : '')}) }}>
{##- The label text. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/RadioGroup.html.twig
{%- props
## string|null The currently selected value.
value = null
-%}
<div role="group" data-slot="dropdown-menu-radio-group" data-value="{{ value }}"{{ attributes.defaults({class: 'isDropdownGroup'}) }}>
{##- One or more `DropdownMenu:RadioItem` components. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/RadioItem.html.twig
{%- props
## string The value submitted when this radio is selected.
value,
## bool Whether this radio is initially checked.
checked = false,
## bool Whether the item is disabled.
disabled = false,
## bool Whether selecting the item closes the menu.
closeOnSelect = false
-%}
<button type="button" role="menuitemradio" data-slot="dropdown-menu-radio-item" data-dropdown-menu-target="item" data-value="{{ value }}" data-close-on-select="{{ closeOnSelect ? 'true' : 'false' }}" data-disabled="{{ disabled ? 'true' : 'false' }}" aria-checked="{{ checked ? 'true' : 'false' }}" aria-disabled="{{ disabled ? 'true' : 'false' }}"{% if disabled %} disabled{% endif %}{{ attributes.defaults({class: 'isDropdownItem', 'data-action': 'click->dropdown-menu#selectRadio'}) }}>
<span class="isDropdownIndicator" aria-hidden="true" data-radio-indicator{% if not checked %} hidden{% endif %}>✓</span>
{##- The label text. -#}
{%- block content %}{% endblock -%}
</button>
templates/components/DropdownMenu/Separator.html.twig
<div role="separator" aria-orientation="horizontal" data-slot="dropdown-menu-separator"{{ attributes.defaults({class: 'isDropdownSeparator'}) }}></div>
templates/components/DropdownMenu/Shortcut.html.twig
<span data-slot="dropdown-menu-shortcut"{{ attributes.defaults({class: 'isDropdownShortcut'}) }}>
{##- The shortcut label, e.g. "Ctrl+K". -#}
{%- block content %}{% endblock -%}
</span>
templates/components/DropdownMenu/Sub.html.twig
{%- props
## number Delay in milliseconds before showing the sub-menu.
openDelay = 0,
## number Delay in milliseconds before hiding the sub-menu.
closeDelay = 0
-%}
<div data-slot="dropdown-menu-sub" data-dropdown-menu-sub-open-delay-value="{{ openDelay }}" data-dropdown-menu-sub-close-delay-value="{{ closeDelay }}"{{ attributes.defaults({class: 'isDropdownSub', 'data-controller': 'dropdown-menu-sub', 'data-action': 'mouseenter->dropdown-menu-sub#show mouseleave->dropdown-menu-sub#hide'}) }}>
{##- A `DropdownMenu:SubTrigger` and a `DropdownMenu:SubContent`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/SubContent.html.twig
<div popover role="menu" data-slot="dropdown-menu-sub-content" data-dropdown-menu-sub-target="content" data-state="closed"{{ attributes.defaults({class: 'isDropdownMenu', 'data-action': 'toggle->dropdown-menu-sub#sync'}) }}>
{##- The sub-menu items, e.g. `DropdownMenu:Item`. -#}
{%- block content %}{% endblock -%}
</div>
templates/components/DropdownMenu/SubTrigger.html.twig
{%- props
## bool Whether the trigger is inset with extra start padding.
inset = false
-%}
<button type="button" role="menuitem" aria-haspopup="menu" aria-expanded="false" data-slot="dropdown-menu-sub-trigger" data-inset="{{ inset ? 'true' : 'false' }}" data-dropdown-menu-target="item" data-dropdown-menu-sub-target="trigger" data-state="closed"{{ attributes.defaults({class: 'isDropdownItem' ~ (inset ? ' isInset' : ''), 'data-action': 'mouseenter->dropdown-menu-sub#show click->dropdown-menu-sub#show'}) }}>
{##- The sub-menu trigger label. -#}
{%- block content %}{% endblock -%}
<span class="isDropdownSubIcon" aria-hidden="true">›</span>
</button>
templates/components/DropdownMenu/Trigger.html.twig
{%- set dropdown_menu_trigger_attrs = {
id: inject('dropdownMenu.triggerId', null),
popovertarget: inject('dropdownMenu.contentId', null),
'data-slot': 'dropdown-menu-trigger',
'data-dropdown-menu-target': 'trigger',
'aria-haspopup': 'menu',
'aria-controls': inject('dropdownMenu.contentId', null),
'aria-expanded': 'false',
'data-state': 'closed',
'data-action': 'click->dropdown-menu#toggle keydown->dropdown-menu#onTriggerKeydown',
} -%}
{##- The trigger label (e.g., a `Button`) that opens the menu on click. -#}
{%- block content %}{% endblock -%}