GrDrawer

Package: @feugene/granularitycoreGroup: overlays

A sliding panel for secondary content and quick actions.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the panel comes from an edge — filters, the details of a record, settings: the content is related to the current screen rather than replacing it;
  • the content is tall — a vertical panel holds a long form better than a window in the centre;
  • the page has to stay usable:modal="false" leaves the background available and does not block scrolling;
  • mobile navigation — a panel from the left edge instead of a menu in the header.

When to take something else

NeedTake
A window in the centre with a header and a footer of its ownGrDialog
A layout of your own for the modal layerGrModal
The content is tied to a buttonGrPopover
Permanent side navigation rather than a sliding oneGrSidebar
Ask a yes/no questionGrConfirmDialog

The sides

side is right (the default), left, top, bottom. The axis decides everything else: a side panel is stretched vertically and takes its width from the scale, the top and the bottom ones are stretched horizontally and take their height. The panel slides in from its own side.

An arbitrary size is set along the same axis: width for left/right, height for top/bottom. A prop of the wrong axis does not apply and complains in a dev build: a silently ignored width on a bottom panel looks like a bug in the component rather than an error in the call.

<!-- a bottom sheet: the height comes from the scale -->
<GrDrawer v-model="open" side="bottom" size="sm" title="Filters" />

<!-- a height of your own -->
<GrDrawer v-model="open" side="bottom" :height="320" />

The modal and the non-modal mode

By default the panel is modal: a backdrop, a lock on page scrolling, inert for everything else and a focus trap — Tab walks in a ring inside.

:modal="false" removes all of that. The panel stays on top of the page, but the page keeps working: it scrolls, it is clickable and it accepts Tab, because the root of the layer passes clicks through itself (pointer-events: none), and only the panel itself is clickable. This is the mode for filters above a table and similar panels that are worked with in parallel with the main screen.

What remains in the non-modal mode: a place in the shared layer stack (Esc closes the top layer), the return of focus to the trigger and role="dialog" — but without aria-modal now. There is no backdrop, so closeOnBackdrop affects nothing in this mode.

<GrDrawer v-model="open" :modal="false" side="right" title="Filters">

</GrDrawer>

The layer and the backdrop

A drawer lives on --gr-z-modal — the same layer as GrModal. That used to be the literal z-50, below the whole scale: the panel of a dropdown or a select (1000) was drawn on top of the opened drawer, and its backdrop did not cover it.

Inside the layer the height is refined by the stack: a drawer opened on top of a window gets calc(var(--gr-z-modal) + depth) and is drawn above it regardless of the order in which the components were mounted (../z-index.md).

The backdrop is the --gr-overlay-bg token, shared with GrModal: in the dark theme it is denser, otherwise the panel does not separate from the background. There used to be a bg-black/40 here — the only overlay background that did not follow the theme.

The header, the sections and API parity with GrDialog

The props coincide with GrDialog on purpose: showHeader, showCloseButton, headerConfig/bodyConfig/footerConfig (paddingX, paddingY, bordered). The consumer must not have to relearn when moving between two overlays of the same library.

The header renders only when there is something to show — a heading or a close button. An empty title counts as absent: the word “Drawer” used to appear in its place.

The #header slot replaces the header as a whole — together with the close button, which in that case is drawn by the consumer. The slot receives title and close:

<GrDrawer v-model="open" title="Filters">
  <template #header="{ title, close }">
    <GrInput v-model="query" :placeholder="title" />
    <GrButton variant="ghost" @click="close">Done</GrButton>
  </template>
</GrDrawer>

The layer always has a name and always a meaningful one: the heading is linked through aria-labelledby, and when there is no header (showHeader: false) or it has been replaced by a slot, the same heading is rendered hidden (sr-only). The generic name from i18n remains a safety net only for a panel with no heading at all: a layer without a name is a violation of aria-dialog-name.

The body of the panel scrolls and therefore enters the tab order (tabindex="0"): otherwise a long text without a single focusable element cannot be scrolled from the keyboard (axe: scrollable-region-focusable).

