Une liste de commandes ouverte par un bouton. Il porte tout le patron ARIA menu : focus glissant, sous-menus imbriqués, et l'empilement des panneaux par le navigateur pour qu'une seule fermeture referme la branche.
Une ligne porte un libellé, une icône à chaque extrémité, et un tone : danger pour ce qui détruit quelque chose, neutral pour tout le reste. disabled la rend muette et les flèches l'enjambent, et href en fait un vrai lien. VMenuSeparator trace un filet entre deux séries de commandes.
<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>
Seconde ligne
sublabel ajoute une seconde ligne sous le libellé, pour ce que la commande fait et que son nom ne dit pas, ou pour le raccourci qui la déclenche.
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>
Sélection
selected marque la ligne en vigueur, la colore et l'annonce comme le choix courant. Choisir une ligne ferme toujours le panneau.
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>
Groupes
VMenuGroup est un bloc nommé de commandes. Son libellé est un titre : rien ne se passe au clic et les flèches ne s'y arrêtent jamais.
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>
Sous-menus
Une ligne à laquelle on donne un slot #submenu ouvre son propre panneau, et ces panneaux peuvent s'imbriquer autant que nécessaire. Le survol l'ouvre après un court délai, et les flèches droite et gauche y entrent et en sortent.
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>
Tailles
size définit la hauteur des lignes à 32, 40 ou 48 pixels, et compact lui retire 4px. Elle se pose une fois sur le menu, les sous-menus la lisant de là.
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>
Largeur
width remplace le plancher et le plafond du panneau par n'importe quelle longueur ou mot-clé CSS. matchTrigger ne remplace que le plancher : le panneau ne peut plus être plus étroit que le bouton qui l'a ouvert. Les deux valent pour le menu lui-même, les sous-menus gardant la valeur par défaut.
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>
Position
placement nomme la direction d'ouverture préférée du panneau, au-dessus ou en dessous du déclencheur.
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>
Savoir si le menu est ouvert
v-model:open est alimenté par le panneau autant que lu par vous : un clic à l'extérieur, Échap ou le choix d'une commande y réécrivent. Un menu ouvert depuis le code s'ancre quand même à son déclencheur.
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>
Où le panneau s'ouvre par rapport à sa gâchette. Le navigateur le déplace de lui-même d'un autre côté quand la place manque.
size
MenuSize'sm' | 'md' | 'lg'
'sm'
La hauteur des lignes : 32, 40 ou 48 pixels. Les sous-menus en héritent, elle se pose donc une fois sur le menu dans son ensemble.
compact
boolean
false
Retire 4px à la hauteur de chaque ligne, sous-menus compris.
width
number | string
aucune
Une largeur pour le panneau : un nombre est lu en pixels, une chaîne comme n'importe quelle longueur ou mot-clé CSS, 16rem ou max-content. Elle s'applique au menu lui-même ; les sous-menus gardent la largeur par défaut.
matchTrigger
boolean
false
Empêche le panneau d'être plus étroit que le bouton qui l'a ouvert, tout en le laissant libre de s'élargir pour son contenu. Les sous-menus ne sont pas concernés.
v-model:open
boolean
false
Si le menu est affiché. Il part fermé et il est alimenté PAR le panneau, si bien que la fermeture propre au navigateur, un clic à l'extérieur, Échap, ou le choix d'une commande, y réécrit.
VMenuItem
Prop
Type
Défaut
label
string
aucune
Ce que dit la commande. Le slot par défaut le remplace.
sublabel
string
aucune
Une seconde ligne sous le libellé, pour un raccourci ou une courte explication.
Une icône après le libellé. Le slot #end la remplace.
selected
boolean
false
Marque cet item comme celui en vigueur, le tri choisi ou la vue active. Il est coloré et annoncé comme tel.
tone
MenuItemTone'neutral' | 'danger'
'neutral'
Ce que signifie la commande, en couleur. danger la marque comme destructrice, ce dont relève la suppression, et neutral, la valeur par défaut, couvre toutes les autres.
disabled
boolean
false
Rend l'item inutilisable : il ne répond plus et les flèches l'enjambent.
href
string
aucune
Transforme l'item en lien pointant vers cette adresse, pour un menu qui navigue plutôt qu'il n'agit.
VMenuGroup
Prop
Type
Défaut
label
string
aucune
Le nom de la section, que le slot #label remplace. L'un des deux est nécessaire : c'est lui qui nomme le groupe. C'est un titre, pas une commande : rien ne se passe au clic.
Événements
VMenuItem
Événement
Type
select
[]
La commande a été choisie, au clic ou au clavier. Le menu se ferme de lui-même.
Le bouton qui ouvre le menu. Liez les triggerProps qu'il reçoit dessus : c'est ce qui relie les deux.
default
{}
Le contenu du menu : VMenuItem, VMenuGroup et VMenuSeparator.
VMenuItem
Slot
Type
default
{}
Le libellé, qui remplace la prop label.
sublabel
{}
La seconde ligne, qui remplace la prop sublabel.
start
{}
Du contenu libre avant le libellé, qui prend la place de iconStart.
end
{}
Du contenu libre après le libellé, qui prend la place de iconEnd.
submenu
{}
Le contenu d'un sous-menu : items, groupes et séparateurs, ce composant compris, si bien que les menus peuvent s'imbriquer aussi profond qu'il le faut.
VMenuGroup
Slot
Type
default
{}
Les commandes appartenant à cette section.
label
{}
Un nom fait de balisage, qui remplace la prop label.
Types
Les types que les tables ci-dessus nomment, écrits comme la librairie les déclare. Ceux qui portent export s'importent depuis vectis-ui pour typer votre propre code ; les autres décrivent la forme de ce qu'un slot fournit.