drawer — Drawer
A drawer is a panel that slides out from a screen edge. Its difference from a dialog is not in the code but in usage semantics: a dialog “interrupts and must be handled”, a drawer “slides in from the side and can be left at any time”. So a drawer does not trap focus by default, does not lock scroll (configurable), and its mask may be click-to-close or not shown at all.
When to use
Section titled “When to use”- Each framework implements
LhDrawer: side-sliding details, filter panels, multi-step forms broken into steps, mobile full-screen panels. - Situations requiring a side slide-out with content longer than a dialog would hold.
- Do not use it for: confirmations that must be forced (use
dialog); instantaneous notices (usemessage); pure text hints (usetooltip).
Import
Section titled “Import”import { LH_DRAWER_DIRECTION_DEFAULT, LH_DRAWER_SIZE_DEFAULT, normalizeDrawerSize, getDrawerPanelStyle, getDrawerClass, canDrawerClose, shouldDrawerTrapFocus, getDrawerAria,} from '@lh-ui/share/core/drawer'import type { LhDrawerDirection, LhDrawerState } from '@lh-ui/share/core/drawer'Design intent
Section titled “Design intent”canDrawerClose defaults are symmetric with dialog but different:
maskClosabledefaults to false — drawers often hold filters or multi-step forms; an accidental mask click loses state.closeOnEscdefaults to true — same as dialog, leaving a keyboard escape hatch.
A comment records a bit of history: closeOnEsc was previously only declared in state and never read by logic — the dismissal gate existed in name only; only after it was filled in as a computable function did Esc actually come under that switch’s control. This kind of “prop declared but logic never consumes it” gap is the most common hidden bug in state models.
shouldDrawerTrapFocus is named with a component prefix: it shares a signature with dialog’s shouldTrapFocus but has different semantics (different state field names: modelValue vs visible). When the star aggregate re-exports, same-name ambiguity would be silently dropped by TS, so per this repo’s convention they are explicitly distinguished (aligned with the getDefaultDelay → getPopconfirmDefaultDelay treatment).
Direction-aware sizing: getDrawerPanelStyle applies size to width (left/right) or height (top/bottom) depending on direction — this is the trade-off of “one size prop expressing two dimensions”, simpler than two props width / height.
Default-value constants: LH_DRAWER_DIRECTION_DEFAULT = 'right', LH_DRAWER_SIZE_DEFAULT = '30%', referenced by the UI layer’s withDefaults / defaultProps to keep all three frameworks consistent.
Function table
Section titled “Function table”| Function | Signature | Returns | Purpose |
|---|---|---|---|
normalizeDrawerSize |
(size: string | number | undefined) => string |
size string | number → px, empty → default |
getDrawerPanelStyle |
(direction, size?) => Record<string, string> |
styles | direction decides width/height |
getDrawerClass |
(state: { direction }) => string[] |
class names | direction modifier |
canDrawerClose |
(state, reason: 'maskClick' | 'escape') => boolean |
whether closing is allowed | dismissal gate |
shouldDrawerTrapFocus |
(state: { modelValue, lockScroll }) => boolean |
whether to trap focus | decision (no DOM access) |
getDrawerAria |
(options?: { id?, titleId? }) => LhAriaAttrs |
aria attribute bag | dialog semantics |
Type definitions
Section titled “Type definitions”export type LhDrawerDirection = 'right' | 'left' | 'top' | 'bottom'
export interface LhDrawerState { direction?: LhDrawerDirection size?: string | number modelValue?: boolean title?: string maskClosable?: boolean showClose?: boolean lockScroll?: boolean closeOnEsc?: boolean}
export const LH_DRAWER_DIRECTION_DEFAULT: LhDrawerDirection = 'right'export const LH_DRAWER_SIZE_DEFAULT = '30%'Accessibility
Section titled “Accessibility”At the accessibility level a drawer is still a dialog (APG has no dedicated drawer pattern):
getDrawerAria({ id: 'lh-drawer-1', titleId: 'lh-drawer-title' })// { role: 'dialog', 'aria-modal': true, id: 'lh-drawer-1', 'aria-labelledby': 'lh-drawer-title' }- Note that
aria-modal: trueis output unconditionally — even if the UI layer shows no mask and traps no focus. If your drawer is non-modal (does not block the background), the UI layer should switch toaria-modal: falseor another role, instead of letting screen readers wrongly assume the background is unreachable. - Focus-management trade-off: a modal drawer (
lockScrolland with a mask) should trap focus; a non-modal drawer (no mask, background still operable) should not trap —shouldDrawerTrapFocusreturns false and the UI layer does not enable the focus trap. - Swipe gestures and the keyboard: swiping right from the edge to close on mobile is a gesture interaction; the keyboard equivalent is Esc (the
escapebranch ofcanDrawerClose). Both must be available, otherwise keyboard users cannot close it. - top/bottom drawers on mobile should span the full width;
aria-modaland the focus trap apply as usual.
Usage examples
Section titled “Usage examples”import { normalizeDrawerSize, getDrawerPanelStyle, canDrawerClose } from '@lh-ui/share/core/drawer'
normalizeDrawerSize(300) // '300px'normalizeDrawerSize('50%') // '50%'normalizeDrawerSize(undefined) // '30%' (default)normalizeDrawerSize('') // '30%'
getDrawerPanelStyle('right', 300) // { width: '300px' }getDrawerPanelStyle('bottom', '40%') // { height: '40%' }getDrawerPanelStyle('left') // { width: '30%' }
canDrawerClose({ maskClosable: false, closeOnEsc: true }, 'maskClick') // falsecanDrawerClose({ maskClosable: false, closeOnEsc: true }, 'escape') // truecanDrawerClose({}, 'escape') // true (Esc closes by default)canDrawerClose({}, 'maskClick') // false (clicking the mask does not close by default)
getDrawerClass({ direction: 'right' }) // ['lh-drawer', 'lh-drawer--right']<!-- Vue3 --><teleport to="body"> <transition name="lh-drawer-fade"> <div v-if="visible" :class="getDrawerClass({ direction })"> <div v-if="showMask" class="lh-drawer__mask" @click="onMaskClick" /> <transition :name="`lh-drawer-slide-${direction}`"> <div v-if="visible" class="lh-drawer__panel" :class="`lh-drawer__panel--${direction}`" :style="getDrawerPanelStyle(direction, size)" v-bind="getDrawerAria({ id: drawerId, titleId })" > <header class="lh-drawer__header"> <h3 class="lh-drawer__title" :id="titleId">{{ title }}</h3> <button v-if="showClose" type="button" class="lh-drawer__close" aria-label="Close" @click="close">✕</button> </header> <div class="lh-drawer__body"><slot /></div> <footer v-if="$slots.footer" class="lh-drawer__footer"><slot name="footer" /></footer> </div> </transition> </div> </transition></teleport>// React: one slide transition per direction; the direction class drives the transform start point// right: translateX(100%) → 0; bottom: translateY(100%) → 0// uniapp: fixed + transform animation; edge-swipe-to-close is decided manually via @touchstart/@touchmove/@touchendStyles and tokens
Section titled “Styles and tokens”| Class name | Semantics |
|---|---|
.lh-drawer |
base class |
.lh-drawer--{right|left|top|bottom} |
direction (drives the mask animation and panel positioning) |
.lh-drawer__mask |
mask |
.lh-drawer__panel |
the panel body |
.lh-drawer__panel--{direction} |
panel positioning (fixed + hugging the corresponding edge) |
.lh-drawer__header / __title / __close |
header |
.lh-drawer__body |
content area (scrollable) |
.lh-drawer__footer |
footer |
Panel --lh-bg-color, mask rgba(0, 0, 0, 0.45) (slightly lighter than dialog — semantically a drawer is a weaker modal).
Transition class names are collected by the lh-transition-ends mixin of styles/theme/common/transition.scss (cross-framework -leave-to differences), so component styles no longer hand-write duplicate class names (red line 2).
Caveats
Section titled “Caveats”- Units of
size: numbers are px, strings are used as-is (supports'30%'/'50vh').'50%'is relative to the viewport, not the trigger element. - Default size of top/bottom drawers:
LH_DRAWER_SIZE_DEFAULTis'30%', which for the top/bottom directions means 30% of the viewport height — a mobile full-screen drawer should explicitly pass'100%'. - Direction and transition animation: each of the four directions needs its own slide animation (different transform start points). The UI layer picks the transition name by direction; core does not manage animation.
- Nested drawers: opening another right drawer from within a right drawer — the latter covers the former; distinguish them with different directions or zIndex. Focus return follows the same rule as dialog (when the inner one closes, return to the inner trigger element).
- Content scrolling: when drawer content is very long, set
__bodytooverflow: auto; the bottom footer stays fixed within the viewport (flex layout) — do not let the footer get pushed out by content. - The historical
closeOnEscgap: in old versions this prop was declared but never read by logic; only after the upgrade does the behavior actually take effect — if downstream relied on the old “Esc always closes” behavior, note that you must explicitly passcloseOnEsc. - Choosing between dialog and drawer: use dialog for interrupting interactions (confirm a deletion), use drawer for parallel viewing (order details). If the user does not need to stop and handle something, you should not be using a modal layer.
@lh-ui/share/core/drawer → getDrawerPanelStyle( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue