GrDrawer

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

Берут, когда панель приходит от края.

Когда брать

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

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

НужноБерите
Окно по центру со своей шапкой и подваломGrDialog
Своя раскладка модального слояGrModal
Содержимое привязано к кнопкеGrPopover
Постоянная боковая навигация, а не выезжающаяGrSidebar
Спросить «да/нет»GrConfirmDialog

Стороны

sideright (по умолчанию), left, top, bottom. Ось решает всё остальное: боковая панель растянута по вертикали и берёт из шкалы ширину, верхняя и нижняя растянуты по горизонтали и берут высоту. Выезжает панель со своей стороны.

Произвольный размер задаётся по той же оси: width — для left/right, height — для top/bottom. Проп не своей оси не применяется и ругается в dev-сборке: молча проигнорированный width у нижней панели выглядит как баг компонента, а не как ошибка вызова.

<!-- bottom-sheet: высота из шкалы -->
<GrDrawer v-model="open" side="bottom" size="sm" title="Фильтры" />

<!-- своя высота -->
<GrDrawer v-model="open" side="bottom" :height="320" />

Модальный и немодальный режим

По умолчанию панель модальная: подложка, блокировка скролла страницы, inert всему остальному и ловушка фокуса — Tab ходит по кругу внутри.

:modal="false" снимает всё это. Панель остаётся поверх страницы, но страница продолжает работать: она скроллится, кликается и принимает Tab, потому что корень слоя пропускает клики сквозь себя (pointer-events: none), а кликабельна только сама панель. Это режим для фильтров над таблицей и подобных панелей, с которыми работают параллельно основному экрану.

Что остаётся в немодальном режиме: место в общем стеке слоёв (Esc закрывает верхний слой), возврат фокуса на триггер и role="dialog" — но уже без aria-modal. Подложки нет, поэтому closeOnBackdrop в этом режиме ни на что не влияет.

<GrDrawer v-model="open" :modal="false" side="right" title="Фильтры">

</GrDrawer>

Слой и подложка

Drawer живёт на --gr-z-modal — том же слое, что GrModal. Раньше это был литерал z-50, ниже всей шкалы: панель dropdown или select (1000) рисовалась поверх выехавшего drawer’а, а его бэкдроп её не перекрывал.

Внутри слоя высоту уточняет стек: drawer, открытый поверх окна, получает calc(var(--gr-z-modal) + глубина) и рисуется выше него независимо от того, в каком порядке компоненты смонтированы (../z-index.md).

Подложка — токен --gr-overlay-bg, общий с GrModal: в тёмной теме она плотнее, иначе панель не отделяется от фона. Раньше здесь стоял bg-black/40 — единственный оверлей-фон, не следовавший за темой.

Хедер, секции и API-паритет с GrDialog

Пропы совпадают с GrDialog намеренно: showHeader, showCloseButton, headerConfig/bodyConfig/footerConfig (paddingX, paddingY, bordered). Потребитель не должен переучиваться при переходе между двумя оверлеями одной библиотеки.

Хедер рендерится, только когда есть что показать — заголовок или кнопка закрытия. Пустой title считается отсутствующим: раньше на его месте появлялось слово «Drawer».

Слот #header подменяет шапку целиком — вместе с кнопкой закрытия, которую в этом случае рисует потребитель. Слот получает title и close:

<GrDrawer v-model="open" title="Фильтры">
  <template #header="{ title, close }">
    <GrInput v-model="query" :placeholder="title" />
    <GrButton variant="ghost" @click="close">Готово</GrButton>
  </template>
</GrDrawer>

Имя слоя есть всегда и всегда осмысленное: заголовок связывается через aria-labelledby, а когда шапки нет (showHeader: false) или она подменена слотом — тот же заголовок рендерится скрытым (sr-only). Обобщённое имя из i18n остаётся страховкой только для панели вовсе без заголовка: слой без имени — нарушение aria-dialog-name.

Тело панели скроллится и потому попадает в таб-порядок (tabindex="0"): длинный текст без единого фокусируемого элемента иначе не прокрутить с клавиатуры (axe: scrollable-region-focusable).

<GrDrawer
  v-model="open"
  title="Фильтры"
  :header-config="{ paddingX: 'px-8' }"
  :body-config="{ paddingY: 'py-4' }"
>

  <template #footer>…</template>
</GrDrawer>

Размер

size — шкала оверлеев (sm…full), а не контролов; глобальный <GrConfigProvider size="…"> её не трогает, канал один — точечный componentDefaults (size, side). Значение читается по оси панели: ширина у боковых, высота у верхней и нижней. width/height задают произвольный размер и отменяют размерный класс.

