GrPopover
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 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 |
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.
Playground 16
Loading…
<GrPopover />Install
npm i @feugene/granularityImport
import { GrPopover } from '@feugene/granularity/components/GrPopover'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
open | boolean | undefined | undefined | Whether the popover is open. Without this prop the component runs the state itself (uncontrolled), with it — listen to `update:open`. |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | The accessible name of the panel. Mandatory for `role="dialog"` without a visible heading. |
placement | Placement | undefined | "bottom-start" | — |
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). |
closeOnEsc | boolean | undefined | true | — |
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 | — |
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. |
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 | — |
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 | { 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
| Event | Type | Description |
|---|---|---|
update:open | [value: boolean] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
close | () => void | — |
toggle | () => void | — |
Examples 7
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
<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
<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
<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: «по триггеру» с потолком даёт 352px — width от триггера срезан max-width; «минимум — триггер» даёт 416px, потому что min-width в CSS сильнее max-width. Пол выигрывает у потолка — и это смысл режима, а не изъян: min задаёт нижнюю границу ширины, а не саму ширину. Переключите оба и сравните числа.
Не шире вьюпорта не настраивается ничем: calc(100vw - 1rem) стоит вторым операндом min(), и снятый потолок его не отменяет — сузьте окно и убедитесь.
<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><GrPopover></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 по той же причине, что и потолок ширины: панель живёт в портале, и инлайновый стиль ляжет на обёртку триггера, а не на неё.
Скролл приезжает вместе с потолком. Потолок без скролла не ограничивает, а обрезает — содержимое молча уходит под нижний край. Пока потолок не упёрся, полосы прокрутки нет: для коротких панелей не меняется ничего.
<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-сборке компонент предупреждает о нём в консоли.
<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>