Skip to content
Destyler UI Destyler UI Destyler UI

Menu

An accessible dropdown and context menu that is used to display a list of actions or options that a user can choose.

Features

Install

Install the component from your command line.

Terminal window
      
        
npm install @destyler/menu @destyler/vue
Terminal window
      
        
npm install @destyler/menu @destyler/react
Terminal window
      
        
npm install @destyler/menu @destyler/svelte
Terminal window
      
        
npm install @destyler/menu @destyler/solid

Anatomy

Import all parts and piece them together.

<script setup lang="ts">
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/vue'
import { computed, ref, useId } from 'vue'
const [state, send] = useMachine(menu.machine({
'id': useId(),
'aria-label': 'File',
}))
const api = computed(() => menu.connect(state.value, send, normalizeProps))
</script>
<template>
<div>
<button v-bind="api.getTriggerProps()" />
<Teleport to="body">
<div v-if="api.open" v-bind="api.getPositionerProps()">
<ul v-bind="api.getContentProps()">
<li v-bind="api.getItemProps({ value: 'item1-1' })" />
<li v-bind="api.getItemProps({ value: 'item1-2' })" />
<li v-bind="api.getItemProps({ value: 'item1-3' })" />
<li v-bind="api.getItemProps({ value: 'item1-4' })" />
<li v-bind="api.getItemProps({ value: 'item1-5' })" />
</ul>
<ul v-bind="api.getContentProps()">
<li v-bind="api.getItemProps({ value: 'item2-1' })" />
<li v-bind="api.getItemProps({ value: 'item2-2' })" />
<li v-bind="api.getItemProps({ value: 'item2-3' })" />
<li v-bind="api.getItemProps({ value: 'item2-4' })" />
<li v-bind="api.getItemProps({ value: 'item2-5' })" />
</ul>
</div>
</Teleport>
</div>
</template>
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/react'
import { useId } from 'react'
import { createPortal } from 'react-dom'
export default function Menu() {
const [state, send] = useMachine(menu.machine({
'id': useId(),
'aria-label': 'File',
}))
const api = menu.connect(state, send, normalizeProps)
return (
<div>
<button {...api.getTriggerProps()}></button>
{api.open && createPortal(
<div {...api.getPositionerProps()}>
<ul {...api.getContentProps()} >
<li {...api.getItemProps({ value: 'item1-1' })} />
<li {...api.getItemProps({ value: 'item1-2' })} />
<li {...api.getItemProps({ value: 'item1-3' })} />
<li {...api.getItemProps({ value: 'item1-4' })} />
<li {...api.getItemProps({ value: 'item1-5' })} />
</ul>
<ul {...api.getContentProps()} >
<li {...api.getItemProps({ value: 'item2-1' })} />
<li {...api.getItemProps({ value: 'item2-2' })} />
<li {...api.getItemProps({ value: 'item2-3' })} />
<li {...api.getItemProps({ value: 'item2-4' })} />
<li {...api.getItemProps({ value: 'item2-5' })} />
</ul>
</div>,
document.body,
)}
</div>
)
}
<script lang="ts">
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine, portal } from '@destyler/svelte'
const id = $props.id()
const [state, send] = useMachine(menu.machine({
id,
'aria-label': 'File',
}))
const api = $derived(menu.connect(state, send, normalizeProps))
</script>
<div>
<button {...api.getTriggerProps()} ></button>
{#if api.open}
<div use:portal>
<div {...api.getPositionerProps()}>
<ul {...api.getContentProps()}>
<li {...api.getItemProps({ value: 'item1-1' })}></li>
<li {...api.getItemProps({ value: 'item1-2' })}></li>
<li {...api.getItemProps({ value: 'item1-3' })}></li>
<li {...api.getItemProps({ value: 'item1-4' })}></li>
<li {...api.getItemProps({ value: 'item1-5' })}></li>
</ul>
<ul {...api.getContentProps()}>
<li {...api.getItemProps({ value: 'item2-1' })}></li>
<li {...api.getItemProps({ value: 'item2-2' })}></li>
<li {...api.getItemProps({ value: 'item2-3' })}></li>
<li {...api.getItemProps({ value: 'item2-4' })}></li>
<li {...api.getItemProps({ value: 'item2-5' })}></li>
</ul>
</div>
</div>
{/if}
</div>
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/solid'
import { createMemo, createUniqueId } from 'solid-js'
import { Portal } from 'solid-js/web'
export default function Menu() {
const [state, send] = useMachine(menu.machine({
'id': createUniqueId(),
'aria-label': 'File',
}))
const api = createMemo(() => menu.connect(state, send, normalizeProps))
return (
<div>
<button {...api().getTriggerProps()} ></button>
{api().open && (
<Portal>
<div {...api().getPositionerProps()}>
<ul {...api().getContentProps()}>
<li {...api().getItemProps({ value: 'item1-1' })} ></li>
<li {...api().getItemProps({ value: 'item1-2' })} ></li>
<li {...api().getItemProps({ value: 'item1-3' })} ></li>
<li {...api().getItemProps({ value: 'item1-4' })} ></li>
<li {...api().getItemProps({ value: 'item1-5' })} ></li>
</ul>
<ul {...api().getContentProps()}>
<li {...api().getItemProps({ value: 'item2-1' })} ></li>
<li {...api().getItemProps({ value: 'item2-2' })} ></li>
<li {...api().getItemProps({ value: 'item2-3' })} ></li>
<li {...api().getItemProps({ value: 'item2-4' })} ></li>
<li {...api().getItemProps({ value: 'item2-5' })} ></li>
</ul>
</div>
</Portal>
)}
</div>
)
}

Listening for item selection

When a menu item is clicked, the onSelect callback is invoked.

const [state, send] = useMachine(
menu.machine({
onSelect(details) {
// details => { value: string }
console.log("selected value is ", details.value)
},
}),
)

Listening for open state changes

When a menu is opened or closed, the onOpenChange callback is invoked.

const [state, send] = useMachine(
menu.machine({
onOpenChange(details) {
// details => { open: boolean }
console.log("open state is ", details.open)
},
}),
)

Grouping menu items

When the number of menu items gets much, it might be useful to group related menu items. To achieve this:

  • Wrap the menu items within an element.

  • Spread the api.getGroupProps(...) JSX properties unto the element, providing an id.

  • Render a label for the menu group, providing the id of the group element.

<script setup lang="ts">
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/vue'
import { computed, ref, useId } from 'vue'
const [state, send] = useMachine(menu.machine({
'id': useId(),
'aria-label': 'File',
}))
const api = computed(() => menu.connect(state.value, send, normalizeProps))
</script>
<template>
<div>
<button v-bind="api.getTriggerProps()" />
<Teleport to="body">
<div v-if="api.open" v-bind="api.getPositionerProps()">
<p v-bind="api.getLabelProps({ htmlFor: 'item' })">Item</p>
<ul v-bind="api.getContentProps({ id: 'item' })">
<li v-bind="api.getItemProps({ id:'item-1', value: 'item1' })" />
<li v-bind="api.getItemProps({ id:'item-2', value: 'item2' })" />
<li v-bind="api.getItemProps({ id:'item-3', value: 'item3' })" />
<li v-bind="api.getItemProps({ id:'item-4', value: 'item4' })" />
<li v-bind="api.getItemProps({ id:'item-5', value: 'item5' })" />
</ul>
</div>
</Teleport>
</div>
</template>
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/react'
import { useId } from 'react'
import { createPortal } from 'react-dom'
export default function Menu() {
const [state, send] = useMachine(menu.machine({
'id': useId(),
'aria-label': 'File',
}))
const api = menu.connect(state, send, normalizeProps)
return (
<div>
<button {...api.getTriggerProps()}></button>
{api.open && createPortal(
<div {...api.getPositionerProps()}>
<p {...api.getLabelProps({ htmlFor: 'item' })}>Item</p>
<ul {...api.getContentProps({ id: 'item' })} >
<li {...api.getItemProps({ id: 'item-1', value: 'item1' })} />
<li {...api.getItemProps({ id: 'item-2', value: 'item2' })} />
<li {...api.getItemProps({ id: 'item-3', value: 'item3' })} />
<li {...api.getItemProps({ id: 'item-4', value: 'item4' })} />
<li {...api.getItemProps({ id: 'item-5', value: 'item5' })} />
</ul>
</div>,
document.body,
)}
</div>
)
}
<script lang="ts">
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine, portal } from '@destyler/svelte'
const id = $props.id()
const [state, send] = useMachine(menu.machine({
id,
'aria-label': 'File',
}))
const api = $derived(menu.connect(state, send, normalizeProps))
</script>
<div>
<button {...api.getTriggerProps()} ></button>
{#if api.open}
<div use:portal>
<div {...api.getPositionerProps()}>
<p {...api.getLabelProps({ htmlFor: 'item' })}>Item</p>
<ul {...api.getContentProps({ id: 'item' })} >
<li {...api.getItemProps({ id: 'item-1', value: 'item1' })}></li>
<li {...api.getItemProps({ id: 'item-2', value: 'item2' })}></li>
<li {...api.getItemProps({ id: 'item-3', value: 'item3' })}></li>
<li {...api.getItemProps({ id: 'item-4', value: 'item4' })}></li>
<li {...api.getItemProps({ id: 'item-5', value: 'item5' })}></li>
</ul>
</div>
</div>
{/if}
</div>
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/solid'
import { createMemo, createUniqueId } from 'solid-js'
import { Portal } from 'solid-js/web'
export default function Menu() {
const [state, send] = useMachine(menu.machine({
'id': createUniqueId(),
'aria-label': 'File',
}))
const api = createMemo(() => menu.connect(state, send, normalizeProps))
return (
<div>
<button {...api().getTriggerProps()} ></button>
{api().open && (
<Portal>
<div {...api().getPositionerProps()}>
<p {...api().getLabelProps({ htmlFor: 'item' })}>Item</p>
<ul {...api().getContentProps()}>
<li {...api().getItemProps({ id: 'item-1', value: 'item1' })} ></li>
<li {...api().getItemProps({ id: 'item-2', value: 'item2' })} ></li>
<li {...api().getItemProps({ id: 'item-3', value: 'item3' })} ></li>
<li {...api().getItemProps({ id: 'item-4', value: 'item4' })} ></li>
<li {...api().getItemProps({ id: 'item-5', value: 'item5' })} ></li>
</ul>
</div>
</Portal>
)}
</div>
)
}

Checkbox and Radio option items

To use checkbox or radio option items, you’ll need to:

  • Add a value property to the machine’s context whose value is an object describing the state of the option items.

  • Use the api.getOptionItemProps(...) function to get the props for the option item.

A common requirement for the option item that you pass the name, value and type properties.

  • type — The type of option item. Either "checkbox" or "radio".

  • value — The value of the option item.

  • checked — The checked state of the option item.

  • onCheckedChange — The callback to invoke when the checked state changes.

<script setup lang="ts">
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/vue'
import { computed, ref, useId } from 'vue'
const data = ref({
radio: [
{ label: "Ascending", value: "asc" },
{ label: "Descending", value: "desc" },
{ label: "None", value: "none" },
],
checkbox: [
{ label: "Email", value: "email" },
{ label: "Phone", value: "phone" },
{ label: "Address", value: "address" },
],
})
const [state, send] = useMachine(menu.machine({
'id': useId(),
'aria-label': 'File',
}))
const api = computed(() => menu.connect(state.value, send, normalizeProps))
const radioRef = ref('')
const checkboxRef = ref([])
const radioDataList = computed(() =>
data.value.radio.map((item) => ({
label: item.label,
id: item.value,
type: "radio",
value: item.value,
checked: item.value === radioRef.value,
onCheckedChange(v) {
radioRef.value = v ? item.value : ""
},
})),
)
const checkboxDataList = computed(() =>
data.value.checkbox.map((item) => ({
id: item.value,
label: item.label,
type: "checkbox",
value: item.value,
checked: checkboxRef.value.includes(item.value),
onCheckedChange(v) {
checkboxRef.value = v
? [...checkboxRef.value, item.value]
: checkboxRef.value.filter((x) => x !== item.value)
},
})),
)
</script>
<template>
<div>
<button v-bind="api.getTriggerProps()" />
<Teleport to="body">
<div v-if="api.open" v-bind="api.getPositionerProps()">
<div v-bind="api.getContentProps()">
<div v-for="item in radioDataList" :key="item.value" v-bind="api.getOptionItemProps(item)">
<span v-bind="api.getItemIndicatorProps(item)"></span>
<span v-bind="api.getItemTextProps(item)">{{ item.label }}</span>
</div>
<hr v-bind="api.getSeparatorProps()" />
<div v-for="item in checkboxDataList" :key="item.value" v-bind="api.getOptionItemProps(item)">
<span v-bind="api.getItemIndicatorProps(item)"></span>
<span v-bind="api.getItemTextProps(item)">{{ item.label }}</span>
</div>
</div>
</div>
</Teleport>
</div>
</template>
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/react'
import { useId, useState } from 'react'
import { createPortal } from 'react-dom'
const data = {
radio: [
{ label: "Ascending", value: "asc" },
{ label: "Descending", value: "desc" },
{ label: "None", value: "none" },
],
checkbox: [
{ label: "Email", value: "email" },
{ label: "Phone", value: "phone" },
{ label: "Address", value: "address" },
],
}
export default function Menu() {
const [radio, setRadio] = useState("")
const [checkbox, setCheckbox] = useState([])
const [state, send] = useMachine(menu.machine({
'id': useId(),
'aria-label': 'File',
}))
const api = menu.connect(state, send, normalizeProps)
const radioDataList = data.radio.map((item) => ({
type: "radio",
name: "order",
value: item.value,
label: item.label,
checked: radio === item.value,
onCheckedChange: (checked) => setRadio(checked ? item.value : ""),
}))
const checkboxDataList = data.checkbox.map((item) => ({
type: "checkbox",
name: "type",
value: item.value,
label: item.label,
checked: checkbox.includes(item.value),
onCheckedChange: (checked) =>
setCheckbox((prev) =>
checked ? [...prev, item.value] : prev.filter((x) => x !== item.value),
),
}))
return (
<div>
<button {...api.getTriggerProps()}></button>
{api.open && createPortal(
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()} >
{radioDataList.map((item) => {
return (
<div key={item.value} {...api.getOptionItemProps(item)}>
<span {...api.getItemIndicatorProps(item)}></span>
<span {...api.getItemTextProps(item)}>{item.label}</span>
</div>
)
})}
<hr {...api.getSeparatorProps()} />
{checkboxDataList.map((item) => {
return (
<div key={item.value} {...api.getOptionItemProps(item)}>
<span {...api.getItemIndicatorProps(item)}></span>
<span {...api.getItemTextProps(item)}>{item.label}</span>
</div>
)
})}
</div>
</div>,
document.body,
)}
</div>
)
}
<script lang="ts">
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine, portal } from '@destyler/svelte'
const data = {
radio: [
{ label: "Ascending", value: "asc" },
{ label: "Descending", value: "desc" },
{ label: "None", value: "none" },
],
checkbox: [
{ label: "Email", value: "email" },
{ label: "Phone", value: "phone" },
{ label: "Address", value: "address" },
],
}
let radio = $state("")
let checkbox = $state<string[]>([])
const id = $props.id()
const [state, send] = useMachine(menu.machine({
id,
'aria-label': 'File',
}))
const api = $derived(menu.connect(state, send, normalizeProps))
const radioDataList = $derived(
data.radio.map((item) => ({
type: "radio" as const,
name: "order",
value: item.value,
label: item.label,
checked: radio === item.value,
onCheckedChange: (checked: boolean) => {
radio = checked ? item.value : ""
},
})),
)
const checkboxDataList = $derived(
data.checkbox.map((item) => ({
type: "checkbox" as const,
name: "type",
value: item.value,
label: item.label,
checked: checkbox.includes(item.value),
onCheckedChange: (checked: boolean) => {
checkbox = checked ? [...type, item.value] : checkbox.filter((x) => x !== item.value)
},
})),
)
</script>
<div>
<button {...api.getTriggerProps()} ></button>
{#if api.open}
<div use:portal>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()} >
{#each radioDataList as item}
<div {...api.getOptionItemProps(item)}>
<span {...api.getItemIndicatorProps(item)}></span>
<span {...api.getItemTextProps(item)}>{item.label}</span>
</div>
{/each}
<hr {...api.getSeparatorProps()} />
{#each checkboxDataList as item}
<div {...api.getOptionItemProps(item)}>
<span {...api.getItemIndicatorProps(item)}></span>
<span {...api.getItemTextProps(item)}>{item.label}</span>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
import * as menu from '@destyler/menu'
import { normalizeProps, useMachine } from '@destyler/solid'
import { createMemo, createUniqueId, createSignal, For } from 'solid-js'
import { Portal } from 'solid-js/web'
const data = {
radio: [
{ label: "Ascending", value: "asc" },
{ label: "Descending", value: "desc" },
{ label: "None", value: "none" },
],
checkbox: [
{ label: "Email", value: "email" },
{ label: "Phone", value: "phone" },
{ label: "Address", value: "address" },
],
}
export default function Menu() {
const [radio, setRadio] = createSignal("")
const [checkbox, setCheckbox] = createSignal([])
const [state, send] = useMachine(menu.machine({
'id': createUniqueId(),
'aria-label': 'File',
}))
const api = createMemo(() => menu.connect(state, send, normalizeProps))
const radioDataList = createMemo(() =>
data.radio.map((item) => ({
type: "radio",
value: item.value,
label: item.label,
checked: radio() === item.value,
onCheckedChange: (checked: boolean) =>
setRadio(checked ? item.value : ""),
})),
)
const checkboxDataList = createMemo(() =>
data.checkbox.map((item) => ({
type: "checkbox",
value: item.value,
label: item.label,
checked: checkbox().includes(item.value),
onCheckedChange: (checked: boolean) =>
setCheckbox((prev) =>
checked
? [...prev, item.value]
: prev.filter((x) => x !== item.value),
),
})),
)
return (
<div>
<button {...api().getTriggerProps()} ></button>
{api().open && (
<Portal>
<div {...api().getPositionerProps()}>
<div {...api().getContentProps()}>
<For each={radioDataList()}>
{(item) => (
<div {...api().getOptionItemProps(item)}>
<span {...api().getItemIndicatorProps(item)}></span>
<span {...api().getItemTextProps(item)}>
{item.label}
</span>
</div>
)}
</For>
<hr {...api().getSeparatorProps()} />
<For each={checkboxDataList()}>
{(item) => (
<div {...api().getOptionItemProps(item)}>
<span {...api().getItemIndicatorProps(item)}></span>
<span {...api().getItemTextProps(item)}>
{item.label}
</span>
</div>
)}
</For>
</div>
</div>
</Portal>
)}
</div>
)
}

Styling guide

Earlier, we mentioned that each QR Code part has a data-part attribute added to them to select and style them in the DOM.

Open and closed state

When the menu is open or closed, the content and trigger parts will have the data-state attribute.

[data-part="content"][data-state="open|closed"] {
/* styles for open or closed state */
}
[data-part="trigger"][data-state="open|closed"] {
/* styles for open or closed state */
}

Highlighted item state

When an item is highlighted, via keyboard navigation or pointer, it is given a data-highlighted attribute.

[data-part="item"][data-highlighted] {
/* styles for highlighted state */
}
[data-part="item"][data-type="radio|checkbox"][data-highlighted] {
/* styles for highlighted state */
}

Disabled item state

When an item or an option item is disabled, it is given a data-disabled attribute.

[data-part="item"][data-disabled] {
/* styles for disabled state */
}
[data-part="item"][data-type="radio|checkbox"][data-disabled] {
/* styles for disabled state */
}

Using arrows

When using arrows within the menu, you can style it using css variables.

[data-part="arrow"] {
--arrow-size: 20px;
--arrow-background: red;
}

Checked option item state

When an option item is checked, it is given a data-state attribute.

[data-part="item"][data-type="radio|checkbox"][data-state="checked"] {
/* styles for checked state */
}

Methods and Properties

Machine Context

The Menu machine exposes the following context properties:

ids
Partial<{ trigger: string; contextTrigger: string; content: string; groupLabel(id: string): string; group(id: string): string; positioner: string; arrow: string; }>
The ids of the elements in the menu. Useful for composition.
highlightedValue
string
The value of the highlighted menu item.
onHighlightChange
(details: HighlightChangeDetails) => void
Function called when the highlighted menu item changes.
onSelect
(details: SelectionDetails) => void
Function called when a menu item is selected.
anchorPoint
Point
The positioning point for the menu. Can be set by the context menu trigger or the button trigger.
loopFocus
boolean
Whether to loop the keyboard navigation.
positioning
PositioningOptions
The options used to dynamically position the menu
closeOnSelect
boolean
Whether to close the menu when an option is selected
aria-label
string
The accessibility label for the menu
open
boolean
Whether the menu is open
onOpenChange
(details: OpenChangeDetails) => void
Function called when the menu opens or closes
open.controlled
boolean
Whether the menu's open state is controlled by the user
typeahead
boolean
Whether the pressing printable characters should trigger typeahead navigation
composite
boolean
Whether the menu is a composed with other composite widgets like a combobox or tabs
navigate
(details: NavigateDetails) => void
Function to navigate to the selected item if it's an anchor element
dir
"ltr" | "rtl"
The document's text/writing direction.
id
string
The unique identifier of the machine.
getRootNode
() => ShadowRoot | Node | Document
A root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.
onEscapeKeyDown
(event: KeyboardEvent) => void
Function called when the escape key is pressed
onPointerDownOutside
(event: PointerDownOutsideEvent) => void
Function called when the pointer is pressed down outside the component
onFocusOutside
(event: FocusOutsideEvent) => void
Function called when the focus is moved outside the component
onInteractOutside
(event: InteractOutsideEvent) => void
Function called when an interaction happens outside the component

Machine API

The menu api exposes the following methods:

open
boolean
Whether the menu is open
setOpen
(open: boolean) => void
Function to open or close the menu
highlightedValue
string
The id of the currently highlighted menuitem
setHighlightedValue
(value: string) => void
Function to set the highlighted menuitem
setParent
(parent: Service) => void
Function to register a parent menu. This is used for submenus
setChild
(child: Service) => void
Function to register a child menu. This is used for submenus
reposition
(options?: Partial<PositioningOptions>) => void
Function to reposition the popover
getOptionItemState
(props: OptionItemProps) => OptionItemState
Returns the state of the option item
getItemState
(props: ItemProps) => ItemState
Returns the state of the menu item

Data Attributes

Trigger

name
desc
data-scope
menu
data-part
trigger
data-placement
The placement of the trigger
data-state
"open" | "closed"

Indicator

name
desc
data-scope
menu
data-part
indicator
data-state
"open" | "closed"

Content

name
desc
data-scope
menu
data-part
content
data-state
"open" | "closed"
data-placement
The placement of the content

Item

name
desc
data-scope
menu
data-part
item
data-disabled
Present when disabled
data-highlighted
Present when highlighted
data-valuetext
The human-readable value

Option Item

name
desc
data-scope
menu
data-part
option-item
data-type
The type of the item
data-value
The value of the item
data-state
"checked" | "unchecked"
data-disabled
Present when disabled
data-highlighted
Present when highlighted
data-valuetext
The human-readable value

Item Indicator

name
desc
data-scope
menu
data-part
item-indicator
data-disabled
Present when disabled
data-highlighted
Present when highlighted
data-state
"checked" | "unchecked"

Item Text

name
desc
data-scope
menu
data-part
item-text
data-disabled
Present when disabled
data-highlighted
Present when highlighted
data-state
"checked" | "unchecked"

Accessibility

Keyboard Interaction

name
desc
Space
Activates/Selects the highlighted item
Enter
Activates/Selects the highlighted item
ArrowDown
Highlights the next item in the menu
ArrowUp
Highlights the previous item in the menu
ArrowRight/ArrowLeft
When focus is on trigger, opens or closes the submenu depending on reading direction.
Esc
Closes the menu and moves focus to the trigger