Aller au contenu

DropdownMenu

composant

A 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

Clair

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>

Sombre

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>

Variantes

open

Whether the menu is open on initial render.

open="false"
open="true"

side

Which side of the trigger the menu appears on.

side="top"
side="right"
side="bottom"
side="left"

align

Alignment of the menu along the cross axis.

align="start"
align="center"
align="end"

Installation

php bin/console ux:install dropdown-menu --kit=github.com/parler-haut-interagir-librement/ux-kit

Propriétés

PropriétéTypePar défautDescription
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 %}>&#10003;</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 %}>&#10003;</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">&rsaquo;</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 -%}