GrPopover
An anchored panel for a short form, settings or a confirmation right next to the trigger.
Overview
When to take it
- content of your own at an anchor — a filter, a form, a palette, a preview card: the layer and the positioning are taken by the primitive, and you write the markup and the keyboard inside;
- a confirmation right at the button —
trigger="manual"plusmodal, so that the background does not take the focus away from the confirmation form; - a context menu — the moment of opening is decided by the consumer, and
roleswitches tomenu; - an overlay component of your own — this is the primitive on top of which the menus, the palettes and the Popconfirm of the package are assembled; there is no need to start a second modal layer.
When to take something else
| Need | Take |
|---|---|
| A menu with ready items, groups and separators | GrDropdownMenu |
| The layer and the keyboard of a menu, the items your own | GrDropdown |
| A text hint for a control, with no interaction inside | GrTooltip |
| A window in the centre with a header and a footer of its own | GrDialog |
| A panel at the edge of the screen | GrDrawer |
| A yes/no about a dangerous action | GrConfirmDialog |
Playground controls come from the API, not from hand
Loading…
0 changed· state is in the URL
<GrPopover open />Examples 3 each with a live demo and its code
A label added right at the issue
The panel is a
dialogand takes the focus on open — a field the keyboard cannot reach is not a form.closecomes to the content slot, so the panel closes on «Add label», not on every click inside it, and a real<form>makes Enter work.#1093 · Invoice shows the wrong VAT numberOpened by Contoso Ltd · 2 hours ago billingA card about the assignee on hover
A card on hover answers «who is this» without a click. It has nothing to answer back, so it takes no focus —
auto-focus="false"— androle="none"keeps a «dialog» from being announced over a fact sheet. The open and close delays forgive a cursor that only passes by.A question that a click past cannot answer
modalmakes the rest of the page inert, andclose-on-click-outside="false"leaves the answer to the buttons — a click past the panel must not count as «yes» or «no». The panel stays at its button, because the question is about this action, andlabelled-bynames it by its visible title.Release notes · OctoberInvoices now carry the purchase order number, and payouts can go to a second account…
Draft · edited 3 minutes ago
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
placement | Placement | undefined | "bottom-start" | The side and alignment of the panel at the trigger; when space runs short it flips. `bottom-start` by default. |
disabled | boolean | undefined | false | The panel opens neither on click nor on hover; the trigger from `triggerProps` gets `disabled`. |
openv-model | boolean | undefined | undefined | Whether the popover is open. Without this prop the component runs the state itself (uncontrolled), with it — listen to `update:open`. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The padding and type size of the panel on the control scale. Unset — from `GrConfigProvider`, otherwise `md`. |
ariaLabel | string | undefined | undefined | The accessible name of the panel. Mandatory for `role="dialog"` without a visible heading. |
block | boolean | undefined | false | The wrapper of the trigger takes the full width of the parent instead of hugging the content. Form controls need it: an `inline-block` wrapper collapses to the content, and the `w-full` of the trigger itself starts resolving against it rather than against the field. A control that declared itself full width is drawn by its content — next to an input field that reads as broken markup. The name is the same as in `GrButton` and `GrSegmented`: in the core it is the settled word for "full width". |
padding | GrPopoverPadding | undefined | "default" | The padding of the panel. `none` — the content draws its own (a menu, a list of options). |
anchor | GrFloatingAnchorRect | null | undefined | undefined | An anchor rectangle in viewport coordinates instead of the wrapper of the `#trigger` slot: the panel stands at the point of the cursor or at a row of a list. A context menu needs it, having no trigger element at all. There is nobody to consume `triggerProps` in this mode, and the popover hangs no ARIA attributes of its own anywhere: `aria-haspopup` is invalid outside an interactive element, and an `aria-expanded` with no owner is noise. The link with the content of the page is declared by whoever opens it. |
closeOnEsc | boolean | undefined | true | Close on `Esc`. Esc closes the top layer of the stack: a popover in a modal closes itself, not the modal. `true` by default. |
trigger | "click" | "manual" | "hover" | undefined | "click" | What opens the panel. `manual` — programmatically only (through `v-model:open`): a context menu and confirmations need it, where the moment of opening is decided by the consumer. `hover` — on hovering, with the delays from `openDelay` and `closeDelay`; the click and the keyboard keep working in this mode. |
offsetPx | number | undefined | 8 | The gap between the trigger and the panel, in px. |
labelledBy | string | undefined | undefined | The `id` of a visible heading inside the panel — an alternative to `ariaLabel`. |
teleportTo | string | HTMLElement | undefined | undefined | A pointed override of the mounting point. By default — the shared overlay portal (`#gr-portal` or the `portalTarget` from `GrConfigProvider`). |
contentClass | string | undefined | undefined | Custom classes for the panel. The padding cannot be removed this way — use `padding="none"` for that. |
modal | boolean | undefined | false | The modal mode: the background goes into `inert`, Tab walks in a ring inside the panel, the scrolling of the page is blocked, and the layer enters the stack as a modal one — like a window. A popover with a form or a confirmation inside needs it: without isolating the background the user takes the focus onto the page the popover belongs to. In this mode the focus is moved into the panel regardless of `autoFocus`: the background is unavailable, and a layer without focus inside it would become a keyboard trap. |
openDelay | number | undefined | 120 | The delay before opening on hover, in ms. Both delays are needed, each for its own reason: without `openDelay` the panel jumps out on any crossing of the cursor, without `closeDelay` it cannot be kept while moving from the trigger to the panel — there is an `offsetPx` gap between them. |
closeDelay | number | undefined | 160 | The delay before closing after the cursor leaves, in ms. |
closeOnContentClick | boolean | undefined | false | Close on a click inside the panel — convenient for a menu, harmful for a form. |
role | GrPopoverRole | undefined | "dialog" | The role of the panel. It is changed by those who build a menu or a list of their own on top of the primitive. |
closeOnClickOutside | boolean | undefined | true | Close on a click outside the panel. Layers opened from the panel itself (a submenu, a select) do not count as outside. `true` by default. |
autoFocus | boolean | undefined | true | Move the focus to the panel on opening. Precisely to the panel rather than to the first control inside it: focusing an input field on the user’s behalf is a decision of the content rather than of the shell. |
matchWidth | boolean | "min" | undefined | false | Take the width from the trigger: `true` — exactly its width, `'min'` — no narrower than it and then by the content. A panel at a field or at a wide button that turns out narrower than its trigger reads as a layout error. It combines with the `--gr-popover-max-width` ceiling rather than arguing with it: the width is set by the trigger and the ceiling limits it — "as wide as the trigger, but no wider than readable". That is why this is a separate prop rather than a value of one enumeration together with the ceiling: the axes are independent, and the combination makes sense. |
Slots
| Slot | Type | Description |
|---|---|---|
trigger |
| The trigger of the panel. `triggerProps` are obliged to land on the interactive element itself rather than on a wrapper around it: `aria-expanded` and `aria-controls` are read from the node that gets the focus. |
content | { close: () => void; } | The content of the panel. |
Events
| Event | Signature | Description |
|---|---|---|
update:open | (event: "update:open", value: boolean): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
close | () => void | Close the panel; the focus returns to the trigger if it was inside. |
toggle | () => void | Open a closed panel or close an open one. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrPopoverProps, GrPopoverEmits, GrPopoverConfigurableProps } from '@feugene/granularity/components/GrPopover'Filedist/types/components/GrPopover/GrPopover.vue.d.ts
interface GrPopoverProps {
open?: boolean;
placement?: UseFloatingPlacement;
offsetPx?: number;
size?: GrPopoverSize;
role?: GrPopoverRole;
modal?: boolean;
ariaLabel?: string;
labelledBy?: string;
trigger?: 'click' | 'manual' | 'hover';
openDelay?: number;
closeDelay?: number;
closeOnEsc?: boolean;
closeOnClickOutside?: boolean;
closeOnContentClick?: boolean;
autoFocus?: boolean;
teleportTo?: string | HTMLElement;
contentClass?: string;
disabled?: boolean;
anchor?: GrFloatingAnchorRect | null;
padding?: GrPopoverPadding;
matchWidth?: boolean | 'min';
block?: boolean;
}interface GrPopoverEmits {
(e: 'update:open', value: boolean): void;
}Filedist/types/components/GrPopover/config.d.ts
const grPopoverConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrPopover">;Filedist/types/components/GrPopover/defaults.d.ts
interface GrPopoverConfigurableProps {
size: GrComponentSize;
}Filedist/types/components/GrPopover/grPopoverStyles.d.ts
type GrPopoverSize = GrComponentSize;type GrPopoverRole = 'dialog' | 'menu' | 'listbox' | 'grid' | 'group' | 'none';type GrPopoverPadding = 'default' | 'none';Filedist/types/components/GrPopover/safelist.d.ts
const grPopoverSafelist: string[];Filedist/types/components/GrPopover/index.d.ts
type GrPopoverInstance = ComponentExposed<typeof GrPopoverComponent>;Usage from the library prose
Why not GrDropdown
For a while the role of a universal popover was played by GrDropdown, but it is about a
menu: it declares role="menu" and aria-haspopup="menu" rigidly and walks the focus across
the items. For a form, a confirmation or a palette that is the wrong semantics — a screen reader
will announce a menu where there is none.
Here it is the other way round: the role is set with a prop (dialog by default, plus menu,
listbox, grid, group and none), because the keyboard pattern inside the panel belongs to
the content. Menus, context menus, palettes and Popconfirm are assembled on this primitive.
The trigger
The #trigger slot receives triggerProps — they have to be hung on a real focusable
element rather than on a wrapper:
<GrPopover>
<template #trigger="{ triggerProps }">
<GrButton v-bind="triggerProps">Open</GrButton>
</template>
</GrPopover>aria-expanded and aria-controls are obliged to live on the interactive element itself: on a
div around it they are mute. The click is listened for by the wrapper in the process — so the
trigger can be anything at all, as long as the ARIA travels to the button.
trigger="manual" switches off opening by a click: the moment of opening is decided by the
consumer through v-model:open. That is the mode of a context menu and of confirmations.
There is no focus trap by default
And that is not an omission. A non-modal layer does not block the page, so Tab is obliged to
take the focus outside — otherwise the user is locked in a panel on a working page.
modal switches the second mode on: the background goes into inert, Tab walks in a ring
inside the panel, the scrolling of the page is blocked, and the layer enters the stack as a modal
one — like a window. A popover with a form or a confirmation needs it: without isolating the
background the user takes the focus onto the very page the popover belongs to. In this mode the
focus is moved into the panel regardless of autoFocus — the background is unavailable, and a
layer without focus inside it would become a keyboard trap.
The modality arrives from the same assembly as in a window, a drawer and a viewer, so there is no
second implementation of the modal layer in the package. The stack, Esc and the return of focus
to the trigger — ../overlays.md.
autoFocus focuses the panel rather than a field inside it
The panel is focusable (tabindex="-1"), and on opening the focus is moved precisely to it.
Focusing the first control on the user’s behalf is a decision of the content rather than of the
shell: in a filter it is appropriate, in a delete confirmation it is not.
The accessible name
For role="dialog" a name is mandatory: either ariaLabel or labelledBy with the id of a
visible heading inside the panel. A panel without a name will be announced by a screen reader as
“dialog” and nothing more.
Closing
closeOnEsc and closeOnClickOutside are on by default. closeOnContentClick is not: it suits a
menu, where a click on an item means the choice, and harms a form, where every click on a field
would close the panel.
Imperatively — open(), close(), toggle() through a ref on the component. That set is
considered the reference one for overlays in the package and is held by the overlayImperativeApi
gate.
The layer and the size
The panel lies at the same height as GrDropdown (--gr-z-dropdown), and that is deliberate:
both are anchored non-modal overlays of one class, and at different heights they would start
covering each other depending on the order of opening. The scale of the layers —
../z-index.md.
placement and offsetPx set the side and the gap; the resulting side may differ from the one
that was set — the panel flips over when there is not enough room, and the transform-origin of
the animation flips with it.
size sets the inner padding and the type size by the control scale — see
../sizes.md.
The trigger is the element carrying triggerProps
triggerProps carry both the ARIA and the click. They have to be bound to the interactive
element itself rather than to a wrapper around it:
<GrPopover>
<template #trigger="{ triggerProps }">
<GrButton v-bind="triggerProps">
Open
</GrButton>
</template>
</GrPopover>The reason is behaviour rather than tidiness. The #trigger slot may contain more than the
trigger: a button next to it, a link in a card, a cross on a chip. A click living on the wrapper
would catch them all and open the panel past the user’s intent. One living in triggerProps opens
it only from the element it was given to.
A slot left without v-bind="triggerProps" still opens on a click on the wrapper: that is how
it used to work, and that has not been taken away. But such a trigger is left without a keyboard
and without aria-haspopup/aria-expanded — the panel is not announced to a screen reader, and
there is no reaching it with Tab. In a dev build the component warns about such a trigger in the
console.
What opens it
trigger is click (the default), manual or hover.
manual opens only programmatically, through v-model:open: that is how a context menu and
confirmations work, where the moment of opening is decided by the consumer.
hover opens on hovering with the openDelay and closeDelay delays. Both are needed, each for
its own reason: without the first the panel jumps out on any crossing of the cursor, without the
second it cannot be kept while moving from the trigger to the panel — there is an offsetPx gap
between them. The panel listens for hovering as well, so a cursor that has moved onto it does not
put the panel out.
A click in the hover mode keeps working. There is no hovering from the keyboard and from a touchscreen, and a panel opened by the cursor alone does not exist for them at all.
The wrapper of the trigger
The wrapper around the #trigger slot is inline-block by default: it hugs the content, and the
panel stands at the edge of the button itself rather than at the edge of the column. For a button
that is right.
For a form control it is not. A control declares itself w-full, as GrInput and GrSelect do,
but w-full resolves against the wrapper, and that has already hugged its content. The
result: a control across the full width is drawn by its content, and next to an input field that
reads as broken markup. A measurement: GrColorPicker in a GrFormField 384px wide was drawn at
113px.
The block prop stretches the wrapper to the full width of the parent:
<GrPopover block>The name is the same as in GrButton and GrSegmented — in the core it is the settled word for
“full width”. matchWidth takes the width from the wrapper precisely, so with block the panel
will follow the width of the field rather than the width of the content of the trigger.
The width
Two independent axes and one limit that is not configurable.
The ceiling of the content is the --gr-popover-max-width hook, 22rem by default. That is a
readable width of a column of text, and for a form or a card it is right. For content wider than
prose — a toolbar, a palette, a grid — the ceiling is removed:
<GrPopover content-class="[--gr-popover-max-width:100vw]">With a hook rather than with a prop, deliberately: a value stays a value, it can be changed by
breakpoint (md:[--gr-popover-max-width:100vw]) and by theme, and it does not argue by
specificity with the class of the panel.
100vw rather than none: min(none, …) is invalid CSS, and writing it as 100vw also leaves
the second limit in place.
Through
contentClassrather than with an inline style. The panel is teleported into the portal (#gr-portalinbody), so the wrapper of the trigger is not its ancestor:<GrPopover style="--gr-popover-max-width: 100vw">will land on the wrapper, will not reach the panel and will silently do nothing.contentClassis the only path to the panel itself. Globally the hook works as usual: in a theme or on:rootit is inherited into the portal together with everything else.
The width from the trigger is the matchWidth prop. true means exactly the width of the
trigger, 'min' means no narrower than it and then by the content. A panel at a field or at a
wide button that turns out narrower than its trigger reads as a layout error.
<GrPopover match-width> <!-- exactly the width of the trigger -->
<GrPopover match-width="min"> <!-- no narrower than the trigger -->The axes combine rather than argue, but they resolve differently, and that is a consequence of CSS rather than a decision of the component:
| Combination | What comes out | Why |
|---|---|---|
matchWidth + the ceiling | “as wide as the trigger, but no wider than readable” | the width comes from the trigger, and max-width cuts it |
matchWidth="min" + the ceiling | wider than the ceiling, if the trigger is wider | in CSS min-width is stronger than max-width: the floor beats the ceiling |
The second is not a flaw but the point of the mode: min means “no narrower than the trigger”,
that is, a floor, and a floor is by definition above a ceiling. If a ceiling is exactly what is
needed, take matchWidth without min.
That is why these are two independent levers rather than one enumeration: the combinations make sense and resolve predictably.
No wider than the viewport — not configurable. calc(100vw - 1rem) stands as the second
operand of the min() and is switched off by nothing from the outside, including
--gr-popover-max-width: 100vw: the positioning shifts the panel within the screen but does not
narrow it, and without that limit a panel at the edge would move off it.
The height
It is built like the width — a min() of a hook and a limit that is not configurable — but the
second operand here is a measurement rather than a constant.
The ceiling of the content is the --gr-popover-max-height hook, 100vh by default, that is,
there is no opinion. It is set when the panel is obliged to be lower than the available room:
<GrPopover content-class="[--gr-popover-max-height:20rem]">Through contentClass and for the same reason as with the width: the panel is teleported, an
inline style will land on the wrapper and will not reach it.
No taller than there is room — not configurable. The second operand is the
--gr-floating-available-height variable, which the layer writes: the distance to the edge of the
viewport on the side the panel ended up on. It is recomputed together with the position, on
scrolling included, and is not switched off from the outside.
That quantity cannot be static, and therein lies the whole difference from the width: 100vw is
known in advance, while how much room there is under the trigger depends on where the trigger
stands. flip will turn the panel to the free side, shift will move it along the edge, but
neither of them can shrink it: a panel taller than the viewport stays taller than the viewport
after both, and its bottom goes off the screen with no way at all to reach it.
The panel scrolls. The ceiling arrives together with overflow-y: auto, because a ceiling
without scrolling does not limit but crops. Until the ceiling is reached, there is no scrollbar —
for short panels nothing changes.
That applies to everything standing on GrPopover as well: a long GrDropdown or GrContextMenu
menu at the bottom edge of the screen now shrinks with a scroll rather than sliding off.
Props in this example