Overlays #
Three app-level singletons that float above the stage: a tooltip, a
modal, and a toast stack. You call them imperatively from anywhere
(stageModal.open(...), stageToast.push(...)) — no ref threading, no context.
The modal and toast each need a one-time host component mounted inside your
.stage-root. The tooltip mounts itself.
function App() {
return (
<div class="stage-root">
<EditorPanels ... />
<StageModalHost /> {/* once */}
<StageToastHost /> {/* once */}
</div>
);
}
stageTooltip #
A single floating speech-bubble, positioned with @floating-ui/dom. It
mounts itself — no host component. Mostly you don't call it directly: a
field's info prop and its validation both drive it for you. Call it manually for
custom anchored hints.
stageTooltip.show({
anchor: myEl, // the element to point at
level: 'info', // 'info' | 'success' | 'warn' | 'error'
content: 'Press ⌘S to save', // string or JSX
owner: myEl, // optional identity for owner-gated hide
});
stageTooltip.hide(myEl); // gated: only hides if this owner holds it
| Method | Signature | Notes |
|---|---|---|
show |
(opts: TooltipShowOptions) => void |
{ anchor, level, content, owner?, keepOpen?, placement?, interactive?, onHide? } |
hide |
(owner?) => void |
No-op unless that owner holds the bubble. |
requestHide |
(owner?) => void |
Immediate, owner-gated. |
owns |
(owner) => boolean |
Auto-hide is automatic: the bubble closes the moment the pointer leaves the
combined anchor+bubble box. keepOpen?: () => boolean suppresses that (a focused
field passes () => focused). Four level skins tint the bubble.
The bubble re-parents into the anchor's nearest .stage-root so tokens cascade. It
hides by itself if its anchor is removed from the document or becomes inert, such as
when the panel or section holding it collapses. keepOpen doesn't prevent either.
Three more options suit tooltips on tightly packed targets, such as a grid of tiles:
| Option | Default | Notes |
|---|---|---|
placement |
'top' |
'top' | 'bottom' | 'left' | 'right'. The preferred side; it still flips and shifts to stay on screen. |
interactive |
true |
false lets the pointer pass through the bubble to what's beneath, and the hover area becomes the anchor alone. Links in the content stop working. |
onHide |
none | Called once per show session, as hiding starts rather than after the fade. Any hide ends a session, and so does another show(), even for the same owner. The old callback runs before the new session is set up, so you can clear one target's state and then set another's. |
owns() stays true during the fade, so use onHide to keep state in step with the
bubble.
Moving the bubble between anchors. An outside pointerdown starts the fade, so
a tap on another anchor that shows from click dips visibly for as long as the press
lasts. To move the bubble without a dip, call show() from that anchor's
pointerdown, which runs before anything paints. If a second tap on the same anchor
means something, as in tap-to-activate, read "was it already active" at pointerdown
too, or that tap's own click will see it as active. Moving between anchors by hover
needs nothing extra, as long as the gap between them is no wider than 6px, the hover
area's padding.
stageModal #
One app-level modal for settings-style flows. Needs <StageModalHost /> mounted
once in your .stage-root.
stageModal.open(() => <SettingsDialog onDone={() => stageModal.close()} />, { label: 'Settings' });
opentakes a thunk —() => JSX.Element, not pre-built JSX. It's called from event handlers (outside a Solid owner), so eager JSX would orphan its effects. The host instantiates the thunk under its own owner.
| Method | Signature |
|---|---|
open |
(content: () => JSX.Element, options?: { label?: string; class?: string; closeOnInteractOutside?: boolean }) => void |
close |
() => void |
isOpen |
() => boolean |
open options:
| Field | Type | Notes |
|---|---|---|
label? |
string |
Accessible name for the dialog (typically the hosted section's title). |
class? |
string |
Class on the dialog container, for example to widen a content-heavy modal. |
closeOnInteractOutside? |
boolean |
Default true — a click on the backdrop closes the modal. Pass false for a persistent modal that only closes via the ×, Escape, or stageModal.close() (e.g. a flow with unsaved changes). |
The host renders a dimming backdrop (clicks never pass through to content beneath)
around a centred container. By default a click on the backdrop closes the modal;
closeOnInteractOutside: false makes it persistent. The container hosts a single
dismissable Section (its × calls close). It owns a11y: focus trap (Tab wraps
within the modal), Escape closes, focus restored to the opener on close,
role=dialog + aria-modal, and aria-label from options.label.
Fields opened inside the modal portal into the modal container (so their select popovers/tooltips stack above the dim correctly) — this works automatically.
function SettingsDialog(props: { onDone: () => void }) {
return (
<Section title="Settings" onClose={props.onDone}>
<FieldGroup>
<TextField name="Name" {...bind('name')} />
<BooleanField name="Telemetry" {...bind('telemetry')} />
</FieldGroup>
</Section>
);
}
stageToast #
A corner stack of transient notifications. Needs <StageToastHost /> mounted
once in your .stage-root.
const id = stageToast.push({ message: 'Saved', level: 'success' });
stageToast.push({ message: 'Could not connect', level: 'error', autoDismissMs: null }); // pinned
stageToast.dismiss(id);
stageToast.clear();
| Method | Signature | Notes |
|---|---|---|
push |
(opts: ToastOptions) => string |
Returns the toast id. |
dismiss |
(id: string) => void |
Animated fade-out. |
clear |
() => void |
Remove all. |
ToastOptions:
| Field | Type | Notes |
|---|---|---|
message |
string | (() => JSX.Element) |
A thunk renders rich content (a link/button). |
level? |
'info' | 'success' | 'warn' | 'error' |
Default 'info'. Tints the left border. |
autoDismissMs? |
number | null |
Default 5000; null pins it (no auto-dismiss). |
A thunk message wires the toast into your app — its onClick can deep-link:
stageToast.push({
level: 'warn',
message: () => (
<span>Unsaved changes. <a onClick={() => stageModal.open(SaveDialog)}>Review</a></span>
),
autoDismissMs: null,
});
Hovering any toast pauses the whole stack's auto-dismiss (so reading one never drops its neighbours). The × removes immediately; auto-dismiss and the API fade out.
Host props #
<StageToastHost placement="bottom-right" limit={5} hide="oldest" />
| Prop | Type | Notes |
|---|---|---|
placement? |
'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' |
Default bottom-right. |
limit? |
number |
Max visible; the excess collapses into a "+N more…" indicator. Default 5. |
hide? |
'oldest' | 'newest' |
Which end collapses on overflow. Default oldest. |
contained? |
boolean |
Use position: absolute instead of fixed — scope + clip toasts to a positioned region (e.g. a CanvasStage viewport) instead of the screen corner. |
Z-order #
--stage-z-popover (1000, select/search dropdowns) < --stage-z-modal (1050) <
--stage-z-toast (1060, so a toast fired from a modal shows over the dim) <
--stage-z-tooltip (1100, always on top). A modal field's own tooltip still wins.