Pane-host: primary pane + openable side panes
framework/ui.PaneHost is a layout shell: one always-visible
primary pane and one or two openable side panes, secondary and
tertiary. It owns the pane lifecycle (show/hide, focus handoff on
open, focus restore on close, and a responsive collapse). It does NOT
own content loading.
Use it for master/detail views, inspector panels, a list + a live preview: anywhere a primary region stays put while a side region opens and closes around it.
import "github.com/DonaldMurillo/gofastr/framework/ui" body := ui.PaneHost(ui.PaneHostConfig{ Primary: customerList, Secondary: detailRegion, // optional Tertiary: inspectorRegion, // optional SecondaryOpen: false, // SSR first-paint state (Hard Rule 6) SecondaryLabel: "Customer details", ID: "customers-host", })
Primary is required (PaneHost panics when empty, mirroring
DataTable). Each side pane is a labelled role="region";
SecondaryLabel/TertiaryLabel default to "Secondary"/"Tertiary",
said in the request's language when Ctx is set (the
ui.paneHost.secondary / ui.paneHost.tertiary phrases).
What it renders
A root <div data-cui-comp="ui-pane-host" data-hui-panehost> with
three slot children, each carrying data-hui-pane="primary|secondary|
tertiary". The grid is display:grid; the column count derives from
the open-state hook the runtime module maintains —
data-hui-pane-open, the space-separated list of open side panes
(matched token-wise, [data-hui-pane-open~="secondary"]) — with the
fui-pane-host--secondary-open / --tertiary-open classes riding
along as the first-paint spelling, so no inline style is ever
emitted (strict CSP, Hard Rule 9b) and a pane opened in the browser
changes the columns. A side pane closed at first paint carries
hidden; the runtime reveals it on open.
Driving panes
Three attribute-driven triggers (delegated, so they survive island swaps):
| Attribute | Effect | |
|---|---|---|
| `data-hui-pane-open-control="secondary\ | tertiary"` | Open that pane. Focus moves to the pane's first focusable (or the region); the trigger is remembered for restore. |
| `data-hui-pane-close="secondary\ | tertiary"` | Hide it and restore focus to the opener. Bare attribute (no value) closes the topmost open pane. |
| `data-hui-pane-swap="secondary\ | tertiary"` | Open the named pane and close the other side sibling: the "this link fills the third pane instead of navigating" flow. |
<button data-hui-pane-open-control="secondary">Show details</button> <!-- inside the secondary pane --> <button data-hui-pane-close>Close</button>
A trigger resolves its host via the nearest [data-hui-panehost]
ancestor. For a trigger that lives OUTSIDE the host (e.g. in a global
toolbar), set data-hui-pane-host-target="<host-id>" to the host's
id; with neither a target nor an enclosing host the first host on
the page answers.
App code can also drive panes programmatically; the API mirrors
openWidget/closeWidget:
__gofastr.openPane("customers-host", "secondary"); __gofastr.closePane("customers-host", "secondary"); __gofastr.swapPane("customers-host", "tertiary");
The host dispatches pane-host:open and pane-host:close events
({ bubbles: true, detail: { pane } }) for observability; no
data-hui-* attribute is needed to receive them.
Filling a pane from the server
PaneHost does NOT fetch pane content. To load a link's response into a
pane, use the existing RPC + signal rail: the trigger carries
data-cui-rpc + data-cui-rpc-signal, and the pane contains a region
bound to that signal in HTML mode:
<div data-hui-pane="secondary" role="region" aria-label="Details"> <div data-cui-signal="customer-detail" data-cui-signal-mode="html"> <!-- RPC response HTML lands here --> </div> </div>
A row trigger then both opens the pane and fires the fetch:
<a href="/api/customers/42/detail" data-cui-rpc data-cui-rpc-method="GET" data-cui-rpc-signal="customer-detail" data-hui-pane-open-control="secondary">View</a>
Open/close is in-page state, never a URL route (Hard Rule 1).
Deep-linking a pane (?pane=…)
Some pane state is worth sharing: send a colleague the workspace with
ticket 4021 open and they should see ticket 4021 open. Set
DeepLinkParam to opt the host in, and label each trigger with the
identity of what it opens:
ui.PaneHost(ui.PaneHostConfig{ Primary: queue, Secondary: ticketPane, DeepLinkParam: "pane", // → ?pane=secondary:4021 SecondaryOpen: ticketLinked, // first paint, see below })
interactive.PaneKey( interactive.OpenPaneOnClick(row, "secondary"), ticket.ID)
Opening through a keyed trigger writes ?pane=secondary:4021; closing
strips it; Back and Forward move between those states. Back does not
just re-show the column: it re-clicks the matching trigger, so the RPC
runs again and the pane comes back filled. A refresh or a shared
link does the same after hydration. The match is the key and the pane,
so one record's id can key both its view (secondary) and its edit
(tertiary) trigger; a key no trigger on the page carries opens the
pane empty.
This is not a route. The pane is still in-page state; the query parameter records it so refresh, share, and Back reproduce what is on screen, the same contract widget deep links give modals. Reach for a real route when the detail deserves its own page and its own title.
Render the first paint yourself. The runtime can only act after
hydration, so a shared link that the server renders closed shows a
closed pane and then pops it open and fills it. Read the parameter with
ui.PaneDeepLink and build the page from it:
slot, key, ok := ui.PaneDeepLink(r.URL.Query(), "pane") detail := emptyState open := false if ok && slot == "secondary" { if t, found := ticketByID(key); found { // look it up, never detail, open = renderTicket(t), true // reflect the raw key } }
ok is false when the parameter is missing or names something that is
not an openable side pane, so an edited URL degrades to the ordinary
closed-pane page instead of erroring. The key arrives from the URL, so
treat it as untrusted: look it up, and let an unknown one fall back to
the empty state.
A trigger with no PaneKey still opens its pane and leaves the URL
alone. That is the right choice for a pane whose contents have no
addressable identity: in examples/site/screen_workspace.go the ticket
pane is keyed and the customer pane is not, so opening a customer never
disturbs the ticket's link.
Only one pane is addressed at a time, because one parameter holds one
value. If two panes both need to survive a refresh, give them separate
PaneHost hosts with different DeepLinkParams.
Responsive collapse
Below 768px (the breakpoint the CSS and the runtime module share;
they MUST stay in sync), an open side pane stops being an inline column
and becomes a fixed overlay drawer. When matchMedia('(max-width:
768px)') matches AND a pane is open, the module sets
data-hui-pane-mode="overlay" on the host (and removes it otherwise,
so a closed phone-width page never paints a scrim); CSS repositions
the open pane to the right edge with a backdrop scrim (::before), and
the module applies:
- a focus trap: Tab cycles within the topmost open pane (the
kernel's shared
NS._focusSelcandidates, filtered by nonnegativetabIndex; it never touches the widgets module's private_modalStack, and defers while a modal widget is open). Entering overlay mode moves focus to the drawer's first focusable when focus sits outside it, so a drawer the server rendered open (SecondaryOpen) on a phone-width first load, or a window narrowed under an open pane, does not leave focus behind the scrim; an open modal widget keeps focus instead. Tab with focus outside the drawer re-enters it at the first focusable, Shift+Tab at the last, - a refcounted scroll lock:
__gofastr.doc.lockScroll('panehost:<id>'), released on close / widen, and whenever the host leaves the document: a client navigation that takes it away, or an island swap that re-renders its region without it (no navigation needed; a swap that renders the host again with its pane open hands the lock to the new host), - ESC and backdrop-click to close — ESC and the backdrop are light-dismiss for the OVERLAY drawer only; an open inline column at desktop width is page content and survives both.
With two hosts on a page, Tab and ESC act on the overlay host that holds focus, or on the only overlay host when focus is in none. With two open drawers and focus in neither, the keys leave both alone.
Widening the viewport or closing the pane clears overlay mode and
releases the lock. Pane widths and the drawer width are themeable via
the --fui-pane-host-secondary-w / --fui-pane-host-tertiary-w /
--fui-pane-host-drawer-w custom properties (defaults 360px / 300px /
420px).
Common mistakes
- Treating pane state as a route. Open/close/swap is in-page state
(Hard Rule 1); don't register a route per pane. When the state should
survive a refresh or a share, use
DeepLinkParamabove; when the detail really wants its own page and title, make it a real screen. - Deep-linking without rendering it server-side. Setting
DeepLinkParamand forgettingPaneDeepLinkin the screen leaves every shared link painting a closed pane that jumps open a moment later. The parameter is only half the feature. - Fetching pane content with a bespoke mechanism.
PaneHostis a layout shell, not a content loader. Usedata-cui-rpc+data-cui-rpc-signalinto adata-cui-signal-mode="html"region inside the pane; don't build a new fetch path. - Forgetting the
hiddenfirst-paint contract. A closed side pane must shiphiddenfrom SSR so first paint matches state (Hard Rule 6) and there is no flash.PaneHostConfig.SecondaryOpen/TertiaryOpencontrol this; don't try to open a pane by removinghiddenyourself. - Editing the 768px breakpoint in only one place. The CSS
@media (max-width: 768px)and the module'smatchMedia('(max-width: 768px)')MUST match, or the drawer and the grid collapse disagree. - Adding inline
styleto fix a column. Column state comes from the root's open-state hook and classes; CSP forbids inline style. Theme widths via the--fui-pane-host-*-wcustom properties instead.