GrDrawer

  • core
  • overlays
  • API · 17 props
  • ⌨ keys

A sliding panel for secondary content and quick actions.

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

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

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

Playground controls come from the API, not from hand

Stage

The window opens above the page with the props set on the right.

0 changed· state is in the URL

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 footer slot keeps the actions below the content. While a refund runs, persistent turns 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. width sets the panel apart from the size scale; the footer counts what matches and resets.

    Support queue · 5 tickets
  • A 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, and size now 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. The header slot 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

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 | undefinedundefinedThe paddings and the top line of the footer. The footer is drawn only when the `#footer` slot is passed.
bodyConfigGrDrawerSectionConfig | undefinedundefinedThe paddings of the panel body. `bordered` does not apply to the body: it has no line of its own.
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.

Slots

SlotTypeDescription
defaultanyThe content of the panel. The body scrolls when it does not fit.
titleanyThe 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.
footeranyThe footer of the panel — usually action buttons. Without the slot there is no footer.

Events

EventSignatureDescription
update:modelValue(event: "update:modelValue", value: boolean): void—
opened(event: "opened"): void—
closed(event: "closed"): void—

Methods

MethodsSignatureDescription
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.

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
import type { GrDrawerConfigurableProps, GrDrawerProps, GrDrawerEmits } from '@feugene/granularity/components/GrDrawer'

Filedist/types/components/GrDrawer/config.d.ts

grDrawerConfigconst
const grDrawerConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDrawer">;

Filedist/types/components/GrDrawer/defaults.d.ts

GrDrawerConfigurablePropsinterface
interface GrDrawerConfigurableProps {
    size: GrDrawerSize;
    side: GrDrawerSide;
}

Filedist/types/components/GrDrawer/safelist.d.ts

grDrawerSafelistconst
const grDrawerSafelist: string[];

Filedist/types/components/GrDrawer/GrDrawer.vue.d.ts

GrDrawerPropsinterface
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;
}
GrDrawerEmitsinterface
interface GrDrawerEmits {
    (e: 'update:modelValue', value: boolean): void;
    (e: 'opened'): void;
    (e: 'closed'): void;
}

Filedist/types/components/GrDrawer/grDrawerStyles.d.ts

GrDrawerSidetype
type GrDrawerSide = 'left' | 'right' | 'top' | 'bottom';
GrDrawerSizetype
type GrDrawerSize = GrOverlaySize;
GrDrawerSectionConfiginterface
interface GrDrawerSectionConfig {
    paddingX?: string;
    paddingY?: string;
    bordered?: boolean;
}

Filedist/types/components/GrDrawer/index.d.ts

GrDrawerInstancetype
type GrDrawerInstance = ComponentExposed<typeof GrDrawerComponent>;

Accessibility

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.

vue
<!-- 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.

vue
<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:

vue
<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).

vue
<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.

vue
<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.