GrPopover

Package: @feugene/granularitycoreGroup: overlays

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

Machine-translated from the Russian original, not yet reviewed. Read the original

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 buttontrigger="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

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

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

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

Playground 16

Loading…

Code
<GrPopover />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
openboolean | undefinedundefinedWhether the popover is open. Without this prop the component runs the state itself (uncontrolled), with it — listen to `update:open`.
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefinedThe accessible name of the panel. Mandatory for `role="dialog"` without a visible heading.
placementPlacement | undefined"bottom-start"
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).
closeOnEscboolean | undefinedtrue
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 | undefinedundefined
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.
modalboolean | undefinedfalseThe 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.
openDelaynumber | undefined120The 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.
closeDelaynumber | undefined160The delay before closing after the cursor leaves, in ms.
closeOnContentClickboolean | undefinedfalseClose on a click inside the panel — convenient for a menu, harmful for a form.
roleGrPopoverRole | 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.
closeOnClickOutsideboolean | undefinedtrue
autoFocusboolean | undefinedtrueMove 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.
matchWidthboolean | "min" | undefinedfalseTake 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

SlotTypeDescription
trigger{ 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

EventTypeDescription
update:open[value: boolean]

Methods / Expose

Methods / ExposeTypeDescription
close() => void
toggle() => void

Examples 7

Form

Form
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrFormField, GrInput, GrPopover } from '@feugene/granularity'

const open = ref(false)
const name = ref('Weekly digest')
const recipients = ref('team@acme.io')

function save(): void {
  open.value = false
}
</script>

<template>
  <GrPopover v-model:open="open" aria-label="Report settings" placement="bottom-start">
    <template #trigger="{ triggerProps }">
      <GrButton variant="outline" v-bind="triggerProps">
        Report settings
      </GrButton>
    </template>

    <template #content>
      <div class="grid w-64 gap-3">
        <GrFormField label="Name">
          <GrInput v-model="name" size="sm" />
        </GrFormField>

        <GrFormField label="Recipients">
          <GrInput v-model="recipients" size="sm" />
        </GrFormField>

        <div class="flex justify-end gap-2">
          <GrButton variant="ghost" size="sm" @click="open = false">
            Cancel
          </GrButton>
          <GrButton size="sm" @click="save">
            Save
          </GrButton>
        </div>
      </div>
    </template>
  </GrPopover>
</template>

Confirm

Confirm
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrPopover } from '@feugene/granularity'

const open = ref(false)
const archived = ref(false)

function confirm(): void {
  archived.value = true
  open.value = false
}
</script>

<template>
  <div class="flex items-center gap-3">
    <GrPopover
      v-model:open="open"
      aria-label="Confirm archiving"
      placement="top"
      size="sm"
    >
      <template #trigger="{ triggerProps }">
        <GrButton variant="outline" tone="danger" v-bind="triggerProps">
          Archive invoice
        </GrButton>
      </template>

      <template #content>
        <div class="grid w-56 gap-3">
          <p class="text-[var(--gr-fg)]">
            Archive this invoice? You can restore it from the archive later.
          </p>

          <div class="flex justify-end gap-2">
            <GrButton variant="ghost" size="xs" @click="open = false">
              Cancel
            </GrButton>
            <GrButton size="xs" tone="danger" @click="confirm">
              Archive
            </GrButton>
          </div>
        </div>
      </template>
    </GrPopover>

    <span v-if="archived" class="text-sm text-[var(--gr-muted-fg)]">
      Invoice archived
    </span>
  </div>
</template>

Modal

Background stays interactive only while the popover is not modal.
Last background click: never

Modal
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrFormField, GrInput, GrPopover, GrSwitch } from '@feugene/granularity'

const modal = ref(true)
const open = ref(false)
const amount = ref('1200')
const comment = ref('')
const lastBackgroundClick = ref<string | null>(null)
</script>

