Raccourci clavier : Ctrl + K
Commencer

Popover

La plomberie sur laquelle repose chaque panneau flottant de la bibliothèque : l'élément popover natif, son ancrage et son état d'ouverture. Il ne porte ni rôle, ni clavier, ni politique de fermeture propre, qui restent à la charge de ce qui l'utilise.

Utilisation

vue

Anyone with the link can open this file.

<script setup lang="ts">
import { VButton, VPopover, VTypography } from 'vectis-ui'
</script>

<template>
  <VPopover>
    <template #trigger="{ triggerProps }">
      <VButton variant="outline" tone="neutral" v-bind="triggerProps">Details</VButton>
    </template>
    <VTypography>Anyone with the link can open this file.</VTypography>
  </VPopover>
</template>

Exemples

Positions

placement propose douze positions par rapport au déclencheur, sur l'un ou l'autre axe et aligné sur l'un ou l'autre bord. Il nomme une préférence : le navigateur bascule le panneau de l'autre côté quand la place manque.

vue

Placed top-start

Placed top

Placed top-end

Placed bottom-start

Placed bottom

Placed bottom-end

Placed left-start

Placed left

Placed left-end

Placed right-start

Placed right

Placed right-end

<script setup lang="ts">
import { VButton, VPopover, VTypography, type PopoverPlacement } from 'vectis-ui'

const PLACEMENTS: PopoverPlacement[] = [
  'top-start',
  'top',
  'top-end',
  'bottom-start',
  'bottom',
  'bottom-end',
  'left-start',
  'left',
  'left-end',
  'right-start',
  'right',
  'right-end',
]
</script>

<template>
  <div class="grid">
    <!-- A preference rather than a position: a browser short of room on that side flips
         the panel to the opposite one by itself. -->
    <VPopover v-for="placement in PLACEMENTS" :key="placement" :placement="placement">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral" size="sm">
          {{ placement }}
        </VButton>
      </template>
      <VTypography variant="body-sm">Placed {{ placement }}</VTypography>
    </VPopover>
  </div>
</template>

<style scoped>
.grid {
  display: grid;
  grid-template-columns: repeat(3, max-content);
  justify-content: center;
  gap: var(--vectis-space-4);
  /* Room on every side, so nothing is flipped for want of space in the demo. */
  padding: var(--vectis-space-10) var(--vectis-space-12);
}
</style>

Contenu interactif

Un panneau peut contenir de vrais contrôles : la fermeture légère ne se déclenche qu'au clic à l'extérieur, et le focus n'est pas piégé. Le composant ne fournit ni rôle, ni clavier, ni politique de fermeture.

vue
Show

Showing: Active

<script setup lang="ts">
import { ref } from 'vue'
import { VButton, VCheckbox, VPopover, VTypography } from 'vectis-ui'

const open = ref(false)

const filters = ref({ active: true, archived: false, mine: false })
const applied = ref('Active')

function apply() {
  const chosen = Object.entries(filters.value)
    .filter(([, on]) => on)
    .map(([key]) => key)
  applied.value = chosen.length ? chosen.join(', ') : 'none'
  open.value = false
}
</script>

<template>
  <div class="column">
    <!-- A panel holds real controls: light dismiss only fires on a click OUTSIDE, so
         everything inside keeps working, and the focus is not trapped, a popover being
         no dialog. -->
    <VPopover v-model:open="open" placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">Filters</VButton>
      </template>

      <div class="panel">
        <VTypography variant="overline" tone="muted">Show</VTypography>
        <VCheckbox v-model="filters.active" label="Active" />
        <VCheckbox v-model="filters.archived" label="Archived" />
        <VCheckbox v-model="filters.mine" label="Mine only" />
        <div class="actions">
          <VButton variant="ghost" tone="neutral" size="sm" @click="open = false">Cancel</VButton>
          <VButton size="sm" @click="apply">Apply</VButton>
        </div>
      </div>
    </VPopover>

    <VTypography variant="body-sm" tone="muted">Showing: {{ applied }}</VTypography>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-3);
}
.panel {
  display: grid;
  gap: var(--vectis-space-2);
  min-inline-size: 14rem;
}
.actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--vectis-space-2);
  margin-block-start: var(--vectis-space-2);
}
</style>

Modes

mode à auto confie la fermeture au navigateur, là où manual vous la rend et impose que le panneau offre une sortie. v-model:open est alimenté par le DOM, et les méthodes exposées show et close sont la voie quand l'ouverture doit être synchrone.

vue

Click outside or press Escape: the browser closes this one.

Clicking outside leaves this open. Escape does nothing either.