<GrConfigProvider :component-defaults="{ GrDrawer: { size: 'lg', side: 'left' } }">
  <GrDrawer v-model="open" :width="640" />
</GrConfigProvider>

Закрытие и жизненный цикл

closeOnBackdrop и closeOnEsc управляют «мягкими» способами закрытия; persistent запрещает оба на время операции, которую нельзя бросить на полпути. Кнопка закрытия при этом остаётся: панель без единого выхода — ловушка.

@opened/@closed срабатывают по окончании анимации — туда вешается дозагрузка контента и возврат состояния. initialFocus задаёт элемент, получающий фокус при открытии (по умолчанию — сама панель).

Императивно: close() (минуя persistent) и focus() — вернуть фокус на панель, если операция увела его наружу.

Playground 10

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

Код
<GrDrawer />

Установка

npm i @feugene/granularity

Импорт

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

API

Props

PropTypeпо умолчаниюОписание
titlestring | undefinedundefinedЗаголовок; если передан — покажется в хедере. Можно переопределить слотом `#title`.
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefinedРазмер панели по её оси: ширина у боковых, высота у верхней и нижней.
closeOnBackdropboolean | undefinedtrueЗакрывать при клике по бэкдропу. В немодальном режиме подложки нет.
closeOnEscboolean | undefinedtrueЗакрывать по Esc.
showHeaderboolean | undefinedtrueРендерить ли хедер (заголовок + кнопка закрытия).
showCloseButtonboolean | undefinedtrueРендерить ли кнопку закрытия в хедере.
headerConfigGrDrawerSectionConfig | undefinedundefinedПаддинги и рамка секций — как у `GrDialog`.
footerConfigGrDrawerSectionConfig | undefinedundefined
bodyConfigGrDrawerSectionConfig | undefinedundefined
closeLabelstring | undefinedundefinedi18n-friendly aria-label для кнопки закрытия.
persistentboolean | undefinedfalseЗапрет закрытия «мягкими» способами (бэкдроп, Esc) — на время операции, которую нельзя бросить на полпути. Кнопка закрытия при этом остаётся: панель без единого выхода — ловушка.
initialFocusHTMLElement | null | undefinednullЭлемент, получающий фокус при открытии. По умолчанию — сама панель.
modalboolean | undefinedtrueМодальная панель: подложка, блокировка скролла, `inert` остальной странице и ловушка фокуса. `false` — панель живёт рядом со страницей: с ней работают, не закрывая, а Tab уходит наружу.
sideGrDrawerSide | undefinedundefinedСторона, с которой выезжает панель.
widthstring | number | undefinedundefinedПроизвольная ширина боковой панели. Число трактуется как пиксели; сильнее `size`.
heightstring | number | undefinedundefinedПроизвольная высота верхней или нижней панели. Число трактуется как пиксели.
modelValueобязательныйbooleanКонтроль открытия через v-model.

Slots

SlotTypeОписание
defaultany
titleany
header{ title?: string | undefined; close: () => void; }Своя шапка целиком: заголовок, кнопка закрытия и всё, что нужно рядом.
footerany

Events

EventTypeОписание
update:modelValue[value: boolean]
opened[]
closed[]

Methods / Expose

Methods / ExposeTypeОписание
close() => voidЗакрыть панель (эквивалент `v-model = false`), минуя `persistent`.
focus() => void | undefinedВернуть фокус на панель — например после операции, уведшей его наружу.

Примеры 7

Панель фильтров

Базовый application-shell сценарий: панель справа открывает форму фильтров без ухода со страницы.

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

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

const open = ref(false)
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Open filters drawer
    </GrButton>

    <GrDrawer v-model="open" title="Report filters" size="sm">
      <div class="grid gap-4 text-sm text-[var(--gr-muted-fg)]">
        <label class="grid gap-2">
          <span class="text-[var(--gr-fg)]">Owner</span>
          <input class="rounded-lg border border-[var(--gr-brd)] bg-transparent px-3 py-2" value="Operations">
        </label>

        <label class="grid gap-2">
          <span class="text-[var(--gr-fg)]">Date range</span>
          <input class="rounded-lg border border-[var(--gr-brd)] bg-transparent px-3 py-2" value="Last 30 days">
        </label>
      </div>

      <template #footer>
        <div class="flex justify-end gap-3">
          <GrButton variant="outline" @click="open = false">
            Reset
          </GrButton>
          <GrButton @click="open = false">
            Apply filters
          </GrButton>
        </div>
      </template>
    </GrDrawer>
  </div>
</template>

Панель снизу

side="bottom" — панель выезжает снизу, и size для неё означает высоту, а не ширину.

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

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

const open = ref(false)
const sort = ref('recent')