<template>
  <div class="grid gap-4">
    <GrSwitch v-model="modal">
      Modal mode
    </GrSwitch>

    <GrPopover v-model:open="open" :modal="modal" aria-label="Refund request" placement="bottom-start">
      <template #trigger="{ triggerProps }">
        <GrButton class="justify-self-start" variant="outline" v-bind="triggerProps">
          Request a refund
        </GrButton>
      </template>

      <template #content>
        <div class="grid w-72 gap-3">
          <GrFormField label="Amount">
            <GrInput v-model="amount" size="sm" />
          </GrFormField>

          <GrFormField label="Comment">
            <GrInput v-model="comment" size="sm" placeholder="Optional" />
          </GrFormField>

          <div class="flex justify-end gap-2">
            <GrButton variant="ghost" size="sm" @click="open = false">
              Cancel
            </GrButton>
            <GrButton size="sm" @click="open = false">
              Send
            </GrButton>
          </div>
        </div>
      </template>
    </GrPopover>

    <!-- Фон для проверки изоляции: в модальном режиме кнопка не кликается,
         не получает фокус по Tab и не читается диктором. -->
    <div class="grid gap-2 rounded-xl border border-[var(--gr-brd)] p-4">
      <div class="text-sm text-[var(--gr-muted-fg)]">
        Background stays interactive only while the popover is not modal.
      </div>
      <GrButton
        class="justify-self-start"
        variant="ghost"
        size="sm"
        @click="lastBackgroundClick = new Date().toLocaleTimeString()"
      >
        Click me
      </GrButton>
      <div class="text-sm">
        Last background click: {{ lastBackgroundClick ?? 'never' }}
      </div>
    </div>
  </div>
</template>

Placement

Placement
<script setup lang="ts">
import { GrButton, GrPopover } from '@feugene/granularity'

const placements = ['top', 'right', 'bottom', 'left'] as const
</script>

<template>
  <div class="flex flex-wrap items-center gap-3">
    <GrPopover
      v-for="placement in placements"
      :key="placement"
      :placement="placement"
      :aria-label="`Opens on the ${placement}`"
      size="sm"
    >
      <template #trigger="{ triggerProps }">
        <GrButton variant="outline" size="sm" v-bind="triggerProps">
          {{ placement }}
        </GrButton>
      </template>

      <template #content>
        <div class="w-40 text-[var(--gr-fg)]">
          Opens on the <b>{{ placement }}</b> and flips itself when the edge is close.
        </div>
      </template>
    </GrPopover>
  </div>
</template>

Width

Потолок — значение, поэтому это CSS-хук --gr-popover-max-width, а не проп: его можно менять по брейкпоинту и по теме, и он не спорит по специфичности с классом панели. Снимают его значением 100vw, а не none: min(none, …) — невалидный CSS.

Доставляется хук через contentClass, потому что панель живёт в портале: инлайновый стиль на <GrPopover> ляжет на обёртку триггера, а панель ей не потомок — свойство до неё не дойдёт и молча ничего не сделает. Глобально (в теме, на :root) хук работает как обычно: портал лежит в body.

Источник — поведение, поэтому проп matchWidth. Оси сочетаются, но разрешаются по-разному, и это следствие CSS: «по триггеру» с потолком даёт 352pxwidth от триггера срезан max-width; «минимум — триггер» даёт 416px, потому что min-width в CSS сильнее max-width. Пол выигрывает у потолка — и это смысл режима, а не изъян: min задаёт нижнюю границу ширины, а не саму ширину. Переключите оба и сравните числа.

Не шире вьюпорта не настраивается ничем: calc(100vw - 1rem) стоит вторым операндом min(), и снятый потолок его не отменяет — сузьте окно и убедитесь.

Width
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrPopover, GrSegmented } from '@feugene/granularity'

/**
 * Ширина панели — две независимые оси, и демо показывает именно их
 * независимость: потолок переключается слева, источник ширины — справа, и
 * любое сочетание осмысленно.
 *
 * Триггер намеренно широкий: при узком разница между «по содержимому» и «по
 * триггеру» не видна вовсе, а именно она тут и предмет.
 *
 * Хук приезжает через `contentClass`, а не инлайновым стилем на `GrPopover`:
 * панель телепортируется в портал, и кастомное свойство с обёртки триггера до
 * неё не наследуется — она ей не потомок.
 */
const ceiling = ref<'default' | 'none'>('default')
const source = ref<'content' | 'trigger' | 'trigger-min'>('content')

