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.
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
Prop
Type
Défaut
id
string
aucune
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.
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.
mode
PopoverMode'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.
anchor
string
aucune
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.
bare
boolean
false
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.
matchTrigger
boolean
false
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:open
boolean
false
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.
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.