Menu
A contextual list of selectable options, often triggered by a control or an input.
Designed for use in components, like Select and Lookup, that display a menu below another element (for example, a text input). This component renders a list of items, manages which item is selected, highlighted, and active, and handles keyboard navigation. It does not display the selected item or manage an input; the parent component needs to do that.
The selected
and expanded
props must be bound with v-model
, even if the parent component doesn't use them. Without these v-model
bindings, the menu won't function correctly.
The menu itself is not focusable; for keyboard navigation to work, the parent component needs to provide a focusable element, listen for keydown
events on that element, and pass those events to the menu by calling the delegateKeyNavigation
method.
WARNING
This is not a standalone component. It's intended for use inside other components, mainly within Codex. For example, the Select and Lookup components use this component internally.
Demos
Simple menu with input
- One
- Two
- Three
- Four
<template>
<div class="cdx-docs-input-with-menu">
<cdx-text-input
v-model="selectedValue"
class="cdx-docs-input-with-menu__input"
:aria-expanded="expanded"
@click="onClick"
@blur="expanded = false"
@keydown="onKeydown"
/>
<cdx-menu
ref="menu"
v-model:selected="selectedValue"
v-model:expanded="expanded"
:menu-items="menuItems"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { CdxMenu, CdxTextInput } from '@wikimedia/codex';
export default defineComponent( {
name: 'InputWithMenu',
components: {
CdxMenu,
CdxTextInput
},
setup() {
const menu = ref<InstanceType<typeof CdxMenu>>();
const selectedValue = ref<string|number>( '' );
const expanded = ref( false );
const menuItems = [
{ label: 'One', value: '1' },
{ label: 'Two', value: '2', disabled: true },
{ label: 'Three', value: '3' },
{ label: 'Four', value: '4' }
];
/**
* Delegate most keydowns on the text input to the Menu component. This
* allows the Menu component to enable keyboard navigation of the menu.
*
* @param e The keyboard event
*/
function onKeydown( e: KeyboardEvent ) {
// The menu component enables the space key to open and close the
// menu. However, for text inputs with menus, the space key should
// always insert a new space character in the input.
if ( e.key === ' ' ) {
return;
}
// Delegate all other key events to the Menu component.
menu.value?.delegateKeyNavigation( e );
}
function onClick(): void {
expanded.value = true;
}
return {
menu,
selectedValue,
expanded,
menuItems,
onKeydown,
onClick
};
}
} );
</script>
<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
.cdx-docs-input-with-menu {
// The Menu component is absolutely positioned, so we need `position: relative` here to
// position the menu relative to this div. This ensure the menu will align with the input.
position: relative;
&__input [ aria-expanded='true' ] {
border-bottom-left-radius: @border-radius-sharp;
border-bottom-right-radius: @border-radius-sharp;
}
}
</style>
Name | Value |
---|---|
View | |
Reading direction |
With custom menu item display
- One (value: 1)
- Two (value: 2)
- Three (value: 3)
- Four (value: 4)
<template>
<div class="cdx-docs-input-with-menu-custom-item-display">
<cdx-text-input
v-model="selectedValue"
class="cdx-docs-input-with-menu-custom-item-display__input"
:aria-expanded="expanded"
@click="onClick"
@blur="expanded = false"
@keydown="onKeydown"
/>
<cdx-menu
ref="menu"
v-model:selected="selectedValue"
v-model:expanded="expanded"
:menu-items="menuItems"
>
<template #default="{ menuItem }">
{{ menuItem.label }} (value: {{ menuItem.value }})
</template>
</cdx-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { CdxMenu, CdxTextInput } from '@wikimedia/codex';
export default defineComponent( {
name: 'InputWithMenuCustomItemDisplay',
components: {
CdxMenu,
CdxTextInput
},
setup() {
const menu = ref<InstanceType<typeof CdxMenu>>();
const selectedValue = ref<string|number>( '' );
const expanded = ref( false );
const menuItems = [
{ label: 'One', value: 1 },
{ label: 'Two', value: 2, disabled: true },
{ label: 'Three', value: 3 },
{ label: 'Four', value: 4 }
];
/**
* Delegate most keydowns on the text input to the Menu component. This
* allows the Menu component to enable keyboard navigation of the menu.
*
* @param e The keyboard event
*/
function onKeydown( e: KeyboardEvent ) {
// The menu component enables the space key to open and close the
// menu. However, for text inputs with menus, the space key should
// always insert a new space character in the input.
if ( e.key === ' ' ) {
return;
}
// Delegate all other key events to the Menu component.
menu.value?.delegateKeyNavigation( e );
}
function onClick(): void {
expanded.value = true;
}
return {
menu,
selectedValue,
expanded,
menuItems,
onKeydown,
onClick
};
}
} );
</script>
<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
.cdx-docs-input-with-menu-custom-item-display {
// The Menu component is absolutely positioned, so we need `position: relative` here to
// position the menu relative to this div. This ensure the menu will align with the input.
position: relative;
&__input [ aria-expanded='true' ] {
border-bottom-left-radius: @border-radius-sharp;
border-bottom-right-radius: @border-radius-sharp;
}
}
</style>
With interactive footer item
Use the footer
prop to add a special menu item that will appear at the end of the menu. When scrolling is enabled, the footer item is pinned to the bottom of the menu (see the next demo). The footer item can be customized via the default
slot, just like regular menu items.
See the TypeaheadSearch demos for a real-world example.
- One
- Two
- Three
- Four
- Footer item with value: menu-footer
<template>
<div class="cdx-docs-input-with-menu-footer">
<cdx-text-input
v-model="selectedValue"
class="cdx-docs-input-with-menu-footer__input"
:aria-expanded="expanded"
@click="onClick"
@blur="expanded = false"
@keydown="onKeydown"
/>
<cdx-menu
ref="menu"
v-model:selected="selectedValue"
v-model:expanded="expanded"
:menu-items="menuItems"
:footer="footer"
>
<template #default="{ menuItem }">
<!-- Custom template just for the footer item. -->
<template v-if="menuItem.value === 'menu-footer'">
Footer item with value: {{ menuItem.value }}
</template>
</template>
</cdx-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { CdxMenu, CdxTextInput } from '@wikimedia/codex';
export default defineComponent( {
name: 'InputWithMenuFooter',
components: {
CdxMenu,
CdxTextInput
},
setup() {
const menu = ref<InstanceType<typeof CdxMenu>>();
const selectedValue = ref<string|number>( '' );
const expanded = ref( false );
const menuItems = [
{ label: 'One', value: 1 },
{ label: 'Two', value: 2, disabled: true },
{ label: 'Three', value: 3 },
{ label: 'Four', value: 4 }
];
const footer = {
value: 'menu-footer'
};
/**
* Delegate most keydowns on the text input to the Menu component. This
* allows the Menu component to enable keyboard navigation of the menu.
*
* @param e The keyboard event
*/
function onKeydown( e: KeyboardEvent ) {
// The menu component enables the space key to open and close the
// menu. However, for text inputs with menus, the space key should
// always insert a new space character in the input.
if ( e.key === ' ' ) {
return;
}
// Delegate all other key events to the Menu component.
menu.value?.delegateKeyNavigation( e );
}
function onClick(): void {
expanded.value = true;
}
return {
menu,
selectedValue,
expanded,
menuItems,
footer,
onKeydown,
onClick
};
}
} );
</script>
<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
.cdx-docs-input-with-menu-footer {
// The Menu component is absolutely positioned, so we need `position: relative` here to
// position the menu relative to this div. This ensure the menu will align with the input.
position: relative;
&__input [ aria-expanded='true' ] {
border-bottom-left-radius: @border-radius-sharp;
border-bottom-right-radius: @border-radius-sharp;
}
}
</style>
With scrolling enabled
In the Menu component, all menu items will be shown by default and the height of the menu will grow to accommodate the menu items. To limit the number of menu items shown at once and enable scrolling within the menu, set the visibleItemLimit
prop to a positive number.
Although the default behavior in the Menu component is to show all menu items, some components that use the Menu component have a default visibleItemLimit
prop set.
This demo includes a footer item, which is "sticky" to the bottom of the menu.
- One
- Two
- Three
- Four
- Five
- Six
- Seven
- Eight
- Nine
- Ten
- Eleven
- Twelve
- Sticky footer item
<template>
<div class="cdx-docs-input-with-menu-scroll">
<cdx-text-input
v-model="selectedValue"
class="cdx-docs-input-with-menu-scroll__input"
:aria-expanded="expanded"
@click="onClick"
@blur="expanded = false"
@keydown="onKeydown"
/>
<cdx-menu
ref="menu"
v-model:selected="selectedValue"
v-model:expanded="expanded"
:menu-items="menuItems"
:footer="footer"
:visible-item-limit="itemLimit ? parseInt( `${itemLimit}` ) : null"
/>
<div class="cdx-docs-input-with-menu-scroll__items">
<label for="cdx-docs-input-with-menu-scroll__items-input">
Number of visible items in Menu (empty or 0 for show all):
</label>
<!-- TODO: replace with NumberInput once it exists. -->
<cdx-text-input
id="cdx-docs-input-with-menu-scroll__items-input"
v-model="itemLimit"
class="cdx-docs-input-with-menu-scroll__items__input"
type="number"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { CdxMenu, CdxTextInput } from '@wikimedia/codex';
export default defineComponent( {
name: 'InputWithMenuScroll',
components: {
CdxMenu,
CdxTextInput
},
setup() {
const menu = ref<InstanceType<typeof CdxMenu>>();
const selectedValue = ref( '' );
const expanded = ref( false );
const menuItems = [
{ label: 'One', value: '1' },
{ label: 'Two', value: '2' },
{ label: 'Three', value: '3' },
{ label: 'Four', value: '4' },
{ label: 'Five', value: '5' },
{ label: 'Six', value: '6' },
{ label: 'Seven', value: '7' },
{ label: 'Eight', value: '8' },
{ label: 'Nine', value: '9' },
{ label: 'Ten', value: '10' },
{ label: 'Eleven', value: '11' },
{ label: 'Twelve', value: '12' }
];
const itemLimit = ref( '6' );
const footer = {
value: 'menu-footer',
label: 'Sticky footer item'
};
/**
* Delegate most keydowns on the text input to the Menu component. This
* allows the Menu component to enable keyboard navigation of the menu.
*
* @param e The keyboard event
*/
function onKeydown( e: KeyboardEvent ) {
// The menu component enables the space key to open and close the
// menu. However, for text inputs with menus, the space key should
// always insert a new space character in the input.
if ( e.key === ' ' ) {
return;
}
// Delegate all other key events to the Menu component.
menu.value?.delegateKeyNavigation( e );
}
function onClick(): void {
expanded.value = true;
}
return {
menu,
selectedValue,
expanded,
menuItems,
footer,
itemLimit,
onKeydown,
onClick
};
}
} );
</script>
<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
.cdx-docs-input-with-menu-scroll {
// The Menu component is absolutely positioned, so we need `position: relative` here to
// position the menu relative to this div. This ensure the menu will align with the input.
position: relative;
&__input [ aria-expanded='true' ] {
border-bottom-left-radius: @border-radius-sharp;
border-bottom-right-radius: @border-radius-sharp;
}
&__items {
display: flex;
align-items: center;
flex-direction: row;
margin-top: @spacing-100;
&__input {
margin-left: @spacing-50;
input {
min-width: @size-250;
width: @size-250;
}
}
}
}
</style>
With no results message
If the no-results
slot is populated, the Menu component will automatically display it when there are zero menu items.
- No results found
<template>
<div class="cdx-docs-input-with-menu-no-results">
<cdx-text-input
v-model="selectedValue"
class="cdx-docs-input-with-menu__input"
:aria-expanded="expanded"
@click="onClick"
@blur="expanded = false"
@keydown="onKeydown"
/>
<cdx-menu
ref="menu"
v-model:selected="selectedValue"
v-model:expanded="expanded"
:menu-items="menuItems"
>
<template #no-results>
No results found
</template>
</cdx-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { CdxMenu, CdxTextInput, MenuItemData } from '@wikimedia/codex';
export default defineComponent( {
name: 'InputWithMenuNoResults',
components: {
CdxMenu,
CdxTextInput
},
setup() {
const menu = ref<InstanceType<typeof CdxMenu>>();
const selectedValue = ref<string|number>( '' );
const expanded = ref( false );
const menuItems: MenuItemData[] = [];
/**
* Delegate most keydowns on the text input to the Menu component. This
* allows the Menu component to enable keyboard navigation of the menu.
*
* @param e The keyboard event
*/
function onKeydown( e: KeyboardEvent ) {
// The menu component enables the space key to open and close the
// menu. However, for text inputs with menus, the space key should
// always insert a new space character in the input.
if ( e.key === ' ' ) {
return;
}
// Delegate all other key events to the Menu component.
menu.value?.delegateKeyNavigation( e );
}
function onClick(): void {
expanded.value = true;
}
return {
menu,
selectedValue,
expanded,
menuItems,
onKeydown,
onClick
};
}
} );
</script>
<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
.cdx-docs-input-with-menu-no-results {
// The Menu component is absolutely positioned, so we need `position: relative` here to
// position the menu relative to this div. This ensure the menu will align with the input.
position: relative;
&__input [ aria-expanded='true' ] {
border-bottom-left-radius: @border-radius-sharp;
border-bottom-right-radius: @border-radius-sharp;
}
}
</style>
Pending state
Pending state indicators can be displayed to indicate that menu items are being fetched. Set the pending
prop to true
to show the inline progress bar and "pending" message, which can be populated via the pending
slot. See TypeaheadSearch for a real-world implementation of this.
When there are no menu items (e.g. on an initial search), the inline progress bar and the "pending" message will display.
- Loading results...
<template>
<div class="cdx-docs-input-with-menu-pending">
<cdx-text-input
v-model="selectedValue"
class="cdx-docs-input-with-menu-pending__input"
:aria-expanded="expanded"
@click="expanded = true"
@blur="expanded = false"
@keydown="onKeydown"
/>
<cdx-menu
ref="menu"
v-model:selected="selectedValue"
v-model:expanded="expanded"
:menu-items="[]"
:show-pending="true"
>
<template #pending>
Loading results...
</template>
</cdx-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { CdxMenu, CdxTextInput } from '@wikimedia/codex';
export default defineComponent( {
name: 'InputWithMenuPending',
components: {
CdxMenu,
CdxTextInput
},
setup() {
const menu = ref<InstanceType<typeof CdxMenu>>();
const selectedValue = ref<string|number>( '' );
const expanded = ref( false );
/**
* Delegate most keydowns on the text input to the Menu component. This
* allows the Menu component to enable keyboard navigation of the menu.
*
* @param e The keyboard event
*/
function onKeydown( e: KeyboardEvent ) {
// The menu component enables the space key to open and close the
// menu. However, for text inputs with menus, the space key should
// always insert a new space character in the input.
if ( e.key === ' ' ) {
return;
}
// Delegate all other key events to the Menu component.
menu.value?.delegateKeyNavigation( e );
}
return {
menu,
selectedValue,
expanded,
onKeydown
};
}
} );
</script>
<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
.cdx-docs-input-with-menu-pending {
// The Menu component is absolutely positioned, so we need `position: relative` here to
// position the menu relative to this div. This ensure the menu will align with the input.
position: relative;
&__input [ aria-expanded='true' ] {
border-bottom-left-radius: @border-radius-sharp;
border-bottom-right-radius: @border-radius-sharp;
}
}
</style>
When there are menu items, only the inline progress bar will display.
- One
- Two
- Three
- Four
<template>
<div class="cdx-docs-input-with-menu-pending">
<cdx-text-input
v-model="selectedValue"
class="cdx-docs-input-with-menu-pending__input"
:aria-expanded="expanded"
@click="expanded = true"
@blur="expanded = false"
@keydown="onKeydown"
/>
<cdx-menu
ref="menu"
v-model:selected="selectedValue"
v-model:expanded="expanded"
class="cdx-docs-menu-pending"
:menu-items="menuItems"
:show-pending="true"
>
<template #pending>
Loading results...
</template>
</cdx-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { CdxMenu, CdxTextInput } from '@wikimedia/codex';
export default defineComponent( {
name: 'InputWithMenuPendingWithItems',
components: {
CdxMenu,
CdxTextInput
},
setup() {
const menu = ref<InstanceType<typeof CdxMenu>>();
const selectedValue = ref<string|number>( '' );
const expanded = ref( false );
const menuItems = [
{ label: 'One', value: '1' },
{ label: 'Two', value: '2', disabled: true },
{ label: 'Three', value: '3' },
{ label: 'Four', value: '4' }
];
/**
* Delegate most keydowns on the text input to the Menu component. This
* allows the Menu component to enable keyboard navigation of the menu.
*
* @param e The keyboard event
*/
function onKeydown( e: KeyboardEvent ) {
// The menu component enables the space key to open and close the
// menu. However, for text inputs with menus, the space key should
// always insert a new space character in the input.
if ( e.key === ' ' ) {
return;
}
// Delegate all other key events to the Menu component.
menu.value?.delegateKeyNavigation( e );
}
return {
menu,
selectedValue,
expanded,
menuItems,
onKeydown
};
}
} );
</script>
<style lang="less">
@import ( reference ) '@wikimedia/codex-design-tokens/theme-wikimedia-ui.less';
.cdx-docs-input-with-menu-pending {
// The Menu component is absolutely positioned, so we need `position: relative` here to
// position the menu relative to this div. This ensure the menu will align with the input.
position: relative;
&__input [ aria-expanded='true' ] {
border-bottom-left-radius: @border-radius-sharp;
border-bottom-right-radius: @border-radius-sharp;
}
}
</style>
Usage
Props
Prop name | Description | Type | Default |
---|---|---|---|
menuItems (required) | Menu items. See the MenuItemData type. | MenuItemData[] | |
footer | Interactive footer item. This is a special menu item which is pinned to the bottom of the menu. When scrolling is enabled within the menu, the footer item will always be visible at the bottom of the menu. When scrolling is not enabled, the footer item will simply appear as the last menu item. The footer item is selectable, like other menu items. | MenuItemData | null |
selected (required) | Value of the selected menu item, or undefined if no item is selected. Must be bound with v-model:selected .The property should be initialized to null rather than using a falsy value. | string|number|null | |
expanded (required) | Whether the menu is expanded. Must be bound with v-model:expanded . | boolean | |
showPending | Whether to display pending state indicators. Meant to indicate that new menu items are being fetched or computed. When true, the menu will expand if not already expanded, and an inline progress bar will display. If there are no menu items yet, a message can be displayed in the pending slot, e.g. "Loading results". | boolean | false |
visibleItemLimit | Limit the number of menu items to display before scrolling. Setting this prop to anything falsy will show all menu items. By default, all menu items are shown. | number|null | null |
showThumbnail | Whether menu item thumbnails (or a placeholder icon) should be displayed. | boolean | false |
boldLabel | Whether to bold menu item labels. | boolean | false |
hideDescriptionOverflow | Whether to hide description text overflow via an ellipsis. | boolean | false |
searchQuery | The search query to be highlighted within the menu items' titles. | string | '' |
showNoResultsSlot | Whether to show the no-results slot content.The Menu component automatically shows this slot when there is content in the no-results slot and there are zero menu items. However, some components may need to customize this behavior, e.g. to show the slot even when there is at least one menu item. This prop can be used to override the default Menu behavior.Possible values: null (default): the no-results slot will display only if there are zero menu items. true : the no-results slot will display, regardless of number of menu items. false : the no-results slot will not display, regardless of number of menu items. | boolean|null | null |
Methods
Method name | Description | Signature |
---|---|---|
getHighlightedMenuItem | Get the highlighted menu item, if any. | Returns: MenuItemDataWithId|null The highlighted menu item, or null if no item is highlighted. |
getHighlightedViaKeyboard | Get whether the last highlighted item was highlighted via the keyboard. | Returns: boolean Whether the last highlighted menu item was highlighted via keyboard. |
clearActive | Ensure no menu item is active. This unsets the active item if there is one. | Returns: void |
delegateKeyNavigation | Handles all necessary keyboard navigation. The parent component should listen for keydown events on its focusable element, and pass those events to this method. Events for arrow keys, tab and enter are handled by this method. If a different key was pressed, this method will return false to indicate that it didn't handle the event. | Params:
boolean Whether the event was handled |
Events
Event name | Properties | Description |
---|---|---|
menu-item-click | menuItem MenuItemDataWithId - The menu item that was clicked | When a menu item is clicked. Typically, components with menus will respond to the selected value change, but occasionally, a component might want to react specifically when a menu item is clicked. |
update:selected | selectedValue string|number|null - The .value property of the selected menu item, or null if no item is selected. | When the selected menu item changes. |
update:expanded | newValue boolean - The new expanded state (true for open, false for closed) | When the menu opens or closes. |
menu-item-keyboard-navigation | highlightedMenuItem MenuItemDataWithId - The menu item was highlighted | When a menu item is highlighted via keyboard navigation. |
load-more | When the user scrolls towards the bottom of the menu. If it is possible to add or load more menu items, then now would be a good moment so that the user can experience infinite scrolling. |
Slots
Name | Description | Bindings |
---|---|---|
pending | Message to indicate pending state. | |
no-results | Message to show if there are no menu items to display. | |
default | Display of an individual item in the menu | active boolean - Whether the current item is visually active |