GrPopover

  • core
  • overlays
  • API · 22 props

An anchored panel for a short form, settings or a confirmation right next to the trigger.

import { GrPopover } from '@feugene/granularity/components/GrPopover'

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" plus modal, 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 role switches to menu;
  • 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

NeedTake
A menu with ready items, groups and separatorsGrDropdownMenu
The layer and the keyboard of a menu, the items your ownGrDropdown
A text hint for a control, with no interaction insideGrTooltip
A window in the centre with a header and a footer of its ownGrDialog
A panel at the edge of the screenGrDrawer
A yes/no about a dangerous actionGrConfirmDialog

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • A label added right at the issue

    The panel is a dialog and takes the focus on open — a field the keyboard cannot reach is not a form. close comes 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
    billing
  • A 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" — and role="none" keeps a «dialog» from being announced over a fact sheet. The open and close delays forgive a cursor that only passes by.

    T-1093Invoice shows the wrong VAT number
    T-1092Cannot add a second admin
    T-1090Export to CSV cuts long notes
  • A question that a click past cannot answer

    modal makes the rest of the page inert, and close-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, and labelled-by names it by its visible title.

    Release notes · October

    Invoices 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

PropTypedefaultDescription
placementPlacement | undefined"bottom-start"The side and alignment of the panel at the trigger; when space runs short it flips. `bottom-start` by default.
disabledboolean | undefinedfalseThe panel opens neither on click nor on hover; the trigger from `triggerProps` gets `disabled`.
openv-modelboolean | undefinedundefinedWhether 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" | undefinedundefinedThe padding and type size of the panel on the control scale. Unset — from `GrConfigProvider`, otherwise `md`.
ariaLabelstring | undefinedundefinedThe accessible name of the panel. Mandatory for `role="dialog"` without a visible heading.
blockboolean | undefinedfalseThe 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".
paddingGrPopoverPadding | undefined"default"The padding of the panel. `none` — the content draws its own (a menu, a list of options).
anchorGrFloatingAnchorRect | null | undefinedundefinedAn 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.
closeOnEscboolean | undefinedtrueClose 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.
offsetPxnumber | undefined8The gap between the trigger and the panel, in px.
labelledBystring | undefinedundefinedThe `id` of a visible heading inside the panel — an alternative to `ariaLabel`.
teleportTostring | HTMLElement | undefinedundefinedA pointed override of the mounting point. By default — the shared overlay portal (`#gr-portal` or the `portalTarget` from `GrConfigProvider`).
contentClassstring | undefinedundefinedCustom classes for the panel. The padding cannot be removed this way — use `padding="none"` for that.

Slots

SlotTypeDescription
trigger
{ open: boolean; toggle: () => void; close: () => void; trig{ open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; }
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

EventSignatureDescription
update:open(event: "update:open", value: boolean): void—

Methods

MethodsSignatureDescription
close() => voidClose the panel; the focus returns to the trigger if it was inside.
toggle() => voidOpen 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
import type { GrPopoverProps, GrPopoverEmits, GrPopoverConfigurableProps } from '@feugene/granularity/components/GrPopover'

Filedist/types/components/GrPopover/GrPopover.vue.d.ts

GrPopoverPropsinterface
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;
}
GrPopoverEmitsinterface
interface GrPopoverEmits {
    (e: 'update:open', value: boolean): void;
}

Filedist/types/components/GrPopover/config.d.ts

grPopoverConfigconst
const grPopoverConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrPopover">;

Filedist/types/components/GrPopover/defaults.d.ts

GrPopoverConfigurablePropsinterface
interface GrPopoverConfigurableProps {
    size: GrComponentSize;
}

Filedist/types/components/GrPopover/grPopoverStyles.d.ts

GrPopoverSizetype
type GrPopoverSize = GrComponentSize;
GrPopoverRoletype
type GrPopoverRole = 'dialog' | 'menu' | 'listbox' | 'grid' | 'group' | 'none';
GrPopoverPaddingtype
type GrPopoverPadding = 'default' | 'none';

Filedist/types/components/GrPopover/safelist.d.ts

grPopoverSafelistconst
const grPopoverSafelist: string[];

Filedist/types/components/GrPopover/index.d.ts

GrPopoverInstancetype
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:

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

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

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

vue
<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 contentClass rather than with an inline style. The panel is teleported into the portal (#gr-portal in body), 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. contentClass is the only path to the panel itself. Globally the hook works as usual: in a theme or on :root it 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.

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

CombinationWhat comes outWhy
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 ceilingwider than the ceiling, if the trigger is widerin 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:

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