GrPopover

Пакет: @feugene/granularityядроГруппа: Слои

Берут, когда своё содержимое у якоря.

Когда брать

  • своё содержимое у якоря — фильтр, форма, палитра, карточка предпросмотра: слой и позиционирование берёт примитив, разметку и клавиатуру внутри пишете вы;
  • подтверждение у самой кнопки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-сборке компонент предупреждает о таком триггере в консоли.

Чем открывается

triggerclick (по умолчанию), 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

PropTypeпо умолчаниюОписание
openboolean | undefinedundefinedОткрыт ли поповер. Без этого пропа компонент ведёт состояние сам (uncontrolled), с ним — слушайте `update:open`.
disabledboolean | undefinedfalse
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefinedДоступное имя панели. Обязательно для `role="dialog"` без видимого заголовка.
placementPlacement | undefined"bottom-start"
blockboolean | undefinedfalseОбёртка триггера занимает всю ширину родителя вместо того, чтобы обжимать содержимое. Нужно форм-контролам: обёртка `inline-block` схлопывается по содержимому, и `w-full` у самого триггера начинает резолвиться относительно неё, а не относительно поля. Контрол, объявивший себя во всю ширину, рисуется по содержимому — рядом с полем ввода это читается как сбитая вёрстка. Имя то же, что у `GrButton` и `GrSegmented`: в ядре это устоявшееся слово для «во всю ширину».
paddingGrPopoverPadding | undefined"default"Поле панели. `none` — содержимое рисует своё (меню, список опций).
closeOnEscboolean | undefinedtrue
trigger"click" | "manual" | "hover" | undefined"click"Чем открывается панель. `manual` — только программно (через `v-model:open`): нужно контекстному меню и подтверждениям, где момент открытия решает потребитель. `hover` — по наведению, с задержками из `openDelay` и `closeDelay`; клик и клавиатура в этом режиме продолжают работать.
offsetPxnumber | undefined8Зазор между триггером и панелью, px.
labelledBystring | undefinedundefined`id` видимого заголовка внутри панели — альтернатива `ariaLabel`.
teleportTostring | HTMLElement | undefinedundefinedТочечное переопределение точки монтирования. По умолчанию — общий портал оверлеев (`#gr-portal` либо `portalTarget` из `GrConfigProvider`).
contentClassstring | undefinedundefined
anchorGrFloatingAnchorRect | null | undefinedundefinedЯкорь-прямоугольник в координатах вьюпорта вместо обёртки слота `#trigger`: панель встаёт у точки курсора или у строки списка. Нужен контекстному меню, у которого триггера-элемента нет вовсе. `triggerProps` в этом режиме некому потребить, и своих ARIA-атрибутов поповер никуда не вешает: `aria-haspopup` невалиден вне интерактивного элемента, а `aria-expanded` без хозяина — шум. Связь с содержимым страницы объявляет тот, кто открывает.
modalboolean | undefinedfalseМодальный режим: фон уходит в `inert`, Tab ходит по кругу внутри панели, скролл страницы блокируется, а слой встаёт в стек модальным — как окно. Нужен поповеру с формой или подтверждением внутри: без изоляции фона пользователь уводит фокус на страницу, к которой поповер и относится. В этом режиме фокус переносится в панель независимо от `autoFocus`: фон недоступен, и слой без фокуса внутри стал бы клавиатурной ловушкой.
openDelaynumber | undefined120Задержка открытия по наведению, мс. Обе задержки нужны, и каждая по своей причине: без `openDelay` панель выпрыгивает на любое пересечение курсором, без `closeDelay` её не удержать при переходе с триггера на панель — между ними зазор `offsetPx`.
closeDelaynumber | undefined160Задержка закрытия после ухода курсора, мс.
closeOnContentClickboolean | undefinedfalseЗакрывать по клику внутри панели — удобно для меню, вредно для формы.
roleGrPopoverRole | undefined"dialog"Роль панели. Меняется теми, кто строит поверх примитива своё меню/список.
closeOnClickOutsideboolean | undefinedtrue
autoFocusboolean | undefinedtrueПереносить фокус на панель при открытии. Именно на панель, а не на первый контрол внутри: сфокусировать поле ввода за пользователя — решение содержимого, а не оболочки.
matchWidthboolean | "min" | undefinedfalseБрать ширину у триггера: `true` — точно его ширина, `'min'` — не уже его, дальше по содержимому. Панель у поля или у широкой кнопки, оказавшаяся уже своего триггера, читается как ошибка вёрстки. С потолком `--gr-popover-max-width` сочетается, а не спорит: ширину задаёт триггер, потолок её ограничивает — «шириной с триггер, но не шире читаемого». Поэтому это отдельный проп, а не значение одного перечисления вместе с потолком: оси независимы, и сочетание осмысленно.

Slots

SlotTypeОписание
trigger{ open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; }Триггер панели. `triggerProps` обязаны попасть на сам интерактивный элемент, а не на обёртку вокруг него: `aria-expanded` и `aria-controls` читаются с того узла, который получает фокус.
content{ close: () => void; }Содержимое панели.

Events

EventTypeОписание
update:open[value: boolean]

Methods / Expose

Methods / ExposeTypeОписание
close() => void
toggle() => void

Примеры 7

Настройки в поповере

Форма прямо у кнопки, без ухода в модалку: поповер держит фокус, закрывается по Esc и клику вне, а клик внутри его не роняет — иначе первое же поле закрывало бы форму.

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>

Модальный режим: изоляция фона

Поповер с формой внутри выключает страницу под собой: фон уходит в inert, Tab ходит по кругу внутри панели, скролл блокируется. Без 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>

Ширина: потолок и источник

Две независимые оси. Потолок содержимого — CSS-хук --gr-popover-max-width, источник ширины — проп matchWidth. Сочетаются, а не спорят: «шириной с триггер, но не шире читаемого».

Потолок — значение, поэтому это 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>

Высота: потолок и доступное место

Панель не выше, чем осталось до края экрана: слой пишет замер, панель сжимается со скроллом. Потолок содержимого — CSS-хук --gr-popover-max-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>

Чем открывается и что считается триггером

Клик и наведение с задержками. Триггером считается элемент с triggerProps, а не весь слот: соседняя кнопка внутри слота панель не открывает.

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>

Документация компонентаВсе компоненты