Opened synchronously, with no tick in between.

<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { VButton, VPopover, VTypography } from 'vectis-ui'

const manualOpen = ref(false)
const syncPanel = useTemplateRef<InstanceType<typeof VPopover>>('syncPanel')
</script>

<template>
  <div class="column">
    <!-- auto: the browser dismisses it on a click outside or on Escape, and stacks it
         with the other panels on the page. The model is written back from the DOM, so
         nothing has to be reset by hand. -->
    <VPopover>
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">auto</VButton>
      </template>
      <VTypography variant="body-sm">
        Click outside or press Escape: the browser closes this one.
      </VTypography>
    </VPopover>

    <!-- manual: nothing dismisses it but you. This is what a panel with rules of its
         own needs, and it means the panel must offer a way out. -->
    <VPopover v-model:open="manualOpen" mode="manual">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">manual</VButton>
      </template>
      <div class="panel">
        <VTypography variant="body-sm">
          Clicking outside leaves this open. Escape does nothing either.
        </VTypography>
        <VButton size="sm" @click="manualOpen = false">Close</VButton>
      </div>
    </VPopover>

    <!-- The model costs a tick. When the opening has to be synchronous, because a focus
         move or a timer is armed on the assumption the panel is already there, the
         exposed methods are the route. -->
    <div class="row">
      <VPopover ref="syncPanel" mode="manual">
        <template #trigger="{ triggerProps }">
          <VButton v-bind="triggerProps" variant="outline" tone="neutral">
            Opened through the ref
          </VButton>
        </template>
        <div class="panel">
          <VTypography variant="body-sm"
            >Opened synchronously, with no tick in between.</VTypography
          >
          <VButton size="sm" @click="syncPanel?.close()">Close</VButton>
        </div>
      </VPopover>
      <VButton variant="ghost" tone="neutral" @click="syncPanel?.show()">show()</VButton>
    </div>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-4);
}
.row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--vectis-space-3);
}
.panel {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-3);
  max-inline-size: 16rem;
}
</style>

Aligner sur le déclencheur

matchTrigger empêche le panneau d'être plus étroit que ce à quoi il est ancré. C'est un plancher et non une contrainte : un contenu qui demande plus de place l'obtient toujours.

vue

Short.

Short.

The content is wider than the trigger, so the panel grows past it rather than being squeezed into its width.

<script setup lang="ts">
import { VButton, VPopover, VTypography } from 'vectis-ui'
</script>

<template>
  <div class="column">
    <!-- Left alone, the panel is only as wide as its content. -->
    <VPopover placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral" class="wide">
          A wide trigger, a narrow panel
        </VButton>
      </template>
      <VTypography variant="body-sm">Short.</VTypography>
    </VPopover>

    <!-- The panel can no longer be narrower than what it is anchored to. -->
    <VPopover match-trigger placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral" class="wide">
          A wide trigger, matched
        </VButton>
      </template>
      <VTypography variant="body-sm">Short.</VTypography>
    </VPopover>

    <!-- It is a FLOOR and not a clamp: content that needs more room still gets it,
         which is what a list of long labels under a short field wants. -->
    <VPopover match-trigger placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <VButton v-bind="triggerProps" variant="outline" tone="neutral">Narrow</VButton>
      </template>
      <VTypography variant="body-sm">
        The content is wider than the trigger, so the panel grows past it rather than being squeezed
        into its width.
      </VTypography>
    </VPopover>
  </div>
</template>

<style scoped>
.column {
  display: grid;
  justify-items: start;
  gap: var(--vectis-space-4);
}
.wide {
  inline-size: 22rem;
}
</style>

Ancrer sur son propre élément

anchor accepte le nom d'une ancre que vous avez posée vous-même, VPopover ne rendant alors aucune enveloppe. C'est la voie obligée dès que le déclencheur est un champ texte. Posez le nom sur l'élément sous lequel le panneau doit s'ouvrir, la boîte du champ et non une enveloppe qui porte aussi un libellé, et confinez-le depuis un élément englobant.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { VPopover, VTypography } from 'vectis-ui'

const CITIES = ['Bordeaux', 'Lyon', 'Marseille', 'Nantes', 'Paris', 'Toulouse']

const query = ref('')
const open = ref(false)

const matches = () =>
  CITIES.filter((city) => city.toLowerCase().startsWith(query.value.toLowerCase()))

function choose(city: string) {
  query.value = city
  open.value = false
}
</script>