const options = [
  { value: 'recent', label: 'Newest first' },
  { value: 'amount', label: 'Largest amount' },
  { value: 'status', label: 'By status' },
]
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Open bottom sheet
    </GrButton>

    <!-- Сторона решает ось: `size` у нижней панели — это высота, а не ширина. -->
    <GrDrawer v-model="open" side="bottom" size="sm" title="Sort orders">
      <GrSegmented v-model="sort" :options="options" class="w-full" />

      <template #footer>
        <div class="flex justify-end">
          <GrButton @click="open = false">
            Apply
          </GrButton>
        </div>
      </template>
    </GrDrawer>
  </div>
</template>

Немодальные фильтры

:modal="false" — ни подложки, ни блокировки скролла, ни ловушки фокуса: с таблицей продолжают работать при открытой панели.

InvoiceClientStatus
INV-1042NorthwindOverdue
INV-1043ContosoPaid
INV-1044FabrikamOverdue

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

import { GrButton, GrCheckbox, GrDrawer, GrTable } from '@feugene/granularity'

const open = ref(false)
const onlyOverdue = ref(false)
const clicks = ref(0)

const rows = [
  { id: 'INV-1042', client: 'Northwind', status: 'Overdue' },
  { id: 'INV-1043', client: 'Contoso', status: 'Paid' },
  { id: 'INV-1044', client: 'Fabrikam', status: 'Overdue' },
]

const visibleRows = () => (onlyOverdue.value ? rows.filter(row => row.status === 'Overdue') : rows)
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton class="justify-self-start" @click="open = true">
        Open filters
      </GrButton>
      <!-- Страница под немодальной панелью остаётся живой: счётчик растёт. -->
      <GrButton variant="outline" @click="clicks++">
        Table still responds: {{ clicks }}
      </GrButton>
    </div>

    <GrTable>
      <template #header>
        <tr>
          <th class="px-4 py-2 text-left">Invoice</th>
          <th class="px-4 py-2 text-left">Client</th>
          <th class="px-4 py-2 text-left">Status</th>
        </tr>
      </template>

      <tr v-for="row in visibleRows()" :key="row.id">
        <td class="px-4 py-2">{{ row.id }}</td>
        <td class="px-4 py-2">{{ row.client }}</td>
        <td class="px-4 py-2">{{ row.status }}</td>
      </tr>
    </GrTable>

    <!-- `modal: false` — ни подложки, ни блокировки скролла, ни ловушки фокуса:
         с панелью работают, не закрывая её. Esc закрывает по-прежнему. -->
    <GrDrawer v-model="open" :modal="false" size="sm" title="Invoice filters">
      <GrCheckbox v-model="onlyOverdue">
        Only overdue
      </GrCheckbox>

      <template #footer>
        <GrButton variant="outline" class="w-full" @click="open = false">
          Done
        </GrButton>
      </template>
    </GrDrawer>
  </div>
</template>

Своя шапка

Слот #header заменяет шапку целиком — поиск вместо заголовка и своя кнопка вместо крестика; имя слоя остаётся скрытым заголовком.

Custom Header
<script setup lang="ts">
import { computed, ref } from 'vue'

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

const open = ref(false)
const query = ref('')

const members = ['Ada Lovelace', 'Alan Turing', 'Grace Hopper', 'Edsger Dijkstra']
const found = computed(() =>
  members.filter(name => name.toLowerCase().includes(query.value.trim().toLowerCase())),
)
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Open member picker
    </GrButton>

    <GrDrawer v-model="open" title="Team members" size="sm">
      <!-- Своя шапка заменяет и заголовок, и крестик. Имя слоя при этом
           остаётся: заголовок уходит в скрытый элемент. -->
      <template #header="{ title, close }">
        <div class="flex items-center gap-2">
          <GrInput v-model="query" :placeholder="title" class="flex-1" />
          <GrButton variant="ghost" size="sm" @click="close">
            Done
          </GrButton>
        </div>
      </template>

      <ul class="grid gap-1 text-sm">
        <li v-for="name in found" :key="name" class="rounded-md px-2 py-1.5 hover:bg-[var(--gr-muted)]">
          {{ name }}
        </li>
        <li v-if="found.length === 0" class="px-2 py-1.5 text-[var(--gr-muted-fg)]">
          Nobody matches “{{ query }}”
        </li>
      </ul>
    </GrDrawer>
  </div>
</template>

Навигация от левого края

side="left" для навигации по разделам рабочего пространства.

Active section: Overview

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

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

const open = ref(false)
const activeItem = ref('Overview')

const items = ['Overview', 'Approvals', 'Members', 'Security']
</script>

