GrPopover

  • ядро
  • Слои
  • API · 22 props

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

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

Обзор

Когда брать

  • своё содержимое у якоря — фильтр, форма, палитра, карточка предпросмотра: слой и позиционирование берёт примитив, разметку и клавиатуру внутри пишете вы;
  • подтверждение у самой кнопки — trigger="manual" плюс modal, чтобы фон не уводил фокус с формы подтверждения;
  • контекстное меню — момент открытия решает потребитель, role переключается на menu;
  • свой оверлейный компонент — это примитив, поверх которого в пакете собраны меню, палитры и Popconfirm; второй модальный слой заводить не надо.

Когда взять другое

НужноБерите
Меню с готовыми пунктами, группами и разделителямиGrDropdownMenu
Слой и клавиатура меню, пункты своиGrDropdown
Текстовая подсказка к контролу, без интерактива внутриGrTooltip
Окно по центру со своей шапкой и подваломGrDialog
Панель у края экранаGrDrawer
Да/нет по опасному действиюGrConfirmDialog

Playground контролы выводятся из API, а не пишутся руками

Сцена

Загружается…

0 изменено· состояние — в адресе

Примеры 3 у каждого — живое демо и его код

  • Метка, добавленная прямо у задачи

    Панель — dialog и при открытии забирает фокус: поле, до которого не дотянуться клавиатурой, — не форма. close приходит в слот содержимого, поэтому панель закрывается по «Добавить», а не на каждый клик внутри, а настоящая <form> делает рабочим Enter.

    #1093 · Invoice shows the wrong VAT numberOpened by Contoso Ltd · 2 hours ago
    billing
  • Карточка исполнителя при наведении

    Карточка при наведении отвечает «кто это» без клика. Отвечать ей нечего, поэтому фокус она не забирает — auto-focus="false", — а role="none" не даёт объявить «диалог» поверх справки. Задержки открытия и закрытия прощают курсор, который просто проходил мимо.

    T-1093Invoice shows the wrong VAT number
    T-1092Cannot add a second admin
    T-1090Export to CSV cuts long notes
  • Вопрос, на который не ответить кликом мимо

    modal делает остальную страницу инертной, а close-on-click-outside="false" оставляет ответ кнопкам — клик мимо панели не должен считаться ни «да», ни «нет». Панель остаётся у своей кнопки: вопрос про это действие, — а labelled-by называет её видимым заголовком.

    Release notes · October

    Invoices now carry the purchase order number, and payouts can go to a second account…

    Draft · edited 3 minutes ago

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
placementPlacement | undefined"bottom-start"Сторона и выравнивание панели у триггера; при нехватке места она переворачивается. По умолчанию `bottom-start`.
disabledboolean | undefinedfalseПанель не открывается ни кликом, ни наведением; триггер из `triggerProps` получает `disabled`.
openv-modelboolean | undefinedundefinedОткрыт ли поповер. Без этого пропа компонент ведёт состояние сам (uncontrolled), с ним — слушайте `update:open`.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedПоле и кегль панели по шкале контролов. Не задан — из `GrConfigProvider`, иначе `md`.
ariaLabelstring | undefinedundefinedДоступное имя панели. Обязательно для `role="dialog"` без видимого заголовка.
blockboolean | undefinedfalseОбёртка триггера занимает всю ширину родителя вместо того, чтобы обжимать содержимое. Нужно форм-контролам: обёртка `inline-block` схлопывается по содержимому, и `w-full` у самого триггера начинает резолвиться относительно неё, а не относительно поля. Контрол, объявивший себя во всю ширину, рисуется по содержимому — рядом с полем ввода это читается как сбитая вёрстка. Имя то же, что у `GrButton` и `GrSegmented`: в ядре это устоявшееся слово для «во всю ширину».
paddingGrPopoverPadding | undefined"default"Поле панели. `none` — содержимое рисует своё (меню, список опций).
anchorGrFloatingAnchorRect | null | undefinedundefinedЯкорь-прямоугольник в координатах вьюпорта вместо обёртки слота `#trigger`: панель встаёт у точки курсора или у строки списка. Нужен контекстному меню, у которого триггера-элемента нет вовсе. `triggerProps` в этом режиме некому потребить, и своих ARIA-атрибутов поповер никуда не вешает: `aria-haspopup` невалиден вне интерактивного элемента, а `aria-expanded` без хозяина — шум. Связь с содержимым страницы объявляет тот, кто открывает.
closeOnEscboolean | undefinedtrueЗакрывать по `Esc`. Esc закрывает верхний слой стека: поповер в модалке закроет себя, а не её. По умолчанию `true`.
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Свои классы панели. Поле так не снять — для этого `padding="none"`.

Слоты

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

События

EventСигнатураОписание
update:open(event: "update:open", value: boolean): void—

Методы

