@1game/solid-popover
v1.16.0
Published
Controlled Popover / Modal / Menu-preset overlays for 1Game Engine (OverlayOutlet + placement flip/shift)
Readme
@1game/solid-popover
Controlled Popover / Modal for 1Game Engine, built on solid-dsl
OverlayOutlet.
- Registers into the per-runtime overlay registry (
registerOverlay/unregisterOverlay) renderGamestays on@1game/engine/@1game/engine-bundle— this package does not mount Outlet- Open state is controlled (
open+onOpenChange) so Public Store / replay can own visibility
Full contract (defaults, dismiss matrix, Escape A3, placement P1, Menu preset, sid rules, out of scope):
docs/overlay-popover-contract.md.
Scene-level pointer/keyboard passthrough background:
packages/solid-dsl/README.md.
Install
pnpm add @1game/solid-popover solid-jsPeer: solid-js. Depends on @1game/engine-bundle (Worker registry + JSX runtime exports).
Basic usage
import { renderGame, createGameStore } from '@1game/engine-bundle/runtime/worker';
import { Popover, Modal } from '@1game/solid-popover';
const { store, commitChange, storeHistory } = createGameStore({ menuOpen: false, dialogOpen: false });
function App() {
return (
<scene name="main" width={640} height={480} backgroundColor="#0f0f1a">
{/* overlayKey required when multiple Popover/Modal hosts share one scene */}
<Popover
overlayKey="menu"
open={store.menuOpen}
onOpenChange={(v) =>
commitChange('menu', (d) => {
d.menuOpen = v;
})
}
>
<Popover.Trigger>
<node x={40} y={40} width={100} height={36} shape="rect" backgroundColor="#7c6aef" />
</Popover.Trigger>
<Popover.Content x={40} y={90} width={180} height={100}>
{/* Panel group is clickable (blocks Modal absorb). Decorative children need no clickable. */}
<node width={180} height={100} shape="rect" backgroundColor="#1e1e3a" />
<Popover.Close>
<node x={120} y={60} width={48} height={28} shape="rect" backgroundColor="#4a9d6e" />
</Popover.Close>
</Popover.Content>
</Popover>
<Modal
overlayKey="dialog"
open={store.dialogOpen}
onOpenChange={(v) =>
commitChange('dialog', (d) => {
d.dialogOpen = v;
})
}
>
<Popover.Trigger>
<node x={200} y={40} width={100} height={36} shape="rect" backgroundColor="#4a9d6e" />
</Popover.Trigger>
<Popover.Content x={170} y={140} width={300} height={160}>
<node width={300} height={160} shape="rect" backgroundColor="#1e1e3a" />
</Popover.Content>
</Modal>
</scene>
);
}
renderGame(() => <App />, { container: '#app', bindStore: storeHistory });Local demos may use @1game/engine instead of engine-bundle/runtime/worker for renderGame /
createGameStore (see apps/demos/pages/popover-demo/).
Defaults (summary)
| | Popover | Modal | Menu (menu) |
| - | ------- | ----- | ------------- |
| pointerPassThrough | true | false | false |
| closeOnOutside | true | false | true |
| keyboardPassThrough | true | false | false |
| closeOnEscape | true | true | true |
No general Focus: keyboardPassThrough: false only means the overlay scene claims keys
(Escape A3 + scene pick). There is no Tab trap or focus restore. Menu highlight / Arrow keys
belong in store state (highlightIndex + onKeyDown), not the engine. Use Root menu or
spread MENU_DEFAULTS:
<Popover
menu
overlayKey="file-menu"
open={store.menuOpen}
onOpenChange={(v) =>
commitChange('menu', (d) => {
d.menuOpen = v;
})
}
>
...
</Popover>Multi-instance + panel hits
- Pass distinct
overlayKeyon each Root when more than one Popover/Modal shares a scene (avoidsE_STABLE_UID_COLLISIONon reserved host__sid). - Outlet content layer is non-clickable fall-through; the Popover Content panel is clickable so interior clicks do not hit the dismiss/block absorb layer.
Anchored placement (P1)
Pass a laid-out host-scene node as anchorNode (the Trigger’s visible child, via
virtualNodeRef — not the 0×0 Trigger group). Hold it in a Solid createSignal so
placement recomputes when the ref binds. Engine getNodeSceneRect / VirtualNode.getSceneRect
measures the scene AABB; Content placement / align / offset write panel x/y via
computePlacementWithAuto (preferred → flip → shift into the overlay viewport).
Panel width/height are required for this path. Without anchorNode (or if measure/size is
incomplete), manual x/y still work and do not run flip/shift.
Anchored defaults: flip and shift are on. Pass flip={false} / shift={false} for
preferred-only (P0) positioning. Optional collisionPadding, shift={{ padding }},
flip={{ fallbackPlacements: [...] }}.
import { createSignal } from 'solid-js';
import type { VirtualNode } from '@1game/engine-bundle/runtime/worker';
const [btn, setBtn] = createSignal<VirtualNode | undefined>();
<Popover.Trigger>
<node x={40} y={40} width={100} height={36} virtualNodeRef={setBtn} />
</Popover.Trigger>
<Popover.Content
anchorNode={btn()}
placement="bottom"
offset={8}
width={180}
height={100}
// flip / shift default true — panel stays inside overlay scene
>
...
</Popover.Content>Pure helpers (also exported): computePlacement, computePlacementWithAuto.
Scripts
pnpm --filter @1game/solid-popover run build
pnpm --filter @1game/solid-popover run test