<template>
  <div class="grid gap-3">
    <GrButton variant="outline" class="justify-self-start" @click="open = true">
      Open left rail
    </GrButton>

    <div class="text-xs text-[var(--gr-muted-fg)]">
      Active section: <span class="font-medium text-[var(--gr-fg)]">{{ activeItem }}</span>
    </div>

    <GrDrawer v-model="open" title="Workspace sections" side="left" size="sm">
      <div class="grid gap-2">
        <button
          v-for="item in items"
          :key="item"
          type="button"
          class="rounded-lg px-3 py-2 text-left text-sm transition"
          :class="item === activeItem ? 'bg-[var(--gr-accent)] text-[var(--gr-accent-fg)]' : 'border border-[var(--gr-brd)] text-[var(--gr-muted-fg)]'"
          @click="activeItem = item"
        >
          {{ item }}
        </button>
      </div>
    </GrDrawer>
  </div>
</template>

Смена размера с защищённым бэкдропом

Переключение шкалы размеров вместе с closeOnBackdrop: false.

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

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

const open = ref(false)
const size = ref<'md' | 'lg'>('md')

function openDrawer(nextSize: 'md' | 'lg') {
  size.value = nextSize
  open.value = true
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap gap-3">
      <GrButton variant="outline" @click="openDrawer('md')">
        Open review drawer
      </GrButton>
      <GrButton @click="openDrawer('lg')">
        Open wide drawer
      </GrButton>
    </div>

    <GrDrawer v-model="open" :title="`Escalation summary (${size})`" :size="size" :close-on-backdrop="false">
      <div class="grid gap-3 text-sm text-[var(--gr-muted-fg)]">
        <p>Размер drawer удобно переключать под compact review или широкие inspector-сценарии.</p>
        <p>Backdrop закрытие отключено, чтобы случайный клик не сбрасывал прогресс.</p>
      </div>

      <template #footer>
        <div class="flex justify-end gap-3">
          <GrButton variant="outline" @click="open = false">
            Continue later
          </GrButton>
          <GrButton @click="open = false">
            Resolve now
          </GrButton>
        </div>
      </template>
    </GrDrawer>
  </div>
</template>

Форма, которую нельзя бросить на полпути

persistent запрещает бэкдроп и Esc на время сохранения; кнопка закрытия остаётся.

Lifecycle:

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

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

const open = ref(false)
const saving = ref(false)
const status = ref('')

const name = ref('Nightly backup')
const note = ref('')

const nameInput = ref<HTMLElement | null>(null)

async function save(): Promise<void> {
  saving.value = true
  status.value = 'saving — drawer is locked'

  await new Promise(resolve => setTimeout(resolve, 1200))

  saving.value = false
  open.value = false
  status.value = `saved “${name.value}`
}
</script>

<template>
  <div class="grid gap-3">
    <GrButton class="justify-self-start" @click="open = true">
      Edit job
    </GrButton>

    <GrDrawer
      v-model="open"
      title="Edit job"
      :persistent="saving"
      :initial-focus="nameInput"
      :body-config="{ paddingY: 'py-4' }"
      @opened="status = 'opened'"
      @closed="status = status.startsWith('saved') ? status : 'closed'"
    >
      <div class="grid gap-4">
        <GrFormField label="Job name">
          <GrInput ref="nameInput" v-model="name" size="sm" />
        </GrFormField>

        <GrFormField label="Note" hint="Виден только команде дежурных">
          <GrTextarea v-model="note" :rows="4" />
        </GrFormField>

        <p class="text-sm text-[var(--gr-muted-fg)]">
          Пока идёт сохранение, панель `persistent`: ни Esc, ни клик по подложке её не закроют —
          кнопка закрытия остаётся, чтобы выход был хотя бы один.
        </p>
      </div>

      <template #footer>
        <div class="flex justify-end gap-3">
          <GrButton variant="outline" :disabled="saving" @click="open = false">
            Cancel
          </GrButton>
          <GrButton :loading="saving" @click="save">
            Save
          </GrButton>
        </div>
      </template>
    </GrDrawer>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] p-3 text-sm text-[var(--gr-muted-fg)]">
      Lifecycle: <span class="font-semibold text-[var(--gr-fg)]">{{ status }}</span>
    </div>
  </div>
</template>

Доступность

Паттерн APG
| GrConfirmDialog | Фокус при открытии — на «Отмена» (focusAction: confirm \| cancel \| none), поэтому Enter сразу после открытия отменяет, а не подтверждает. persistent на время асинхронного подтверждения снимает Esc и клик по бэкдропу, крестик и «Отмена» остаются
Клавиши
то же — но только в модальном режиме. :modal="false" снимает ловушку: Tab уводит фокус на страницу, ради работы с которой панель и открыта, а Esc по-прежнему закрывает верхний слой

Полный клавиатурный контракт пакета

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