const ceilingOptions = [
  { value: 'default', label: 'Потолок 22rem' },
  { value: 'none', label: 'Потолок снят' },
]

const sourceOptions = [
  { value: 'content', label: 'По содержимому' },
  { value: 'trigger', label: 'По триггеру' },
  { value: 'trigger-min', label: 'Минимум — триггер' },
]

const LONG = 'Панель с длинным текстом, по которому видно, где именно проходит потолок ширины: '
  + 'по умолчанию это 22rem — читаемая ширина колонки, дальше строка переносится.'
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-4">
      <label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
        <span class="showcase-demo-text">Потолок содержимого — хук</span>
        <GrSegmented v-model="ceiling" :options="ceilingOptions" size="sm" />
      </label>

      <label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
        <span class="showcase-demo-text">Источник ширины — проп</span>
        <GrSegmented v-model="source" :options="sourceOptions" size="sm" />
      </label>
    </div>

    <div>
      <GrPopover
        :key="`${ceiling}-${source}`"
        :match-width="source === 'trigger' ? true : source === 'trigger-min' ? 'min' : false"
        :content-class="ceiling === 'none' ? '[--gr-popover-max-width:100vw]' : undefined"
        placement="bottom-start"
        aria-label="Ширина панели"
        size="sm"
      >
        <template #trigger="{ triggerProps }">
          <GrButton variant="outline" v-bind="triggerProps" class="w-[26rem]">
            Широкий триггер — 26rem
          </GrButton>
        </template>

        <template #content>
          <div class="text-[var(--gr-fg)]">{{ LONG }}</div>
        </template>
      </GrPopover>
    </div>

    <p class="showcase-demo-text text-sm">
      <b>Потолок</b> — значение, поэтому это CSS-хук <code>--gr-popover-max-width</code>, а не проп:
      его можно менять по брейкпоинту и по теме, и он не спорит по специфичности с классом панели.
      Снимают его значением <code>100vw</code>, а не <code>none</code>: <code>min(none, …)</code>
      невалидный CSS.

      <br><br>

      Доставляется хук <b>через <code>contentClass</code></b>, потому что панель живёт в портале:
      инлайновый стиль на <code>&lt;GrPopover&gt;</code> ляжет на обёртку триггера, а панель ей не
      потомок — свойство до неё не дойдёт и молча ничего не сделает. Глобально (в теме, на
      <code>:root</code>) хук работает как обычно: портал лежит в <code>body</code>.
    </p>

    <p class="showcase-demo-text text-sm">
      <b>Источник</b> — поведение, поэтому проп <code>matchWidth</code>. Оси сочетаются, но
      разрешаются по-разному, и это следствие CSS: «по триггеру» с потолком даёт
      <b>352px</b><code>width</code> от триггера срезан <code>max-width</code>;
      «минимум — триггер» даёт <b>416px</b>, потому что <code>min-width</code> в CSS сильнее
      <code>max-width</code>. Пол выигрывает у потолка — и это смысл режима, а не изъян:
      <code>min</code> задаёт нижнюю границу ширины, а не саму ширину. Переключите оба и сравните
      числа.
    </p>

    <p class="showcase-demo-text text-sm">
      <b>Не шире вьюпорта</b> не настраивается ничем: <code>calc(100vw - 1rem)</code> стоит вторым
      операндом <code>min()</code>, и снятый потолок его не отменяет — сузьте окно и убедитесь.
    </p>
  </div>
</template>

Height

Не выше, чем есть места — предел, который не настраивается. Слой пишет на панель замер --gr-floating-available-height: расстояние до края вьюпорта на той стороне, куда панель в итоге встала. Он стоит вторым операндом min() и снаружи не снимается.

Прокрутите страницу так, чтобы триггер оказался у нижнего края, и откройте снова: панель сожмётся под оставшееся место, а не уедет за экран. flip перевернёт её на свободную сторону, shift подвинет вдоль края — но сжать её не может ни тот, ни другой, и без этого предела низ длинной панели был бы недостижим ничем.

