Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs overlays.md
7.8 kB · 192 lines
Markdown
at main

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' });

open takes 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.