drawer — a panel from an edge, minimizable to a handle
UiDrawer is a panel that slides in from one edge — top, bottom, left or
right — and can be open, minimized to a small handle, or closed. It belongs to
the whole window or to a container such as a dialog. A typical use is the AI
chat of a mail composer: it comes up from the bottom over the buttons and
minimizes to a "✨ Draft with AI" handle without losing the conversation.
UiDrawer.of("ai-chat", "Draft with AI", UiCustom.of("chat-widget").prop("session", sid))
.edge(UiDrawer.Edge.BOTTOM).scope(UiDrawer.Scope.CONTAINER)
.size("45%").minSize("200px").maxSize("80%").resizable()
.icon("sparkles").badge("1")
.onClose(UiTrigger.api("DELETE", "/chat/" + sid))
.onStateChange(UiTrigger.api("POST", "/chat/" + sid + "/state/{state}"));
Fields
| Field | Type | Meaning |
|---|---|---|
title | String | Shown on the handle and in the header; the panel's accessible name. |
content | UiNode | Anything — a form, a list, a plugin's UiCustom. |
icon, badge | String | Leading icon (handle and header); a short count or status on the handle. |
edge | TOP · BOTTOM · LEFT · RIGHT | Where it slides in from. Default RIGHT. |
state | OPEN · MINIMIZED · CLOSED | Minimized shows only the handle; closed shows nothing. Absent means "as it is": a new drawer opens, and a replaced one keeps the state the user chose. |
size, minSize, maxSize | CSS length | Height for TOP/BOTTOM, width for LEFT/RIGHT ("40%", "360px"). Anything but a length is refused. |
resizable | boolean | The inner edge can be dragged, within minSize/maxSize. |
scope | VIEWPORT · CONTAINER | The window (default), or the nearest positioned container — a dialog, or any node with the css class sui-drawer-host — over its content and inside its bounds. |
mode | OVERLAY · PUSH | Over the content (default), or beside it: in PUSH it takes room in the flex row (LEFT/RIGHT) or column (TOP/BOTTOM) it stands in, and the content moves over, as it does for a push sidebar menu. |
closable | boolean | An X in the header closes it. Set by onClose too. |
onClose | UiTrigger | Fired when the X closes it, so the server can clean up. |
onStateChange | UiTrigger | Fired on every change the user makes; {state} in its URL becomes OPEN, MINIMIZED or CLOSED. |
Notes
Minimizing redraws nothing. Open, minimized and closed are classes on the one element, switched in the browser. The content is never rendered again, so what was typed and a widget's own state (a chat's scroll position, a connection) survive a minimize. That is the difference from sending a new node.
A patch keeps what the user chose. A REPLACE of the drawer, or of
anything around it, keeps the drawer's current state unless the new node sets
one. Set state in a patch only to change it on purpose, for example
.minimized() once a draft has been taken over.
Keyboard and focus. The handle is a button, so Enter or Space opens the drawer. Opening puts the focus on the first control inside, or on the panel. Esc inside minimizes the drawer rather than closing it, and the focus returns to the handle. An open menu inside the drawer takes Esc first.
Several at once. Any number may be open at once, at different edges. Minimized handles at the same edge of the same container line up side by side. A handle stays above the open panels. At one edge of one area, one drawer is open at a time: opening a second sends the first to its handle — it would otherwise lie under the new one with its handle hidden, and could neither be seen nor brought back. The server may still send two open at one edge; the rule applies when the user opens one. Two that should be open together belong in a group, below.
Two at one edge: drawer-group
A UiDrawerGroup holds several drawers at one edge and says how they get on
with each other — as configuration, not as a rule the renderer guesses:
UiDrawerGroup.of("dock", chat, files)
.edge(UiDrawer.Edge.BOTTOM).scope(UiDrawer.Scope.CONTAINER)
.share() // or .stack().active("chat")
.size("60%").minSize("120px").resizable();
share()— the open drawers divide the edge between them: two at the bottom stand side by side, each half the width, the full height of the strip. Minimize one and the other takes its room; the strip's grip resizes them together.stack()— they lie on top of each other. The one in front (active) shows its content; the others are their header bars, stacked above it, and a click on a bar brings that drawer forward — a class swap, nothing redrawn. Opening a drawer from its handle puts it in front; when the front one is minimized the next open one takes its place. The group reports a switch throughonActiveChange, with{id}in the URL. Like a drawer'sstate, a nullactiveon a patch means "as it is": what the user brought forward stays there.
The group owns the edge, the scope and the mode — a drawer inside ignores
its own, so there is one truth about where it stands — and the size,
which is the strip's extent along the edge's axis; the drawers share the
other axis. A drawer keeps its title, icon and badge, its content, closable
and onClose, and its own state with the same rules as outside a group.
The browser and the server render a group alike. The JavaFX painter is still to come: the desktop client shows nothing for it yet.
Look. The drawer uses the theme's tokens, so it follows dark mode as well.
The handle is as tall as a button. It slides in and folds away softly, and
not at all with prefers-reduced-motion. On a screen narrower than 640px, a
LEFT or RIGHT drawer comes up from the bottom instead.
No sidepanel to replace. mc-semantic-ui had no side-panel node. The
closest relative is the sidebar menu, whose PUSH and OVERLAY
modes the drawer's mode follows. The menu stays what it is: navigation with
its rail state. A panel of your own content beside the page is a drawer with
edge(RIGHT).mode(PUSH).