The plumbing every floating panel in the library is built on: the native popover element, its anchoring and its open state. It carries no role, no keyboard and no dismissal policy of its own, which stay with whatever uses it.
Usage
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>
Examples
Placements
placement offers twelve positions relative to the trigger, on either axis and aligned to either edge. It names a preference: the browser flips the panel to the opposite side when there is not enough room.
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>
Interactive content
A panel can hold real controls: light dismiss only fires on a click outside, and the focus is not trapped. The component provides no role, no keyboard and no dismissal policy.
mode set to auto hands the dismissal to the browser, where manual hands it back to you and means the panel has to offer a way out. v-model:open is fed from the DOM, and the exposed show and close are the route when the opening has to be synchronous.
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>
Match trigger
matchTrigger stops the panel being narrower than whatever it is anchored to. It is a floor and not a clamp: content needing more room still gets it.
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>
Anchoring to your own element
anchor takes the name of an anchor you have set yourself, VPopover then rendering no wrapper of its own. It is the required route as soon as the trigger is a text input. Put the name on the element the panel should sit under, the field's own box rather than a wrapper that also holds a label, and confine it from an enclosing element.
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
Default
id
string
none
The id of the panel, which the trigger points at. One is generated when none is given, so this is only needed to tie the panel to something outside the component.
Where the panel is placed relative to its trigger. The browser flips it to the opposite side by itself when there is not enough room.
mode
PopoverMode'auto' | 'manual'
'auto'
How the panel closes. auto lets the browser dismiss it on a click outside or on Escape, and stack it with other panels; manual leaves everything to you, which is what a panel with its own focus and dismissal rules needs.
anchor
string
none
The name of an anchor you have set on your own control, written as a CSS dashed identifier such as --tooltip-anchor. Supplying it replaces the internal wrapper, which is the required route as soon as the trigger is a text input, where the browser's own popovertarget attribute is not allowed.
bare
boolean
false
Strips the panel of the design system's surface: no background, no border, no shadow and no rounded corners. It is what a panel whose content brings its own asks for, as VDatePicker does.
matchTrigger
boolean
false
Stops the panel being narrower than whatever it is anchored to. It is a floor, so a panel with a width of its own still grows past it rather than being clamped to the trigger, which is what a list of long labels under a short field wants.
v-model:open
boolean
false
Whether the panel is showing. It starts closed and is bidirectional, fed from the DOM: in auto mode the browser's own light dismiss writes back to it. Setting it opens and closes the panel; when the change has to be synchronous, use the exposed show and close instead, which is what VTooltip and the pickers do.
The element that opens the panel. Bind the triggerProps it receives onto a button of your own: that is what wires the two together.
default
{}
What the panel contains.
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.