PlDrawer
A panel attached to one edge of the window. Two things in one component, because they are the same panel: the drawer you open, and the drawer that is simply part of the page.
import { PlButton, PlDrawer, PlDrawerClose } from 'plass-ui';
<PlDrawer side="right" trigger={<PlButton>Filters</PlButton>} title="Filters">
Everything you can narrow by.
</PlDrawer>;import 'package:plass_ui/plass_ui.dart';
PlDrawer(
side: PlassSide.right,
open: filtering,
onOpenChanged: (bool next) => setState(() => filtering = next),
title: const Text('Filters'),
child: const FilterForm(),
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sideshared | 'top' | 'right' | 'bottom' | 'left' | 'left' | Which edge the panel is attached to. Physical rather than logical |
| mode | 'overlay' | 'inline' | 'overlay' | overlay is the drawer you open — a scrim, a focus trap, Escape. inline is a panel in the layout |
| open | boolean | — | The drawer is shown. Use with onOpenChange for a controlled drawer |
| defaultOpen | boolean | — | Whether it starts open, for an uncontrolled one. false in overlay, true in inline |
| onOpenChange | (open: boolean) => void | — | Called when the open state changes |
| trigger | ReactElement | — | The element that opens the drawer. overlay only |
| title | ReactNode | — | The heading, and what names the drawer |
| description | ReactNode | — | A line under the title, and the drawer's accessible description |
| actions | ReactNode | — | The bottom row, held against the foot of the panel. Laid out end-aligned |
| dividers | boolean | false | Scores the panel between its sections with a hairline instead of separating them with space |
| showClose | boolean | — | Shows the × in the corner. On in overlay, off in inline |
| closeLabel | string | 'Close' | Accessible name of the × button |
| extent | number | string | — | How far the panel reaches in from its edge: a width for left/right, a height for top/bottom |
| rounded | boolean | true | Rounds the two corners that face the page. The ones against the window edge are always square |
| modal | boolean | 'trap-focus' | true | Whether the page behind is taken away. 'trap-focus' keeps it usable while still holding focus inside |
| dismissible | boolean | true | Whether Escape or a press on the scrim closes it. overlay only |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | The panel's width, radius and padding |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. It reaches the focus rings inside and nothing else |
| densityshared | 'default' | 'compact' | 'default' | How tightly the sections pack |
| classNames | { backdrop?: string } | — | Classes on the parts a className does not reach. backdrop is the scrim drawn behind the surface |
| Prop | Type | Default | Description |
|---|---|---|---|
| open * | bool | — | The drawer is shown. Use with onOpenChange for a controlled drawer |
| onOpenChanged | ValueChanged<bool>? | — | Called with what the open state should become. The × and a press outside report rather than act |
| child | Widget? | — | The body — the only part that scrolls |
| sideshared | PlassSide | PlassSide.left | Which edge the panel is attached to. Physical rather than logical |
| mode | PlDrawerMode | PlDrawerMode.overlay | overlay is the drawer you open — a scrim, a focus trap, Escape. inline is a panel in the layout |
| title | Widget? | — | The heading, and what names the drawer |
| description | Widget? | — | A line under the title, and the drawer's accessible description |
| actions | List<Widget>? | — | The bottom row, held against the foot of the panel. Laid out end-aligned |
| dividers | bool | false | Scores the panel between its sections with a hairline instead of separating them with space |
| showClose | bool? | — | Shows the × in the corner. On in overlay, off in inline |
| closeLabel | String | 'Close' | Accessible name of the × button |
| extent | double? | — | How far the panel reaches in from its edge, in logical pixels: a width for left/right, a height for top/bottom |
| rounded | bool | true | Rounds the two corners that face the page. The ones against the window edge are always square |
| modal | bool | true | Whether the screen behind is taken away for the pointer as well as the keyboard |
| dismissible | bool | true | Whether Escape or a press on the scrim closes it. overlay only |
| sizeshared | PlassSize | PlassSize.md | The panel's width, radius and padding |
| colorshared | PlassColor | PlassColor.primary | Semantic colour role. It reaches the focus rings inside and nothing else |
| densityshared | PlassDensity | PlassDensity.standard | How tightly the sections pack |
Every other <div> attribute passes through to the panel, and so does a className. The scrim an overlay drawer draws behind it is a second element in the same portal. classNames.backdrop is the way to reach it, and an inline drawer has none for it to land on.
What the shared axes mean across the library is in prop conventions.
Two modes, one panel
mode is what separates them, and it is a separate axis from variant, which already means the weight of a surface across the whole library and would be a second spelling of nothing.
overlay. It is opened, it floats over the page on a scrim, it holds the focus, and it is dismissed. The navigation drawer behind a hamburger, the filter panel beside a table.inline. It is part of the layout and the page is laid out around it. No scrim, no focus trap, nothing to dismiss. The sidebar that is simply there.
Everything else about them is identical, which is exactly why they are not two components a caller has to switch between when a sidebar becomes a hamburger at a breakpoint.
defaultOpen follows: false in overlay, true in inline, because a fixed sidebar that had to be opened before it appeared would not be a fixed sidebar.
No variant and no elevation
The three materials answer "how much does this surface assert itself against the page", and a panel that has taken an edge of the window has answered it. An overlay drawer floats and carries a shadow at the top of the ladder; an inline one is part of the layout and carries none. Neither is a decision worth offering.
Examples
side
Physical rather than logical, the way PlassSide is everywhere: a drawer along the top of the window is along the top in every writing direction.
The panel is square against the window and cut on the free side, the corners that face the page take the house fillet, the two against the edge do not, because a corner cut off something with no visible end is a corner cut off nothing. The hairline follows the same rule and is drawn on the free edge only.
A left or right panel takes the width its size implies; a top or bottom one is as tall as what is in it, up to 85% of the window. A bottom sheet holding three rows should be three rows tall. extent overrides either.
Nothing slides
The panel fades, and only fades. A drawer that slid in would be dragging its own text across the screen for the length of the transition, and a panel is nothing but text and controls, so this is the case the no-transform rule was written for rather than the exception to it.
What says the panel came from an edge is that it is attached to one.
dividers
Scores the panel between the header, the body and the actions with a hairline instead of separating them with space. Worth turning on the moment the body scrolls: the lines are what say the header stayed put.
The body is the only part that scrolls either way.
Accessibility
- An
overlaydrawer holds the focus while it is up, puts it back where it came from on the way out, and takes the screen behind it away. titlenames it anddescriptiondescribes it. Both wired to the panel rather than sitting near it, and the title is announced as a heading.- An
inlinedrawer is not a dialog and claims none of that. It is a panel in the layout, and its heading is an ordinary one. dismissible={false}refuses both Escape and a press on the scrim. Give a drawer that refuses them actions that answer it, because there will be no other way out.
- Base UI owns the focus trap, the scroll lock, the
aria-labelledby/aria-describedbywiring and the inert page behind.modal="trap-focus"keeps the page scrollable and clickable while still holding focus inside. PlDrawerCloseexists so an uncontrolled drawer's Cancel button has something to call.rendermakes it a real Plass button:<PlDrawerClose render={<PlButton variant="ghost">Cancel</PlButton>} />.
- The lift, the scrim, the focus scope, Escape and focus going back where it came from are
PlassPortal's. The same layer aPlModaland aPlOverlayare built on, so a drawer opened over an overlay shows no seam.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
open / defaultOpen / onOpenChange | open / onOpenChanged | Flutter's own controls are controlled, and so is every stateful widget in this package. |
trigger | — | There is nothing to wire a trigger up to here: the app opens the drawer by setting open, and the button that does it is the app's own. |
PlDrawerClose | — | It exists over there so an uncontrolled drawer's Cancel button has something to call. Every drawer here is controlled, so the button already has onOpenChanged. |
extent: number | string | extent: double | Pixels stay pixels. There is no CSS length to accept. |
modal: boolean | 'trap-focus' | modal: bool | The two values that differ are "the pointer is held out" and "it is not". Flutter has no scroll lock to be the third thing. |
className, style | — | There is no class list and no style attribute to pass through. |