@carissa-io/components-drawer
v0.1.3
Published
Carissa UI: side panel built on the native dialog element, as a React component with SCSS variables
Readme
@carissa-io/components-drawer
A panel that slides in from an edge of the screen: Drawer, built on the native <dialog> element. It uses showModal(), so
the browser provides the top layer, the focus trap, the inert background, Escape and the focus return; there is no portal or
focus-trap package. A React component, SCSS-themed, that builds on @carissa-io/components-typography
for the title and the description. It has the same contract as Modal; on small screens it is where an app renders its
sidebar or navigation.
npm i @carissa-io/components-drawerUse
import { useState } from 'react'
import { Drawer } from '@carissa-io/components-drawer'
function Filters() {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>{t('filters')}</button>
<Drawer
open={open}
on_close={() => setOpen(false)}
side="end"
title={t('filters')}
description={t('filters_description')}
close_label={t('close')}
footer={<button type="button" onClick={apply}>{t('apply')}</button>}
>
{t('filters_body')}
</Drawer>
</>
)
}The drawer never closes itself: it calls on_close(reason) and the caller sets open to false.
| Prop | |
|---|---|
| open | shown while true (the dialog stays in the DOM; the browser hides it when closed) |
| on_close | on_close(reason), where reason is escape, backdrop (a click on the dimmed area) or close_button |
| side | end (default), start, top or bottom; start and end follow the writing direction; anything else falls back to end |
| dismissible | default true; when false, Escape and the backdrop click do nothing and the close button is not rendered |
| title | optional; rendered with the typography Heading (visual size 4) and used as the dialog name (aria-labelledby) |
| title_level | the document level of the title, default 2 |
| description | optional; rendered with the typography Text and used as the dialog description (aria-describedby) |
| close_label | the accessible name of the close button; default the code close, the app passes the translated text |
| size | sm, md (default) or lg: the width of a start/end drawer, the height of a top/bottom one; anything else falls back to md |
| footer | a node for the actions, placed after the body |
| className, id, ... | className is appended; unknown props go to the dialog element (for example aria-label when there is no title) |
Children are the body. The ids of the title and the description are generated with useId. While any drawer is open the class
c-drawer-lock is on <html> (reference counted, removed on close and on unmount) and the package styles it with overflow: hidden.
The drawer carries its own small scroll lock instead of depending on the modal package. Browsers without showModal (and jsdom)
get the open attribute instead.
Style
@use '@carissa-io/components-drawer/scss' with (
$c-drawer-radius: 0.5rem,
$c-drawer-backdrop-color: rgb(0 0 0 / 0.7)
);Classes: c-drawer with --start, --end, --top, --bottom and --sm, --md, --lg; c-drawer__header, c-drawer__heading,
c-drawer__title, c-drawer__description, c-drawer__close, c-drawer__body and c-drawer__footer; c-drawer--overflowing on the root while the body scrolls; c-drawer-lock on <html>.
The native ::backdrop is styled through $c-drawer-backdrop-color. Every variable is !default (see
scss/_variables.scss); the defaults are neutral, so the component works without a theme. The slide
transition is disabled with prefers-reduced-motion.
| Variable | Default |
|---|---|
| $c-drawer-background, $c-drawer-color | #fff, inherit |
| $c-drawer-border-width, $c-drawer-border-color | 1px, rgb(0 0 0 / 0.15) |
| $c-drawer-radius, $c-drawer-shadow | 0 (the corners facing the page), 0 0 3rem rgb(0 0 0 / 0.25) |
| $c-drawer-padding-x, $c-drawer-padding-y, $c-drawer-gap | 1.5rem, 1.25rem, 1rem |
| $c-drawer-header-padding-bottom, $c-drawer-footer-padding-top | 0.5rem, 0.5rem |
| $c-drawer-header-divider-width, $c-drawer-header-divider-color | 0, currentColor |
| $c-drawer-footer-divider-width, $c-drawer-footer-divider-color | 0, currentColor |
| $c-drawer-size-sm, $c-drawer-size-md, $c-drawer-size-lg | 20rem, 28rem, 40rem |
| $c-drawer-close-size, $c-drawer-close-color, $c-drawer-close-color-hover | 1.25rem, inherit, inherit |
| $c-drawer-footer-justify | flex-end |
| $c-drawer-backdrop-color | rgb(0 0 0 / 0.5) |
| $c-drawer-transition-duration, $c-drawer-transition-timing | 0.2s, ease-out |
The title and the description take their look from the typography package, whose variables are forwarded by this package's
scss.
Scroll edges: the gap and the dividers
The body is the scroll area, and padding inside a scroller scrolls away: with all the vertical padding on the body, the content would sit flush against the header and the footer as soon as it moves, with nothing to show where the scroll area ends. So the vertical spacing is split between the fixed parts and the body:
$c-drawer-header-padding-bottom(default0.5rem) is the gap between the header content and the header's bottom edge, and$c-drawer-footer-padding-top(default0.5rem) the gap between the footer's top edge and the footer content. They never move while the body scrolls.- The body keeps the rest of
$c-drawer-padding-y(max(0px, padding-y - gap)), so unscrolled the layout is the same as a singlepadding-ybetween the header and the body and between the body and the footer. A gap larger thanpadding-yleaves the body with no vertical padding. - When there is no header (no title, no description and
dismissible={false}) or no footer, the body is the first or the last child of the dialog and keeps the wholepadding-yon that side (.c-drawer__body:first-childand:last-child).
A divider is a border under the header and over the footer. The widths default to 0, so the component draws no line; a theme
turns them on. The footer line exists only when there is a footer, because it is the footer's own border.
The dividers show only while the body overflows. The component measures the scrolling body (scrollHeight against
clientHeight, ignoring a 1px difference from sub-pixel rounding) and puts the modifier c-drawer--overflowing on the root
(<dialog>) while the content is taller than the body's box. The base rules keep a 0-width border, and the widths come into
play only under the modifier:
.c-drawer__header { border-bottom: 0 solid $c-drawer-header-divider-color; }
.c-drawer--overflowing .c-drawer__header { border-bottom-width: $c-drawer-header-divider-width; }
// and the same for the footer (border-top, $c-drawer-footer-divider-width)So a short drawer has no line (there is nothing to separate), and a scrolling one shows both. The modifier is re-measured when the
drawer opens (a closed <dialog> is display: none and cannot be measured), when the body is resized (ResizeObserver, which also
covers a window resize) and when its content changes (MutationObserver on the body); it is removed when the drawer closes. The
observers are disconnected on close and on unmount. Without ResizeObserver or MutationObserver (or on the server) the
component does not throw: it measures once when it opens and the modifier follows that. The padding model and the divider
variables are the same as before; only when the line is drawn changed. A consumer className is kept next to the modifier.
@use '@carissa-io/components-drawer/scss' with (
$c-drawer-header-divider-width: 1px,
$c-drawer-header-divider-color: rgb(0 0 0 / 0.15),
$c-drawer-footer-divider-width: 1px,
$c-drawer-footer-divider-color: rgb(0 0 0 / 0.15)
);The lines are the clip edge of the scroll area: while the body scrolls, its content is cut right at each line, with the header
or footer gap on the outer side of the line. Unscrolled, the body content starts padding-y minus the gap below the header line
(and ends the same distance above the footer line). Change the two gap variables to move the line closer to or farther from the
header and footer content.
Development
npm test # vitest + Testing Library (jsdom: showModal and close are mocked)
npm run lint
npm run build # esbuild -> dist/Published only by GitHub Actions on push to main; see AGENTS.md.