<template>
  <!-- The wrapper CONFINES the name to this instance. Without it, a shown popover moves
       to the top layer and is resolved against the whole document, so every panel on the
       page would attach to the last element that named the anchor. -->
  <div class="field-wrapper">
    <!-- A plain input, because that is the case the prop exists for: `popovertarget` is
         not valid on a text field, so the panel cannot be wired to it that way. -->
    <input
      v-model="query"
      class="field"
      type="text"
      placeholder="A city"
      aria-label="A city"
      @focus="open = true"
      @input="open = true"
    />

    <!-- Given a name, VPopover renders no wrapper of its own and positions the panel
         against whatever carries it. -->
    <VPopover v-model:open="open" anchor="--city-anchor" match-trigger mode="manual" bare>
      <ul class="list">
        <li v-for="city in matches()" :key="city">
          <button type="button" class="row" @click="choose(city)">{{ city }}</button>
        </li>
        <li v-if="matches().length === 0" class="empty">
          <VTypography variant="body-sm" tone="muted">No city matches</VTypography>
        </li>
      </ul>
    </VPopover>
  </div>
</template>

<style scoped>
.field-wrapper {
  anchor-scope: --city-anchor;
  inline-size: 16rem;
}
.field {
  anchor-name: --city-anchor;
  inline-size: 100%;
  padding: 0 var(--vectis-space-3);
  block-size: var(--vectis-control-height-md);
  border: 1px solid var(--vectis-color-border);
  border-radius: var(--vectis-radius-interactive);
  background: var(--vectis-color-surface);
  color: var(--vectis-color-text);
  font: inherit;
}
.list {
  display: grid;
  margin: 0;
  padding: var(--vectis-space-1);
  border: 1px solid var(--vectis-color-border);
  border-radius: var(--vectis-radius-overlay);
  background: var(--vectis-color-surface-overlay);
  box-shadow: var(--vectis-shadow-lg);
  list-style: none;
}
.row {
  inline-size: 100%;
  padding: var(--vectis-space-2) var(--vectis-space-3);
  border: none;
  border-radius: var(--vectis-radius-interactive);
  background: none;
  color: inherit;
  font: inherit;
  text-align: start;
  cursor: pointer;
}
.row:hover {
  background: var(--vectis-color-surface-muted);
}
.empty {
  padding: var(--vectis-space-2) var(--vectis-space-3);
}
</style>

API

Props

PropTypeDéfaut
idstringaucune
L'identifiant du panneau, celui que la gâchette désigne. Un identifiant est généré quand aucun n'est donné : cette prop ne sert donc qu'à relier le panneau à quelque chose d'extérieur au composant.
placementPopoverPlacement'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end''bottom-start'
Où le panneau se place par rapport à sa gâchette. Le navigateur le bascule de lui-même du côté opposé quand la place manque.
modePopoverMode'auto' | 'manual''auto'
Comment le panneau se ferme. auto laisse le navigateur le fermer sur un clic à l'extérieur ou sur Échap, et l'empiler avec d'autres panneaux ; manual vous laisse tout, ce dont a besoin un panneau ayant ses propres règles de focus et de fermeture.
anchorstringaucune
Le nom d'une ancre que vous avez posée sur votre propre contrôle, écrit en identifiant CSS à tirets comme --tooltip-anchor. Le fournir remplace l'enveloppe interne, ce qui est la voie obligatoire dès que la gâchette est un champ de texte, où l'attribut popovertarget du navigateur n'est pas admis.
barebooleanfalse
Retire au panneau la surface du design system : ni fond, ni bordure, ni ombre, ni coins arrondis. C'est ce que demande un panneau dont le contenu apporte les siens, comme le fait VDatePicker.
matchTriggerbooleanfalse
Empêche le panneau d'être plus étroit que ce à quoi il est ancré. C'est un plancher : un panneau qui a sa propre largeur la dépasse toujours au lieu d'être ramené à celle du déclencheur, ce que veut une liste de libellés longs sous un champ court.
v-model:openbooleanfalse
Si le panneau est affiché. Il part fermé et il est bidirectionnel, alimenté depuis le DOM : en mode auto, la fermeture légère du navigateur y réécrit. Le poser ouvre et ferme le panneau ; quand le changement doit être synchrone, utilisez plutôt les show et close exposés, ce que font VTooltip et les sélecteurs.

Slots

SlotType
trigger{ triggerProps: PopoverTriggerProps; }
L'élément qui ouvre le panneau. Liez les triggerProps qu'il reçoit sur un bouton à vous : c'est ce qui relie les deux.
default{}
Ce que contient le panneau.

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 type PopoverTriggerProps = {
  popovertarget: string
  'aria-expanded': boolean
  'aria-controls': string
}