Panel
Defined in: components/panels.ts:122
What a route handler gets: the params from its route, plus everything the
shell needs to know about the panel it is drawing. It’s an Aberdeen proxy, so
you can set things later, such as a title that arrives with your data or
loading going back to false, and the shell keeps up.
Search params and the #hash belong to the current panel only. Any other
panel keeps just its path, so anything a panel needs in order to redraw
itself has to live in that path. (A panel browsed away from does get its
search and hash back when a crumb makes it current again.)
Type Parameters
Section titled “Type Parameters”P = Record<string, string | number | string[]>
Properties
Section titled “Properties”params
Section titled “params”
readonlyparams:P
Defined in: components/panels.ts:132
The params matched from this panel’s path, typed per its route key:
[x] is a string, [x=integer] a number, [...x] a string.
Read-only.
Single-segment params are percent-decoded; [...x] is not, since decoding
it would make an encoded slash indistinguishable from a separator. Split it
yourself: x.split("/").map(decodeURIComponent).
readonlystack:PanelStack
Defined in: components/panels.ts:138
The stack this panel is in — the very object S.main() hands back. It is
here as well because a route handler runs while that call is still
going, so its return value isn’t available to it yet; this always is.
readonlypath:string
Defined in: components/panels.ts:140
This panel’s path, e.g. "/projects/7". Read-only.
title?
Section titled “title?”
optionaltitle?:string
Defined in: components/panels.ts:151
Names this screen, in the top bar’s breadcrumb stack and in
document.title while the panel is current. A panel that doesn’t set one
borrows the first line of text in its own body, so the stack never shows a
blank — but a borrowed paragraph makes a poor name, so say it yourself.
It does not conjure a heading: naming a screen and heading its content are different jobs, and a screen that wants its name in its own body writes it there, where it owns the typography.
actions?
Section titled “actions?”
optionalactions?:Slot
Defined in: components/panels.ts:167
This screen’s own actions: a couple of buttons, a menu. The shell draws
them — never the panel — and where depends on facts only the shell has:
a quiet strip at the top of this panel’s column while several columns are
up, and the top bar (where they take the app’s own menu slot) once the
shell is narrow and this panel is the screen.
They are drawn in exactly one of those places at a time, so crossing the threshold redraws them; anything stateful inside (the focus in a search box) is lost. Buttons and menus are fine.
Assign one function and keep it: a fresh closure on every redraw of your handler is a new slot, so the shell redraws the actions — and on a narrow shell, where they sit in the top bar, the app’s own chrome with them.
readonlywidth:number
Defined in: components/panels.ts:177
How wide this panel’s column actually is, in pixels — what Panel.maxWidth asked for, resolved against the window. Reactive and read-only, and correct before your handler draws, so content that sizes itself can read it instead of measuring.
You rarely need it: the shell places the chrome for you. It’s for content that genuinely differs by width, such as a table that becomes a list.
visible
Section titled “visible”
readonlyvisible:boolean
Defined in: components/panels.ts:187
Whether this panel is on screen right now: not crowded out from under the visible run, not parked past its right end, and not on its way out. Reactive and read-only.
The one to hang per-panel floating UI on (a FAB, a “3 selected” bar), for which “am I the current panel?” is the wrong question — two columns can be visible at once, and both of them are really there.
maxWidth?
Section titled “maxWidth?”
optionalmaxWidth?:PanelSize
Defined in: components/panels.ts:221
The widest this panel can usefully be — a ceiling the shell never exceeds, so the draw function never has to look right past it. It is counted in the shell’s columns: the content area divides into the fewest whole number of columns of at most 540px each (two columns of 540 in a 1080px area, three of ~507 in 1520px), and a column is never narrower than 360 — where the area holds just one, a small centres in it rather than stretching. So an ask never exceeds its column count × 540:
"small"— one column, never above 540px: lists, detail forms."medium"(the default) — two columns, never above 1080px."large"— three columns, never above 1620px: wide tables."none"— the whole content area, unbounded: boards, dashboards. Bound it with the shell’s ownmaxWidthwhere that matters.
Every size is capped at the content area, so on a phone they all come to the same thing: one screen at a time. And a width depends only on the window, never on what else is open, so opening or closing a panel never resizes another — the run of columns just shifts over in the area.
The ask is a ceiling only; there is no matching floor, since the window can be any width. Aim your layout at 360px — about the narrowest phone still in common use — and let it degrade gracefully below that.
Ask only for what your content can actually use: a panel that would cap its own content narrower than its ask is holding room that would have let another column fit beside it.
Set it at the top of your handler and the panel is already that wide when you draw (see Panel.width); set it later — when your data tells you — and the panel reflows without being redrawn, keeping its state, while the columns beside it move over.
loading?
Section titled “loading?”
optionalloading?:boolean
Defined in: components/panels.ts:230
Set this while you’re fetching what the panel needs, and back to false
when you’re done. A new panel slides into place right away but waits a
moment before fading in, so it can appear with real content instead of
empty; if the wait drags on it fades in anyway and shows a loading
indicator until the flag clears. It only affects the animation; the
stack and the URL never wait for it.
pinned?
Section titled “pinned?”
optionalpinned?:boolean
Defined in: components/panels.ts:244
Keeps this panel from being closed by navigation happening elsewhere. A navigation normally closes everything after the panel it came from (or returned to); a pinned panel survives that, staying in the stack — slotted in beneath the new panel, or parked out of sight when the new panel was already beneath it. Either way it is one crumb click away. The user toggles it from the crumb’s context menu (right-click or long-press), which is also where the pin shows; setting it from code does the same thing.
A pin never blocks an explicit close: Escape at the stack’s end,
Panel.close, the crumb menu’s Close and data-panel=replace all
still close the panel.
unsaved?
Section titled “unsaved?”
optionalunsaved?:boolean
Defined in: components/panels.ts:270
Set this while the panel holds work that must not be lost — a dirty form, an upload in flight. An unsaved panel cannot be closed, by anything: navigation that would prune it parks it out of sight instead, wearing a ● in its crumb — even the browser’s back button only parks it. Panel.close and the crumb menu’s Close refuse, Escape on it steps left along the stack rather than closing, and closing the browser tab runs into the browser’s own are-you-sure, the unsaved panel brought on screen as the question is raised.
Only the app clears it; the user has no toggle. A Save or Discard button clears it and then closes:
A(() => { $panel.unsaved = $form.dirty || undefined; });S.button({ content: "Discard", attrs: ".neutral", click: () => { $panel.unsaved = false; // explicitly — see below void $panel.close();}});The explicit unsaved = false before close() matters when the flag is
kept by a reactive scope, as above: resetting the form marks that scope
dirty, but it reruns after the running handler — after close() has
already been refused.
Methods
Section titled “Methods”close()
Section titled “close()”close():
Promise<boolean>
Defined in: components/panels.ts:290
Closes this panel, wherever it sits in the stack. Closing the current panel hands the focus to the panel on its left; closing any other panel takes just it away, leaving the columns around it where they are, with their state. Either way it becomes a history entry, so the browser’s back button brings the panel back.
Resolves false if the panel didn’t close: it holds
Panel.unsaved work, it was the only panel on the stack (so
there’s nothing to show instead), or another navigation got there first.
The shell’s breadcrumbs already travel back, so reach for this when a
screen wants a more explicit way out: a Cancel button, or a Save that
closes.
Returns
Section titled “Returns”Promise<boolean>
Example
Section titled “Example”S.button({ content: "Cancel", attrs: ".neutral", click: () => void $panel.close() });open()
Section titled “open()”open(
href,how?):Promise<boolean>
Defined in: components/panels.ts:316
Opens href exactly as a click on a link inside this panel does — the
shell’s own link handling runs through this very call, so the two can’t
drift apart. By default that is a push: the target opens on top of this
panel, closing the panels after it first (pinned ones ride along
beneath the new panel, unsaved ones park), and a path that is already
open is returned to rather than opened twice. how plays the part of a
link’s data-panel attribute: "replace" puts the target in this
panel’s place — history entry included, so the back button skips the
replaced panel — "open" leaves the panel behind and gives the target
its own stack, and omitting it follows the shell’s
MainOptions.linkNavigation, like a link without the attribute.
This is the one for navigation that can’t be a link: a row’s click handler, a keyboard shortcut acting on this screen. The stack’s PanelStack.pushPanel builds on the current panel instead — a different panel exactly when the interaction happened in a column beside it, where it would pile the new panel on top of the open detail rather than pruning back to this one.
Parameters
Section titled “Parameters”string
"push" | "replace" | "open"
Returns
Section titled “Returns”Promise<boolean>
Example
Section titled “Example”A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);