Потолок содержимого — хук --gr-popover-max-height, по умолчанию 100vh, то есть мнения нет: высоту диктует замер. Задают его, когда панель обязана быть ниже доступного места. Доставляется через contentClass по той же причине, что и потолок ширины: панель живёт в портале, и инлайновый стиль ляжет на обёртку триггера, а не на неё.

Скролл приезжает вместе с потолком. Потолок без скролла не ограничивает, а обрезает — содержимое молча уходит под нижний край. Пока потолок не упёрся, полосы прокрутки нет: для коротких панелей не меняется ничего.

Height
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrPopover, GrSegmented } from '@feugene/granularity'

/**
 * Высота устроена как ширина — `min()` из хука и неотключаемого предела, — но
 * второй операнд здесь не константа, а замер слоя: сколько места осталось до
 * края вьюпорта на той стороне, куда панель встала.
 *
 * Содержимое намеренно длинное: пока потолок не упёрся, не видно ни его, ни
 * скролла, и демонстрировать было бы нечего.
 */
const ceiling = ref<'auto' | 'short'>('auto')

const ceilingOptions = [
  { value: 'auto', label: 'Мнения нет' },
  { value: 'short', label: 'Потолок 12rem' },
]

const ROWS = Array.from({ length: 24 }, (_, i) => `Строка ${i + 1} — содержимое, которого заведомо больше, чем экрана`)
</script>

<template>
  <div class="grid gap-4">
    <label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
      <span class="showcase-demo-text">Потолок содержимого — хук</span>
      <GrSegmented v-model="ceiling" :options="ceilingOptions" size="sm" />
    </label>

    <div>
      <GrPopover
        :key="ceiling"
        :content-class="ceiling === 'short' ? '[--gr-popover-max-height:12rem]' : undefined"
        placement="bottom-start"
        aria-label="Высота панели"
        size="sm"
      >
        <template #trigger="{ triggerProps }">
          <GrButton variant="outline" v-bind="triggerProps">
            Открыть длинную панель — 24 строки
          </GrButton>
        </template>

        <template #content>
          <div class="grid gap-1 text-[var(--gr-fg)]">
            <div v-for="row in ROWS" :key="row">{{ row }}</div>
          </div>
        </template>
      </GrPopover>
    </div>

    <p class="showcase-demo-text text-sm">
      <b>Не выше, чем есть места</b> — предел, который не настраивается. Слой пишет на панель замер
      <code>--gr-floating-available-height</code>: расстояние до края вьюпорта на той стороне, куда
      панель в итоге встала. Он стоит вторым операндом <code>min()</code> и снаружи не снимается.

      <br><br>

      Прокрутите страницу так, чтобы триггер оказался у нижнего края, и откройте снова: панель
      сожмётся под оставшееся место, а не уедет за экран. <code>flip</code> перевернёт её на
      свободную сторону, <code>shift</code> подвинет вдоль края — но сжать её не может ни тот, ни
      другой, и без этого предела низ длинной панели был бы недостижим ничем.
    </p>

    <p class="showcase-demo-text text-sm">
      <b>Потолок содержимого</b> — хук <code>--gr-popover-max-height</code>, по умолчанию
      <code>100vh</code>, то есть мнения нет: высоту диктует замер. Задают его, когда панель обязана
      быть <b>ниже</b> доступного места. Доставляется через <code>contentClass</code> по той же
      причине, что и потолок ширины: панель живёт в портале, и инлайновый стиль ляжет на обёртку
      триггера, а не на неё.

      <br><br>

      <b>Скролл приезжает вместе с потолком.</b> Потолок без скролла не ограничивает, а обрезает —
      содержимое молча уходит под нижний край. Пока потолок не упёрся, полосы прокрутки нет: для
      коротких панелей не меняется ничего.
    </p>
  </div>
</template>

Trigger

openDelay и closeDelay нужны обе: без первой панель выпрыгивает на любое пересечение курсором, без второй её не удержать при переходе с триггера на панель — между ними зазор offsetPx. Клик в режиме наведения продолжает работать: с клавиатуры и с тачскрина наведения не бывает.

Триггер — элемент с triggerProps, а не весь слот

