GrPopover
Берут, когда своё содержимое у якоря.
Когда брать
- своё содержимое у якоря — фильтр, форма, палитра, карточка предпросмотра: слой и позиционирование берёт примитив, разметку и клавиатуру внутри пишете вы;
- подтверждение у самой кнопки —
trigger="manual"плюсmodal, чтобы фон не уводил фокус с формы подтверждения; - контекстное меню — момент открытия решает потребитель,
roleпереключается наmenu; - свой оверлейный компонент — это примитив, поверх которого в пакете собраны меню, палитры и Popconfirm; второй модальный слой заводить не надо.
Когда взять другое
| Нужно | Берите |
|---|---|
| Меню с готовыми пунктами, группами и разделителями | GrDropdownMenu |
| Слой и клавиатура меню, пункты свои | GrDropdown |
| Текстовая подсказка к контролу, без интерактива внутри | GrTooltip |
| Окно по центру со своей шапкой и подвалом | GrDialog |
| Панель у края экрана | GrDrawer |
| Да/нет по опасному действию | GrConfirmDialog |
Почему не `GrDropdown`
Роль универсального поповера какое-то время исполнял GrDropdown, но он про
меню: жёстко объявляет role="menu", aria-haspopup="menu" и водит фокус по
пунктам. Для формы, подтверждения или палитры это неверная семантика — диктор
объявит меню там, где меню нет.
Здесь наоборот: role задаётся пропом (dialog по умолчанию, плюс menu,
listbox, grid, group и none), потому что клавиатурный паттерн внутри
панели принадлежит содержимому. На этом примитиве собираются меню, контекстные
меню, палитры и Popconfirm.
Триггер
Слот #trigger получает triggerProps — их нужно навесить на реальный
фокусируемый элемент, а не на обёртку:
<GrPopover>
<template #trigger="{ triggerProps }">
<GrButton v-bind="triggerProps">Открыть</GrButton>
</template>
</GrPopover>
aria-expanded и aria-controls обязаны жить на самом интерактивном элементе:
на div вокруг него они немы. Клик при этом слушает обёртка — поэтому триггером
может быть что угодно, лишь бы ARIA уехала на кнопку.
trigger="manual" отключает открытие по клику: момент открытия решает
потребитель через v-model:open. Это режим контекстного меню и подтверждений.
Ловушки фокуса по умолчанию нет
И это не упущение. Немодальный слой не блокирует страницу, поэтому Tab обязан
уводить фокус наружу — иначе пользователь заперт в панели на работающей
странице.
modal включает второй режим: фон уходит в inert, Tab ходит по кругу внутри
панели, скролл страницы блокируется, а слой встаёт в стек модальным — как окно.
Нужен поповеру с формой или подтверждением: без изоляции фона пользователь
уводит фокус на ту самую страницу, к которой поповер и относится. В этом режиме
фокус переносится в панель независимо от autoFocus — фон недоступен, и слой без
фокуса внутри стал бы клавиатурной ловушкой.
Модальность приходит той же сборкой, что у окна, drawer’а и просмотрщика, так
что второй реализации модального слоя в пакете нет. Стек, Esc и возврат фокуса
на триггер — ../overlays.md.
`autoFocus` фокусирует панель, а не поле внутри
Панель фокусируемая (tabindex="-1"), и при открытии фокус переносится именно на
неё. Сфокусировать первый контрол за пользователя — решение содержимого, а не
оболочки: в фильтре это уместно, в подтверждении удаления — нет.
Доступное имя
Для role="dialog" имя обязательно: либо ariaLabel, либо labelledBy с id
видимого заголовка внутри панели. Панель без имени диктор объявит как «диалог» и
ничего больше.
Закрытие
closeOnEsc и closeOnClickOutside включены по умолчанию.
closeOnContentClick — нет: он удобен меню, где клик по пункту и означает
выбор, и вреден форме, где каждый клик по полю закрывал бы панель.
Императивно — open(), close(), toggle() через ref компонента. Этот набор в
пакете считается эталонным для оверлеев и закреплён гейтом
overlayImperativeApi.
Слой и размер
Панель лежит на той же высоте, что GrDropdown (--gr-z-dropdown), и это
осознанно: оба — якорные немодальные оверлеи одного класса, и на разных высотах
они начали бы перекрывать друг друга в зависимости от порядка открытия. Шкала
слоёв — ../z-index.md.
placement и offsetPx задают сторону и зазор; итоговая сторона может
отличаться от заданной — панель переворачивается, когда места не хватает, и
transform-origin анимации переворачивается вместе с ней.
size задаёт внутренние отступы и кегль по контрольной шкале — см.
../sizes.md.
Триггером считается элемент с `triggerProps`
triggerProps несут и ARIA, и клик. Биндить их надо на сам интерактивный
элемент, а не на обёртку вокруг него:
<GrPopover>
<template #trigger="{ triggerProps }">
<GrButton v-bind="triggerProps">
Открыть
</GrButton>
</template>
</GrPopover>
Причина не в аккуратности, а в поведении. Слот #trigger может содержать не
только триггер: кнопку рядом, ссылку в карточке, крестик на чипе. Клик,
живущий на обёртке, ловил бы их все и открывал панель мимо намерения
пользователя. Живущий в triggerProps — открывает только с того элемента,
которому его отдали.
Слот, оставленный без v-bind="triggerProps", по-прежнему открывается кликом
по обёртке: так работало раньше, и это не отняли. Но такой триггер остаётся без
клавиатуры и без aria-haspopup/aria-expanded — панель для скринридера не
объявлена, и с Tab до неё не добраться. В dev-сборке компонент предупреждает о
таком триггере в консоли.
Чем открывается
trigger — click (по умолчанию), manual или hover.
manual открывает только программно, через v-model:open: так работают
контекстное меню и подтверждения, где момент открытия решает потребитель.
hover открывает по наведению с задержками openDelay и closeDelay. Нужны
обе, и каждая по своей причине: без первой панель выпрыгивает на любое
пересечение курсором, без второй её не удержать при переходе с триггера на
панель — между ними зазор offsetPx. Наведение слушает и панель, поэтому курсор,
переехавший на неё, панель не гасит.
Клик в режиме наведения продолжает работать. С клавиатуры и с тачскрина наведения не бывает, и панель, открываемая только курсором, для них не существует вовсе.
Обёртка триггера
Обёртка вокруг слота #trigger по умолчанию inline-block: она обжимает содержимое,
и панель встаёт у края самой кнопки, а не у края колонки. Для кнопки это верно.
Форм-контролу — нет. Контрол объявляет себя w-full, как GrInput и GrSelect, но
w-full резолвится относительно обёртки, а она уже обжалась по содержимому. Итог:
контрол во всю ширину рисуется по содержимому, а рядом с полем ввода это читается как
сбитая вёрстка. Замер: GrColorPicker в GrFormField шириной 384px рисовался на 113px.
Проп block растягивает обёртку на всю ширину родителя:
<GrPopover block>
Имя то же, что у GrButton и GrSegmented, — в ядре это устоявшееся слово для «во всю
ширину». matchWidth при этом берёт ширину именно у обёртки, поэтому с block панель
пойдёт по ширине поля, а не по ширине содержимого триггера.
Ширина
Две независимые оси и один предел, который не настраивается.
Потолок содержимого — хук --gr-popover-max-width, по умолчанию 22rem. Это
читаемая ширина колонки текста, и для формы или карточки она верна. Содержимому
шире прозы — тулбару, палитре, сетке — потолок снимают:
<GrPopover content-class="[--gr-popover-max-width:100vw]">
Хуком, а не пропом, намеренно: значение остаётся значением, его можно менять по
брейкпоинту (md:[--gr-popover-max-width:100vw]) и по теме, и оно не спорит по
специфичности с классом панели.
100vw, а не none: min(none, …) — невалидный CSS, и запись через 100vw
заодно оставляет на месте второй предел.
Через
contentClass, а не инлайновым стилем. Панель телепортируется в портал (#gr-portalвbody), поэтому обёртка триггера ей не предок:<GrPopover style="--gr-popover-max-width: 100vw">ляжет на обёртку, до панели не дойдёт и молча ничего не сделает.contentClass— единственный путь на саму панель. Глобально хук работает как обычно: в теме или на:rootон наследуется в портал вместе со всем остальным.
Ширина от триггера — проп matchWidth. true — точно ширина триггера,
'min' — не уже его, дальше по содержимому. Панель у поля или у широкой кнопки,
оказавшаяся уже своего триггера, читается как ошибка вёрстки.
<GrPopover match-width> <!-- ровно ширина триггера -->
<GrPopover match-width="min"> <!-- не уже триггера -->
Оси сочетаются, а не спорят, но разрешаются по-разному, и это следствие CSS, а не решение компонента:
| Сочетание | Что получится | Почему |
|---|---|---|
matchWidth + потолок | «шириной с триггер, но не шире читаемого» | width от триггера, max-width его срезает |
matchWidth="min" + потолок | шире потолка, если триггер шире | min-width в CSS сильнее max-width: пол выигрывает у потолка |
Второе — не изъян, а смысл режима: min означает «не уже триггера», то есть пол, а
пол по определению главнее потолка. Нужен именно потолок — берите matchWidth
без min.
Поэтому это два независимых рычага, а не одно перечисление: сочетания осмысленны и разрешаются предсказуемо.
Не шире вьюпорта — не настраивается. calc(100vw - 1rem) стоит вторым
операндом min() и снаружи не отключается ничем, включая
--gr-popover-max-width: 100vw: позиционирование смещает панель в пределах
экрана, но не сужает её, и без этого предела панель у края уезжала бы за него.
Высота
Устроена как ширина — min() из хука и предела, который не настраивается, — но
второй операнд здесь не константа, а замер.
Потолок содержимого — хук --gr-popover-max-height, по умолчанию 100vh,
то есть мнения нет. Задают его, когда панель обязана быть ниже доступного
места:
<GrPopover content-class="[--gr-popover-max-height:20rem]">
Через contentClass и по той же причине, что у ширины: панель телепортирована,
инлайновый стиль ляжет на обёртку и до неё не дойдёт.
Не выше, чем есть места, — не настраивается. Второй операнд — переменная
--gr-floating-available-height, которую пишет слой: расстояние до края
вьюпорта на той стороне, куда панель встала. Она пересчитывается вместе с
позицией, в том числе на скролле, и снаружи не отключается.
Статичной эта величина быть не может, и в этом всё отличие от ширины: 100vw
известен заранее, а сколько места под триггером — зависит от того, где триггер
стоит. flip перевернёт панель на свободную сторону, shift подвинет вдоль
края, но сжать её не может ни тот, ни другой: панель выше вьюпорта после
обоих остаётся выше вьюпорта, и её низ уходит за экран без всякой возможности
туда добраться.
Панель скроллится. Потолок приезжает вместе с overflow-y: auto, потому что
потолок без скролла не ограничивает, а обрезает. Пока потолок не упёрся, полосы
прокрутки нет — для коротких панелей не меняется ничего.
Это касается и всего, что стоит на GrPopover: длинное меню GrDropdown или
GrContextMenu у нижнего края экрана теперь сжимается со скроллом, а не
съезжает.
Playground 16
Загружается…
<GrPopover />Установка
npm i @feugene/granularityИмпорт
import { GrPopover } from '@feugene/granularity/components/GrPopover'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
open | boolean | undefined | undefined | Открыт ли поповер. Без этого пропа компонент ведёт состояние сам (uncontrolled), с ним — слушайте `update:open`. |
disabled | boolean | undefined | false | — |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | Доступное имя панели. Обязательно для `role="dialog"` без видимого заголовка. |
placement | Placement | undefined | "bottom-start" | — |
block | boolean | undefined | false | Обёртка триггера занимает всю ширину родителя вместо того, чтобы обжимать содержимое. Нужно форм-контролам: обёртка `inline-block` схлопывается по содержимому, и `w-full` у самого триггера начинает резолвиться относительно неё, а не относительно поля. Контрол, объявивший себя во всю ширину, рисуется по содержимому — рядом с полем ввода это читается как сбитая вёрстка. Имя то же, что у `GrButton` и `GrSegmented`: в ядре это устоявшееся слово для «во всю ширину». |
padding | GrPopoverPadding | undefined | "default" | Поле панели. `none` — содержимое рисует своё (меню, список опций). |
closeOnEsc | boolean | undefined | true | — |
trigger | "click" | "manual" | "hover" | undefined | "click" | Чем открывается панель. `manual` — только программно (через `v-model:open`): нужно контекстному меню и подтверждениям, где момент открытия решает потребитель. `hover` — по наведению, с задержками из `openDelay` и `closeDelay`; клик и клавиатура в этом режиме продолжают работать. |
offsetPx | number | undefined | 8 | Зазор между триггером и панелью, px. |
labelledBy | string | undefined | undefined | `id` видимого заголовка внутри панели — альтернатива `ariaLabel`. |
teleportTo | string | HTMLElement | undefined | undefined | Точечное переопределение точки монтирования. По умолчанию — общий портал оверлеев (`#gr-portal` либо `portalTarget` из `GrConfigProvider`). |
contentClass | string | undefined | undefined | — |
anchor | GrFloatingAnchorRect | null | undefined | undefined | Якорь-прямоугольник в координатах вьюпорта вместо обёртки слота `#trigger`: панель встаёт у точки курсора или у строки списка. Нужен контекстному меню, у которого триггера-элемента нет вовсе. `triggerProps` в этом режиме некому потребить, и своих ARIA-атрибутов поповер никуда не вешает: `aria-haspopup` невалиден вне интерактивного элемента, а `aria-expanded` без хозяина — шум. Связь с содержимым страницы объявляет тот, кто открывает. |
modal | boolean | undefined | false | Модальный режим: фон уходит в `inert`, Tab ходит по кругу внутри панели, скролл страницы блокируется, а слой встаёт в стек модальным — как окно. Нужен поповеру с формой или подтверждением внутри: без изоляции фона пользователь уводит фокус на страницу, к которой поповер и относится. В этом режиме фокус переносится в панель независимо от `autoFocus`: фон недоступен, и слой без фокуса внутри стал бы клавиатурной ловушкой. |
openDelay | number | undefined | 120 | Задержка открытия по наведению, мс. Обе задержки нужны, и каждая по своей причине: без `openDelay` панель выпрыгивает на любое пересечение курсором, без `closeDelay` её не удержать при переходе с триггера на панель — между ними зазор `offsetPx`. |
closeDelay | number | undefined | 160 | Задержка закрытия после ухода курсора, мс. |
closeOnContentClick | boolean | undefined | false | Закрывать по клику внутри панели — удобно для меню, вредно для формы. |
role | GrPopoverRole | undefined | "dialog" | Роль панели. Меняется теми, кто строит поверх примитива своё меню/список. |
closeOnClickOutside | boolean | undefined | true | — |
autoFocus | boolean | undefined | true | Переносить фокус на панель при открытии. Именно на панель, а не на первый контрол внутри: сфокусировать поле ввода за пользователя — решение содержимого, а не оболочки. |
matchWidth | boolean | "min" | undefined | false | Брать ширину у триггера: `true` — точно его ширина, `'min'` — не уже его, дальше по содержимому. Панель у поля или у широкой кнопки, оказавшаяся уже своего триггера, читается как ошибка вёрстки. С потолком `--gr-popover-max-width` сочетается, а не спорит: ширину задаёт триггер, потолок её ограничивает — «шириной с триггер, но не шире читаемого». Поэтому это отдельный проп, а не значение одного перечисления вместе с потолком: оси независимы, и сочетание осмысленно. |
Slots
| Slot | Type | Описание |
|---|---|---|
trigger | { open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; } | Триггер панели. `triggerProps` обязаны попасть на сам интерактивный элемент, а не на обёртку вокруг него: `aria-expanded` и `aria-controls` читаются с того узла, который получает фокус. |
content | { close: () => void; } | Содержимое панели. |
Events
| Event | Type | Описание |
|---|---|---|
update:open | [value: boolean] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
close | () => void | — |
toggle | () => void | — |
Примеры 7
Настройки в поповере
Форма прямо у кнопки, без ухода в модалку: поповер держит фокус, закрывается по Esc и клику вне, а клик внутри его не роняет — иначе первое же поле закрывало бы форму.
<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('[email protected]')
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>Подтверждение действия
Лёгкая альтернатива диалогу для необратимых мелочей: подтверждение появляется у самой кнопки, а не перекрывает экран.
<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>Модальный режим: изоляция фона
Поповер с формой внутри выключает страницу под собой: фон уходит в inert, Tab ходит по кругу внутри панели, скролл блокируется. Без 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>Ширина: потолок и источник
Две независимые оси. Потолок содержимого — CSS-хук --gr-popover-max-width, источник ширины — проп matchWidth. Сочетаются, а не спорят: «шириной с триггер, но не шире читаемого».
Потолок — значение, поэтому это 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>Высота: потолок и доступное место
Панель не выше, чем осталось до края экрана: слой пишет замер, панель сжимается со скроллом. Потолок содержимого — CSS-хук --gr-popover-max-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>Чем открывается и что считается триггером
Клик и наведение с задержками. Триггером считается элемент с triggerProps, а не весь слот: соседняя кнопка внутри слота панель не открывает.
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>