MethodsСигнатураОписание
close() => voidЗакрыть панель; фокус возвращается на триггер, если был внутри.
toggle() => voidОткрыть закрытую панель или закрыть открытую.

Типы

Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.

Импорт
import type { GrPopoverProps, GrPopoverEmits, GrPopoverConfigurableProps } from '@feugene/granularity/components/GrPopover'

Файлdist/types/components/GrPopover/GrPopover.vue.d.ts

GrPopoverPropsinterface
interface GrPopoverProps {
    /**
     * Открыт ли поповер. Без этого пропа компонент ведёт состояние сам
     * (uncontrolled), с ним — слушайте `update:open`.
     */
    open?: boolean;
    /**
     * Сторона и выравнивание панели у триггера; при нехватке места она переворачивается.
     * По умолчанию `bottom-start`.
     */
    placement?: UseFloatingPlacement;
    /** Зазор между триггером и панелью, px. */
    offsetPx?: number;
    /** Поле и кегль панели по шкале контролов. Не задан — из `GrConfigProvider`, иначе `md`. */
    size?: GrPopoverSize;
    /** Роль панели. Меняется теми, кто строит поверх примитива своё меню/список. */
    role?: GrPopoverRole;
    /**
     * Модальный режим: фон уходит в `inert`, Tab ходит по кругу внутри панели,
     * скролл страницы блокируется, а слой встаёт в стек модальным — как окно.
     * Нужен поповеру с формой или подтверждением внутри: без изоляции фона
     * пользователь уводит фокус на страницу, к которой поповер и относится.
     *
     * В этом режиме фокус переносится в панель независимо от `autoFocus`: фон
     * недоступен, и слой без фокуса внутри стал бы клавиатурной ловушкой.
     */
    modal?: boolean;
    /** Доступное имя панели. Обязательно для `role="dialog"` без видимого заголовка. */
    ariaLabel?: string;
    /** `id` видимого заголовка внутри панели — альтернатива `ariaLabel`. */
    labelledBy?: string;
    /**
     * Чем открывается панель.
     *
     * `manual` — только программно (через `v-model:open`): нужно контекстному меню
     * и подтверждениям, где момент открытия решает потребитель. `hover` — по
     * наведению, с задержками из `openDelay` и `closeDelay`; клик и клавиатура в
     * этом режиме продолжают работать.
     */
    trigger?: 'click' | 'manual' | 'hover';
    /**
     * Задержка открытия по наведению, мс.
     *
     * Обе задержки нужны, и каждая по своей причине: без `openDelay` панель
     * выпрыгивает на любое пересечение курсором, без `closeDelay` её не удержать
     * при переходе с триггера на панель — между ними зазор `offsetPx`.
     */
    openDelay?: number;
    /** Задержка закрытия после ухода курсора, мс. */
    closeDelay?: number;
    /**
     * Закрывать по `Esc`. Esc закрывает верхний слой стека: поповер в модалке закроет себя, а не её.
     * По умолчанию `true`.
     */
    closeOnEsc?: boolean;
    /**
     * Закрывать по клику вне панели. Слои, открытые из самой панели (подменю, селект),
     * внешними не считаются. По умолчанию `true`.
     */
    closeOnClickOutside?: boolean;
    /** Закрывать по клику внутри панели — удобно для меню, вредно для формы. */
    closeOnContentClick?: boolean;
    /**
     * Переносить фокус на панель при открытии. Именно на панель, а не на первый
     * контрол внутри: сфокусировать поле ввода за пользователя — решение
     * содержимого, а не оболочки.
     */
    autoFocus?: boolean;
    /**
     * Точечное переопределение точки монтирования. По умолчанию — общий портал
     * оверлеев (`#gr-portal` либо `portalTarget` из `GrConfigProvider`).
     */
    teleportTo?: string | HTMLElement;
    /** Свои классы панели. Поле так не снять — для этого `padding="none"`. */
    contentClass?: string;
    /** Панель не открывается ни кликом, ни наведением; триггер из `triggerProps` получает `disabled`. */
    disabled?: boolean;
    /**
     * Якорь-прямоугольник в координатах вьюпорта вместо обёртки слота `#trigger`:
     * панель встаёт у точки курсора или у строки списка. Нужен контекстному меню,
     * у которого триггера-элемента нет вовсе.
     *
     * `triggerProps` в этом режиме некому потребить, и своих ARIA-атрибутов
     * поповер никуда не вешает: `aria-haspopup` невалиден вне интерактивного
     * элемента, а `aria-expanded` без хозяина — шум. Связь с содержимым страницы
     * объявляет тот, кто открывает.
     */
    anchor?: GrFloatingAnchorRect | null;
    /** Поле панели. `none` — содержимое рисует своё (меню, список опций). */
    padding?: GrPopoverPadding;
    /**
     * Брать ширину у триггера: `true` — точно его ширина, `'min'` — не уже его,
     * дальше по содержимому. Панель у поля или у широкой кнопки, оказавшаяся уже
     * своего триггера, читается как ошибка вёрстки.
     *
     * С потолком `--gr-popover-max-width` сочетается, а не спорит: ширину задаёт
     * триггер, потолок её ограничивает — «шириной с триггер, но не шире
     * читаемого». Поэтому это отдельный проп, а не значение одного перечисления
     * вместе с потолком: оси независимы, и сочетание осмысленно.
     */
    matchWidth?: boolean | 'min';
    /**
     * Обёртка триггера занимает всю ширину родителя вместо того, чтобы обжимать
     * содержимое.
     *
     * Нужно форм-контролам: обёртка `inline-block` схлопывается по содержимому, и
     * `w-full` у самого триггера начинает резолвиться относительно неё, а не
     * относительно поля. Контрол, объявивший себя во всю ширину, рисуется по
     * содержимому — рядом с полем ввода это читается как сбитая вёрстка.
     *
     * Имя то же, что у `GrButton` и `GrSegmented`: в ядре это устоявшееся слово
     * для «во всю ширину».
     */
    block?: boolean;
}
GrPopoverEmitsinterface
interface GrPopoverEmits {
    /** Панель открылась или закрылась — триггером, `Esc`, кликом вне или из кода (`v-model:open`). */
    (e: 'update:open', value: boolean): void;
}