<GrDrawer
  v-model="open"
  title="Filters"
  :header-config="{ paddingX: 'px-8' }"
  :body-config="{ paddingY: 'py-4' }"
>

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

The size

size is the overlay scale (sm…full), not the control one; a global <GrConfigProvider size="…"> does not touch it, and the only channel is a pointed componentDefaults (size, side). The value is read along the axis of the panel: the width for the side ones, the height for the top and the bottom. width/height set an arbitrary size and cancel the size class.

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

Closing and the lifecycle

closeOnBackdrop and closeOnEsc govern the “soft” ways of closing; persistent forbids both for the duration of an operation that must not be abandoned halfway. The close button stays in the process: a panel with no way out is a trap.

@opened/@closed fire when the animation ends — that is where the loading of extra content and the resetting of state are hung. initialFocus sets the element that gets the focus on opening (by default the panel itself).

Imperatively: close() (bypassing persistent) and focus() — to return the focus to the panel if an operation has taken it outside.

Playground 10

Loading…

Code
<GrDrawer />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
titlestring | undefinedundefinedThe heading; if it is passed, it is shown in the header. It can be overridden with the `#title` slot.
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefinedThe size of the panel along its axis: the width for the side ones, the height for the top and the bottom.
closeOnBackdropboolean | undefinedtrueClose on a click on the backdrop. In the non-modal mode there is no backdrop.
closeOnEscboolean | undefinedtrueClose on Esc.
showHeaderboolean | undefinedtrueWhether to render the header (the heading plus the close button).
showCloseButtonboolean | undefinedtrueWhether to render the close button in the header.
headerConfigGrDrawerSectionConfig | undefinedundefinedThe padding and the border of the sections — as in `GrDialog`.
footerConfigGrDrawerSectionConfig | undefinedundefined
bodyConfigGrDrawerSectionConfig | undefinedundefined
closeLabelstring | undefinedundefinedAn i18n-friendly aria-label for the close button.
persistentboolean | undefinedfalseA ban on the "soft" ways of closing (the backdrop, Esc) — for the duration of an operation that must not be abandoned halfway. The close button remains in the process: a panel with no way out is a trap.
initialFocusHTMLElement | null | undefinednullThe element that gets the focus on opening. By default — the panel itself.
modalboolean | undefinedtrueA modal panel: a backdrop, a scroll lock, `inert` for the rest of the page and a focus trap. `false` — the panel lives beside the page: it is worked with without closing it, and Tab goes outside.
sideGrDrawerSide | undefinedundefinedThe side the panel slides in from.
widthstring | number | undefinedundefinedAn arbitrary width of a side panel. A number is treated as pixels; it is stronger than `size`.
heightstring | number | undefinedundefinedAn arbitrary height of a top or bottom panel. A number is treated as pixels.
modelValuerequiredbooleanControl of the opening through v-model.

Slots

SlotTypeDescription
defaultany
titleany
header{ title?: string | undefined; close: () => void; }A header of your own as a whole: the heading, the close button and everything needed beside them.
footerany

Events

EventTypeDescription
update:modelValue[value: boolean]
opened[]
closed[]

Methods / Expose

Methods / ExposeTypeDescription
close() => voidClose the panel (the equivalent of `v-model = false`), bypassing `persistent`.
focus() => void | undefinedReturn the focus to the panel — after an operation that took it outside, for instance.

Examples 7

Filter panel drawer

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>

Bottom sheet

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>

Non-modal filters

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>

Custom 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>

Left navigation rail

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>

Size switch with guarded backdrop

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 form

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>

Accessibility

APG pattern
| GrConfirmDialog | Фокус при открытии — на «Отмена» (focusAction: confirm \| cancel \| none), поэтому Enter сразу после открытия отменяет, а не подтверждает. persistent на время асинхронного подтверждения снимает Esc и клик по бэкдропу, крестик и «Отмена» остаются

Full keyboard contract of the package

Component documentationAll components