Обе кнопки лежат внутри слота #trigger, но панель открывает только левая. «Сохранить» делает своё дело — счётчик: 0 — и панели не касается. Клик живёт в triggerProps, а не на обёртке слота: иначе кнопка рядом, ссылка в карточке-триггере или крестик на чипе открывали бы панель мимо намерения.

Слот без v-bind="triggerProps" по-прежнему открывается кликом по обёртке — так работало раньше, и это не отняли. Но такой триггер остаётся без клавиатуры и без aria-haspopup/aria-expanded, поэтому в dev-сборке компонент предупреждает о нём в консоли.

Trigger
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrPopover, GrSegmented } from '@feugene/granularity'

/**
 * Чем открывается панель и что считается триггером.
 *
 * Второй пример здесь важнее первого: он показывает не возможность, а границу.
 * Триггером считается элемент с `triggerProps`, а не весь слот, — и увидеть это
 * можно только рядом с соседней кнопкой, которая панель не открывает.
 */
const mode = ref<'click' | 'hover'>('click')

const modeOptions = [
  { value: 'click', label: 'По клику' },
  { value: 'hover', label: 'По наведению' },
]

const saved = ref(0)
</script>

<template>
  <div class="grid gap-5">
    <div class="grid gap-2">
      <label class="grid gap-1 text-[length:var(--gr-control-text-sm)]">
        <span class="showcase-demo-text">Чем открывается</span>
        <GrSegmented v-model="mode" :options="modeOptions" size="sm" />
      </label>

      <div>
        <GrPopover
          :key="mode"
          :trigger="mode"
          :open-delay="120"
          :close-delay="160"
          placement="bottom-start"
          aria-label="Режим открытия"
          size="sm"
        >
          <template #trigger="{ triggerProps }">
            <GrButton variant="outline" v-bind="triggerProps">
              {{ mode === 'hover' ? 'Наведите курсор' : 'Нажмите' }}
            </GrButton>
          </template>

          <template #content>
            <div class="w-56 text-[var(--gr-fg)]">
              Панель держится, пока курсор на ней: задержка закрытия даёт перейти
              с триггера через зазор.
            </div>
          </template>
        </GrPopover>
      </div>

      <p class="showcase-demo-text text-sm">
        <code>openDelay</code> и <code>closeDelay</code> нужны обе: без первой панель выпрыгивает на
        любое пересечение курсором, без второй её не удержать при переходе с триггера на панель —
        между ними зазор <code>offsetPx</code>. Клик в режиме наведения продолжает работать: с
        клавиатуры и с тачскрина наведения не бывает.
      </p>
    </div>

    <div class="grid gap-2">
      <span class="showcase-demo-text text-[length:var(--gr-control-text-sm)]">
        Триггер — элемент с <code>triggerProps</code>, а не весь слот
      </span>

      <GrPopover placement="bottom-start" aria-label="Что считается триггером" size="sm">
        <template #trigger="{ triggerProps }">
          <div class="flex items-center gap-2">
            <GrButton variant="outline" v-bind="triggerProps">Открыть панель</GrButton>
            <GrButton variant="ghost" @click="saved += 1">Сохранить</GrButton>
          </div>
        </template>

        <template #content>
          <div class="w-56 text-[var(--gr-fg)]">Открыла только левая кнопка.</div>
        </template>
      </GrPopover>

      <p class="showcase-demo-text text-sm">
        Обе кнопки лежат внутри слота <code>#trigger</code>, но панель открывает только левая.
        «Сохранить» делает своё дело — счётчик: <b>{{ saved }}</b> — и панели не касается.
        Клик живёт в <code>triggerProps</code>, а не на обёртке слота: иначе кнопка рядом,
        ссылка в карточке-триггере или крестик на чипе открывали бы панель мимо намерения.
      </p>

      <p class="showcase-demo-text text-sm">
        Слот без <code>v-bind="triggerProps"</code> по-прежнему открывается кликом по обёртке —
        так работало раньше, и это не отняли. Но такой триггер остаётся без клавиатуры и без
        <code>aria-haspopup</code>/<code>aria-expanded</code>, поэтому в dev-сборке компонент
        предупреждает о нём в консоли.
      </p>
    </div>
  </div>
</template>

Component documentationAll components