A list of commands opened by a button. It carries the full ARIA menu pattern: roving focus, nested submenus, and the browser stacking the panels so that one dismissal closes the branch.
A row carries a label, an icon at either end, and a tone: danger for what destroys something, neutral for everything else. disabled stops it responding and the arrow keys step over it, and href turns it into a real link. VMenuSeparator draws a rule between two runs of commands.
<script setup lang="ts">
import { VButton, VMenu, VMenuItem, VMenuSeparator } from 'vectis-ui'
import {
arrow_right_alt as arrowRightAlt,
attach_file as attachFile,
close,
cloud_upload as cloudUpload,
description,
info,
schedule,
} from 'vectis-ui/icons'
</script>
<template>
<VMenu>
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">Document</VButton>
</template>
<VMenuItem label="Open" :icon-start="description" />
<VMenuItem label="Upload a new version" :icon-start="cloudUpload" />
<VMenuItem label="Attach a file" :icon-start="attachFile" />
<VMenuSeparator />
<!-- `href` turns the row into a real link, so it can be middle-clicked and its
address copied. -->
<VMenuItem label="Documentation" href="#usage" :icon-start="info" :icon-end="arrowRightAlt" />
<VMenuSeparator />
<!-- A disabled row stops responding and the arrow keys step over it. -->
<VMenuItem label="Remind me later" :icon-start="schedule" disabled />
<!-- A row is an action, so what it means is a tone, the same word on the same prop
as a button. There is no accent: a menu has no primary command. -->
<VMenuItem label="Delete" :icon-start="close" tone="danger" />
</VMenu>
</template>
Sublabels
sublabel adds a second line under the label, for what the command does that its name does not say, or for the shortcut that triggers it.
vue
<script setup lang="ts">
import { VButton, VMenu, VMenuItem } from 'vectis-ui'
import { description, image, picture_as_pdf as pictureAsPdf } from 'vectis-ui/icons'
</script>
<template>
<VMenu width="20rem">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">Export</VButton>
</template>
<!-- A second line under the label: what the command does that its name does not
already say. The row grows to hold it and the icon stays centred on both. -->
<VMenuItem
label="PDF"
sublabel="Laid out for printing, one page per sheet"
:icon-start="pictureAsPdf"
/>
<VMenuItem label="CSV" sublabel="The raw rows, for a spreadsheet" :icon-start="description" />
<VMenuItem label="PNG" sublabel="An image of the chart alone" :icon-start="image" />
</VMenu>
</template>
Selection
selected marks the row currently in effect, colouring it and announcing it as the current choice. Choosing a row still closes the panel.
vue
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VMenu, VMenuItem } from 'vectis-ui'
import { check } from 'vectis-ui/icons'
const SORTS = [
{ value: 'name', label: 'Name' },
{ value: 'modified', label: 'Date modified' },
{ value: 'size', label: 'Size' },
]
const sort = ref('name')
const current = () => SORTS.find((option) => option.value === sort.value)?.label
</script>
<template>
<VMenu match-trigger>
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">
Sort by {{ current() }}
</VButton>
</template>
<!-- `selected` says which one is in effect right now. It colours the row and is
announced as the current choice, so the tick beside it is decoration rather
than the information itself. -->
<VMenuItem
v-for="option in SORTS"
:key="option.value"
:label="option.label"
:selected="sort === option.value"
:icon-end="sort === option.value ? check : undefined"
@select="sort = option.value"
/>
</VMenu>
</template>
Groups
VMenuGroup is a named block of commands. Its label is a heading: nothing happens when it is clicked and the arrow keys never stop on it.
vue
Create
Find
<script setup lang="ts">
import { VButton, VMenu, VMenuGroup, VMenuItem, VMenuSeparator } from 'vectis-ui'
import { close, description, image, schedule, search } from 'vectis-ui/icons'
</script>
<template>
<VMenu width="16rem">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">Workspace</VButton>
</template>
<!-- A group is a named block: its label is a heading and not a command, so nothing
happens on click and the arrows never stop on it. -->
<VMenuGroup label="Create">
<VMenuItem label="Document" :icon-start="description" />
<VMenuItem label="Image board" :icon-start="image" />
</VMenuGroup>
<VMenuSeparator />
<VMenuGroup label="Find">
<VMenuItem label="Search everything" :icon-start="search" />
<VMenuItem label="Recently opened" :icon-start="schedule" />
</VMenuGroup>
<VMenuSeparator />
<VMenuItem label="Leave this workspace" :icon-start="close" tone="danger" />
</VMenu>
</template>
Submenus
A row given a #submenu slot opens a panel of its own, and those panels may nest as deep as needed. Hovering the row opens it after a short delay, and the right and left arrows enter and leave it.
vue
<script setup lang="ts">
import { VButton, VMenu, VMenuItem, VMenuSeparator } from 'vectis-ui'
import { close, description, folder_zip as folderZip, image } from 'vectis-ui/icons'
</script>
<template>
<VMenu width="15rem">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">Document</VButton>
</template>
<VMenuItem label="Open" :icon-start="description" />
<!-- A row given a `#submenu` slot opens a panel of its own. Hovering it opens the
panel after a short delay; from the keyboard the right arrow enters it, and
the left arrow or Escape goes back one level. -->
<VMenuItem label="Export as" :icon-start="image">
<template #submenu>
<VMenuItem label="PDF" />
<VMenuItem label="PNG" />
<VMenuItem label="SVG" />
</template>
</VMenuItem>
<VMenuItem label="Move to" :icon-start="folderZip">
<template #submenu>
<VMenuItem label="Drafts" />
<VMenuItem label="Published" />
<VMenuSeparator />
<VMenuItem label="Archive" />
</template>
</VMenuItem>
<VMenuSeparator />
<VMenuItem label="Delete" :icon-start="close" tone="danger" />
</VMenu>
</template>
Sizes
size sets the row height to 32, 40 or 48 pixels, and compact takes 4px off it. It is set once on the menu, submenus reading it from there.
vue
<script setup lang="ts">
import { VButton, VMenu, VMenuItem } from 'vectis-ui'
import { description, image, schedule } from 'vectis-ui/icons'
const sizes = ['sm', 'md', 'lg'] as const
</script>
<template>
<!-- The size is set once on the menu and every row follows, submenus included: the
panel carries it and the rows read it from there. -->
<VMenu v-for="size in sizes" :key="size" :size="size">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" :size="size" variant="outline" tone="neutral">
{{ size }}
</VButton>
</template>
<VMenuItem label="Open" :icon-start="description" />
<VMenuItem label="Export" :icon-start="image" />
<VMenuItem label="Remind me later" :icon-start="schedule" />
</VMenu>
<VMenu size="md" compact>
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" size="md" compact variant="outline" tone="neutral">
md compact
</VButton>
</template>
<VMenuItem label="Open" :icon-start="description" />
<VMenuItem label="Export" :icon-start="image" />
<VMenuItem label="Remind me later" :icon-start="schedule" />
</VMenu>
</template>
Width
width replaces the panel's own floor and ceiling with any CSS length or keyword. matchTrigger replaces the floor alone, so the panel can no longer be narrower than the button that opened it. Both apply to the menu itself, submenus keeping the default.
vue
<script setup lang="ts">
import { VButton, VMenu, VMenuItem } from 'vectis-ui'
</script>
<template>
<!-- Left alone, the panel sits between a floor and a ceiling of its own. -->
<VMenu>
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">Default</VButton>
</template>
<VMenuItem label="10" />
<VMenuItem label="25" />
<VMenuItem label="50" />
</VMenu>
<!-- Any CSS length or keyword. `max-content` shrinks the panel to its longest row. -->
<VMenu width="max-content">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">max-content</VButton>
</template>
<VMenuItem label="10" />
<VMenuItem label="25" />
<VMenuItem label="50" />
</VMenu>
<VMenu width="22rem">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">22rem</VButton>
</template>
<VMenuItem label="Rename" />
<VMenuItem label="Duplicate" />
</VMenu>
<!-- The panel can no longer be narrower than the button that opened it, while
staying free to grow for a longer row. -->
<VMenu match-trigger>
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">
Match this wide trigger
</VButton>
</template>
<VMenuItem label="A to Z" />
<VMenuItem label="Z to A" />
</VMenu>
</template>
Placement
placement names the preferred opening direction of the panel, above or below the trigger.
vue
<script setup lang="ts">
import { VButton, VMenu, VMenuItem, type MenuPlacement } from 'vectis-ui'
const PLACEMENTS: MenuPlacement[] = [
'bottom-start',
'bottom',
'bottom-end',
'top-start',
'top',
'top-end',
]
</script>
<template>
<div class="grid">
<!-- Only the block axis is offered: a list of commands opening beside its button
would leave the reader looking in the wrong place. -->
<VMenu v-for="placement in PLACEMENTS" :key="placement" :placement="placement">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">
{{ placement }}
</VButton>
</template>
<VMenuItem label="Rename" />
<VMenuItem label="Duplicate" />
<VMenuItem label="Move to archive" />
</VMenu>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(3, max-content);
gap: var(--vectis-space-4);
/* Room above and below, so a menu is never flipped by a shortage of space. */
padding-block: var(--vectis-space-10);
}
</style>
Knowing whether it is open
v-model:open is fed by the panel as well as read from it: a click outside, Escape or choosing a command all write back to it. A menu opened from code still anchors itself to its trigger.
vue
The menu is closed.
<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VMenu, VMenuItem, VTypography } from 'vectis-ui'
const open = ref(false)
</script>
<template>
<div class="column">
<div class="row">
<VMenu v-model:open="open">
<template #trigger="{ triggerProps }">
<VButton v-bind="triggerProps" variant="outline" tone="neutral">Actions</VButton>
</template>
<VMenuItem label="Rename" />
<VMenuItem label="Duplicate" />
<VMenuItem label="Move to archive" />
</VMenu>
<VButton variant="ghost" tone="neutral" @click="open = true">Open from code</VButton>
<VButton variant="ghost" tone="neutral" @click="open = false">Close from code</VButton>
</div>
<!-- The model is fed BY the panel, so every dismissal writes back to it: a click
outside, Escape, or choosing a command. Nothing has to be reset by hand. -->
<VTypography variant="body-sm" tone="muted">
The menu is {{ open ? 'open' : 'closed' }}.
</VTypography>
</div>
</template>
<style scoped>
.column {
display: grid;
justify-items: start;
gap: var(--vectis-space-3);
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--vectis-space-3);
}
</style>
Where the panel opens relative to its trigger. The browser moves it to another side by itself when there is not enough room.
size
MenuSize'sm' | 'md' | 'lg'
'sm'
How tall the rows are: 32, 40 or 48 pixels. Submenus inherit it, so it is set once on the menu as a whole.
compact
boolean
false
Takes 4px off the height of every row, submenus included.
width
number | string
none
A width for the panel: a number is read as pixels, a string as any CSS length or keyword, 16rem or max-content. It applies to the menu itself; submenus keep the default width.
matchTrigger
boolean
false
Stops the panel from being narrower than the button that opened it, while leaving it free to grow wider for its content. Submenus are unaffected.
v-model:open
boolean
false
Whether the menu is showing. It starts closed and is fed by the panel, so the browser's own dismissal, a click outside, Escape, or choosing a command, writes back to it.
VMenuItem
Prop
Type
Default
label
string
none
What the command says. The default slot replaces it.
sublabel
string
none
A second line under the label, for a shortcut or a short explanation.
An icon after the label. The #end slot replaces it.
selected
boolean
false
Marks this item as the one currently in effect, the chosen sort order or the active view. It is coloured and announced as such.
tone
MenuItemTone'neutral' | 'danger'
'neutral'
What the command means, in colour. danger marks it destructive, which is where deleting something belongs, and neutral, the default, covers every other command.
disabled
boolean
false
Makes the item unusable: it no longer responds and the arrows skip over it.
href
string
none
Turns the item into a link pointing at this address, for a menu that navigates rather than acts.
VMenuGroup
Prop
Type
Default
label
string
none
The name of the section, replaced by the #label slot. One of the two is needed: it is what names the group. It is a heading, not a command: nothing happens on click.
Events
VMenuItem
Event
Type
select
[]
The command was chosen, by click or by keyboard. The menu closes on its own.
The button that opens the menu. Bind the triggerProps it receives onto it: that is what wires the two together.
default
{}
The contents of the menu: VMenuItem, VMenuGroup and VMenuSeparator.
VMenuItem
Slot
Type
default
{}
The label, replacing the label prop.
sublabel
{}
The second line, replacing the sublabel prop.
start
{}
Free content before the label, which takes the place of iconStart.
end
{}
Free content after the label, which takes the place of iconEnd.
submenu
{}
The contents of a submenu: items, groups and separators, this component included, so menus may nest as deep as needed.
VMenuGroup
Slot
Type
default
{}
The commands belonging to this section.
label
{}
A name made of markup, replacing the label prop.
Types
The types the tables above name, written as the library declares them. The ones carrying export can be imported from vectis-ui to type your own code; the others are the shape of what a slot hands out.