Файлdist/types/components/GrPopover/config.d.ts

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

Файлdist/types/components/GrPopover/defaults.d.ts

GrPopoverConfigurablePropsinterface

Пропы GrPopover, настраиваемые глобально через componentDefaults. Только оформление: open и обработчики принадлежат конкретному экземпляру и через конфиг задаваться не должны.

interface GrPopoverConfigurableProps {
    size: GrComponentSize;
}

Файлdist/types/components/GrPopover/grPopoverStyles.d.ts

GrPopoverSizetype
type GrPopoverSize = GrComponentSize;
GrPopoverRoletype

Роль панели. dialog — произвольный интерактивный контент (значение по умолчанию). Остальные нужны компонентам, которые строятся поверх этого примитива: меню (GrMenu, GrContextMenu), списки, палитры. Роль задаёт потребитель, потому что клавиатурный паттерн принадлежит ему, а не поповеру.

type GrPopoverRole = 'dialog' | 'menu' | 'listbox' | 'grid' | 'group' | 'none';
GrPopoverPaddingtype

Поле панели. none — поле рисует содержимое (меню, список опций).

type GrPopoverPadding = 'default' | 'none';

Файлdist/types/components/GrPopover/safelist.d.ts

grPopoverSafelistconst
const grPopoverSafelist: string[];

Файлdist/types/components/GrPopover/index.d.ts

GrPopoverInstancetype
type GrPopoverInstance = ComponentExposed<typeof GrPopoverComponent>;

Использование из прозы библиотеки

Почему не GrDropdown

Роль универсального поповера какое-то время исполнял GrDropdown, но он про меню: жёстко объявляет role="menu", aria-haspopup="menu" и водит фокус по пунктам. Для формы, подтверждения или палитры это неверная семантика — диктор объявит меню там, где меню нет.

Здесь наоборот: role задаётся пропом (dialog по умолчанию, плюс menu, listbox, grid, group и none), потому что клавиатурный паттерн внутри панели принадлежит содержимому. На этом примитиве собираются меню, контекстные меню, палитры и Popconfirm.

Триггер

Слот #trigger получает triggerProps — их нужно навесить на реальный фокусируемый элемент, а не на обёртку:

vue
<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, и клик. Биндить их надо на сам интерактивный элемент, а не на обёртку вокруг него:

vue
<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 растягивает обёртку на всю ширину родителя:

vue
<GrPopover block>

Имя то же, что у GrButton и GrSegmented, — в ядре это устоявшееся слово для «во всю ширину». matchWidth при этом берёт ширину именно у обёртки, поэтому с block панель пойдёт по ширине поля, а не по ширине содержимого триггера.

Ширина

Две независимые оси и один предел, который не настраивается.

Потолок содержимого — хук --gr-popover-max-width, по умолчанию 22rem. Это читаемая ширина колонки текста, и для формы или карточки она верна. Содержимому шире прозы — тулбару, палитре, сетке — потолок снимают:

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

vue
<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, то есть мнения нет. Задают его, когда панель обязана быть ниже доступного места:

vue
<GrPopover content-class="[--gr-popover-max-height:20rem]">

Через contentClass и по той же причине, что у ширины: панель телепортирована, инлайновый стиль ляжет на обёртку и до неё не дойдёт.

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

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

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

Это касается и всего, что стоит на GrPopover: длинное меню GrDropdown или GrContextMenu у нижнего края экрана теперь сжимается со скроллом, а не съезжает.