GrDrawer
Берут, когда панель приходит от края.
Когда брать
- панель приходит от края — фильтры, детали записи, настройки: содержимое связано с текущим экраном, а не заменяет его;
- содержимое высокое — вертикальная панель вмещает длинную форму лучше окна по центру;
- страница должна оставаться рабочей —
:modal="false"оставляет фон доступным и не блокирует скролл; - мобильная навигация — панель от левого края вместо меню в шапке.
Когда взять другое
| Нужно | Берите |
|---|---|
| Окно по центру со своей шапкой и подвалом | GrDialog |
| Своя раскладка модального слоя | GrModal |
| Содержимое привязано к кнопке | GrPopover |
| Постоянная боковая навигация, а не выезжающая | GrSidebar |
| Спросить «да/нет» | GrConfirmDialog |
Стороны
side — right (по умолчанию), 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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
title | string | undefined | undefined | Заголовок; если передан — покажется в хедере. Можно переопределить слотом `#title`. |
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | Размер панели по её оси: ширина у боковых, высота у верхней и нижней. |
closeOnBackdrop | boolean | undefined | true | Закрывать при клике по бэкдропу. В немодальном режиме подложки нет. |
closeOnEsc | boolean | undefined | true | Закрывать по Esc. |
showHeader | boolean | undefined | true | Рендерить ли хедер (заголовок + кнопка закрытия). |
showCloseButton | boolean | undefined | true | Рендерить ли кнопку закрытия в хедере. |
headerConfig | GrDrawerSectionConfig | undefined | undefined | Паддинги и рамка секций — как у `GrDialog`. |
footerConfig | GrDrawerSectionConfig | undefined | undefined | — |
bodyConfig | GrDrawerSectionConfig | undefined | undefined | — |
closeLabel | string | undefined | undefined | i18n-friendly aria-label для кнопки закрытия. |
persistent | boolean | undefined | false | Запрет закрытия «мягкими» способами (бэкдроп, Esc) — на время операции, которую нельзя бросить на полпути. Кнопка закрытия при этом остаётся: панель без единого выхода — ловушка. |
initialFocus | HTMLElement | null | undefined | null | Элемент, получающий фокус при открытии. По умолчанию — сама панель. |
modal | boolean | undefined | true | Модальная панель: подложка, блокировка скролла, `inert` остальной странице и ловушка фокуса. `false` — панель живёт рядом со страницей: с ней работают, не закрывая, а Tab уходит наружу. |
side | GrDrawerSide | undefined | undefined | Сторона, с которой выезжает панель. |
width | string | number | undefined | undefined | Произвольная ширина боковой панели. Число трактуется как пиксели; сильнее `size`. |
height | string | number | undefined | undefined | Произвольная высота верхней или нижней панели. Число трактуется как пиксели. |
modelValueобязательный | boolean | — | Контроль открытия через v-model. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | — |
title | any | — |
header | { title?: string | undefined; close: () => void; } | Своя шапка целиком: заголовок, кнопка закрытия и всё, что нужно рядом. |
footer | any | — |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: boolean] | — |
opened | [] | — |
closed | [] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
close | () => void | Закрыть панель (эквивалент `v-model = false`), минуя `persistent`. |
focus | () => void | undefined | Вернуть фокус на панель — например после операции, уведшей его наружу. |
Примеры 7
Панель фильтров
Базовый application-shell сценарий: панель справа открывает форму фильтров без ухода со страницы.
<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 для неё означает высоту, а не ширину.
<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" — ни подложки, ни блокировки скролла, ни ловушки фокуса: с таблицей продолжают работать при открытой панели.
| Invoice | Client | Status |
|---|---|---|
| INV-1042 | Northwind | Overdue |
| INV-1043 | Contoso | Paid |
| INV-1044 | Fabrikam | Overdue |
<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 заменяет шапку целиком — поиск вместо заголовка и своя кнопка вместо крестика; имя слоя остаётся скрытым заголовком.
<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" для навигации по разделам рабочего пространства.
<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.
<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 на время сохранения; кнопка закрытия остаётся.
<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по-прежнему закрывает верхний слой