Raccourci clavier : Ctrl + K
Commencer

Menu

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.

Utilisation

vue
<script setup lang="ts">
import { VButton, VMenu, VMenuItem, VMenuSeparator } from 'vectis-ui'
</script>

<template>
  <VMenu>
    <template #trigger="{ triggerProps }">
      <VButton variant="outline" tone="neutral" v-bind="triggerProps">Actions</VButton>
    </template>
    <VMenuItem label="Rename" />
    <VMenuItem label="Duplicate" />
    <VMenuSeparator />
    <VMenuItem label="Delete" tone="danger" />
  </VMenu>
</template>

Exemples

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.

vue
<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
<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>

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>

API

Props

VMenu
PropTypeDéfaut
placementMenuPlacement'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end''bottom-start'
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.
sizeMenuSize'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.
compactbooleanfalse
Retire 4px à la hauteur de chaque ligne, sous-menus compris.
widthnumber | stringaucune
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.
matchTriggerbooleanfalse
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:openbooleanfalse
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
PropTypeDéfaut
labelstringaucune
Ce que dit la commande. Le slot par défaut le remplace.
sublabelstringaucune
Une seconde ligne sous le libellé, pour un raccourci ou une courte explication.
iconStartIconSourceaucune
Une icône avant le libellé. Le slot #start la remplace.
iconEndIconSourceaucune
Une icône après le libellé. Le slot #end la remplace.
selectedbooleanfalse
Marque cet item comme celui en vigueur, le tri choisi ou la vue active. Il est coloré et annoncé comme tel.
toneMenuItemTone'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.
disabledbooleanfalse
Rend l'item inutilisable : il ne répond plus et les flèches l'enjambent.
hrefstringaucune
Transforme l'item en lien pointant vers cette adresse, pour un menu qui navigue plutôt qu'il n'agit.
VMenuGroup
PropTypeDéfaut
labelstringaucune
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énementType
select[]
La commande a été choisie, au clic ou au clavier. Le menu se ferme de lui-même.

Slots

VMenu
SlotType
trigger{ triggerProps: MenuTriggerProps; }
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
SlotType
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
SlotType
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.

export interface BuiltinIcon {
  name: string
  paths: readonly [string] | readonly [string, string]
}
export type IconRender =
  | { path: string; viewBox?: string }
  | { component: Component; props?: Record<string, unknown> }
  | { src: string }
  | { text: string; class?: string }
  | { class: string }
export type IconSource = string | BuiltinIcon | IconRender
export type MenuTriggerProps = {
  popovertarget: string
  'aria-haspopup': 'menu'
  'aria-expanded': boolean
  'aria-controls': string
}

Variables CSS

TokenValeur
--vectis-control-size-menu-min11rem
--vectis-control-size-menu-max20rem