GrDrawer
A sliding panel for secondary content and quick actions.
Overview
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
| Need | Take |
|---|---|
| A window in the centre with a header and a footer of its own | GrDialog |
| A layout of your own for the modal layer | GrModal |
| The content is tied to a button | GrPopover |
| Permanent side navigation rather than a sliding one | GrSidebar |
| Ask a yes/no question | GrConfirmDialog |
Playground controls come from the API, not from hand
The window opens above the page with the props set on the right.
0 changed· state is in the URL
<GrDrawer title="Filters" v-model="value"><div class="grid gap-3"><GrCheckbox :modelValue="true">Unpaid only</GrCheckbox><GrCheckbox>Overdue</GrCheckbox><GrCheckbox>Due this week</GrCheckbox></div></GrDrawer>Examples 3 each with a live demo and its code
Order details from a row
The details of a row come from the right: the list stays in place, and closing the panel returns to the same row. The
footerslot keeps the actions below the content. While a refund runs,persistentturns off Esc and the backdrop, so a stray key does not dismiss it; the cross stays, because a panel with no way out is a trap.Filters next to the queue they filter
:modal="false"removes the backdrop, the focus trap and the scroll lock: the queue under the panel keeps answering clicks, and every change of a filter is seen in the result at once.widthsets the panel apart from thesizescale; the footer counts what matches and resets.Support queue · 5 ticketsA delivery slot from the bottom edge
On a phone the thumb is at the bottom, and so is the sheet:
side="bottom"flips the axis, andsizenow sets the height. The choice is a draft applied by the footer button, so a stray tap on the backdrop leaves the slot as it was. Theheaderslot replaces the head whole, «Cancel» included, to line it up with the column.Checkout · 3 items DeliveryThu 8 Oct · 14:00–16:00
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
title | string | undefined | undefined | The 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" | undefined | undefined | The size of the panel along its axis: the width for the side ones, the height for the top and the bottom. |
closeOnBackdrop | boolean | undefined | true | Close on a click on the backdrop. In the non-modal mode there is no backdrop. |
closeOnEsc | boolean | undefined | true | Close on Esc. |
showHeader | boolean | undefined | true | Whether to render the header (the heading plus the close button). |
showCloseButton | boolean | undefined | true | Whether to render the close button in the header. |
headerConfig | GrDrawerSectionConfig | undefined | undefined | The padding and the border of the sections — as in `GrDialog`. |
footerConfig | GrDrawerSectionConfig | undefined | undefined | The paddings and the top line of the footer. The footer is drawn only when the `#footer` slot is passed. |
bodyConfig | GrDrawerSectionConfig | undefined | undefined | The paddings of the panel body. `bordered` does not apply to the body: it has no line of its own. |
closeLabel | string | undefined | undefined | An i18n-friendly aria-label for the close button. |
persistent | boolean | undefined | false | A 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. |
initialFocus | HTMLElement | null | undefined | null | The element that gets the focus on opening. By default — the panel itself. |
modal | boolean | undefined | true | A 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. |
side | GrDrawerSide | undefined | undefined | The side the panel slides in from. |
width | string | number | undefined | undefined | An arbitrary width of a side panel. A number is treated as pixels; it is stronger than `size`. |
height | string | number | undefined | undefined | An arbitrary height of a top or bottom panel. A number is treated as pixels. |
modelValuerequiredv-model | boolean | — | Control of the opening through v-model. |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | The content of the panel. The body scrolls when it does not fit. |
title | any | The heading instead of the `title` prop. It gives the panel an accessible name even without a visible header. |
header | { title?: string | undefined; close: () => void; } | A header of your own as a whole: the heading, the close button and everything needed beside them. |
footer | any | The footer of the panel — usually action buttons. Without the slot there is no footer. |
Events
| Event | Signature | Description |
|---|---|---|
update:modelValue | (event: "update:modelValue", value: boolean): void | — |
opened | (event: "opened"): void | — |
closed | (event: "closed"): void | — |
Methods
| Methods | Signature | Description |
|---|---|---|
close | () => void | Close the panel (the equivalent of `v-model = false`), bypassing `persistent`. |
focus | () => void | undefined | Return the focus to the panel — after an operation that took it outside, for instance. |
Types
Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.
import type { GrDrawerConfigurableProps, GrDrawerProps, GrDrawerEmits } from '@feugene/granularity/components/GrDrawer'Filedist/types/components/GrDrawer/config.d.ts
const grDrawerConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDrawer">;Filedist/types/components/GrDrawer/defaults.d.ts
interface GrDrawerConfigurableProps {
size: GrDrawerSize;
side: GrDrawerSide;
}Filedist/types/components/GrDrawer/safelist.d.ts
const grDrawerSafelist: string[];Filedist/types/components/GrDrawer/GrDrawer.vue.d.ts
interface GrDrawerProps {
modelValue: boolean;
title?: string;
modal?: boolean;
closeOnBackdrop?: boolean;
closeOnEsc?: boolean;
persistent?: boolean;
side?: GrDrawerSide;
size?: GrDrawerSize;
width?: string | number;
height?: string | number;
showHeader?: boolean;
showCloseButton?: boolean;
headerConfig?: GrDrawerSectionConfig;
bodyConfig?: GrDrawerSectionConfig;
footerConfig?: GrDrawerSectionConfig;
initialFocus?: HTMLElement | null;
closeLabel?: string;
}interface GrDrawerEmits {
(e: 'update:modelValue', value: boolean): void;
(e: 'opened'): void;
(e: 'closed'): void;
}Filedist/types/components/GrDrawer/grDrawerStyles.d.ts
type GrDrawerSide = 'left' | 'right' | 'top' | 'bottom';type GrDrawerSize = GrOverlaySize;interface GrDrawerSectionConfig {
paddingX?: string;
paddingY?: string;
bordered?: boolean;
}Filedist/types/components/GrDrawer/index.d.ts
type GrDrawerInstance = ComponentExposed<typeof GrDrawerComponent>;Accessibility
Full contract of the package: docs/keyboard.md ↗
Usage from the library prose
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.
Props in this example