From 9455f1cd9dd6fd6c948cfee9ffd5f6cc5088ae45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:40:18 +0000 Subject: [PATCH 1/6] docs: triage the cat-factory slice-5 overlay-host request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept the substance (a pick-one, app-state-keyed modal host with framework-managed behaviour — focus trap + return, scroll lock, stacked Escape, a11y — is a real, framework-shaped gap; even our own nuxt-modal-journey example hand-rolls this chrome without it), redirect the shape: no defineModule({ overlays }) descriptor seam (slots are the ingress, per the slice-4 precedent), no /testing subpath, no Nuxt manifest threading, no route-sync helper, and not Vue-only — the behaviour is engine-first with React and Vue hosts in the same train. Corrects the stale Gap D picture: the peer-range/hard-dep residuals the request re-files were already closed by the shared-peer-dependency change (#95). Counter-proposal: OverlayEntry (a ComponentEntry superset) + defineOverlayHost + resolveOverlay + a pure createOverlayStack in the engine, managed OverlayOutlet hosts + useModalBehavior in both bindings. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012ezPjgGYoWHHg1vVNLZUQT --- docs/overlay-host-triage.md | 323 ++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 docs/overlay-host-triage.md diff --git a/docs/overlay-host-triage.md b/docs/overlay-host-triage.md new file mode 100644 index 0000000..8ae693f --- /dev/null +++ b/docs/overlay-host-triage.md @@ -0,0 +1,323 @@ +# Triage: the cat-factory slice-5 request for a Vue overlay host (`OverlayOutlet`) + +Downstream request: [kibertoad/cat-factory#1217](https://github.com/kibertoad/cat-factory/pull/1217) +(`docs/initiatives/modular-vue-slice5-upstream-overlays.md` on their side). Status here: +**triaged — accepted in substance, redirected in shape — and now implemented.** This +document is the upstream half of the co-evolution artifact, the slice-5 sibling of +[`subject-keyed-zones-triage.md`](subject-keyed-zones-triage.md). See +[Resolution](#resolution--implemented) at the bottom for what shipped; the guide is +[`docs/overlay-host.md`](overlay-host.md). + +## Verdict in one paragraph + +The need is real, and this time the request's self-diagnosis is almost exactly right: a +**pick-one, app-state-keyed overlay host with framework-managed modal behaviour** is a +genuinely missing primitive — the modal dual of slice 4's render-all panels — and the +evidence (2 of 18 windows trap focus, every window re-registers its own global Escape +listener, `z-50` hard-coded eighteen times, `role="dialog"` dropped in one, test ids +drifting) shows precisely the kind of _behavioural_ defect that no amount of copy-paste +discipline fixes and that only a host owning the behaviour fixes structurally. Our own +codebase agrees: `examples/vue/nuxt-modal-journey` hand-rolls the same chrome with none +of the a11y behaviour, and `frontend-core`'s `MountKind` comment has anticipated "modal +hosts" as a future surface since PR-33. But the proposed delivery repeats, almost +clause for clause, the shape mistakes slice 4 made and this repo already redirected: +a new `overlays` registration seam on module descriptors, Nuxt manifest threading, a +`/testing` subpath, a route-sync helper, and a Vue-only landing. The selection half of +the request is **already shipped** (it is slice-2 pairing over a slot); the genuinely +new half is the **managed modal behaviour**, which is framework-shaped but not +Vue-shaped, and lands engine-first with React and Vue hosts in the same train. + +## What the request gets right + +- **Gap A/B are real, and correctly separated from selection.** Nothing in the family + hosts a modal: no `Teleport`/portal use exists in any published package, no focus + trap, no scroll lock, no overlay stack. The request is also right that + `` cannot be bent into this — panels are render-all + inline; an + overlay is pick-one + teleported + behaviourally managed. Different reduction on + both axes, correctly identified as a different primitive. +- **The slice-4 §5.6 correction is honest and load-bearing.** The whole-surface survey + (11 step-keyed windows, 7 block-keyed, exactly one open at a time) is exactly the + evidence needed to justify a pick-one host keyed by caller-supplied state rather + than "reuse panels keyed by the selected step". Panels remain the right tool for the + shared header regions — as a _secondary_ use inside the app's shell, needing no + upstream change. Both calls are correct. +- **The headless/styling split is right.** The framework owns behaviour (teleport, + backdrop-close, Escape, focus trap + return, scroll lock, stacking, a11y wiring); + the app owns pixels. That is what makes a consumer-contributed window inherit + _correct_ chrome instead of re-deriving it — the same extensibility argument that + moved panels upstream, and the load-bearing one here too. +- **Selection must reuse slice-2 pairing semantics.** Correct instinct: the engine + must not grow a second, divergent id-matcher. (We hold the request to this harder + than it holds itself — see shape point 2.) +- **`useModalBehaviour` as a public escape hatch is a good idea.** Their deferred + full-bleed surfaces (`AgentStepDetail`, `ObservabilityPanel`) and our own + modal-journey example both want the behaviour without the centered-card host. +- **The "why not a local shell" section is the initiative working as designed.** A + local `ResultWindowShell` would fix the duplication and leave the behaviour + re-derived per app and unavailable to consumer modules — the "local shim that + reimplements a missing library primitive" both sides' protocol forbids. + +## Where the request goes wrong + +### 1. `defineModule({ overlays: … })` — the same seam slice 4 already rejected + +The request proposes windows "declared on module descriptors +(`defineModule({ overlays: { resultViews: [...] } })`)". Slice 4 litigated exactly +this and the answer has not changed: **slots are the ingress.** `SlotMap` entries are +opaque by design; contributing typed `OverlayEntry` objects under the host's slot key +is what slots are for, not a workaround. A new `overlays` descriptor field would be a +second registration concept sitting beside `zones` (route/module-keyed, pick-one) and +the slot path, for no capability the slot path lacks — and consumer extension via +`registerAppModule` falls out of the existing module → slot path for free, which is +the property the request actually wants. No descriptor change ships. + +### 2. `resolveOverlay` as specified is bigger than the gap + +The request specifies `resolveOverlay(entries, activeId)` "reusing `pairById` under +the hood" — but `pairById` joins a _list_ of manifest items against a registry; the +overlay case is one id against one slot. What is actually missing from the engine is +small: a typed entry, a phantom-typed handle carrying `TSubject` (the +`definePanelGroup` convention), and a resolver that is `collapseEntriesById` (the +_shared_ duplicate-id implementation behind `resolveComponentRegistry` and +`resolvePanels` — the stances cannot drift) plus a map lookup. Semantics follow the +panels precedent exactly: duplicate validation runs **before** the null guard so a +registration bug surfaces on first resolve even while nothing is open; `activeId: +null` → closed; an id that resolves to nothing → `null`, which the hosts dev-warn on +(the `pairById` "missing"-bucket stance: a dangling reference is a warn-and-fallback, +not a throw — the id may name a window a consumer deployment ships and this one +doesn't). + +Two entry-shape redirects: + +- **`icon` is rejected; `title` is kept.** `title` is a11y-load-bearing — it is what + the host wires into the dialog's accessible name, so it must be a first-class, + subject-aware field (`string | ((subject) => string)`). `icon` is pure app + presentation with no behaviour attached; it rides the existing `meta` field + (`OverlayEntry` extends `ComponentEntry`, so `meta` is already there), where the + app's shell — the thing that renders icons — reads it. The engine blesses what the + behaviour needs, nothing more. +- **`OverlayEntry` extends `ComponentEntry`.** Deliberate: cat-factory's + `resultViews` slot already holds `ComponentEntry`-shaped objects consumed by + `resolveComponentRegistry`. Making the overlay entry a superset means the same slot + serves the existing registry path and the new host during migration — the slice-5 + refactor is additive window-by-window, not a flag-day. + +### 3. Vue-only delivery inverts the family's architecture — again + +The behaviour in this request — focus trap, focus return, scroll lock, stack-aware +Escape, backdrop-close, a11y wiring — contains not one Vue-shaped element. React +consumers have the identical need (the React family has no modal host either), and +the operating rule restated in the slice-4 triage holds: _engine-first, both bindings +in the same train._ Shipping a Vue-only overlay host would leave React to later port +from the Vue binding — the inversion the family exists to prevent. The neutral parts +(entry/handle/resolver, and the overlay **stack** — pure order-of-registration data +with a subscribe seam, no DOM) land in `@modular-frontend/core`; each binding ships +the same thin host and behaviour composable/hook over them. The DOM-touching parts +(focus, scroll, key events) are per-binding by necessity, but they share the engine +stack so "the top overlay closes first" is one implementation of _semantics_, twice +of _glue_. + +### 4. §4E Nuxt threading and the `/testing` entry — obsolete for the same reason as slice 4 + +`` reads the same slots context (`slotsKey` / `reactiveSlotsKey`) the +runtime already provides and `installModularApp` already installs. There is no +overlay manifest to thread, no `manifest.overlays` to type, no plugin, no cast — +the request's §4E collapses to zero code, exactly as its §4E predecessor did in +slice 4. Likewise the headless testing entry: `resolveOverlay` is a pure named +export of the engine; cat-factory unit-tests its wiring by calling it. No subpath. + +### 5. The route-sync helper — cut (YAGNI, their own assessment) + +The host is route-free _by construction_: nothing in the slot + `activeId` path can +see a router. The request itself marks the routed case a nice-to-have with no +consumer. Same disposition as slice 4: cut entirely; nothing needs keeping off a +"baseline import path" because no such path exists to guard. + +### 6. Gap D is stale — for the third consecutive slice + +The request tracks peer ranges as of the published `1.3.x`/`1.4.x` line. In-repo, +the whole category is already closed (the "shared peer dependency" change, PR #95): +every `@modular-frontend/core` peer range family-wide is the single forward-looking +`>=0.1.0 <2.0.0`, and `@modular-react/core`, `@modular-frontend/journeys-engine`, +and `@modular-frontend/compositions-engine` no longer carry core as a hard +dependency at all — it is a peer backed by a dev dependency, so the "second engine +copy beside 0.4.0" their override works around cannot recur. Both residuals the +request re-files are fixed by changes that ship in the same release train as the +overlay surface; the new surface is born onto the unified range with no per-package +edit. cat-factory can drop its `@modular-frontend/core: 0.4.0` override on adoption. + +### 7. Small redirects on the host surface + +- **`aria-labelledby` → `aria-label`.** A headless host renders no title element, so + there is nothing for `aria-labelledby` to reference. The host derives the dialog's + accessible name from `entry.title` (resolved against the subject) and sets + `aria-label` — the same a11y outcome, zero styling opinion, works with zero app + effort. An app that renders its own labelled heading may override via its chrome. +- **Five named slots → one render-prop + `#empty`.** The requested + `#backdrop` / `#panel` / `#header` / `#header-extras` / `#default` decomposition + bakes a particular chrome anatomy into the framework surface. The host instead + renders exactly two elements (backdrop, dialog panel — the two the behaviour needs + to own), styleable via `backdropClass` / `panelClass`, and hands everything inside + the dialog to a single `#wrap` (Vue) / `wrap` (React) render-prop receiving + `{ entry, subject, close, isTop, children }` — the same shape `` + established. Header, icon badge, close button, `StepRestartControl`, + header-hosted `` — all of that is the app's `wrap`, where its design + system lives. `#empty` mirrors panels. +- **Ad-hoc `data-testid` props → stable namespaced data attributes.** The host stamps + `data-modular-overlay-backdrop`, `data-modular-overlay-panel`, and + `data-overlay-id=""` unconditionally. E2E suites get a stable modal + selector with no configuration and no drift; app-level `data-testid`s remain the + app's business inside its chrome. +- **`useModalBehaviour` → `useModalBehavior`.** House spelling (cf. "Default exit + behavior", `docs/` throughout). Same contract as requested: `{ active, onClose, +initialFocus? }` → `{ dialogRef, isTop }`, public in both bindings. + +## What we will build instead (proposed) + +Engine (`@modular-frontend/core`; components stay opaque `UiComponent`s): + +```ts +/** Superset of ComponentEntry — the same slot can serve resolveComponentRegistry + * and the overlay host during an incremental migration. */ +export interface OverlayEntry extends ComponentEntry< + UiComponent, + TMeta +> { + /** Accessible name for the dialog; subject-aware. The host wires it to aria-label. */ + readonly title?: string | ((subject: TSubject | null) => string); + /** Extra props merged with the injected { subject } by a binding's host. */ + readonly props?: Record; +} + +/** Phantom-typed handle, the definePanelGroup convention: slotKey + TSubject. */ +export function defineOverlayHost(slotKey: string): OverlayHostHandle; + +/** Pure pick-one: dedupe (shared collapseEntriesById; throw by default, first/last-wins + * escape hatch), null activeId → null, then a map lookup. Dangling id → null. */ +export function resolveOverlay( + entries: readonly OverlayEntry[], + activeId: string | null | undefined, + opts?: { onDuplicate?: OnDuplicateComponentId }, +): OverlayEntry | null; + +/** Resolve an entry's accessible name against the subject. */ +export function resolveOverlayTitle( + entry: OverlayEntry, + subject: TSubject | null, +): string | undefined; + +/** Pure LIFO stack with a subscribe seam — the shared "top overlay closes first" + * semantics both bindings' behaviour implementations consume. No DOM. */ +export function createOverlayStack(): OverlayStack; // push(): { release(); isTop() }, size, subscribe(cb) +``` + +Vue (`@modular-vue/vue`, re-exported from `@modular-vue/core`): + +- `useOverlay(host, activeId, opts?)` — `computed` over the slots context (both + runtime sources, the `usePanels` pattern) + the active id. +- `` — teleports (default `body`), renders + backdrop + dialog (`role="dialog"`, `aria-modal`, `aria-label` from `title`, + `tabindex="-1"`, the stable data attributes), mounts the one active entry inside + `ModuleErrorBoundary` (label `"Overlay"`), injects the subject as a prop **and** + via `provide` (`useOverlaySubject`), keys per `(entry.id, subjectKey)`, and owns + the behaviour via `useModalBehavior`. Emits `close` on backdrop click, Escape + (top-of-stack only) — state stays app-owned: the host _requests_ close, the app + clears the id. `#wrap` / `#empty` slots. +- `useOverlaySubject()`, `overlaySubjectKey`. +- `useModalBehavior({ active, onClose, initialFocus? })` → `{ dialogRef, isTop }` — + stack registration, focus trap + focus return, scroll lock (shared lock count), + Escape gated on top-of-stack. Public, for bespoke roots. + +React (`@modular-react/react`, same train): `useOverlay`, `` (portal, +`empty` / `wrap` props, `onClose`), `useOverlaySubject` / `OverlaySubjectContext`, +`useModalBehavior` — the same surface with `useMemo` / context / +`useSyncExternalStore` over the same engine stack. Angular: the engine part is done +for it; hosts when its gate opens. + +Module authors contribute through the existing field — no descriptor change: + +```ts +defineModule({ + id: "agent-windows", + slots: { + resultViews: [ + { + id: "test-report", + component: TestReportWindow, + title: (step: StepRef | null) => + step ? `Test report — step ${step.stepIndex}` : "Test report", + meta: { icon: "i-lucide-flask-conical", width: "wide" }, + }, + ], + }, +}); +``` + +Dropped from the proposed API, deliberately: `defineModule({ overlays })`, +`icon` as a blessed field, the five-slot chrome anatomy, the route-sync helper, the +`/testing` subpath, Nuxt manifest threading, and any router awareness. + +## What cat-factory's §6 acceptance criteria become + +| Their criterion | Disposition | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OverlayOutlet` / `useOverlaySubject` / `useModalBehaviour` importable from the Vue binding | **Met** (spelling: `useModalBehavior`); engine surface via `@modular-vue/core` re-export. | +| Managed modal shell, no router, `activeId = null` → `#empty` | **Met**; unit-covered in both bindings. | +| Pick-one vs `PanelsOutlet` render-all, demonstrated distinct | **Met**; documented side by side in the guide + comparison table. | +| Uniform structural behaviour incl. nested stacking, Escape top-first | **Met** — engine stack + per-binding behaviour; test-covered. | +| Subject-reactive, injected two ways, null-subject safe, re-keyed per subject | **Met** — the `usePanelSubject` guarantees, pick-one edition. | +| Consumer window via module registration, correct chrome, zero host edits | **Met** — it is the existing module → slot path. | +| Headless testing entry | **Met** by `resolveOverlay` being a pure engine export; no subpath. | +| `installModularApp` provides a typed overlay manifest | **Obsolete** — the slots context Nuxt already provides _is_ the manifest; nothing to thread. | +| Peer/dep alignment (`^0.4.0` family-wide, journeys-engine dep) | **Superseded** — already closed in-repo by the shared-peer change (`>=0.1.0 <2.0.0` unified; core promoted from dependency to peer in the engines); ships this train. | +| Docs | **Met** per the docs plan below. | +| Additive, no breaking changes | **Met** — and, as in slice 4, only met because the descriptor seam was rejected. | + +## Docs plan + +- New guide [`docs/overlay-host.md`](overlay-host.md): the entry shape, the resolver, + both bindings' hosts, the behaviour contract (stacking, focus, scroll, Escape, + a11y), the state-hosted modal recipe (app shell via `wrap` + a consumer + contributing a window for a custom kind), and the pick-one-modal vs + render-all-panels contrast. +- Extend the compositions README's "pick by problem shape" table four → five. +- Cross-links: `subject-panels.md` (the sibling), `remote-capability-manifests.md` + (pairing selects, overlays host), `framework-mode-nuxt.md` (consumer seam). + +## Decisions for the maintainer before implementation + +1. **Name** — "overlay" recommended: unclaimed in the family (checked: no package + uses it; "modal"/"dialog" appear only as prose), matches the `*Outlet` host + convention, and names the surface (a layer above the app) rather than one styling + (modal). The behaviour composable keeps "modal" (`useModalBehavior`) because + that _is_ the behaviour bundle's name. +2. **React host in the same train** — recommended yes, same grounds as slice 4. +3. **Dangling active id: warn vs throw** — dev-warn + render nothing (recommended; + the `pairById` "missing" stance — the id may name a window another deployment + ships), vs throw (rejected: turns a data-driven id into a crash). + +## Resolution — implemented + +All three decisions landed on the recommended option; the counter-proposal shipped +as-scoped (no new package, no descriptor change, no `/testing` subpath, no Nuxt +threading, no route helper): + +- **Engine** (`@modular-frontend/core`): `OverlayEntry`, + `OverlayHostHandle`, `defineOverlayHost`, `resolveOverlay` (dedupe via + the shared `collapseEntriesById` before the null guard; pick-one lookup; dangling + → `null`), `resolveOverlayTitle`, and `createOverlayStack` (pure LIFO with + `subscribe`, the shared stacking semantics). Re-exported by `@modular-react/core` + (`export *`) and `@modular-vue/core`. +- **Vue** (`@modular-vue/vue`, re-exported from `@modular-vue/core`): `useOverlay`, + `` (Teleport, backdrop `click.self` → `close`, managed behaviour, + `#wrap` / `#empty`, subject as prop + `provide`, per-subject keying, stable data + attributes, `ModuleErrorBoundary` label `"Overlay"`), `useOverlaySubject` / + `overlaySubjectKey`, `useModalBehavior`. +- **React** (`@modular-react/react`): `useOverlay`, `` (portal, + `empty` / `wrap` / `onClose` props), `useOverlaySubject` / `OverlaySubjectContext`, + `useModalBehavior` (`useSyncExternalStore` over the engine stack). +- **Docs**: [`docs/overlay-host.md`](overlay-host.md); comparison table extended + four → five; cross-links per the plan. +- **Gap D**: confirmed already closed by the shared-peer-dependency change; ships in + the same release train. Nothing further to widen. From 7813ce588142d243b740df38f81a854ad816008e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:40:38 +0000 Subject: [PATCH 2/6] feat(overlays): state-keyed overlay host (pick-one, modal, managed behaviour) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the counter-proposal from the cat-factory slice-5 triage: an overlay host — the pick-one, modal dual of the render-all panels — landed engine-first with both bindings in the same train. Contributions ride the existing module -> slot path; no new package, descriptor change, registry plugin, /testing subpath, or Nuxt manifest threading. Engine (@modular-frontend/core): - OverlayEntry — a ComponentEntry superset (title is first-class because the shell wires it to aria-label; icons/variants stay in opaque meta), OverlayHostHandle, defineOverlayHost - resolveOverlay: pure pick-one resolver (duplicate-id validation first via the shared collapseEntriesById so the stances can't drift; null id -> null; dangling id -> null, hosts dev-warn — pairById's "missing" stance), resolveOverlayTitle - createOverlayStack: pure LIFO stack with a subscribe seam — the shared "top overlay closes first" semantics both bindings consume - Re-exported by @modular-react/core (export *) and @modular-vue/core Vue (@modular-vue/vue, re-exported from @modular-vue/core): - useOverlay (computed over both runtime slot sources + the active id), (Teleport, backdrop click-self -> close emit, managed behaviour, #wrap / #empty slots, subject as prop + provide, per- (entry.id, subjectKey) keying, ModuleErrorBoundary label "Overlay", stable data-modular-overlay-* e2e hooks), useOverlaySubject, useModalBehavior — the behaviour standalone for bespoke roots - injectSlotsSource shared with panels (caller-labeled error) React (@modular-react/react): - The same surface with hooks/context/portal: useOverlay, (empty / wrap / onClose props), useOverlaySubject / OverlaySubjectContext, useModalBehavior (version-bump re-render over the engine stack — a sync-external-store snapshot would read the ticket ref before the activation effect assigns it) Docs: - docs/overlay-host.md guide (entry shape, resolver, both hosts, the behaviour contract, the app-shell recipe, overlays-vs-neighbours) - compositions README "pick by problem shape" table extended 4 -> 5; cross-links from subject-panels.md, remote-capability-manifests.md, and the Nuxt consumer-seam guide; CHANGELOG entry - Gap D verified already closed by the shared-peer change; the new surface is born onto the unified >=0.1.0 <2.0.0 range Verified: turbo typecheck/build/test green across the workspace (214 tasks); engine + Vue + React overlay suites cover pick-one selection, dangling/duplicate stances, stacking, Escape top-first, focus trap + return, scroll lock, subject injection + rekeying, error containment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012ezPjgGYoWHHg1vVNLZUQT --- CHANGELOG.md | 10 + docs/framework-mode-nuxt.md | 9 + docs/overlay-host.md | 289 ++++++++++++ docs/remote-capability-manifests.md | 2 +- docs/subject-panels.md | 8 +- packages/compositions/README.md | 31 +- packages/frontend-core/src/index.ts | 18 + packages/frontend-core/src/overlay.test-d.ts | 48 ++ packages/frontend-core/src/overlay.test.ts | 168 +++++++ packages/frontend-core/src/overlay.ts | 262 +++++++++++ packages/react/src/index.ts | 14 + packages/react/src/overlay.test.tsx | 295 ++++++++++++ packages/react/src/overlay.tsx | 427 +++++++++++++++++ packages/vue-core/src/index.ts | 24 + packages/vue/src/index.ts | 13 + packages/vue/src/overlay.test.ts | 359 ++++++++++++++ packages/vue/src/overlay.ts | 467 +++++++++++++++++++ packages/vue/src/panels.ts | 7 +- 18 files changed, 2431 insertions(+), 20 deletions(-) create mode 100644 docs/overlay-host.md create mode 100644 packages/frontend-core/src/overlay.test-d.ts create mode 100644 packages/frontend-core/src/overlay.test.ts create mode 100644 packages/frontend-core/src/overlay.ts create mode 100644 packages/react/src/overlay.test.tsx create mode 100644 packages/react/src/overlay.tsx create mode 100644 packages/vue/src/overlay.test.ts create mode 100644 packages/vue/src/overlay.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 598ec30..24931e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Per-package detail lives in the GitHub release tagged `@`. ## Unreleased +### Added — state-keyed overlay host (pick-one, modal) + +Implements the counter-proposal from the cat-factory slice-5 triage (`docs/overlay-host-triage.md`): the **overlay host**, a pick-**one**, app-state-keyed, open-contribution modal surface — the modal dual of the render-all panels. Modules (first-party and consumer alike) contribute _windows_ to a host's slot; application state names the one active window by id; the framework mounts it inside a managed modal shell owning the behaviour hand-rolled modals get wrong somewhere: teleport/portal, backdrop click-to-close, a shared overlay **stack** (nested overlays layer; Escape closes the top first), focus trap + focus return, body scroll lock, and a11y wiring (`role="dialog"` / `aria-modal` / `aria-label` from the entry's subject-aware `title`). Headless — the host renders only the backdrop and dialog elements (class props + stable `data-modular-overlay-*` e2e hooks); the app's chrome lives in a `wrap` render-prop/slot, and the host never closes itself (it _requests_ close; state stays app-owned). Contributions ride the existing module → slot path; no new registration seam, package, or descriptor change. + +- **`@modular-frontend/core`** — `OverlayEntry` (a superset of `ComponentEntry`, so a slot serving the pick-one pairing surface can serve an overlay host during an incremental migration; `title` is first-class because the shell wires it to `aria-label`, icons/variants stay in opaque `meta`), `OverlayHostHandle`, `defineOverlayHost(slotKey)` (phantom-typed handle, the `definePanelGroup` convention), `resolveOverlay(entries, activeId, opts?)` (duplicate-id validation first via the same shared collapse as `resolveComponentRegistry` / `resolvePanels`, then null-id → null, then pick-one lookup; a dangling id is data — `null`, host dev-warns — mirroring `pairById`'s `missing` stance), `resolveOverlayTitle`, and `createOverlayStack()` — a pure LIFO stack with a subscribe seam, the shared "top overlay closes first" semantics both bindings' behaviour implementations consume. Re-exported by `@modular-react/core` and `@modular-vue/core`. +- **`@modular-vue/vue`** (re-exported from `@modular-vue/core`) — `useOverlay` (a `computed` over both runtime slot sources + the active id, all inputs `MaybeRefOrGetter`), `` (Teleport with `to` / `teleportDisabled`, backdrop click-self → `close` emit, managed behaviour, `#wrap` / `#empty` slots, subject injected as a prop **and** via `provide`, per-`(entry.id, subjectKey)` keying, per-window `ModuleErrorBoundary` labeled `"Overlay"`), `useOverlaySubject` / `overlaySubjectKey`, and `useModalBehavior({ active, onClose, initialFocus? }) → { dialogRef, isTop }` — the managed behaviour standalone, for bespoke roots (full-bleed surfaces) that must stack/focus/scroll-lock correctly against hosted windows. +- **`@modular-react/react`** — the same surface with hooks/context/portal: `useOverlay`, `` (`empty` / `wrap` / `onClose` props, `to` / `portalDisabled`), `useOverlaySubject` / `OverlaySubjectContext`, `useModalBehavior`. +- **Docs** — new [`docs/overlay-host.md`](docs/overlay-host.md) guide (entry shape, resolver semantics, both hosts, the behaviour contract, the app-shell recipe, overlays-vs-neighbours); the compositions README's sibling-primitive comparison extended four → five; cross-links from `subject-panels.md`, `remote-capability-manifests.md`, and the Nuxt consumer-seam guide; the triage doc records what was accepted, redirected (no `defineModule({ overlays })` seam, no `/testing` subpath, no Nuxt manifest threading, no route-sync helper, Vue-only → both bindings), and why. +- **Version alignment** — the request's carry-over peer-range item ("Gap D") was already closed by the shared-peer-dependency change below; the overlay surface is born onto the unified `>=0.1.0 <2.0.0` range with no per-package edits. + ### Added — subject-keyed panels (render-all, predicate-gated) Implements the counter-proposal from the cat-factory slice-4 triage (`docs/subject-keyed-zones-triage.md`): **panels**, a named region whose module-contributed entries are selected at render time by a runtime **subject**, gated by per-entry `when(subject)` predicates, ordered, and rendered **all-matching** — the render-all, open-contribution sibling of the pick-one component-pairing helpers. Contributions ride the existing module → slot path; no new registration seam, package, or descriptor change. Deliberately _not_ named "zones" (that word already names two pick-one primitives). diff --git a/docs/framework-mode-nuxt.md b/docs/framework-mode-nuxt.md index 0b667fd..2f6eee8 100644 --- a/docs/framework-mode-nuxt.md +++ b/docs/framework-mode-nuxt.md @@ -347,6 +347,15 @@ code-shipped components](remote-capability-manifests.md#pairing-wire-safe-manife for that join — the components ship as code through the seam above; only the selecting id crosses the wire. +The seam also carries the two selection-hosted UI surfaces with no extra +wiring, because their hosts read the same slots context the layer already +provides: a consumer module can contribute inspector panels (render-all, +subject-keyed — [subject-panels.md](subject-panels.md)) or modal windows with +framework-managed chrome behaviour (pick-one, state-keyed — +[overlay-host.md](overlay-host.md)) to slots the layer's shell hosts with +`` / ``. No `installModularApp` change, no +manifest threading — the slots manifest _is_ the manifest. + ## Rules of thumb - **Registry as a factory, called in the plugin.** This is the one rule that diff --git a/docs/overlay-host.md b/docs/overlay-host.md new file mode 100644 index 0000000..17806e4 --- /dev/null +++ b/docs/overlay-host.md @@ -0,0 +1,289 @@ +# State-keyed overlay host (`useOverlay` / `` / `useModalBehavior`) + +A pick-**one**, app-state-keyed, open-contribution **modal host**: modules (first-party +and consumer alike) contribute _windows_ — a body component plus presentation metadata — +to a named overlay host; application state names the one active window by id; the +framework mounts it inside a **managed modal shell** that owns the behaviour every +hand-rolled modal gets subtly wrong somewhere: teleport/portal out of the document flow, +backdrop click-to-close, a shared overlay **stack** (nested overlays layer in open order; +Escape closes the top first), **focus trap + focus return**, **body scroll lock**, and +a11y wiring (`role="dialog"`, `aria-modal`, `aria-label` from the entry's title). + +This is the pick-one, modal sibling of [subject-keyed panels](subject-panels.md): + +| | Panels (``) | Overlay host (``) | +| ----------- | ----------------------------------- | ---------------------------------------------------- | +| Cardinality | **all** matching entries, ordered | **one** entry (or none) | +| Selection | subject + per-entry `when(subject)` | a caller-supplied **active id** (app state) | +| Placement | inline, in the document flow | teleported/portaled, above the app | +| Behaviour | none (plain rendering) | managed modal behaviour, structural | +| Subject | drives selection | payload only — threaded to the window, not selecting | + +Both are pure, read-side projections of an already-resolved slot. Neither registers +anything; contributions ride the existing `module.slots` path. The engine surface lives +in `@modular-frontend/core`, the hosts in `@modular-react/react` and `@modular-vue/vue` +(re-exported by `@modular-vue/core`; the React facade re-exports the engine wholesale). + +The host is **headless**: it renders exactly two elements — the backdrop and the dialog +panel — styleable via class props, and hands everything inside the dialog to your +chrome via a `wrap` render-prop/slot. Your design system supplies the pixels; the +framework supplies the behaviour. And the host **never closes itself**: backdrop click +and Escape _request_ close (`onClose` / `@close`); your handler clears the active id. +State stays app-owned, exactly like every other selection in the family. + +## When to use an overlay host + +- A surface of modal windows where **exactly one is open at a time**, selected by app + state (not by route): result views, detail dialogs, pickers, confirmation flows + contributed by feature modules. +- Consumer deployments must be able to **contribute their own window** (for a custom + entity/agent/block kind) and inherit correct modal behaviour with zero host edits. +- You are hand-rolling ``/portal + backdrop + Escape + focus code per window + today — that duplication (and its drift: missed focus traps, missing `role`, + inconsistent test ids) is exactly what the managed shell removes structurally. + +Not this primitive: render-all inline detail regions ([panels](subject-panels.md)), +route-driven surfaces (`ModuleRoute` / zones), stepped flows (journeys), multi-module +layouts with shared state (compositions). + +## The entry shape + +```ts +import type { OverlayEntry } from "@modular-frontend/core"; + +interface OverlayEntry extends ComponentEntry { + readonly id: string; // the id app state selects by; unique per host + readonly component: UiComponent; // the window body — opaque to the engine + readonly title?: string | ((subject: TSubject | null) => string); // → aria-label + readonly props?: Record; // merged under the injected { subject } + readonly meta?: TMeta; // app presentation (icon, width variant, …) for YOUR chrome +} +``` + +Two fields deserve a note: + +- **`title` is behaviour, `meta` is presentation.** The host resolves `title` against + the current subject and wires it to the dialog's `aria-label`, so every contributed + window ships a labelled dialog with zero app effort. Icons, size variants, badges — + anything only _your_ chrome renders — belongs in `meta`, which the framework carries + opaquely. +- **`OverlayEntry` is a superset of `ComponentEntry`**, so a slot that already serves + `resolveComponentRegistry` (the slice-2 pairing surface) can serve an overlay host + too — migrate a hand-rolled modal surface window by window, no flag-day. + +## Declaring a host + +```ts +import { defineOverlayHost } from "@modular-frontend/core"; // or your binding's core + +export interface StepRef { + readonly instanceId: string; + readonly stepIndex: number; +} + +/** Export once; import at the host and at every contributor. */ +export const resultViews = defineOverlayHost("resultViews"); +``` + +Like `definePanelGroup`, the handle's only runtime field is the slot key; the subject +type rides along as a phantom so `title(subject)` and `useOverlaySubject()` type-check +end to end. The subject is whatever state you thread to the active window — and it may +be `null` while a window is open (selection is by id; a window that reads its own store +simply ignores the subject). + +## Contributing a window (any module, including a consumer's) + +```ts +defineModule({ + id: "agent-windows", + version: "1.0.0", + slots: { + resultViews: [ + { + id: "test-report", + component: TestReportWindow, + title: (step: StepRef | null) => + step ? `Test report — step ${step.stepIndex}` : "Test report", + meta: { icon: "i-lucide-flask-conical", width: "wide" }, + }, + ], + }, +}); +``` + +A consumer module contributes the same way through its own registration path — no host +edit, no framework change. Namespace consumer ids (`"acme:security-report"`) so they +cannot collide with first-party ones; duplicate ids **throw** by default +(`onDuplicate: "first-wins" | "last-wins"` to shadow intentionally, same stance and same +shared implementation as `resolveComponentRegistry` / `resolvePanels`). + +## The resolver + +```ts +import { resolveOverlay } from "@modular-frontend/core"; + +const active = resolveOverlay(slotEntries, activeId, opts?); +// 1. duplicate-id validation (before anything else — a registration bug +// surfaces on first resolve, even while nothing is open) +// 2. activeId null/undefined → null (closed) +// 3. otherwise the entry with that id, or null for a DANGLING id +``` + +A dangling id — app state names a window no installed module provides — is **data, not +a crash** (it may name a window another deployment ships). The resolver returns `null`; +the hosts render nothing and `console.warn` in dev. This mirrors `pairById`'s `missing` +bucket. `resolveOverlay` is a pure engine export: unit-test your wiring by calling it +directly, no DOM, no testing subpath needed. + +## The host — Vue + +```vue + + + +``` + +Props: `host`, `activeId`, `subject`, `subjectKey`, `onDuplicate`, `to` (teleport +target, default `"body"`), `teleportDisabled`, `closeOnBackdrop` (default `true`), +`backdropClass`, `panelClass`. Emits `close`. Also exported: `useOverlay(host, +activeId, opts?)` (a `computed` over the slots context — every argument is a +`MaybeRefOrGetter`), `useOverlaySubject()` (reads the provided subject inside +the window, no prop-drilling; throws outside an outlet), `overlaySubjectKey`. + +The same reactivity boundary as `usePanels` applies: pass `activeId`/`subject` from +reactive state (a Pinia computed, a ref) — see the caveat in +[subject-panels.md](subject-panels.md) and [reactive-slots-vue.md](reactive-slots-vue.md). +Windows contributed through `dynamicSlots` update on both runtime slot sources. + +## The host — React + +```tsx +import { OverlayOutlet } from "@modular-react/react"; +import { resultViews } from "../overlay-hosts"; + + `${s?.instanceId}:${s?.stepIndex}`} + onClose={() => ui.closeResultView()} + backdropClassName="app-backdrop" + panelClassName="app-dialog" + wrap={({ entry, subject, close, children }) => ( + + {children} + + )} +/>; +``` + +Same surface with props instead of slots (`empty` / `wrap` are props), `to` / +`portalDisabled` for the portal target, and `useOverlay` / `useOverlaySubject` / +`OverlaySubjectContext` as the hook-shaped reads. + +## What the managed shell guarantees (both bindings) + +- **Teleported/portaled** to `body` by default; nothing renders while closed (the + `#empty` slot renders in place). +- **Backdrop click-self** requests close (`closeOnBackdrop={false}` to opt out). +- **One overlay stack per app.** Every open overlay — outlet-hosted or bespoke via + `useModalBehavior` — registers on one shared stack. Nested overlays layer in open + order; **Escape closes only the top**; when it closes, the one below becomes top. +- **Focus**: moved into the dialog on open (first focusable, else the dialog itself), + Tab-cycled within it while open, **returned to the opener** on close. +- **Scroll**: body scroll locked while any overlay is open, restored when the last + closes. +- **A11y**: `role="dialog"`, `aria-modal="true"`, `aria-label` from `title` resolved + against the subject, `tabindex="-1"` on the panel. +- **Stable e2e hooks**: `data-modular-overlay-backdrop`, `data-modular-overlay-panel`, + `data-overlay-id=""` — no configuration, no per-app drift. +- **Containment**: the window body renders inside `ModuleErrorBoundary` (label + `"Overlay"`), so a throwing window cannot take down the shell. +- **Subject injection**: as a `subject` prop _and_ via `provide`/context + (`useOverlaySubject`), keyed per `(entry.id, subjectKey)` so switching subjects + remounts the window instead of leaking state across opens — the `subjectKey` + contract from panels, pick-one edition. + +## `useModalBehavior` — the behaviour without the shell + +For a surface that needs a bespoke root (a full-bleed detail view, a hand-styled +transition wrapper) but must behave like a first-class overlay: + +```ts +const { dialogRef, isTop } = useModalBehavior({ + active: () => ui.detailOpen, // React: a boolean + onClose: () => ui.closeDetail(), + // initialFocus?: element to focus on activation +}); +// Put dialogRef on your root (give it tabindex="-1"); style it however you like. +``` + +Same stack, same focus/scroll/Escape rules as the outlet — a bespoke overlay and a +hosted window layer and close correctly against each other because they share the one +stack. (`` is implemented on exactly this composable/hook.) + +## End-to-end: an app shell over the host + +The intended division of labor, using the agent-run "result windows" shape as the +example: + +1. The app declares the host (`defineOverlayHost("resultViews")`) and mounts + **one** `` in its shell, driving `activeId` from its ui store and + styling via `backdropClass` / `panelClass` / `wrap`. The `wrap` chrome renders the + header (icon and width variant from `entry.meta`, title text, close button) — and + can host cross-cutting header regions with a nested `` keyed by the + step, so any module can add a header control to every window without touching the + shell ([panels](subject-panels.md) doing what panels do). +2. Each window module contributes `{ id, component, title, meta }` to the slot and + keeps its body logic; all ``/backdrop/Escape/focus code it carried is + deleted — the shell owns it now. +3. A consumer deployment ships `registerAppModule`-style modules contributing windows + for its custom kinds, paired against the same wire-delivered id space + ([remote-capability-manifests.md](remote-capability-manifests.md)) — and inherits + the full behaviour contract with zero host edits. + +## Overlays vs the neighbours + +| Question | Use | +| ----------------------------------------------- | --------------------------------------------------------------------------------- | +| One window, selected by app state, modal | **Overlay host** (this guide) | +| Many detail panels for a selection, inline | [Panels](subject-panels.md) | +| One component per id, no hosting opinion | Pairing (`resolveComponentRegistry`) | +| Route-attached widget | `module.zones` / route zones | +| Stepped multi-module flow (possibly in a modal) | Journeys — hosted _inside_ an overlay window or a bespoke `useModalBehavior` root | + +## Testing + +- **Engine**: call `resolveOverlay(entries, activeId)` directly — pure, no DOM. The + duplicate-id and dangling-id stances are deterministic and unit-testable. +- **Hosts**: mount `` with a provided slots context (see + `packages/vue/src/overlay.test.ts` / `packages/react/src/overlay.test.tsx` for the + provide pattern). Vue tests can pass `teleport-disabled` to keep the shell inside + the wrapper — but attach to the document for focus assertions, since `focus()` only + moves `document.activeElement` for connected elements. +- **E2e**: select on the stable `data-modular-overlay-*` hooks rather than app test + ids, so suites survive chrome restyling. diff --git a/docs/remote-capability-manifests.md b/docs/remote-capability-manifests.md index ca07366..52eba23 100644 --- a/docs/remote-capability-manifests.md +++ b/docs/remote-capability-manifests.md @@ -93,7 +93,7 @@ Sometimes a manifest entry needs to select **which component** renders it — a This is the sanctioned shape of "backend data lights up a locally-installed view." It is deliberately narrow: read [Why this isn't the anti-pattern](#why-this-isnt-the-anti-pattern) below before reaching for it, because a sloppy version — a shell-owned `string → component` map used to dodge shipping code — is exactly the [anti-pattern](#anti-patterns-to-avoid) this guide otherwise warns against. -> **Pairing is pick-one; panels are render-all.** `resolveComponentRegistry` / `pairById` select **one** component per id. When a region should instead show **every** contribution that applies to a runtime selection — an inspector rail where several detail panels light up for the selected item, keyed by a `when(subject)` predicate rather than an id — that is the render-all sibling, [subject-keyed panels](subject-panels.md). Same slot substrate, different reduction. +> **Pairing is pick-one; panels are render-all.** `resolveComponentRegistry` / `pairById` select **one** component per id. When a region should instead show **every** contribution that applies to a runtime selection — an inspector rail where several detail panels light up for the selected item, keyed by a `when(subject)` predicate rather than an id — that is the render-all sibling, [subject-keyed panels](subject-panels.md). Same slot substrate, different reduction. And when the id-selected component should mount as a **modal window** with managed chrome behaviour (the wire-delivered id names which window opens), that is the [state-keyed overlay host](overlay-host.md) — pairing's selection semantics plus a managed modal shell; `OverlayEntry` is a superset of `ComponentEntry`, so the same slot serves both. ### The shape diff --git a/docs/subject-panels.md b/docs/subject-panels.md index 789efc6..62f827c 100644 --- a/docs/subject-panels.md +++ b/docs/subject-panels.md @@ -34,9 +34,13 @@ Reach for panels when **all** of these hold: If instead you need to select **one** component by a data id (a wire manifest naming which view to render), that is the pick-one pairing surface, not panels — -see [Panels vs component pairing](#panels-vs-component-pairing). +see [Panels vs component pairing](#panels-vs-component-pairing). And if that one +selected component should mount as a **modal window** with framework-managed +chrome behaviour (focus trap, scroll lock, stacked Escape, a11y), that is the +pick-one, modal sibling of panels: the +[state-keyed overlay host](overlay-host.md) (``). -For the full "which primitive?" matrix (route/module zones · panels · +For the full "which primitive?" matrix (route/module zones · panels · overlays · compositions · journeys), see [Comparison with sibling primitives](../packages/compositions/README.md#comparison-with-sibling-primitives). diff --git a/packages/compositions/README.md b/packages/compositions/README.md index 0b6f64a..402ff10 100644 --- a/packages/compositions/README.md +++ b/packages/compositions/README.md @@ -1148,28 +1148,29 @@ The runtime defers disposal one microtask so React 18/19 StrictMode's mount/unmo ## Comparison with sibling primitives -Four primitives in the framework arrange modules on a screen. Pick by problem shape: contributing one widget to a route vs. contributing many detail panels keyed by a selection vs. coordinating several modules in parallel vs. driving a stepped flow. - -| | `module.zones` (route-level) | **panels** (`usePanels` / ``) | `@modular-react/compositions` (this package) | `@modular-react/journeys` | -| ------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| **Primary use** | A foreign module contributes a single component to a named slot on the active route | Modules contribute detail panels to a named region, selected by a runtime **subject**, rendered all-matching | Multi-module screen layout with shared state, rendered in parallel | Multi-module stepped workflow with typed transitions | -| **Cardinality** | One contribution per zone (most-recent active wins) | **All** matching contributions, ordered (render-all) | N panels mounted simultaneously, one per declared zone | One step rendered at a time | -| **Selection** | active route / active module | caller-supplied **subject** + per-panel `when(subject)` predicate | per-zone `select(state)` | current step in the graph | -| **Declared by** | `defineModule({ zones })` + route `staticData` | `module.slots` entries + `definePanelGroup(slotKey)` | `defineComposition({ zones })` | `defineJourney({ start, transitions })` | -| **Contributed by** | route `handle`/`meta`, module descriptor (closed) | any module — first-party **or** consumer (**open**) | the composition definition (closed) | the journey definition (closed) | -| **State model** | None — slots map id → component | None — a pure projection of the slot entries against the subject | Scoped store; selectors project state into zones | Step + accumulated state; transitions advance step | -| **Read in shell** | `useZones` / `useActiveZones` | `usePanels` / `` | `` render-prop with zone names | `` (leaf-walk through current step) | -| **Panel ↔ host data flow** | n/a | Subject injected as a `subject` prop **and** via context (`usePanelSubject`) | Stores (`ReadableStore`/`WritableStore` via `input`) **or** hooks (`useCompositionState`/`Dispatch`/`Emit`/`Zone`) | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | -| **Validation** | Slot-name + route lookup | Duplicate-id check (throw by default; `first-wins`/`last-wins` to shadow) | Zone contracts (spot-check) + `moduleCompat` | Reachability + transition exhaustiveness + contracts | -| **Composition with the other** | n/a | A panel is any component — it can itself host a `` / `` | A zone can mount `` via `kind: "journey"` | A journey step can render `` like any other component | +Five primitives in the framework arrange modules on a screen. Pick by problem shape: contributing one widget to a route vs. contributing many detail panels keyed by a selection vs. hosting one state-selected modal window vs. coordinating several modules in parallel vs. driving a stepped flow. + +| | `module.zones` (route-level) | **panels** (`usePanels` / ``) | **overlays** (`useOverlay` / ``) | `@modular-react/compositions` (this package) | `@modular-react/journeys` | +| ------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| **Primary use** | A foreign module contributes a single component to a named slot on the active route | Modules contribute detail panels to a named region, selected by a runtime **subject**, rendered all-matching | Modules contribute modal **windows** to a host; app state names the one open window by **id** | Multi-module screen layout with shared state, rendered in parallel | Multi-module stepped workflow with typed transitions | +| **Cardinality** | One contribution per zone (most-recent active wins) | **All** matching contributions, ordered (render-all) | **One** entry (or none), pick-one | N panels mounted simultaneously, one per declared zone | One step rendered at a time | +| **Selection** | active route / active module | caller-supplied **subject** + per-panel `when(subject)` predicate | caller-supplied **active id** (subject is payload, not selector) | per-zone `select(state)` | current step in the graph | +| **Declared by** | `defineModule({ zones })` + route `staticData` | `module.slots` entries + `definePanelGroup(slotKey)` | `module.slots` entries + `defineOverlayHost(slotKey)` | `defineComposition({ zones })` | `defineJourney({ start, transitions })` | +| **Contributed by** | route `handle`/`meta`, module descriptor (closed) | any module — first-party **or** consumer (**open**) | any module — first-party **or** consumer (**open**) | the composition definition (closed) | the journey definition (closed) | +| **State model** | None — slots map id → component | None — a pure projection of the slot entries against the subject | None — a pure projection against the active id, plus managed modal _behaviour_ (stack/focus/scroll/Escape) | Scoped store; selectors project state into zones | Step + accumulated state; transitions advance step | +| **Read in shell** | `useZones` / `useActiveZones` | `usePanels` / `` | `useOverlay` / `` | `` render-prop with zone names | `` (leaf-walk through current step) | +| **Panel ↔ host data flow** | n/a | Subject injected as a `subject` prop **and** via context (`usePanelSubject`) | Subject injected as a `subject` prop **and** via context (`useOverlaySubject`) | Stores (`ReadableStore`/`WritableStore` via `input`) **or** hooks (`useCompositionState`/`Dispatch`/`Emit`/`Zone`) | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | +| **Validation** | Slot-name + route lookup | Duplicate-id check (throw by default; `first-wins`/`last-wins` to shadow) | Same duplicate-id stance; dangling active id dev-warns and renders nothing | Zone contracts (spot-check) + `moduleCompat` | Reachability + transition exhaustiveness + contracts | +| **Composition with the other** | n/a | A panel is any component — it can itself host a `` / `` | A window is any component — a modal-hosted journey is a `` inside a window | A zone can mount `` via `kind: "journey"` | A journey step can render `` like any other component | Choose by problem shape: - **`module.zones`** when a route already exists and another module needs to contribute one widget (a header chip, a command, a sidebar entry) — the contribution is static and tied to the active route. - **Panels** when a region shows **detail for a current selection** and any module (including a consumer's) should be able to add a panel for a subject type it owns — filtered by `when(subject)`, ordered, and rendered **all-matching**. See [Subject-keyed panels](../../docs/subject-panels.md). +- **Overlays** when app state opens **one modal window at a time** and any module (including a consumer's) should be able to contribute a window that inherits managed modal behaviour — teleport, backdrop, focus trap + return, scroll lock, stacked Escape, a11y. See [State-keyed overlay host](../../docs/overlay-host.md). - **Compositions** when one screen layout coordinates several modules with **shared state** — multiple panels mounted in parallel, each reactive to a per-instance scoped store. - **Journeys** when the screen is a **flow problem** — do A, then B, then maybe C, with typed handoffs between steps. -The four are complementary, not competing. A screen can use all of them: a route hosting a `` whose `inspector` zone hosts a ``, an inspector rail driven by `` keyed on the selected item, while the route itself contributes a `module.zones` chip to the shell header. +The five are complementary, not competing. A screen can use all of them: a route hosting a `` whose `inspector` zone hosts a ``, an inspector rail driven by `` keyed on the selected item, an `` opening the selected item's detail window above it all, while the route itself contributes a `module.zones` chip to the shell header. > **Why panels are not a third kind of "zone".** Panels are subject-keyed, render-all, and open to consumer contribution — different on every axis from both route/module zones (route-keyed, pick-one) and composition zones (state-keyed, pick-one, closed). Overloading "zone" a third time would make `useZones` (route-driven) and a subject-driven `useZone` one letter apart with disjoint meanings, so the render-all primitive gets its own vocabulary: **panels**. diff --git a/packages/frontend-core/src/index.ts b/packages/frontend-core/src/index.ts index f719888..e3afa42 100644 --- a/packages/frontend-core/src/index.ts +++ b/packages/frontend-core/src/index.ts @@ -111,6 +111,24 @@ export type { export { definePanelGroup, resolvePanels } from "./panels.js"; export type { PanelEntry, PanelGroupHandle } from "./panels.js"; +// State-keyed overlay host — the pick-one, modal sibling of the render-all +// panels: one entry, named by a caller-supplied active id, mounted by a +// binding's managed modal host. A pure resolver over a slot's entries plus the +// shared overlay-stack semantics; the thin per-binding hosts (`useOverlay` / +// `` / `useModalBehavior`) live in the framework packages. +export { + createOverlayStack, + defineOverlayHost, + resolveOverlay, + resolveOverlayTitle, +} from "./overlay.js"; +export type { + OverlayEntry, + OverlayHostHandle, + OverlayStack, + OverlayStackTicket, +} from "./overlay.js"; + // Validation export { validateNoDuplicateIds, diff --git a/packages/frontend-core/src/overlay.test-d.ts b/packages/frontend-core/src/overlay.test-d.ts new file mode 100644 index 0000000..b9c1216 --- /dev/null +++ b/packages/frontend-core/src/overlay.test-d.ts @@ -0,0 +1,48 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { + defineOverlayHost, + resolveOverlay, + type OverlayEntry, + type OverlayHostHandle, +} from "./overlay.js"; +import type { ComponentEntry } from "./component-registry.js"; +import type { UiComponent } from "./ui-types.js"; + +interface StepRef { + readonly instanceId: string; + readonly stepIndex: number; +} + +describe("overlay types", () => { + it("defineOverlayHost pins the subject type on the handle", () => { + const host = defineOverlayHost("resultViews"); + expectTypeOf(host).toEqualTypeOf>(); + // The phantom subject carrier is typed but never present at runtime. + expectTypeOf(host.__subject).toEqualTypeOf(); + }); + + it("an entry's function title receives the host's subject, nullable", () => { + const entry: OverlayEntry = { + id: "test-report", + component: () => null, + title: (subject) => { + // Selection is by id, so the subject may be absent while open. + expectTypeOf(subject).toEqualTypeOf(); + return subject ? `Step ${subject.stepIndex}` : "Test report"; + }, + }; + expectTypeOf(entry.props).toEqualTypeOf | undefined>(); + }); + + it("an OverlayEntry is assignable where a ComponentEntry is expected (superset)", () => { + const entry: OverlayEntry = { id: "a", component: () => null }; + // The same slot can serve resolveComponentRegistry and the overlay host. + expectTypeOf(entry).toMatchTypeOf>(); + }); + + it("resolveOverlay accepts a nullable id and returns an entry typed to the subject", () => { + const entries: OverlayEntry[] = []; + const activeId = null as string | null; + expectTypeOf(resolveOverlay(entries, activeId)).toEqualTypeOf | null>(); + }); +}); diff --git a/packages/frontend-core/src/overlay.test.ts b/packages/frontend-core/src/overlay.test.ts new file mode 100644 index 0000000..3d19d5b --- /dev/null +++ b/packages/frontend-core/src/overlay.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi } from "vitest"; +import { + createOverlayStack, + defineOverlayHost, + resolveOverlay, + resolveOverlayTitle, + type OverlayEntry, +} from "./overlay.js"; + +// A stand-in for an opaque framework component. The resolver never inspects it. +type FakeComponent = { readonly name: string }; +const comp = (name: string): FakeComponent => ({ name }); + +// The subject the agent-run result windows key on. +interface StepRef { + readonly instanceId: string; + readonly stepIndex: number; +} + +const win = ( + id: string, + extra?: Partial, "id" | "component">>, +): OverlayEntry => ({ id, component: comp(id), ...extra }); + +describe("defineOverlayHost", () => { + it("carries the slot key as its only runtime field", () => { + const host = defineOverlayHost("resultViews"); + expect(host.slotKey).toBe("resultViews"); + expect(Object.keys(host)).toEqual(["slotKey"]); + }); +}); + +describe("resolveOverlay", () => { + it("picks the one entry the active id names", () => { + const entries = [win("test-report"), win("merger-verdict")]; + expect(resolveOverlay(entries, "merger-verdict")?.id).toBe("merger-verdict"); + }); + + it("resolves exactly one entry, never a concatenation (pick-one, not render-all)", () => { + const entries = [win("a"), win("b"), win("c")]; + const active = resolveOverlay(entries, "b"); + expect(active).not.toBeInstanceOf(Array); + expect(active?.id).toBe("b"); + }); + + it("returns null for a null or undefined active id", () => { + const entries = [win("a")]; + expect(resolveOverlay(entries, null)).toBeNull(); + expect(resolveOverlay(entries, undefined)).toBeNull(); + }); + + it("returns null for a dangling id (data, not a crash)", () => { + expect(resolveOverlay([win("a")], "not-registered")).toBeNull(); + expect(resolveOverlay([], "anything")).toBeNull(); + }); + + it("throws on duplicate ids by default, even while nothing is open", () => { + const entries = [win("a"), win("a")]; + // Before the null-id guard: the registration bug surfaces deterministically + // on first resolve, not only once a window is opened. + expect(() => resolveOverlay(entries, null)).toThrowError(/duplicate overlay id "a"/); + expect(() => resolveOverlay(entries, "a")).toThrowError(/duplicate overlay id "a"/); + }); + + it("supports first-wins / last-wins shadowing", () => { + const first = win("a", { title: "first" }); + const last = win("a", { title: "last" }); + expect(resolveOverlay([first, last], "a", { onDuplicate: "first-wins" })?.title).toBe("first"); + expect(resolveOverlay([first, last], "a", { onDuplicate: "last-wins" })?.title).toBe("last"); + }); + + it("does not mutate the input entries", () => { + const entries = [win("a"), win("b")]; + const before = [...entries]; + resolveOverlay(entries, "a"); + expect(entries).toEqual(before); + }); +}); + +describe("resolveOverlayTitle", () => { + const step: StepRef = { instanceId: "i1", stepIndex: 3 }; + + it("returns a string title as-is", () => { + expect(resolveOverlayTitle(win("a", { title: "Test report" }), step)).toBe("Test report"); + }); + + it("calls a function title with the subject", () => { + const entry = win("a", { title: (s) => (s ? `Step ${s.stepIndex}` : "No step") }); + expect(resolveOverlayTitle(entry, step)).toBe("Step 3"); + }); + + it("passes null through to a function title (selection is by id, not subject)", () => { + const entry = win("a", { title: (s) => (s ? "with" : "without") }); + expect(resolveOverlayTitle(entry, null)).toBe("without"); + }); + + it("resolves to undefined when the entry has no title", () => { + expect(resolveOverlayTitle(win("a"), step)).toBeUndefined(); + }); +}); + +describe("createOverlayStack", () => { + it("the newest live registration is the top", () => { + const stack = createOverlayStack(); + const a = stack.push(); + expect(a.isTop()).toBe(true); + const b = stack.push(); + expect(a.isTop()).toBe(false); + expect(b.isTop()).toBe(true); + expect(stack.size).toBe(2); + }); + + it("releasing the top re-tops the one below (Escape closes top-first, then the next)", () => { + const stack = createOverlayStack(); + const a = stack.push(); + const b = stack.push(); + b.release(); + expect(a.isTop()).toBe(true); + expect(b.isTop()).toBe(false); + expect(stack.size).toBe(1); + }); + + it("supports out-of-order release (a lower overlay closing under a higher one)", () => { + const stack = createOverlayStack(); + const a = stack.push(); + const b = stack.push(); + const c = stack.push(); + b.release(); + expect(c.isTop()).toBe(true); + expect(a.isTop()).toBe(false); + expect(stack.size).toBe(2); + }); + + it("release is idempotent", () => { + const stack = createOverlayStack(); + const a = stack.push(); + const b = stack.push(); + a.release(); + a.release(); + expect(stack.size).toBe(1); + expect(b.isTop()).toBe(true); + }); + + it("notifies subscribers on push and release, and honors unsubscribe", () => { + const stack = createOverlayStack(); + const listener = vi.fn(); + const unsubscribe = stack.subscribe(listener); + const a = stack.push(); + expect(listener).toHaveBeenCalledTimes(1); + a.release(); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + stack.push(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("a listener unsubscribing mid-notify does not skip its peers", () => { + const stack = createOverlayStack(); + const calls: string[] = []; + const unsubA = stack.subscribe(() => { + calls.push("a"); + unsubA(); + }); + stack.subscribe(() => calls.push("b")); + stack.push(); + expect(calls).toEqual(["a", "b"]); + }); +}); diff --git a/packages/frontend-core/src/overlay.ts b/packages/frontend-core/src/overlay.ts new file mode 100644 index 0000000..4f7f5ac --- /dev/null +++ b/packages/frontend-core/src/overlay.ts @@ -0,0 +1,262 @@ +/** + * State-keyed overlay host — a pick-**one**, app-state-keyed, open-contribution + * projection of a resolved slot, plus the pure stacking semantics behind a + * framework-managed modal surface. + * + * An *overlay host* is a named surface (a slot key) whose module-contributed + * windows are selected at render time by a caller-supplied **active id** — a + * piece of application state such as "the result view the user opened". Exactly + * one entry (or none) is active at a time; a binding's `` mounts + * it inside a managed modal shell (teleported, backdrop-closed, focus-trapped, + * scroll-locked, stacked, a11y-wired). Contributions come from first-party and + * consumer modules alike, through the existing `module.slots` path — overlays + * introduce no new registration seam. + * + * This is the pick-one, modal sibling of the render-all, inline **panels** + * primitive ({@link resolvePanels} / `definePanelGroup`): panels render every + * contribution whose `when(subject)` passes, inline in the document flow; + * an overlay host renders the one contribution named by the active id, outside + * the flow, with managed behaviour. Same slot substrate, opposite reduction on + * both axes — a different primitive. + * + * Like the pairing and panels helpers, {@link resolveOverlay} is a **pure, + * read-side projection of an already-resolved slot**: it registers nothing and + * introduces no module type. The thin per-binding hosts (`useOverlay` / + * `` / `useOverlaySubject` / `useModalBehavior`) live in + * `@modular-react/react` and `@modular-vue/vue`. + * + * Components stay opaque {@link UiComponent}s here (per PR-01) — the engine + * never renders, calls, or inspects them. + * + * See the "State-keyed overlay host" guide (`docs/overlay-host.md`) for the + * full pattern, both bindings' hosts, and the behaviour contract. + */ + +import { + collapseEntriesById, + type ComponentEntry, + type OnDuplicateComponentId, +} from "./component-registry.js"; +import type { UiComponent } from "./ui-types.js"; + +/** + * One overlay window contributed to an overlay host, addressed by `id`. + * + * A deliberate **superset of {@link ComponentEntry}** (id + component + meta), + * so a slot that already serves the pick-one pairing surface + * (`resolveComponentRegistry`) can serve an overlay host too — an app migrating + * a hand-rolled modal surface onto the host converts window by window, not in a + * flag-day. + * + * Placed in a module's `slots` under the host's slot key — a slot entry is + * opaque by design, so contributing typed `OverlayEntry` objects is exactly + * what slots are for. + * + * @typeParam TSubject - the application-state value threaded to the active + * window (a selected step, a block, …). The {@link OverlayHostHandle} carries + * it so `title(subject)` and a binding's injected subject are typed end to end. + * The subject is deliberately allowed to be `null` while the overlay is open — + * selection is by id, and a window may read its own store instead. + * @typeParam TMeta - app-defined presentation metadata (icon name, width + * variant, …), carried opaquely for the app's chrome to interpret. Behaviour + * the *host* needs is first-class (`title`); presentation the *app* renders is + * `meta`. + */ +export interface OverlayEntry extends ComponentEntry< + UiComponent, + TMeta +> { + /** + * Accessible name for the dialog, resolved against the current subject (see + * {@link resolveOverlayTitle}). A binding's host wires it to the dialog + * element's `aria-label`, so a contributed window gets a labelled dialog with + * zero app effort. Optional — an app whose chrome renders its own labelled + * heading may omit it. + */ + readonly title?: string | ((subject: TSubject | null) => string); + /** + * Extra props merged with the injected `{ subject }` by a binding's host. The + * engine never reads these — it only carries them. The injected subject wins: + * a `subject` key placed here is overwritten by the host's own injection. + */ + readonly props?: Record; +} + +/** + * A lightweight, phantom-typed token identifying an overlay host: its slot key + * plus the subject type its windows receive. Mirrors the handle convention of + * `definePanelGroup` / `defineJourneyHandle` — the only runtime field is + * `slotKey`; `__subject` is phantom (never read at runtime), carrying + * `TSubject` so a binding's `useOverlay(host, activeId)` and + * `useOverlaySubject()` type-check against the host. + */ +export interface OverlayHostHandle { + /** The slot key modules contribute {@link OverlayEntry} objects under. */ + readonly slotKey: string; + /** Phantom carrier for `TSubject` — never present at runtime. */ + readonly __subject?: TSubject; +} + +/** + * Declare an overlay host over a slot key, pinning the subject type. + * + * Runtime identity is just `{ slotKey }`; the return type carries `TSubject` so + * every downstream call site (the host outlet, the module contributions, nested + * `useOverlaySubject` reads) shares one typed handle instead of restating the + * subject type. + * + * @example + * ```ts + * // Shared handle — export once, import at both the host and the contributors. + * export const resultViews = defineOverlayHost("resultViews"); + * ``` + */ +export function defineOverlayHost(slotKey: string): OverlayHostHandle { + return { slotKey }; +} + +/** + * Resolve a host's raw slot entries against the active id: dedupe by id, then + * pick the one entry the id names (or none). + * + * Semantics, in order: + * + * 1. **Duplicate ids throw by default.** Two modules contributing the same + * window id is a bug, mirroring `resolveComponentRegistry` / + * `resolvePanels` (the collapse is the same shared implementation, so the + * stances cannot drift). `onDuplicate: "last-wins"` / `"first-wins"` opt out + * when a deployment intentionally shadows a first-party window. Validation + * runs over *all* contributions, before the null-id guard, so a + * registration bug surfaces deterministically on first resolve — including + * the usual initial state where nothing is open. + * 2. **Null active id → null.** Nothing open resolves to no entry. + * 3. **Pick-one lookup.** The entry whose `id` equals `activeId`, or `null` + * when the id names nothing — a *dangling* reference. Dangling is data, not + * a crash (the id may name a window another deployment ships), matching + * `pairById`'s "missing" bucket stance: the binding hosts dev-warn on it and + * render nothing. + * + * Pure over its inputs — a Vue `computed` / React `useMemo` re-runs it on + * reactive change with no glue, and unit tests call it with no DOM. + * + * @example + * ```ts + * const active = resolveOverlay(slots.resultViews, ui.resultView?.view ?? null) + * // → the one active window's entry, or null + * ``` + */ +export function resolveOverlay( + entries: readonly OverlayEntry[], + activeId: string | null | undefined, + opts?: { onDuplicate?: OnDuplicateComponentId }, +): OverlayEntry | null { + // Validate registration before anything selection-dependent: duplicate ids + // are a contribution bug whether or not something is open, so the throw (or + // collapse) happens deterministically on first resolve. + const { byId } = collapseEntriesById( + entries, + opts?.onDuplicate ?? "throw", + (id) => + new Error( + `[@modular-frontend/core] resolveOverlay: duplicate overlay id "${id}". ` + + `Two modules contributed the same window id to one host. Namespace consumer ids ` + + `(e.g. "acme:security-report") so they can't collide with first-party ones, or pass ` + + `onDuplicate: "last-wins" / "first-wins" to intentionally shadow an id.`, + ), + ); + + if (activeId === null || activeId === undefined) return null; + return byId.get(activeId) ?? null; +} + +/** + * Resolve an entry's accessible name against the current subject: a function + * `title` is called with the subject (which may be `null` — selection is by id, + * not by subject), a string is returned as-is, an absent title resolves to + * `undefined`. Shared by both bindings' hosts so the labelling rule cannot + * drift. + */ +export function resolveOverlayTitle( + entry: OverlayEntry, + subject: TSubject | null, +): string | undefined { + if (entry.title === undefined) return undefined; + return typeof entry.title === "function" ? entry.title(subject) : entry.title; +} + +/** + * A live registration on an {@link OverlayStack}: `release()` when the overlay + * deactivates (idempotent), `isTop()` to ask whether this overlay is the + * topmost live registration — the one an Escape press should close. + */ +export interface OverlayStackTicket { + /** Remove this registration from the stack. Safe to call more than once. */ + release(): void; + /** Whether this registration is currently the top of the stack. `false` after release. */ + isTop(): boolean; +} + +/** + * The shared stacking semantics behind "the top overlay closes first" and + * "nested overlays layer correctly": a LIFO stack of live registrations with a + * subscribe seam so a binding can make `isTop` reactive (a Vue `ref` bumped on + * notify, a React `useSyncExternalStore`). Pure data — no DOM, no listeners of + * its own — which is what lets it live in the neutral engine while the + * DOM-touching behaviour (key events, focus, scroll) stays per binding. Both + * bindings consume one module-level stack instance, so every overlay in an app + * — outlet-hosted or bespoke via `useModalBehavior` — shares one ordering. + */ +export interface OverlayStack { + /** Register an activating overlay; the newest registration is the top. */ + push(): OverlayStackTicket; + /** Number of live registrations (0 = no overlay open). */ + readonly size: number; + /** + * Subscribe to stack changes (any push or release). Returns an unsubscribe + * function. Listeners are notified after the change is applied. + */ + subscribe(listener: () => void): () => void; +} + +/** + * Create an {@link OverlayStack}. Bindings create one per module scope; apps + * only need their own for bespoke coordination outside the shipped hosts. + */ +export function createOverlayStack(): OverlayStack { + // Tokens are per-ticket object identities; order of the array is stack order. + const live: object[] = []; + const listeners = new Set<() => void>(); + + const notify = () => { + // Snapshot so a listener mutating the set mid-notify (unsubscribing itself + // or a peer) can't affect this round's delivery. + const snapshot = Array.from(listeners); + for (const listener of snapshot) listener(); + }; + + return { + push() { + const token = {}; + live.push(token); + notify(); + return { + release() { + const at = live.indexOf(token); + if (at === -1) return; // idempotent + live.splice(at, 1); + notify(); + }, + isTop() { + return live.length > 0 && live[live.length - 1] === token; + }, + }; + }, + get size() { + return live.length; + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 59e96a3..f261db0 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -72,6 +72,20 @@ export { ModuleErrorBoundary } from "./error-boundary.js"; export { usePanels, PanelsOutlet, usePanelSubject, PanelSubjectContext } from "./panels.js"; export type { PanelsOutletProps, PanelWrapArgs } from "./panels.js"; +// React host for the state-keyed overlay — the pick-one, modal sibling of the +// render-all panels. The pure resolver (`resolveOverlay` / `defineOverlayHost`) +// and the shared stack live in `@modular-react/core`; this binding adds the +// `useOverlay` hook, the managed `` modal host, +// `useOverlaySubject`, and the standalone `useModalBehavior`. +export { + useOverlay, + OverlayOutlet, + useOverlaySubject, + OverlaySubjectContext, + useModalBehavior, +} from "./overlay.js"; +export type { OverlayOutletProps, OverlayWrapArgs } from "./overlay.js"; + // React-specific: module-exit plumbing (hosted by ModuleTab / ModuleRoute; // the "step 0" pattern — modules fire exits outside a journey, composition // root decides what they mean). diff --git a/packages/react/src/overlay.test.tsx b/packages/react/src/overlay.test.tsx new file mode 100644 index 0000000..da6389d --- /dev/null +++ b/packages/react/src/overlay.test.tsx @@ -0,0 +1,295 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { useState } from "react"; +import { cleanup, fireEvent, render, renderHook } from "@testing-library/react"; +import { defineOverlayHost, type OverlayEntry } from "@modular-react/core"; +import { OverlayOutlet, useModalBehavior, useOverlay, useOverlaySubject } from "./overlay.js"; +import { SlotsContext } from "./slots-context.js"; + +interface StepRef { + readonly instanceId: string; + readonly stepIndex: number; +} + +const host = defineOverlayHost("resultViews"); +const step: StepRef = { instanceId: "i1", stepIndex: 3 }; + +// A window that renders what it received, so tests can assert injection. +function Probe({ subject, extra = "" }: { subject?: StepRef | null; extra?: string }) { + return ( +
+ {String(subject?.stepIndex ?? "none")}:{extra} +
+ ); +} + +const withSlots = + (slots: object) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + +const slotsOf = (...list: OverlayEntry[]) => ({ resultViews: list }); + +const twoWindows = slotsOf( + { id: "test-report", component: Probe, title: (s) => (s ? `Report ${s.stepIndex}` : "Report") }, + { id: "merger-verdict", component: Probe }, +); + +const renderOutlet = ( + props: Partial>> = {}, + slots: object = twoWindows, +) => + render( + + + , + ); + +afterEach(() => { + cleanup(); + document.body.style.overflow = ""; +}); + +const pressEscape = () => fireEvent.keyDown(document, { key: "Escape" }); + +describe("useOverlay", () => { + it("resolves the one entry the active id names; null and dangling ids resolve to null", () => { + const { result, rerender } = renderHook( + ({ id }: { id: string | null }) => useOverlay(host, id), + { wrapper: withSlots(twoWindows), initialProps: { id: null as string | null } }, + ); + expect(result.current).toBeNull(); + rerender({ id: "merger-verdict" }); + expect(result.current?.id).toBe("merger-verdict"); + rerender({ id: "dangling" }); + expect(result.current).toBeNull(); + }); +}); + +describe("OverlayOutlet — selection", () => { + it("renders exactly the one active window into document.body (portal, pick-one)", () => { + const { baseElement } = renderOutlet({ activeId: "test-report" }); + expect(baseElement.querySelectorAll(".window")).toHaveLength(1); + expect(document.body.querySelector("[data-overlay-id='test-report']")).not.toBeNull(); + }); + + it("renders empty (and no shell) when nothing is active", () => { + renderOutlet({ activeId: null, empty:
nothing open
}); + expect(document.querySelector(".empty")).not.toBeNull(); + expect(document.querySelector("[data-modular-overlay-backdrop]")).toBeNull(); + }); + + it("renders nothing and dev-warns on a dangling active id", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + renderOutlet({ activeId: "not-registered" }); + expect(document.querySelector(".window")).toBeNull(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('active id "not-registered"')); + warn.mockRestore(); + }); +}); + +describe("OverlayOutlet — shell and a11y", () => { + it("wires role, aria-modal, aria-label (from title + subject), and the stable data hooks", () => { + renderOutlet({ activeId: "test-report" }); + const panel = document.querySelector("[data-modular-overlay-panel]")!; + expect(panel.getAttribute("role")).toBe("dialog"); + expect(panel.getAttribute("aria-modal")).toBe("true"); + expect(panel.getAttribute("aria-label")).toBe("Report 3"); + expect(panel.getAttribute("tabindex")).toBe("-1"); + expect( + document.querySelector("[data-modular-overlay-backdrop]")!.getAttribute("data-overlay-id"), + ).toBe("test-report"); + }); + + it("passes entry props through with the injected subject winning", () => { + const slots = slotsOf({ + id: "w", + component: Probe, + props: { extra: "carried", subject: { instanceId: "hijack", stepIndex: 99 } }, + }); + renderOutlet({ activeId: "w" }, slots); + expect(document.querySelector(".window")!.textContent).toBe("3:carried"); + }); + + it("applies classNames and renders the wrap chrome inside the dialog", () => { + renderOutlet({ + activeId: "test-report", + backdropClassName: "bd", + panelClassName: "pn", + wrap: ({ entry, children }) => ( +
+ {children} +
+ ), + }); + expect(document.querySelector("[data-modular-overlay-backdrop]")!.className).toBe("bd"); + expect(document.querySelector("[data-modular-overlay-panel]")!.className).toBe("pn"); + expect( + document.querySelector("[data-modular-overlay-panel] section.chrome .window"), + ).not.toBeNull(); + }); + + it("contains a throwing window in its error boundary, labeled Overlay", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + function Boom(): React.ReactNode { + throw new Error("window exploded"); + } + renderOutlet({ activeId: "boom" }, slotsOf({ id: "boom", component: Boom })); + expect(document.body.textContent).toContain('Overlay "boom"'); + spy.mockRestore(); + }); +}); + +describe("OverlayOutlet — close requests", () => { + it("requests close on backdrop click-self only, honoring closeOnBackdrop", () => { + const onClose = vi.fn(); + renderOutlet({ activeId: "test-report", onClose }); + fireEvent.click(document.querySelector("[data-modular-overlay-panel]")!); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.click(document.querySelector("[data-modular-overlay-backdrop]")!); + expect(onClose).toHaveBeenCalledTimes(1); + + cleanup(); + const onClose2 = vi.fn(); + renderOutlet({ activeId: "test-report", onClose: onClose2, closeOnBackdrop: false }); + fireEvent.click(document.querySelector("[data-modular-overlay-backdrop]")!); + expect(onClose2).not.toHaveBeenCalled(); + }); + + it("Escape closes only the top of the stack; the one below closes next", () => { + const closeUnder = vi.fn(); + const closeOver = vi.fn(); + renderOutlet({ activeId: "test-report", onClose: closeUnder }); + const over = renderOutlet({ activeId: "merger-verdict", onClose: closeOver }); + + pressEscape(); + expect(closeOver).toHaveBeenCalledTimes(1); + expect(closeUnder).not.toHaveBeenCalled(); + + // The app answers the close request by clearing the top's active id. + over.rerender( + + + , + ); + pressEscape(); + expect(closeUnder).toHaveBeenCalledTimes(1); + expect(closeOver).toHaveBeenCalledTimes(1); + }); +}); + +describe("OverlayOutlet — managed behaviour", () => { + it("locks body scroll while open and restores it on close", () => { + document.body.style.overflow = "scroll"; + const view = renderOutlet({ activeId: "test-report" }); + expect(document.body.style.overflow).toBe("hidden"); + view.rerender( + + + , + ); + expect(document.body.style.overflow).toBe("scroll"); + }); + + it("moves focus into the dialog on open and returns it to the opener on close", () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + + function Focusable() { + return ; + } + const view = renderOutlet({ activeId: "w" }, slotsOf({ id: "w", component: Focusable })); + expect((document.activeElement as HTMLElement | null)?.className).toBe("inside"); + + view.rerender( + + + , + ); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); + + it("remounts window content when subjectKey changes with the subject", () => { + // Captures its first subject in state, so the test can tell a reused + // instance from a remounted one. + function Sticky({ subject }: { subject?: StepRef | null }) { + const [initial] = useState(subject?.stepIndex); + return
{String(initial)}
; + } + const slots = slotsOf({ id: "sticky", component: Sticky }); + const next: StepRef = { instanceId: "i1", stepIndex: 9 }; + + const reused = renderOutlet({ activeId: "sticky" }, slots); + reused.rerender( + + + , + ); + expect(document.querySelector(".window")!.textContent).toBe("3"); + cleanup(); + + const keyed = renderOutlet({ activeId: "sticky", subjectKey: (s) => s?.stepIndex ?? 0 }, slots); + keyed.rerender( + + s?.stepIndex ?? 0} + /> + , + ); + expect(document.querySelector(".window")!.textContent).toBe("9"); + }); +}); + +describe("useOverlaySubject", () => { + it("reads the outlet's subject without prop-drilling", () => { + function Reader() { + const subject = useOverlaySubject(); + return
{String(subject?.stepIndex ?? "none")}
; + } + renderOutlet({ activeId: "r" }, slotsOf({ id: "r", component: Reader })); + expect(document.querySelector(".window")!.textContent).toBe("3"); + }); + + it("throws outside an OverlayOutlet", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderHook(() => useOverlaySubject())).toThrow( + /useOverlaySubject must be used inside an /, + ); + spy.mockRestore(); + }); +}); + +describe("useModalBehavior (standalone)", () => { + it("tracks top-of-stack across bespoke and hosted overlays, and closes top-first", () => { + const closed = vi.fn(); + const { result, rerender } = renderHook( + ({ active }: { active: boolean }) => useModalBehavior({ active, onClose: closed }), + { initialProps: { active: false } }, + ); + expect(result.current.isTop).toBe(false); + + rerender({ active: true }); + expect(result.current.isTop).toBe(true); + + // A hosted overlay opening above takes the top. + const onClose = vi.fn(); + const over = renderOutlet({ activeId: "test-report", onClose }); + expect(result.current.isTop).toBe(false); + pressEscape(); + expect(closed).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + + over.unmount(); + expect(result.current.isTop).toBe(true); + pressEscape(); + expect(closed).toHaveBeenCalledTimes(1); + + rerender({ active: false }); + expect(result.current.isTop).toBe(false); + }); +}); diff --git a/packages/react/src/overlay.tsx b/packages/react/src/overlay.tsx new file mode 100644 index 0000000..86a21af --- /dev/null +++ b/packages/react/src/overlay.tsx @@ -0,0 +1,427 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, +} from "react"; +import { createPortal } from "react-dom"; +import type { ComponentType, ReactNode, RefObject } from "react"; +import { + createOverlayStack, + isDevEnv, + resolveOverlay, + resolveOverlayTitle, + type OnDuplicateComponentId, + type OverlayEntry, + type OverlayHostHandle, + type OverlayStackTicket, +} from "@modular-react/core"; +import { ModuleErrorBoundary } from "./error-boundary.js"; +import { useSlots } from "./slots-context.js"; + +/** + * React host for the framework-neutral **state-keyed overlay** primitive (see + * `resolveOverlay` / `defineOverlayHost` in `@modular-react/core`). An overlay + * host is a named surface whose module-contributed windows are selected by a + * caller-supplied **active id** — exactly one open at a time — and mounted + * inside a framework-managed modal shell: portaled, backdrop-closed, + * focus-trapped with focus return, scroll-locked, stack-registered, + * a11y-wired. The pick-one, modal sibling of the render-all, inline + * ``. + * + * The host is **headless**: it renders exactly the two elements the behaviour + * must own (backdrop, dialog panel), styleable via `backdropClassName` / + * `panelClassName`; everything inside the dialog belongs to the app's `wrap` + * chrome. State stays app-owned — the host *requests* close (backdrop click, + * Escape) via `onClose`; the app clears the active id. + */ + +// Sentinel default so a read outside an `` is a loud error +// rather than a silently-undefined subject that a valid `null` subject can't +// be told apart from. +const NO_SUBJECT = Symbol("modular-react.no-overlay-subject"); + +/** + * Context carrying the current overlay subject, set by {@link OverlayOutlet}. + * Prefer {@link useOverlaySubject} — it throws outside the outlet instead of + * handing back the private sentinel default. + */ +export const OverlaySubjectContext = createContext(NO_SUBJECT); + +/** + * Read the subject of the enclosing {@link OverlayOutlet}. May be `null`: + * overlay selection is by id, and a window that keys on its own store can be + * open with no subject. Throws when called outside an `` so a + * missing host is a loud error. + * + * @example + * ```tsx + * const step = useOverlaySubject() + * return

{step ? `Step ${step.stepIndex}` : ""}

+ * ``` + */ +export function useOverlaySubject(): TSubject | null { + const subject = useContext(OverlaySubjectContext); + if (subject === NO_SUBJECT) { + throw new Error( + "[@modular-react/react] useOverlaySubject must be used inside an " + + "(the outlet provides the current overlay subject).", + ); + } + return subject as TSubject | null; +} + +/** + * Resolve an overlay host against an active id. Reads the host's slot key from + * the slots context and runs the pure `resolveOverlay`, memoized on the + * entries, the id, and the duplicate stance. + * + * A `null` / `undefined` active id resolves to no entry; a dangling id (no + * entry registered under it) also resolves to `null` — see the dev warning in + * {@link OverlayOutlet}. + * + * @example + * ```tsx + * const active = useOverlay(resultViews, ui.resultView?.view ?? null) + * ``` + */ +// Stable identity for "this host has no contributions", so the memo below +// doesn't see a fresh `[]` on every render. +const NO_ENTRIES: readonly OverlayEntry[] = []; + +export function useOverlay( + host: OverlayHostHandle, + activeId: string | null | undefined, + opts?: { onDuplicate?: OnDuplicateComponentId }, +): OverlayEntry | null { + const slots = useSlots[]>>(); + const entries = (slots[host.slotKey] ?? NO_ENTRIES) as readonly OverlayEntry[]; + const onDuplicate = opts?.onDuplicate; + return useMemo( + () => resolveOverlay(entries, activeId, onDuplicate ? { onDuplicate } : undefined), + [entries, activeId, onDuplicate], + ); +} + +// --------------------------------------------------------------------------- +// Managed modal behaviour +// --------------------------------------------------------------------------- + +// One stack per binding module scope: every overlay in the app — outlet-hosted +// or bespoke via useModalBehavior — shares one ordering, so "the top overlay +// closes first" holds across both. Pure data (no DOM), so module-level +// creation is SSR-safe. +const modalStack = createOverlayStack(); + +// Shared scroll lock: one body-overflow save/restore across however many +// overlays are open, whichever host or hook opened them. +let scrollLockCount = 0; +let prevBodyOverflow = ""; + +function lockScroll(): void { + if (scrollLockCount++ === 0) { + prevBodyOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } +} + +function unlockScroll(): void { + if (scrollLockCount > 0 && --scrollLockCount === 0) { + document.body.style.overflow = prevBodyOverflow; + } +} + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(", "); + +// Tab-cycle containment: wrap focus at the dialog's edges; if focus escaped +// (or the dialog has no focusable content), pull it back in. +function trapTab(event: KeyboardEvent, root: HTMLElement): void { + const focusables = Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusables.length === 0) { + event.preventDefault(); + root.focus(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + const inside = active instanceof HTMLElement && root.contains(active); + if (event.shiftKey) { + if (!inside || active === first) { + event.preventDefault(); + last.focus(); + } + } else if (!inside || active === last) { + event.preventDefault(); + first.focus(); + } +} + +/** + * The managed modal *behaviour* as a standalone hook, for a window that needs + * a bespoke root (a full-bleed detail surface, a hand-styled shell) but still + * wants correct, consistent behaviour instead of re-deriving it: + * + * - registers on the shared overlay **stack** while `active` (nested overlays + * layer in open order; `isTop` tells this one whether it is topmost); + * - closes on **Escape** only when topmost (the top overlay closes first); + * - **traps focus** inside `dialogRef` (Tab cycles; focus that escapes is + * pulled back) and moves initial focus in on activation — `initialFocus` if + * given, else the first focusable, else the dialog element itself (give it + * `tabIndex={-1}`); + * - **returns focus** to the previously-focused element on deactivation; + * - **locks body scroll** while any overlay is open (shared count). + * + * `{@link OverlayOutlet}` uses exactly this hook internally, so a bespoke root + * and a hosted window behave identically. Effect-driven: inert on the server. + * + * @example + * ```tsx + * const { dialogRef, isTop } = useModalBehavior({ + * active: detailOpen, + * onClose: () => setDetailOpen(false), + * }) + * ``` + */ +export function useModalBehavior(opts: { + /** Whether the overlay is currently open. Drives activation/deactivation. */ + active: boolean; + /** Called when the behaviour requests close (Escape while topmost). */ + onClose: () => void; + /** Element to receive initial focus on activation (default: first focusable). */ + initialFocus?: HTMLElement | null; +}): { dialogRef: RefObject; isTop: boolean } { + const dialogRef = useRef(null); + const ticketRef = useRef(null); + + // Latest-ref pattern: the activation effect must not re-run (and re-stack + // the overlay) because a caller passed an inline onClose/initialFocus. + const onCloseRef = useRef(opts.onClose); + onCloseRef.current = opts.onClose; + const initialFocusRef = useRef(opts.initialFocus); + initialFocusRef.current = opts.initialFocus; + + // Re-render on stack changes and re-read `isTop` during render. Deliberately + // NOT useSyncExternalStore: `modalStack.push()` notifies synchronously + // *before* the activation effect below can assign `ticketRef`, so an eager + // snapshot read at notify time would see a stale null and settle on `false`. + // A version bump re-renders instead, and the render-time read happens after + // the ticket is assigned. The explicit bump after assignment covers this + // instance's own push; the subscription covers everyone else's. + const [, bumpStackVersion] = useReducer((n: number) => n + 1, 0); + useEffect(() => modalStack.subscribe(bumpStackVersion), []); + const isTop = ticketRef.current?.isTop() ?? false; + + useEffect(() => { + if (!opts.active) return; + + const ticket = modalStack.push(); + ticketRef.current = ticket; + bumpStackVersion(); + const restoreFocusTo = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + lockScroll(); + + const onKeydown = (event: KeyboardEvent) => { + if (!ticket.isTop()) return; + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key === "Tab" && dialogRef.current) { + trapTab(event, dialogRef.current); + } + }; + document.addEventListener("keydown", onKeydown, true); + + // The portal content committed with this effect's render; focus directly. + const target = + initialFocusRef.current ?? + dialogRef.current?.querySelector(FOCUSABLE_SELECTOR) ?? + dialogRef.current; + target?.focus(); + + return () => { + document.removeEventListener("keydown", onKeydown, true); + unlockScroll(); + ticketRef.current = null; + ticket.release(); + if (restoreFocusTo?.isConnected) restoreFocusTo.focus(); + }; + }, [opts.active]); + + return { dialogRef, isTop }; +} + +// --------------------------------------------------------------------------- +// The managed host +// --------------------------------------------------------------------------- + +/** Chrome render-prop argument for {@link OverlayOutletProps.wrap}. */ +export interface OverlayWrapArgs { + readonly entry: OverlayEntry; + readonly subject: TSubject | null; + /** Request close (the same request backdrop click and Escape make). */ + readonly close: () => void; + /** Whether this overlay is currently the top of the stack. */ + readonly isTop: boolean; + readonly children: ReactNode; +} + +export interface OverlayOutletProps { + /** The {@link OverlayHostHandle} from `defineOverlayHost`. */ + readonly host: OverlayHostHandle; + /** The active window id from app state; `null` renders `empty` (or nothing). */ + readonly activeId: string | null | undefined; + /** + * The value threaded to the window as a `subject` prop **and** via context + * ({@link useOverlaySubject}); may be `null` (selection is by id). + */ + readonly subject?: TSubject | null; + /** + * Optional discriminator folded into the mounted window's React key so + * switching subjects remounts window content rather than reusing a stale + * instance — the `` `subjectKey` contract. + */ + readonly subjectKey?: string | ((subject: TSubject | null) => string | number); + /** Duplicate-id stance forwarded to `resolveOverlay` (default: throw). */ + readonly onDuplicate?: OnDuplicateComponentId; + /** Called when the host requests close (backdrop click, Escape while topmost). */ + readonly onClose?: () => void; + /** Portal target (default `document.body`). */ + readonly to?: Element | null; + /** Render in place instead of portaling (tests, inline embedding). */ + readonly portalDisabled?: boolean; + /** Close on backdrop click-self (default `true`). */ + readonly closeOnBackdrop?: boolean; + /** The app's styling for the two host-rendered elements. Headless otherwise. */ + readonly backdropClassName?: string; + readonly panelClassName?: string; + /** Rendered (in place, not portaled) when no window is active. */ + readonly empty?: ReactNode; + /** + * The app's chrome around the window body, rendered inside the dialog + * element; receives `{ entry, subject, close, isTop, children }` and must + * render `children` somewhere. Header, icon (from `entry.meta`), close + * button — all live here. + */ + readonly wrap?: (args: OverlayWrapArgs) => ReactNode; +} + +/** + * Render the one active overlay of a host inside a framework-managed modal + * shell: portaled (default `document.body`), backdrop click-self → `onClose`, + * focus trap + focus return, body scroll lock, shared-stack registration + * (Escape closes the top overlay first), `role="dialog"` / `aria-modal` / + * `aria-label` (from the entry's `title`, resolved against the subject). + * + * The backdrop and dialog carry stable, namespaced hooks for e2e suites: + * `data-modular-overlay-backdrop`, `data-modular-overlay-panel`, and + * `data-overlay-id=""`. + * + * **The host never closes itself** — state stays app-owned; clear the active + * id in `onClose`. A dangling active id renders nothing and dev-warns (the + * `pairById` "missing" stance). The window body renders inside + * `ModuleErrorBoundary` (label `"Overlay"`). + * + * @example + * ```tsx + * ui.closeResultView()} + * backdropClassName="app-backdrop" + * panelClassName="app-dialog" + * wrap={({ entry, close, children }) => ( + * {children} + * )} + * /> + * ``` + */ +export function OverlayOutlet({ + host, + activeId, + subject = null, + subjectKey, + onDuplicate, + onClose, + to, + portalDisabled, + closeOnBackdrop = true, + backdropClassName, + panelClassName, + empty, + wrap, +}: OverlayOutletProps): ReactNode { + const entry = useOverlay(host, activeId, onDuplicate ? { onDuplicate } : undefined); + + const close = useCallback(() => onClose?.(), [onClose]); + const { dialogRef, isTop } = useModalBehavior({ active: entry !== null, onClose: close }); + + useEffect(() => { + if (isDevEnv() && activeId != null && entry === null) { + // A dangling active id is data, not a crash (the id may name a window + // another deployment ships) — but it is worth a loud dev breadcrumb. + console.warn( + `[@modular-react/react] OverlayOutlet: active id "${activeId}" matches no registered ` + + `overlay in slot "${host.slotKey}". Rendering nothing. Register a window under that ` + + `id (module slots) or clear the id.`, + ); + } + }, [activeId, entry, host.slotKey]); + + if (entry === null) return empty ?? null; + + const Component = entry.component as ComponentType>; + const content = ; + const inner = wrap ? wrap({ entry, subject, close, isTop, children: content }) : content; + const key = + subjectKey === undefined + ? entry.id + : `${entry.id}:${typeof subjectKey === "function" ? subjectKey(subject) : subjectKey}`; + + const shell = ( + +
{ + if (closeOnBackdrop && event.target === event.currentTarget) close(); + }} + > +
} + role="dialog" + aria-modal="true" + aria-label={resolveOverlayTitle(entry, subject)} + tabIndex={-1} + className={panelClassName} + data-modular-overlay-panel="" + > + + {inner} + +
+
+
+ ); + + if (portalDisabled) return shell; + // Client-only resolution of the default target; on the server the outlet + // renders nothing (the modal is interaction-driven state). + const target = to ?? (typeof document !== "undefined" ? document.body : null); + return target ? createPortal(shell, target) : null; +} diff --git a/packages/vue-core/src/index.ts b/packages/vue-core/src/index.ts index 4056ac6..f1ae4e3 100644 --- a/packages/vue-core/src/index.ts +++ b/packages/vue-core/src/index.ts @@ -44,6 +44,30 @@ export { definePanelGroup, resolvePanels } from "@modular-frontend/core"; export type { PanelEntry, PanelGroupHandle } from "@modular-frontend/core"; export { usePanels, PanelsOutlet, usePanelSubject, panelSubjectKey } from "@modular-vue/vue"; +// State-keyed overlay host — the pick-one, modal sibling of the render-all +// panels. The pure engine surface plus the Vue managed modal host, both +// surfaced here so consumers import overlays from the Vue binding rather than +// reaching into the engine. +export { + createOverlayStack, + defineOverlayHost, + resolveOverlay, + resolveOverlayTitle, +} from "@modular-frontend/core"; +export type { + OverlayEntry, + OverlayHostHandle, + OverlayStack, + OverlayStackTicket, +} from "@modular-frontend/core"; +export { + useOverlay, + OverlayOutlet, + useOverlaySubject, + overlaySubjectKey, + useModalBehavior, +} from "@modular-vue/vue"; + // Module definition export { defineModule } from "./define-module.js"; export { defineSlots } from "./define-slots.js"; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 688ba11..d928f3d 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -94,6 +94,19 @@ export { ModuleErrorBoundary } from "./error-boundary.js"; // and `usePanelSubject`. export { usePanels, PanelsOutlet, usePanelSubject, panelSubjectKey } from "./panels.js"; +// Vue host for the state-keyed overlay — the pick-one, modal sibling of the +// render-all panels. The pure resolver (`resolveOverlay` / `defineOverlayHost`) +// and the shared stack live in `@modular-frontend/core`; this binding adds the +// reactive `useOverlay` composable, the managed `` modal host, +// `useOverlaySubject`, and the standalone `useModalBehavior`. +export { + useOverlay, + OverlayOutlet, + useOverlaySubject, + overlaySubjectKey, + useModalBehavior, +} from "./overlay.js"; + // Vue-specific: module-exit plumbing (hosted by ModuleRoute / tabs; the // "step 0" pattern — modules fire exits outside a journey, composition root // decides what they mean). diff --git a/packages/vue/src/overlay.test.ts b/packages/vue/src/overlay.test.ts new file mode 100644 index 0000000..2abd675 --- /dev/null +++ b/packages/vue/src/overlay.test.ts @@ -0,0 +1,359 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { defineComponent, h, nextTick, ref, shallowRef } from "vue"; +import { mount, type VueWrapper } from "@vue/test-utils"; +import { defineOverlayHost, type OverlayEntry } from "@modular-frontend/core"; +import { OverlayOutlet, useModalBehavior, useOverlay, useOverlaySubject } from "./overlay.js"; +import { slotsKey } from "./slots-context.js"; +import { renderComposable } from "./test-render.js"; + +interface StepRef { + readonly instanceId: string; + readonly stepIndex: number; +} + +const host = defineOverlayHost("resultViews"); +const step: StepRef = { instanceId: "i1", stepIndex: 3 }; + +// A window component that records the subject it was rendered with. +const seen: unknown[] = []; +const Probe = defineComponent({ + props: { subject: { type: null as never, default: null }, extra: { type: String, default: "" } }, + setup(props) { + seen.push(props.subject); + return () => h("div", { class: "window" }, `window:${props.extra}`); + }, +}); + +const entries = (...list: OverlayEntry[]): { resultViews: OverlayEntry[] } => ({ + resultViews: list, +}); + +const twoWindows = entries( + { id: "test-report", component: Probe, title: (s) => (s ? `Report ${s.stepIndex}` : "Report") }, + { id: "merger-verdict", component: Probe }, +); + +const mountOutlet = ( + props: Record = {}, + slots?: object, + outletSlots?: Record, +) => + mount(OverlayOutlet, { + props: { host, activeId: null, teleportDisabled: true, ...props }, + slots: outletSlots as never, + global: { provide: { [slotsKey as symbol]: shallowRef(slots ?? twoWindows) } }, + }); + +// The behaviour registers on a module-level shared stack; make sure no overlay +// leaks into the next test. +const mounted: VueWrapper[] = []; +const track = (w: W): W => { + mounted.push(w); + return w; +}; +afterEach(() => { + while (mounted.length) mounted.pop()!.unmount(); + document.body.innerHTML = ""; +}); + +const pressEscape = () => + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + +describe("useOverlay", () => { + it("resolves the one entry the active id names, reactively", () => { + const activeId = ref(null); + const { result, wrapper } = renderComposable(() => useOverlay(host, activeId), { + provide: { [slotsKey as symbol]: shallowRef(twoWindows) }, + }); + track(wrapper); + expect(result().value).toBeNull(); + activeId.value = "merger-verdict"; + expect(result().value?.id).toBe("merger-verdict"); + activeId.value = "dangling"; + expect(result().value).toBeNull(); + }); + + it("throws outside a modular app", () => { + expect(() => renderComposable(() => useOverlay(host, ref(null)))).toThrow( + /useOverlay must be used within a modular app/, + ); + }); +}); + +describe("OverlayOutlet — selection", () => { + it("renders exactly the one active window (pick-one, not render-all)", () => { + const wrapper = track(mountOutlet({ activeId: "test-report", subject: step })); + expect(wrapper.findAll(".window")).toHaveLength(1); + expect(wrapper.find("[data-overlay-id='test-report']").exists()).toBe(true); + }); + + it("renders the #empty slot (and no shell) when nothing is active", () => { + const wrapper = track( + mountOutlet({ activeId: null }, undefined, { + empty: () => h("div", { class: "empty" }, "nothing open"), + }), + ); + expect(wrapper.find(".empty").exists()).toBe(true); + expect(wrapper.find("[data-modular-overlay-backdrop]").exists()).toBe(false); + }); + + it("renders nothing and dev-warns on a dangling active id", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const wrapper = track(mountOutlet({ activeId: "not-registered" })); + expect(wrapper.find(".window").exists()).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('active id "not-registered"')); + warn.mockRestore(); + }); + + it("switches windows when the active id changes", async () => { + const wrapper = track(mountOutlet({ activeId: "test-report" })); + await wrapper.setProps({ activeId: "merger-verdict" }); + expect(wrapper.find("[data-overlay-id='merger-verdict']").exists()).toBe(true); + await wrapper.setProps({ activeId: null }); + expect(wrapper.find("[data-modular-overlay-backdrop]").exists()).toBe(false); + }); + + it("teleports to body by default", () => { + track( + mount(OverlayOutlet, { + props: { host, activeId: "test-report", subject: step }, + global: { provide: { [slotsKey as symbol]: shallowRef(twoWindows) } }, + }), + ); + expect(document.body.querySelector("[data-modular-overlay-panel]")).not.toBeNull(); + }); +}); + +describe("OverlayOutlet — shell and a11y", () => { + it("wires role, aria-modal, aria-label (from title + subject), and the stable data hooks", () => { + const wrapper = track(mountOutlet({ activeId: "test-report", subject: step })); + const panel = wrapper.find("[data-modular-overlay-panel]"); + expect(panel.attributes("role")).toBe("dialog"); + expect(panel.attributes("aria-modal")).toBe("true"); + expect(panel.attributes("aria-label")).toBe("Report 3"); + expect(panel.attributes("tabindex")).toBe("-1"); + expect(wrapper.find("[data-modular-overlay-backdrop]").attributes("data-overlay-id")).toBe( + "test-report", + ); + }); + + it("passes entry props through with the injected subject winning", () => { + seen.length = 0; + const slots = entries({ + id: "w", + component: Probe, + props: { extra: "carried", subject: "hijack" }, + }); + const wrapper = track(mountOutlet({ activeId: "w", subject: step }, slots)); + expect(wrapper.find(".window").text()).toBe("window:carried"); + expect(seen[0]).toEqual(step); + }); + + it("applies backdropClass / panelClass and renders the #wrap chrome inside the dialog", () => { + const wrapper = track( + mountOutlet( + { activeId: "test-report", subject: step, backdropClass: "bd", panelClass: "pn" }, + undefined, + { + wrap: (args: { entry: OverlayEntry; children: unknown }) => + h("section", { class: "chrome", "data-id": args.entry.id }, [args.children as never]), + }, + ), + ); + expect(wrapper.find("[data-modular-overlay-backdrop]").classes()).toContain("bd"); + expect(wrapper.find("[data-modular-overlay-panel]").classes()).toContain("pn"); + const chrome = wrapper.find("[data-modular-overlay-panel] section.chrome"); + expect(chrome.exists()).toBe(true); + expect(chrome.find(".window").exists()).toBe(true); + }); + + it("contains a throwing window in its error boundary, labeled Overlay", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const Boom = defineComponent({ + setup() { + return () => { + throw new Error("window exploded"); + }; + }, + }); + const wrapper = track( + mountOutlet({ activeId: "boom" }, entries({ id: "boom", component: Boom })), + ); + await nextTick(); + expect(wrapper.text()).toContain('Overlay "boom"'); + spy.mockRestore(); + }); +}); + +describe("OverlayOutlet — close requests", () => { + it("emits close on backdrop click, but not on a click inside the dialog", async () => { + const wrapper = track(mountOutlet({ activeId: "test-report", subject: step })); + await wrapper.find("[data-modular-overlay-panel]").trigger("click"); + expect(wrapper.emitted("close")).toBeUndefined(); + await wrapper.find("[data-modular-overlay-backdrop]").trigger("click"); + expect(wrapper.emitted("close")).toHaveLength(1); + }); + + it("does not emit close on backdrop click when closeOnBackdrop is false", async () => { + const wrapper = track( + mountOutlet({ activeId: "test-report", subject: step, closeOnBackdrop: false }), + ); + await wrapper.find("[data-modular-overlay-backdrop]").trigger("click"); + expect(wrapper.emitted("close")).toBeUndefined(); + }); + + it("emits close on Escape", async () => { + const wrapper = track(mountOutlet({ activeId: "test-report", subject: step })); + await nextTick(); + pressEscape(); + expect(wrapper.emitted("close")).toHaveLength(1); + }); + + it("Escape closes only the top of the stack; the one below closes next", async () => { + const under = track(mountOutlet({ activeId: "test-report", subject: step })); + await nextTick(); + const over = track(mountOutlet({ activeId: "merger-verdict" })); + await nextTick(); + + pressEscape(); + expect(over.emitted("close")).toHaveLength(1); + expect(under.emitted("close")).toBeUndefined(); + + // The app answers the close request by clearing the top's active id. + await over.setProps({ activeId: null }); + await nextTick(); + pressEscape(); + expect(under.emitted("close")).toHaveLength(1); + expect(over.emitted("close")).toHaveLength(1); + }); +}); + +describe("OverlayOutlet — managed behaviour", () => { + it("locks body scroll while open and restores it on close", async () => { + document.body.style.overflow = "scroll"; + const wrapper = track(mountOutlet({ activeId: "test-report", subject: step })); + await nextTick(); + expect(document.body.style.overflow).toBe("hidden"); + await wrapper.setProps({ activeId: null }); + await nextTick(); + expect(document.body.style.overflow).toBe("scroll"); + document.body.style.overflow = ""; + }); + + it("moves focus into the dialog on open and returns it to the opener on close", async () => { + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + expect(document.activeElement).toBe(opener); + + const Focusable = defineComponent({ + props: { subject: { type: null as never, default: null } }, + setup() { + return () => h("button", { class: "inside" }, "ok"); + }, + }); + // Teleport for real: focus() only moves document.activeElement for + // elements attached to the document. + const wrapper = track( + mountOutlet( + { activeId: "w", subject: step, teleportDisabled: false }, + entries({ id: "w", component: Focusable }), + ), + ); + await nextTick(); + await nextTick(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("inside"); + + await wrapper.setProps({ activeId: null }); + await nextTick(); + expect(document.activeElement).toBe(opener); + }); + + it("keeps a window's instance state across subject changes without subjectKey, remounts with it", async () => { + // Captures its first subject at setup, so the test can tell a reused + // instance (stale capture survives) from a remounted one (fresh capture). + const Sticky = defineComponent({ + props: { subject: { type: Object as never, required: true } }, + setup(props) { + const initial = (props.subject as StepRef).stepIndex; + return () => h("div", { class: "window" }, String(initial)); + }, + }); + const slots = entries({ id: "sticky", component: Sticky }); + + const reused = track(mountOutlet({ activeId: "sticky", subject: step }, slots)); + await reused.setProps({ subject: { instanceId: "i1", stepIndex: 9 } }); + expect(reused.find(".window").text()).toBe("3"); + + const remounted = track( + mountOutlet( + { + activeId: "sticky", + subject: step, + subjectKey: (s: unknown) => (s as StepRef).stepIndex, + }, + slots, + ), + ); + await remounted.setProps({ subject: { instanceId: "i1", stepIndex: 9 } }); + expect(remounted.find(".window").text()).toBe("9"); + }); +}); + +describe("useOverlaySubject", () => { + it("reads the outlet's subject reactively, without prop-drilling", async () => { + const Reader = defineComponent({ + setup() { + const subject = useOverlaySubject(); + return () => h("div", { class: "window" }, String(subject.value?.stepIndex ?? "none")); + }, + }); + const wrapper = track( + mountOutlet({ activeId: "r", subject: step }, entries({ id: "r", component: Reader })), + ); + expect(wrapper.find(".window").text()).toBe("3"); + await wrapper.setProps({ subject: { instanceId: "i1", stepIndex: 7 } }); + expect(wrapper.find(".window").text()).toBe("7"); + }); + + it("throws outside an OverlayOutlet", () => { + expect(() => renderComposable(() => useOverlaySubject())).toThrow( + /useOverlaySubject must be used inside an /, + ); + }); +}); + +describe("useModalBehavior (standalone)", () => { + it("tracks top-of-stack across bespoke and hosted overlays, and closes top-first", async () => { + const active = ref(false); + const closed = vi.fn(); + const { result, wrapper } = renderComposable(() => + useModalBehavior({ active, onClose: closed }), + ); + track(wrapper); + expect(result().isTop.value).toBe(false); + + active.value = true; + await nextTick(); + expect(result().isTop.value).toBe(true); + + // A hosted overlay opening above takes the top. + const over = track(mountOutlet({ activeId: "test-report", subject: step })); + await nextTick(); + expect(result().isTop.value).toBe(false); + pressEscape(); + expect(closed).not.toHaveBeenCalled(); + expect(over.emitted("close")).toHaveLength(1); + + over.unmount(); + mounted.splice(mounted.indexOf(over), 1); + await nextTick(); + expect(result().isTop.value).toBe(true); + pressEscape(); + expect(closed).toHaveBeenCalledTimes(1); + + active.value = false; + await nextTick(); + expect(result().isTop.value).toBe(false); + }); +}); diff --git a/packages/vue/src/overlay.ts b/packages/vue/src/overlay.ts new file mode 100644 index 0000000..372afae --- /dev/null +++ b/packages/vue/src/overlay.ts @@ -0,0 +1,467 @@ +import { + Teleport, + computed, + defineComponent, + h, + inject, + nextTick, + onBeforeUnmount, + provide, + ref, + toValue, + watch, + type Component, + type ComputedRef, + type InjectionKey, + type MaybeRefOrGetter, + type PropType, + type Ref, + type VNode, +} from "vue"; +import { + createOverlayStack, + isDevEnv, + resolveOverlay, + resolveOverlayTitle, + type OnDuplicateComponentId, + type OverlayEntry, + type OverlayHostHandle, + type OverlayStackTicket, +} from "@modular-frontend/core"; +import { injectSlotsSource } from "./panels.js"; +import { ModuleErrorBoundary } from "./error-boundary.js"; + +/** + * Vue host for the framework-neutral **state-keyed overlay** primitive (see + * `resolveOverlay` / `defineOverlayHost` in `@modular-frontend/core`). An + * overlay host is a named surface whose module-contributed windows are selected + * by a caller-supplied **active id** — exactly one open at a time — and mounted + * inside a framework-managed modal shell: teleported, backdrop-closed, + * focus-trapped with focus return, scroll-locked, stack-registered, a11y-wired. + * The pick-one, modal sibling of the render-all, inline ``. + * + * The host is **headless**: it renders exactly the two elements the behaviour + * must own (backdrop, dialog panel), styleable via `backdropClass` / + * `panelClass`; everything inside the dialog belongs to the app's `#wrap` + * chrome. State stays app-owned — the host *requests* close (backdrop click, + * Escape) by emitting `close`; the app clears the active id. + * + * ## Reactivity caveat + * + * The active entry recomputes when `activeId` changes — pass it from reactive + * state (a Pinia computed, a ref) exactly as `usePanels` documents for its + * subject; a non-reactive snapshot tracks nothing. Contributions track both + * runtime slot sources, so windows contributed through `dynamicSlots` appear on + * either update path (see `injectSlotsSource` in `panels.ts`). + */ + +/** + * Injection key holding the current overlay subject, provided by + * {@link OverlayOutlet} so the active window (and its descendants) can read the + * subject without prop-drilling. Held as a `ComputedRef` so reads stay reactive + * to subject changes. + */ +export const overlaySubjectKey: InjectionKey> = Symbol( + "modular-vue.overlaySubject", +); + +/** + * Read the subject of the enclosing {@link OverlayOutlet}. Reactive — the + * returned `computed` updates when the outlet's subject changes. May hold + * `null`: overlay selection is by id, and a window that keys on its own store + * can be open with no subject. Throws when called outside an `` + * so a missing host is a loud error, not a silently-undefined subject. + * + * @example + * ```ts + * const step = useOverlaySubject() + * const label = computed(() => (step.value ? `Step ${step.value.stepIndex}` : "")) + * ``` + */ +export function useOverlaySubject(): ComputedRef { + const subject = inject(overlaySubjectKey, null); + if (!subject) { + throw new Error( + "[@modular-vue/vue] useOverlaySubject must be used inside an " + + "(the outlet provides the current overlay subject).", + ); + } + return subject as ComputedRef; +} + +/** + * Resolve an overlay host against an active id as a reactive `computed`. + * + * Every input is a `MaybeRefOrGetter`, resolved *inside* the `computed` so it + * is live: pass `activeId` as a ref/getter over reactive state (a ui-store + * computed) so the active entry re-resolves when the selection changes. The + * pure `resolveOverlay` recomputes on the slot contributions, the id, or the + * duplicate stance changing. + * + * @example + * ```ts + * const active = useOverlay(resultViews, () => ui.resultView?.view ?? null) + * // active.value → the one active OverlayEntry, or null + * ``` + */ +export function useOverlay( + host: MaybeRefOrGetter>, + activeId: MaybeRefOrGetter, + opts?: { onDuplicate?: MaybeRefOrGetter }, +): ComputedRef | null> { + const readSlots = injectSlotsSource("useOverlay"); + return computed(() => + resolveOverlay( + (readSlots()[toValue(host).slotKey] ?? []) as readonly OverlayEntry[], + toValue(activeId), + { onDuplicate: toValue(opts?.onDuplicate) }, + ), + ); +} + +// --------------------------------------------------------------------------- +// Managed modal behaviour +// --------------------------------------------------------------------------- + +// One stack per binding module scope: every overlay in the app — outlet-hosted +// or bespoke via useModalBehavior — shares one ordering, so "the top overlay +// closes first" holds across both. Pure data (no DOM), so module-level +// creation is SSR-safe. +const modalStack = createOverlayStack(); + +// Reactive mirror of stack changes so per-instance `isTop` computeds re-read. +const stackVersion = ref(0); +modalStack.subscribe(() => { + stackVersion.value++; +}); + +// Shared scroll lock: one body-overflow save/restore across however many +// overlays are open, whichever host or composable opened them. +let scrollLockCount = 0; +let prevBodyOverflow = ""; + +function lockScroll(): void { + if (typeof document === "undefined") return; + if (scrollLockCount++ === 0) { + prevBodyOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } +} + +function unlockScroll(): void { + if (typeof document === "undefined") return; + if (scrollLockCount > 0 && --scrollLockCount === 0) { + document.body.style.overflow = prevBodyOverflow; + } +} + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(", "); + +function focusableWithin(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)); +} + +// Tab-cycle containment: wrap focus at the dialog's edges; if focus escaped +// (or the dialog has no focusable content), pull it back in. +function trapTab(event: KeyboardEvent, root: HTMLElement): void { + const focusables = focusableWithin(root); + if (focusables.length === 0) { + event.preventDefault(); + root.focus(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + const inside = active instanceof HTMLElement && root.contains(active); + if (event.shiftKey) { + if (!inside || active === first) { + event.preventDefault(); + last.focus(); + } + } else if (!inside || active === last) { + event.preventDefault(); + first.focus(); + } +} + +/** + * The managed modal *behaviour* as a standalone composable, for a window that + * needs a bespoke root (a full-bleed detail surface, a hand-styled shell) but + * still wants correct, consistent behaviour instead of re-deriving it: + * + * - registers on the shared overlay **stack** while `active` (nested overlays + * layer in open order; `isTop` tells this one whether it is topmost); + * - closes on **Escape** only when topmost (the top overlay closes first); + * - **traps focus** inside `dialogRef` (Tab cycles; focus that escapes is + * pulled back) and moves initial focus in on activation — `initialFocus` if + * given, else the first focusable, else the dialog element itself (give it + * `tabindex="-1"`); + * - **returns focus** to the previously-focused element on deactivation; + * - **locks body scroll** while any overlay is open (shared count). + * + * `{@link OverlayOutlet}` uses exactly this composable internally, so a + * bespoke root and a hosted window behave identically. All DOM work is + * client-only; on the server this is inert. + * + * @example + * ```ts + * const { dialogRef, isTop } = useModalBehavior({ + * active: () => ui.detailOpen, + * onClose: () => ui.closeDetail(), + * }) + * ``` + */ +export function useModalBehavior(opts: { + /** Whether the overlay is currently open. Drives activation/deactivation. */ + active: MaybeRefOrGetter; + /** Called when the behaviour requests close (Escape while topmost). */ + onClose: () => void; + /** Element to receive initial focus on activation (default: first focusable). */ + initialFocus?: MaybeRefOrGetter; +}): { dialogRef: Ref; isTop: ComputedRef } { + const dialogRef = ref(null); + let ticket: OverlayStackTicket | null = null; + let restoreFocusTo: HTMLElement | null = null; + + const isTop = computed(() => { + // Depend on the stack version so pushes/releases elsewhere re-evaluate. + void stackVersion.value; + return ticket?.isTop() ?? false; + }); + + const onKeydown = (event: KeyboardEvent) => { + if (!ticket?.isTop()) return; + if (event.key === "Escape") { + event.preventDefault(); + opts.onClose(); + return; + } + if (event.key === "Tab" && dialogRef.value) { + trapTab(event, dialogRef.value); + } + }; + + const activate = () => { + // Client-only: on the server the behaviour is inert (nothing teleports, + // nothing focuses); the app hydrates and activates in the browser. + if (typeof document === "undefined" || ticket) return; + ticket = modalStack.push(); + restoreFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null; + lockScroll(); + document.addEventListener("keydown", onKeydown, true); + // The dialog renders in the same tick the activation flag flips; focus + // after the DOM settles. + void nextTick(() => { + if (!ticket) return; // deactivated before the tick settled + const target = + toValue(opts.initialFocus) ?? + dialogRef.value?.querySelector(FOCUSABLE_SELECTOR) ?? + dialogRef.value; + target?.focus(); + }); + }; + + const deactivate = () => { + if (!ticket) return; + document.removeEventListener("keydown", onKeydown, true); + unlockScroll(); + ticket.release(); + ticket = null; + if (restoreFocusTo?.isConnected) restoreFocusTo.focus(); + restoreFocusTo = null; + }; + + watch( + () => toValue(opts.active), + (active) => { + if (active) activate(); + else deactivate(); + }, + { immediate: true, flush: "post" }, + ); + onBeforeUnmount(deactivate); + + return { dialogRef, isTop }; +} + +// --------------------------------------------------------------------------- +// The managed host +// --------------------------------------------------------------------------- + +function keyFor(entry: OverlayEntry, subjectKey: unknown, subject: unknown): string { + if (subjectKey === undefined) return entry.id; + const discriminator = + typeof subjectKey === "function" + ? (subjectKey as (s: unknown) => string | number)(subject) + : (subjectKey as string | number); + return `${entry.id}:${discriminator}`; +} + +/** + * Render the one active overlay of a host inside a framework-managed modal + * shell: teleported (default `body`), backdrop click-self → `close`, focus + * trap + focus return, body scroll lock, shared-stack registration (Escape + * closes the top overlay first), `role="dialog"` / `aria-modal` / + * `aria-label` (from the entry's `title`, resolved against the subject). + * + * - `host` — the {@link OverlayHostHandle} from `defineOverlayHost`. + * - `activeId` — the active window id from app state; `null` renders the + * `#empty` slot (or nothing). A dangling id (no entry registered under it) + * renders nothing and dev-warns — the `pairById` "missing" stance. + * - `subject` — the value threaded to the window as a `subject` prop **and** + * via `provide` ({@link useOverlaySubject}); may be `null` (selection is by + * id). `subjectKey` folds the subject's identity into the mounted window's + * key so switching subjects remounts rather than reusing a stale instance — + * the `` `subjectKey` contract. + * - `to` — teleport target (default `"body"`); `teleportDisabled` renders in + * place (tests, inline embedding). + * - `closeOnBackdrop` — default `true`. + * - `backdropClass` / `panelClass` — the app's styling for the only two + * elements the host renders. Headless: no opinionated CSS is applied; a + * bare host is functional but unstyled. + * + * The backdrop and dialog carry stable, namespaced hooks for e2e suites: + * `data-modular-overlay-backdrop`, `data-modular-overlay-panel`, and + * `data-overlay-id=""` — no configuration, no drift. + * + * Emits `close` when the backdrop is clicked or Escape is pressed while + * topmost. **The host never closes itself** — state stays app-owned; clear the + * active id in the handler. + * + * Slots: + * - `#empty` — shown (in place, not teleported) when no window is active. + * - `#wrap` — the app's chrome around the window body, rendered inside the + * dialog element; receives `{ entry, subject, close, isTop, children }` and + * must render `children` somewhere. Header, icon (from `entry.meta`), close + * button, header-hosted `` regions — all live here. + * + * The window body renders inside `ModuleErrorBoundary` (label `"Overlay"`) so + * a throwing window can't take down the shell around it. + * + * Authored with `defineComponent` + a render function (no SFC compiler in the + * package build; see decision D4). + */ +export const OverlayOutlet = defineComponent({ + name: "OverlayOutlet", + props: { + host: { type: Object as PropType>, required: true }, + activeId: { type: String as PropType, default: null }, + subject: { type: null as unknown as PropType, default: null }, + subjectKey: { + type: null as unknown as PropType string | number)>, + default: undefined, + }, + onDuplicate: { type: String as PropType, default: undefined }, + to: { type: [String, Object] as PropType, default: "body" }, + teleportDisabled: { type: Boolean, default: false }, + closeOnBackdrop: { type: Boolean, default: true }, + backdropClass: { type: null as unknown as PropType, default: undefined }, + panelClass: { type: null as unknown as PropType, default: undefined }, + }, + emits: ["close"], + setup(props, { slots, emit }) { + // Every prop flows into useOverlay as a getter so the computed tracks it — + // reading `props.x` here in setup would freeze its mount-time value. + const entry = useOverlay( + () => props.host, + () => props.activeId, + { onDuplicate: () => props.onDuplicate }, + ); + const subjectRef = computed(() => (entry.value ? props.subject : null)); + provide(overlaySubjectKey, subjectRef); + + const close = () => emit("close"); + const { dialogRef, isTop } = useModalBehavior({ + active: () => entry.value !== null, + onClose: close, + }); + + if (isDevEnv()) { + // A dangling active id is data, not a crash (the id may name a window + // another deployment ships) — but it is worth a loud dev breadcrumb. + watch( + () => (props.activeId != null && entry.value === null ? props.activeId : null), + (dangling) => { + if (dangling !== null) { + console.warn( + `[@modular-vue/vue] OverlayOutlet: active id "${dangling}" matches no registered ` + + `overlay in slot "${props.host.slotKey}". Rendering nothing. Register a window ` + + `under that id (module slots) or clear the id.`, + ); + } + }, + { immediate: true }, + ); + } + + const onBackdropClick = (event: MouseEvent) => { + if (props.closeOnBackdrop && event.target === event.currentTarget) close(); + }; + + return () => { + const active = entry.value; + if (!active) { + return slots.empty ? slots.empty() : null; + } + + const body: VNode = h(active.component as Component, { + ...active.props, + subject: props.subject, + }); + const inner = slots.wrap + ? slots.wrap({ + entry: active, + subject: props.subject, + close, + isTop: isTop.value, + children: body, + }) + : body; + + return h(Teleport, { to: props.to, disabled: props.teleportDisabled }, [ + h( + "div", + { + class: props.backdropClass, + "data-modular-overlay-backdrop": "", + "data-overlay-id": active.id, + onClick: onBackdropClick, + }, + [ + h( + "div", + { + ref: dialogRef, + role: "dialog", + "aria-modal": "true", + "aria-label": resolveOverlayTitle(active, props.subject), + tabindex: -1, + class: props.panelClass, + "data-modular-overlay-panel": "", + }, + [ + h( + ModuleErrorBoundary, + { + key: keyFor(active, props.subjectKey, props.subject), + moduleId: active.id, + label: "Overlay", + }, + () => inner, + ), + ], + ), + ], + ), + ]); + }; + }, +}); diff --git a/packages/vue/src/panels.ts b/packages/vue/src/panels.ts index 7dd1993..627f248 100644 --- a/packages/vue/src/panels.ts +++ b/packages/vue/src/panels.ts @@ -95,8 +95,11 @@ export function usePanelSubject(): ComputedRef { * evaluate the same factories over the same deps, so the latest evaluation is * always the correct one; panels therefore update on *either* path, matching * the React host (whose single context carries both). + * + * Package-internal (shared with the overlay host; deliberately not exported + * from the package index). */ -function injectSlotsSource(): () => Record { +export function injectSlotsSource(caller = "usePanels"): () => Record { const reactive = inject(reactiveSlotsKey, null); const signal = inject(slotsKey, null); if (reactive && signal) { @@ -125,7 +128,7 @@ function injectSlotsSource(): () => Record { if (reactive) return () => reactive.value as Record; if (signal) return () => signal.value as Record; throw new Error( - "[@modular-vue/vue] usePanels must be used within a modular app " + + `[@modular-vue/vue] ${caller} must be used within a modular app ` + "(install the resolved manifest so a slots source is provided).", ); } From c388dc06d445a8d68f3c7587c707df31d5804bd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 14:20:22 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(overlays):=20review=20hardening=20?= =?UTF-8?q?=E2=80=94=20dedupe=20behaviour=20into=20the=20engine,=20guard?= =?UTF-8?q?=20backdrop=20drag-out,=20refocus=20on=20window=20swap,=20freez?= =?UTF-8?q?e=20the=20behaviour=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the PR-96 review findings, prioritizing the architectural ones: - Move the behaviour's framework-neutral DOM semantics into the engine (frontend-core overlay-dom.ts, client-only and SSR-guarded): the single app-wide sharedOverlayStack instance both bindings register on, the counted body scroll lock, and the focusable-scan/Tab-trap rules. One implementation so the behaviour cannot drift between bindings (the collapseEntriesById argument applied to focus and scroll); each binding now contributes only event/reactivity glue. This also makes the "one overlay ordering per app" claim true by construction, across bindings. - Backdrop close is now press-and-release: a press that starts inside the dialog and slips onto the backdrop (text selection, missed drag) no longer closes the overlay. Tested in both bindings. - Initial focus is re-applied when the active window swaps without closing (new optional contentKey on useModalBehavior; the outlets pass the mounted window's key), so focus follows the new content instead of falling to body. Tested in both bindings. - Close the review's test-coverage gaps: Tab/Shift+Tab wrap and escaped- focus pull-back, no-focusables fallback, initialFocus, and the React custom portal target + portalDisabled, in both bindings where relevant. - Formalize the behaviour bundle's scope: new "Conscious constraints" section in docs/overlay-host.md freezes the guarantee list as the whole contract (no pixels ever, no chrome anatomy, no background inert, no focusable-detection long tail, no platform scroll workarounds, no router awareness) so the first DOM-owning surface in the family holds its line by documented decision rather than per-request renegotiation. - Fix doc drift: useSyncExternalStore references (the React glue is a deliberate subscription-driven re-render), the per-binding stack wording, and the dedup of the React dangling-id dev warning. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G9FSij7zRf1Qt1AzHLvHP6 --- CHANGELOG.md | 8 +- docs/overlay-host-triage.md | 50 ++++-- docs/overlay-host.md | 56 +++++- packages/frontend-core/src/index.ts | 11 ++ .../frontend-core/src/overlay-dom.test.ts | 36 ++++ packages/frontend-core/src/overlay-dom.ts | 117 +++++++++++++ packages/frontend-core/src/overlay.ts | 18 +- packages/react/src/overlay.test.tsx | 107 ++++++++++++ packages/react/src/overlay.tsx | 165 +++++++++--------- packages/vue-core/src/index.ts | 1 + packages/vue/src/overlay.test.ts | 112 ++++++++++++ packages/vue/src/overlay.ts | 144 +++++++-------- 12 files changed, 628 insertions(+), 197 deletions(-) create mode 100644 packages/frontend-core/src/overlay-dom.test.ts create mode 100644 packages/frontend-core/src/overlay-dom.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 24931e6..660ff70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,12 @@ Per-package detail lives in the GitHub release tagged `@`. ### Added — state-keyed overlay host (pick-one, modal) -Implements the counter-proposal from the cat-factory slice-5 triage (`docs/overlay-host-triage.md`): the **overlay host**, a pick-**one**, app-state-keyed, open-contribution modal surface — the modal dual of the render-all panels. Modules (first-party and consumer alike) contribute _windows_ to a host's slot; application state names the one active window by id; the framework mounts it inside a managed modal shell owning the behaviour hand-rolled modals get wrong somewhere: teleport/portal, backdrop click-to-close, a shared overlay **stack** (nested overlays layer; Escape closes the top first), focus trap + focus return, body scroll lock, and a11y wiring (`role="dialog"` / `aria-modal` / `aria-label` from the entry's subject-aware `title`). Headless — the host renders only the backdrop and dialog elements (class props + stable `data-modular-overlay-*` e2e hooks); the app's chrome lives in a `wrap` render-prop/slot, and the host never closes itself (it _requests_ close; state stays app-owned). Contributions ride the existing module → slot path; no new registration seam, package, or descriptor change. +Implements the counter-proposal from the cat-factory slice-5 triage (`docs/overlay-host-triage.md`): the **overlay host**, a pick-**one**, app-state-keyed, open-contribution modal surface — the modal dual of the render-all panels. Modules (first-party and consumer alike) contribute _windows_ to a host's slot; application state names the one active window by id; the framework mounts it inside a managed modal shell owning the behaviour hand-rolled modals get wrong somewhere: teleport/portal, backdrop press-and-release close (a press that starts inside the dialog and slips onto the backdrop is not a close request), a shared overlay **stack** (nested overlays layer; Escape closes the top first), focus trap + focus return (re-applied when the active window swaps without closing), body scroll lock, and a11y wiring (`role="dialog"` / `aria-modal` / `aria-label` from the entry's subject-aware `title`). Headless — the host renders only the backdrop and dialog elements (class props + stable `data-modular-overlay-*` e2e hooks); the app's chrome lives in a `wrap` render-prop/slot, and the host never closes itself (it _requests_ close; state stays app-owned). Contributions ride the existing module → slot path; no new registration seam, package, or descriptor change. -- **`@modular-frontend/core`** — `OverlayEntry` (a superset of `ComponentEntry`, so a slot serving the pick-one pairing surface can serve an overlay host during an incremental migration; `title` is first-class because the shell wires it to `aria-label`, icons/variants stay in opaque `meta`), `OverlayHostHandle`, `defineOverlayHost(slotKey)` (phantom-typed handle, the `definePanelGroup` convention), `resolveOverlay(entries, activeId, opts?)` (duplicate-id validation first via the same shared collapse as `resolveComponentRegistry` / `resolvePanels`, then null-id → null, then pick-one lookup; a dangling id is data — `null`, host dev-warns — mirroring `pairById`'s `missing` stance), `resolveOverlayTitle`, and `createOverlayStack()` — a pure LIFO stack with a subscribe seam, the shared "top overlay closes first" semantics both bindings' behaviour implementations consume. Re-exported by `@modular-react/core` and `@modular-vue/core`. -- **`@modular-vue/vue`** (re-exported from `@modular-vue/core`) — `useOverlay` (a `computed` over both runtime slot sources + the active id, all inputs `MaybeRefOrGetter`), `` (Teleport with `to` / `teleportDisabled`, backdrop click-self → `close` emit, managed behaviour, `#wrap` / `#empty` slots, subject injected as a prop **and** via `provide`, per-`(entry.id, subjectKey)` keying, per-window `ModuleErrorBoundary` labeled `"Overlay"`), `useOverlaySubject` / `overlaySubjectKey`, and `useModalBehavior({ active, onClose, initialFocus? }) → { dialogRef, isTop }` — the managed behaviour standalone, for bespoke roots (full-bleed surfaces) that must stack/focus/scroll-lock correctly against hosted windows. +- **`@modular-frontend/core`** — `OverlayEntry` (a superset of `ComponentEntry`, so a slot serving the pick-one pairing surface can serve an overlay host during an incremental migration; `title` is first-class because the shell wires it to `aria-label`, icons/variants stay in opaque `meta`), `OverlayHostHandle`, `defineOverlayHost(slotKey)` (phantom-typed handle, the `definePanelGroup` convention), `resolveOverlay(entries, activeId, opts?)` (duplicate-id validation first via the same shared collapse as `resolveComponentRegistry` / `resolvePanels`, then null-id → null, then pick-one lookup; a dangling id is data — `null`, host dev-warns — mirroring `pairById`'s `missing` stance), `resolveOverlayTitle`, and `createOverlayStack()` — a pure LIFO stack with a subscribe seam, the shared "top overlay closes first" semantics both bindings' behaviour implementations consume. The behaviour's framework-neutral DOM semantics also live here (`overlay-dom.ts`, client-only, SSR-guarded): the single app-wide `sharedOverlayStack` instance, the counted body scroll lock (`lockBodyScroll` / `unlockBodyScroll`), and the focusable-scan/Tab-trap rules (`firstFocusableIn` / `trapTabFocus`) — one implementation, so the behaviour cannot drift between bindings; each binding contributes only event/reactivity glue. Re-exported by `@modular-react/core` and `@modular-vue/core`. +- **`@modular-vue/vue`** (re-exported from `@modular-vue/core`) — `useOverlay` (a `computed` over both runtime slot sources + the active id, all inputs `MaybeRefOrGetter`), `` (Teleport with `to` / `teleportDisabled`, backdrop press-and-release → `close` emit, managed behaviour, `#wrap` / `#empty` slots, subject injected as a prop **and** via `provide`, per-`(entry.id, subjectKey)` keying, per-window `ModuleErrorBoundary` labeled `"Overlay"`), `useOverlaySubject` / `overlaySubjectKey`, and `useModalBehavior({ active, onClose, initialFocus?, contentKey? }) → { dialogRef, isTop }` — the managed behaviour standalone, for bespoke roots (full-bleed surfaces) that must stack/focus/scroll-lock correctly against hosted windows (`contentKey` re-applies initial focus when the hosted content swaps under an open overlay). - **`@modular-react/react`** — the same surface with hooks/context/portal: `useOverlay`, `` (`empty` / `wrap` / `onClose` props, `to` / `portalDisabled`), `useOverlaySubject` / `OverlaySubjectContext`, `useModalBehavior`. -- **Docs** — new [`docs/overlay-host.md`](docs/overlay-host.md) guide (entry shape, resolver semantics, both hosts, the behaviour contract, the app-shell recipe, overlays-vs-neighbours); the compositions README's sibling-primitive comparison extended four → five; cross-links from `subject-panels.md`, `remote-capability-manifests.md`, and the Nuxt consumer-seam guide; the triage doc records what was accepted, redirected (no `defineModule({ overlays })` seam, no `/testing` subpath, no Nuxt manifest threading, no route-sync helper, Vue-only → both bindings), and why. +- **Docs** — new [`docs/overlay-host.md`](docs/overlay-host.md) guide (entry shape, resolver semantics, both hosts, the behaviour contract, the app-shell recipe, overlays-vs-neighbours), including a **"Conscious constraints"** section that freezes the behaviour bundle's scope: the guarantee list is the whole contract, and no-pixels-ever, no chrome anatomy, no background `inert` management, structural-only focusable detection, no platform scroll-lock workarounds, and no router awareness are out of scope by decision — the overlay host is the family's first DOM-owning surface, and the line it holds is documented rather than renegotiated per request. The compositions README's sibling-primitive comparison extended four → five; cross-links from `subject-panels.md`, `remote-capability-manifests.md`, and the Nuxt consumer-seam guide; the triage doc records what was accepted, redirected (no `defineModule({ overlays })` seam, no `/testing` subpath, no Nuxt manifest threading, no route-sync helper, Vue-only → both bindings), and why. - **Version alignment** — the request's carry-over peer-range item ("Gap D") was already closed by the shared-peer-dependency change below; the overlay surface is born onto the unified `>=0.1.0 <2.0.0` range with no per-package edits. ### Added — subject-keyed panels (render-all, predicate-gated) diff --git a/docs/overlay-host-triage.md b/docs/overlay-host-triage.md index 8ae693f..2bb615f 100644 --- a/docs/overlay-host-triage.md +++ b/docs/overlay-host-triage.md @@ -110,12 +110,14 @@ consumers have the identical need (the React family has no modal host either), a the operating rule restated in the slice-4 triage holds: _engine-first, both bindings in the same train._ Shipping a Vue-only overlay host would leave React to later port from the Vue binding — the inversion the family exists to prevent. The neutral parts -(entry/handle/resolver, and the overlay **stack** — pure order-of-registration data -with a subscribe seam, no DOM) land in `@modular-frontend/core`; each binding ships -the same thin host and behaviour composable/hook over them. The DOM-touching parts -(focus, scroll, key events) are per-binding by necessity, but they share the engine -stack so "the top overlay closes first" is one implementation of _semantics_, twice -of _glue_. +land in `@modular-frontend/core`: the entry/handle/resolver, the overlay **stack** +(pure order-of-registration data with a subscribe seam — one shared app-wide +instance), and the framework-neutral DOM behaviour semantics (focusable scan, +Tab-cycle trap, counted body scroll lock) as guarded client-only helpers. Each +binding ships the same thin host and behaviour composable/hook over them; only the +event registration and reactivity glue (effects/watchers, refs, re-renders) is +per-binding, so "the top overlay closes first" — and every other behaviour rule — +is one implementation of _semantics_, twice of _glue_. ### 4. §4E Nuxt threading and the `/testing` entry — obsolete for the same reason as slice 4 @@ -231,9 +233,9 @@ Vue (`@modular-vue/vue`, re-exported from `@modular-vue/core`): React (`@modular-react/react`, same train): `useOverlay`, `` (portal, `empty` / `wrap` props, `onClose`), `useOverlaySubject` / `OverlaySubjectContext`, -`useModalBehavior` — the same surface with `useMemo` / context / -`useSyncExternalStore` over the same engine stack. Angular: the engine part is done -for it; hosts when its gate opens. +`useModalBehavior` — the same surface with `useMemo` / context / a +subscription-driven re-render over the same engine stack. Angular: the engine part +is done for it; hosts when its gate opens. Module authors contribute through the existing field — no descriptor change: @@ -307,17 +309,33 @@ threading, no route helper): `OverlayHostHandle`, `defineOverlayHost`, `resolveOverlay` (dedupe via the shared `collapseEntriesById` before the null guard; pick-one lookup; dangling → `null`), `resolveOverlayTitle`, and `createOverlayStack` (pure LIFO with - `subscribe`, the shared stacking semantics). Re-exported by `@modular-react/core` - (`export *`) and `@modular-vue/core`. + `subscribe`, the shared stacking semantics). The behaviour's framework-neutral + DOM semantics also live in the engine (`overlay-dom.ts`, client-only and + SSR-guarded): the single app-wide `sharedOverlayStack` instance both bindings + register on, the counted body scroll lock, and the focusable-scan/Tab-trap rules + — one implementation, so the behaviour cannot drift between bindings. + Re-exported by `@modular-react/core` (`export *`) and `@modular-vue/core`. - **Vue** (`@modular-vue/vue`, re-exported from `@modular-vue/core`): `useOverlay`, - `` (Teleport, backdrop `click.self` → `close`, managed behaviour, - `#wrap` / `#empty`, subject as prop + `provide`, per-subject keying, stable data - attributes, `ModuleErrorBoundary` label `"Overlay"`), `useOverlaySubject` / - `overlaySubjectKey`, `useModalBehavior`. + `` (Teleport, backdrop press-and-release → `close`, managed + behaviour, `#wrap` / `#empty`, subject as prop + `provide`, per-subject keying, + stable data attributes, `ModuleErrorBoundary` label `"Overlay"`), + `useOverlaySubject` / `overlaySubjectKey`, `useModalBehavior`. - **React** (`@modular-react/react`): `useOverlay`, `` (portal, `empty` / `wrap` / `onClose` props), `useOverlaySubject` / `OverlaySubjectContext`, - `useModalBehavior` (`useSyncExternalStore` over the engine stack). + `useModalBehavior` (subscription-driven re-render over the engine stack). - **Docs**: [`docs/overlay-host.md`](overlay-host.md); comparison table extended four → five; cross-links per the plan. - **Gap D**: confirmed already closed by the shared-peer-dependency change; ships in the same release train. Nothing further to widen. + +A review-hardening pass in the same train tightened the behaviour exactly where +hand-rolled modals classically drift: backdrop close is **press-and-release** (a +press that starts inside the dialog and slips onto the backdrop — a text selection — +is not a close request), initial focus is **re-applied when the active window swaps +without closing** (`contentKey` on `useModalBehavior`; the outlets pass the mounted +window's key), and the behaviour bundle's scope is now a **documented contract** — +see "Conscious constraints" in [`docs/overlay-host.md`](overlay-host.md). That +section is the standing answer to future requests to grow the bundle: a behaviour +joins the guarantee list only when it is structural and visual-opinion-free; pixels, +chrome anatomy, background `inert`, the focusable-detection long tail, platform +scroll quirks, and router awareness stay out by decision. diff --git a/docs/overlay-host.md b/docs/overlay-host.md index 17806e4..5f5861e 100644 --- a/docs/overlay-host.md +++ b/docs/overlay-host.md @@ -209,12 +209,18 @@ Same surface with props instead of slots (`empty` / `wrap` are props), `to` / - **Teleported/portaled** to `body` by default; nothing renders while closed (the `#empty` slot renders in place). -- **Backdrop click-self** requests close (`closeOnBackdrop={false}` to opt out). +- **Backdrop press-and-release** requests close (`closeOnBackdrop={false}` to opt + out). Only a press that both starts _and_ releases on the backdrop counts — a press + that starts inside the dialog and slips onto the backdrop (a text selection, a + missed drag) is not a close request. - **One overlay stack per app.** Every open overlay — outlet-hosted or bespoke via - `useModalBehavior` — registers on one shared stack. Nested overlays layer in open - order; **Escape closes only the top**; when it closes, the one below becomes top. -- **Focus**: moved into the dialog on open (first focusable, else the dialog itself), - Tab-cycled within it while open, **returned to the opener** on close. + `useModalBehavior`, whichever binding mounted it — registers on the engine's single + `sharedOverlayStack`. Nested overlays layer in open order; **Escape closes only the + top**; when it closes, the one below becomes top. +- **Focus**: moved into the dialog on open (`initialFocus` if given, else the first + focusable, else the dialog itself), Tab-cycled within it while open, re-applied when + the active window **swaps without closing** (so focus follows the new content), and + **returned to the opener** on close. - **Scroll**: body scroll locked while any overlay is open, restored when the last closes. - **A11y**: `role="dialog"`, `aria-modal="true"`, `aria-label` from `title` resolved @@ -228,6 +234,43 @@ Same surface with props instead of slots (`empty` / `wrap` are props), `to` / remounts the window instead of leaking state across opens — the `subjectKey` contract from panels, pick-one edition. +## Conscious constraints — what the shell deliberately does _not_ do + +The overlay host is the first surface in this family that owns DOM behaviour rather +than pure rendering, and that line is held on purpose. The guarantee list above is the +whole contract: a behaviour joins it only when it is **structural** (it cannot be made +uniformly correct per app or per window — the reason the host exists) and carries +**zero visual opinion**. Everything below is out of scope by decision, not omission, +and feature requests for them start from a default of _no_: + +- **No pixels, ever.** No CSS ships, no default `z-index`, no positioning, no + transitions or animation hooks. Stacking order is DOM order at the teleport/portal + target; your `backdropClass` supplies `position`/`inset`/`z-index` along with the + rest of your design system. Enter/leave animation belongs to your `wrap` chrome. +- **No chrome anatomy.** The host renders exactly two elements — backdrop and dialog + panel — and will not grow header/footer/close-button slots. That anatomy was + proposed and rejected in the [triage](overlay-host-triage.md); `wrap` is the whole + answer. +- **No background `inert`/`aria-hidden` management.** `aria-modal="true"` is the + contract with assistive tech. Marking the rest of the app inert (and the churn of + restoring it correctly around portals, toasts, and third-party DOM) stays app scope. +- **Structural focusable detection only.** The focus trap scans by attributes + (`a[href]`, non-disabled form controls, tabindex) and does not chase the rendered + long tail — `visibility: hidden` content, elements hidden by an ancestor, + `contenteditable`, zero-size targets. If a window's first structural focusable is + visually hidden, pass `initialFocus` or restructure the window. +- **No platform scroll-lock workarounds.** The lock is a counted + `body.style.overflow` save/restore. iOS rubber-band suppression, + scrollbar-gutter compensation, and nested-scroll-container policy are app scope. +- **No router awareness** — held from the original triage. State in, requests out. + +If a surface needs behaviour beyond this contract, the supported moves are, in order: +put it in your `wrap`/chrome (visual), layer it over `useModalBehavior` (structural +but yours), or use a dedicated dialog library for that one surface — accepting that a +surface which bypasses `useModalBehavior` is invisible to the shared stack, so Escape +ordering and scroll-lock counting no longer coordinate with hosted windows. Do **not** +nest another focus-trapping dialog library _inside_ a hosted window: two traps fight. + ## `useModalBehavior` — the behaviour without the shell For a surface that needs a bespoke root (a full-bleed detail view, a hand-styled @@ -238,6 +281,9 @@ const { dialogRef, isTop } = useModalBehavior({ active: () => ui.detailOpen, // React: a boolean onClose: () => ui.closeDetail(), // initialFocus?: element to focus on activation + // contentKey?: identity of the hosted content — when it changes while + // active (the surface swaps content without closing), initial focus is + // re-applied so focus follows the swap }); // Put dialogRef on your root (give it tabindex="-1"); style it however you like. ``` diff --git a/packages/frontend-core/src/index.ts b/packages/frontend-core/src/index.ts index e3afa42..d1e1560 100644 --- a/packages/frontend-core/src/index.ts +++ b/packages/frontend-core/src/index.ts @@ -128,6 +128,17 @@ export type { OverlayStack, OverlayStackTicket, } from "./overlay.js"; +// Client-only DOM behaviour shared by the bindings' overlay hosts: the single +// app-wide stack instance plus the focus/scroll semantics that must not drift +// between bindings. Consumed by `useModalBehavior` in the framework packages; +// apps normally never touch these directly. +export { + sharedOverlayStack, + lockBodyScroll, + unlockBodyScroll, + firstFocusableIn, + trapTabFocus, +} from "./overlay-dom.js"; // Validation export { diff --git a/packages/frontend-core/src/overlay-dom.test.ts b/packages/frontend-core/src/overlay-dom.test.ts new file mode 100644 index 0000000..03248f2 --- /dev/null +++ b/packages/frontend-core/src/overlay-dom.test.ts @@ -0,0 +1,36 @@ +// Runs in the engine's node environment on purpose: what the engine can +// meaningfully assert about overlay-dom is that importing and calling it +// without a `document` is safe (the SSR guarantee) and that the shared stack +// instance is live. The DOM semantics (focus trap, initial-focus scan, actual +// body scroll lock) are exercised through both bindings' jsdom suites, which +// consume this same single implementation. +import { describe, it, expect } from "vitest"; +import { lockBodyScroll, sharedOverlayStack, unlockBodyScroll } from "./overlay-dom.js"; + +describe("sharedOverlayStack", () => { + it("is one live app-wide stack", () => { + const before = sharedOverlayStack.size; + const a = sharedOverlayStack.push(); + const b = sharedOverlayStack.push(); + expect(sharedOverlayStack.size).toBe(before + 2); + expect(b.isTop()).toBe(true); + expect(a.isTop()).toBe(false); + b.release(); + expect(a.isTop()).toBe(true); + a.release(); + expect(sharedOverlayStack.size).toBe(before); + }); +}); + +describe("body scroll lock (no document)", () => { + it("no-ops without a document instead of throwing (SSR safety)", () => { + expect(typeof document).toBe("undefined"); + expect(() => { + lockBodyScroll(); + lockBodyScroll(); + unlockBodyScroll(); + unlockBodyScroll(); + unlockBodyScroll(); // over-release is safe too + }).not.toThrow(); + }); +}); diff --git a/packages/frontend-core/src/overlay-dom.ts b/packages/frontend-core/src/overlay-dom.ts new file mode 100644 index 0000000..e9d9e10 --- /dev/null +++ b/packages/frontend-core/src/overlay-dom.ts @@ -0,0 +1,117 @@ +/** + * Client-only DOM behaviour shared by the bindings' overlay hosts — the single + * implementation of the modal behaviour *semantics* that must not drift + * between React and Vue (the `collapseEntriesById` argument, applied to focus + * and scroll): which elements count as focusable, how Tab wraps at the + * dialog's edges, how the body scroll lock is counted, and the one app-wide + * {@link sharedOverlayStack} every overlay registers on. + * + * The engine's resolver surface (`overlay.ts`) stays pure — this module is the + * deliberate exception, and it holds a narrow line: + * + * - **No DOM at module scope.** Everything here is a function a binding calls + * from client-side effects/watchers; each entry point no-ops without a + * `document`, so importing this module is SSR-safe. + * - **No event registration, no reactivity.** Listening for keydowns, wiring + * refs, and re-rendering on stack changes is the per-binding glue; this + * module only answers "what should happen". + * + * Consumed by `useModalBehavior` in `@modular-react/react` and + * `@modular-vue/vue`; apps normally never touch these directly. + */ + +import { createOverlayStack, type OverlayStack } from "./overlay.js"; + +/** + * The one app-wide overlay stack. Both bindings' `useModalBehavior` (and + * therefore both `` hosts) register on this instance, so every + * overlay in an app — outlet-hosted or bespoke — shares one ordering and + * "Escape closes the top first" holds across all of them. It lives in the + * engine (a shared peer dependency, so exactly one copy exists) rather than in + * a binding, so the ordering even survives a mixed-binding app — e.g. a + * migration running React and Vue surfaces side by side. + * + * Use {@link createOverlayStack} instead for an isolated stack (tests, a + * coordination scope deliberately separate from the app's overlays). + */ +export const sharedOverlayStack: OverlayStack = createOverlayStack(); + +// One body-overflow save/restore across however many overlays are open, +// whichever binding, host, or composable opened them. +let scrollLockCount = 0; +let prevBodyOverflow = ""; + +/** + * Lock body scroll (counted): the first lock saves `body.style.overflow` and + * sets it to `hidden`; further locks only increment. No-op without a + * `document`. Pair every call with {@link unlockBodyScroll}. + */ +export function lockBodyScroll(): void { + if (typeof document === "undefined") return; + if (scrollLockCount++ === 0) { + prevBodyOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + } +} + +/** + * Release one {@link lockBodyScroll}; the last release restores the saved + * `body.style.overflow`. Safe to call when nothing is locked (no-op). + */ +export function unlockBodyScroll(): void { + if (typeof document === "undefined") return; + if (scrollLockCount > 0 && --scrollLockCount === 0) { + document.body.style.overflow = prevBodyOverflow; + } +} + +// What the behaviour treats as focusable. Deliberately structural (attributes +// only): it does not chase the rendered-visibility long tail (visibility: +// hidden, display: none in a parent, zero-size) — see the "conscious +// constraints" section of docs/overlay-host.md. +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(", "); + +/** + * The first focusable element inside `root` (the behaviour's initial-focus + * default), or `null` when the dialog has no focusable content — callers then + * focus `root` itself (which is why the hosts render the panel with + * `tabindex="-1"`). + */ +export function firstFocusableIn(root: HTMLElement): HTMLElement | null { + return root.querySelector(FOCUSABLE_SELECTOR); +} + +/** + * Tab-cycle containment for a Tab/Shift+Tab `keydown`: wrap focus at the + * dialog's edges; if focus escaped the dialog (or the dialog has no focusable + * content), pull it back in. The caller is responsible for only invoking this + * for the top-of-stack overlay. + */ +export function trapTabFocus(event: KeyboardEvent, root: HTMLElement): void { + const focusables = Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusables.length === 0) { + event.preventDefault(); + root.focus(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + const active = document.activeElement; + const inside = active instanceof HTMLElement && root.contains(active); + if (event.shiftKey) { + if (!inside || active === first) { + event.preventDefault(); + last.focus(); + } + } else if (!inside || active === last) { + event.preventDefault(); + first.focus(); + } +} diff --git a/packages/frontend-core/src/overlay.ts b/packages/frontend-core/src/overlay.ts index 4f7f5ac..166e08c 100644 --- a/packages/frontend-core/src/overlay.ts +++ b/packages/frontend-core/src/overlay.ts @@ -200,11 +200,13 @@ export interface OverlayStackTicket { * The shared stacking semantics behind "the top overlay closes first" and * "nested overlays layer correctly": a LIFO stack of live registrations with a * subscribe seam so a binding can make `isTop` reactive (a Vue `ref` bumped on - * notify, a React `useSyncExternalStore`). Pure data — no DOM, no listeners of - * its own — which is what lets it live in the neutral engine while the - * DOM-touching behaviour (key events, focus, scroll) stays per binding. Both - * bindings consume one module-level stack instance, so every overlay in an app - * — outlet-hosted or bespoke via `useModalBehavior` — shares one ordering. + * notify, a version-bump re-render in React). Pure data — no DOM, no listeners + * of its own — which is what lets it live in the neutral engine while the + * event/reactivity glue stays per binding. Both bindings' behaviour + * implementations register on the engine's single `sharedOverlayStack` + * instance (see `overlay-dom.ts`), so every overlay in an app — outlet-hosted + * or bespoke via `useModalBehavior`, whichever binding mounted it — shares one + * ordering. */ export interface OverlayStack { /** Register an activating overlay; the newest registration is the top. */ @@ -219,8 +221,10 @@ export interface OverlayStack { } /** - * Create an {@link OverlayStack}. Bindings create one per module scope; apps - * only need their own for bespoke coordination outside the shipped hosts. + * Create an {@link OverlayStack}. The bindings' hosts all register on the one + * engine-provided `sharedOverlayStack` — create your own only for an isolated + * coordination scope (tests, a surface deliberately outside the app's overlay + * ordering). */ export function createOverlayStack(): OverlayStack { // Tokens are per-ticket object identities; order of the array is stack order. diff --git a/packages/react/src/overlay.test.tsx b/packages/react/src/overlay.test.tsx index da6389d..81ad553 100644 --- a/packages/react/src/overlay.test.tsx +++ b/packages/react/src/overlay.test.tsx @@ -86,6 +86,19 @@ describe("OverlayOutlet — selection", () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('active id "not-registered"')); warn.mockRestore(); }); + + it("portals into a custom target, and renders in place with portalDisabled", () => { + const target = document.createElement("div"); + target.className = "custom-target"; + document.body.appendChild(target); + renderOutlet({ activeId: "test-report", to: target }); + expect(target.querySelector("[data-modular-overlay-panel]")).not.toBeNull(); + cleanup(); + target.remove(); + + const { container } = renderOutlet({ activeId: "test-report", portalDisabled: true }); + expect(container.querySelector("[data-modular-overlay-panel]")).not.toBeNull(); + }); }); describe("OverlayOutlet — shell and a11y", () => { @@ -156,6 +169,24 @@ describe("OverlayOutlet — close requests", () => { expect(onClose2).not.toHaveBeenCalled(); }); + it("does not close when a press starts inside the dialog and releases on the backdrop", () => { + const onClose = vi.fn(); + renderOutlet({ activeId: "test-report", onClose }); + const backdrop = document.querySelector("[data-modular-overlay-backdrop]")!; + const panel = document.querySelector("[data-modular-overlay-panel]")!; + + // Text selection / slipped drag: press starts on the panel, click lands on + // the backdrop — not a close request. + fireEvent.pointerDown(panel); + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + + // A deliberate backdrop press: starts and releases on the backdrop. + fireEvent.pointerDown(backdrop); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("Escape closes only the top of the stack; the one below closes next", () => { const closeUnder = vi.fn(); const closeOver = vi.fn(); @@ -211,6 +242,63 @@ describe("OverlayOutlet — managed behaviour", () => { opener.remove(); }); + it("wraps Tab at the dialog's edges and pulls escaped focus back in", () => { + function TwoButtons() { + return ( + <> + + + + ); + } + renderOutlet({ activeId: "w" }, slotsOf({ id: "w", component: TwoButtons })); + expect((document.activeElement as HTMLElement | null)?.className).toBe("first"); + + // Tab from the last focusable wraps to the first. + (document.querySelector(".second") as HTMLElement).focus(); + fireEvent.keyDown(document, { key: "Tab" }); + expect((document.activeElement as HTMLElement | null)?.className).toBe("first"); + + // Shift+Tab from the first wraps to the last. + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + expect((document.activeElement as HTMLElement | null)?.className).toBe("second"); + + // Focus that escaped the dialog entirely is pulled back in on Tab. + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + fireEvent.keyDown(document, { key: "Tab" }); + expect((document.activeElement as HTMLElement | null)?.className).toBe("first"); + outside.remove(); + }); + + it("focuses the panel itself when the window has no focusable content, and keeps it there on Tab", () => { + renderOutlet({ activeId: "merger-verdict" }); + const panel = document.querySelector("[data-modular-overlay-panel]")!; + expect(document.activeElement).toBe(panel); + fireEvent.keyDown(document, { key: "Tab" }); + expect(document.activeElement).toBe(panel); + }); + + it("moves focus into the new window when the active id switches without closing", () => { + function WinA() { + return ; + } + function WinB() { + return ; + } + const slots = slotsOf({ id: "a", component: WinA }, { id: "b", component: WinB }); + const view = renderOutlet({ activeId: "a" }, slots); + expect((document.activeElement as HTMLElement | null)?.className).toBe("in-a"); + + view.rerender( + + + , + ); + expect((document.activeElement as HTMLElement | null)?.className).toBe("in-b"); + }); + it("remounts window content when subjectKey changes with the subject", () => { // Captures its first subject in state, so the test can tell a reused // instance from a remounted one. @@ -265,6 +353,25 @@ describe("useOverlaySubject", () => { }); describe("useModalBehavior (standalone)", () => { + it("gives initial focus to initialFocus when provided", () => { + function Bespoke({ active }: { active: boolean }) { + const [wanted, setWanted] = useState(null); + const { dialogRef } = useModalBehavior({ active, onClose: () => {}, initialFocus: wanted }); + return ( +
} tabIndex={-1}> + + +
+ ); + } + const view = render(); + view.rerender(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("wanted"); + view.unmount(); + }); + it("tracks top-of-stack across bespoke and hosted overlays, and closes top-first", () => { const closed = vi.fn(); const { result, rerender } = renderHook( diff --git a/packages/react/src/overlay.tsx b/packages/react/src/overlay.tsx index 86a21af..4fcf314 100644 --- a/packages/react/src/overlay.tsx +++ b/packages/react/src/overlay.tsx @@ -10,10 +10,14 @@ import { import { createPortal } from "react-dom"; import type { ComponentType, ReactNode, RefObject } from "react"; import { - createOverlayStack, + firstFocusableIn, isDevEnv, + lockBodyScroll, resolveOverlay, resolveOverlayTitle, + sharedOverlayStack, + trapTabFocus, + unlockBodyScroll, type OnDuplicateComponentId, type OverlayEntry, type OverlayHostHandle, @@ -110,62 +114,11 @@ export function useOverlay( // Managed modal behaviour // --------------------------------------------------------------------------- -// One stack per binding module scope: every overlay in the app — outlet-hosted -// or bespoke via useModalBehavior — shares one ordering, so "the top overlay -// closes first" holds across both. Pure data (no DOM), so module-level -// creation is SSR-safe. -const modalStack = createOverlayStack(); - -// Shared scroll lock: one body-overflow save/restore across however many -// overlays are open, whichever host or hook opened them. -let scrollLockCount = 0; -let prevBodyOverflow = ""; - -function lockScroll(): void { - if (scrollLockCount++ === 0) { - prevBodyOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - } -} - -function unlockScroll(): void { - if (scrollLockCount > 0 && --scrollLockCount === 0) { - document.body.style.overflow = prevBodyOverflow; - } -} - -const FOCUSABLE_SELECTOR = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - '[tabindex]:not([tabindex="-1"])', -].join(", "); - -// Tab-cycle containment: wrap focus at the dialog's edges; if focus escaped -// (or the dialog has no focusable content), pull it back in. -function trapTab(event: KeyboardEvent, root: HTMLElement): void { - const focusables = Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)); - if (focusables.length === 0) { - event.preventDefault(); - root.focus(); - return; - } - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - const active = document.activeElement; - const inside = active instanceof HTMLElement && root.contains(active); - if (event.shiftKey) { - if (!inside || active === first) { - event.preventDefault(); - last.focus(); - } - } else if (!inside || active === last) { - event.preventDefault(); - first.focus(); - } -} +// The stack instance, scroll lock, and focus semantics live in the engine +// (`sharedOverlayStack`, `lockBodyScroll` / `unlockBodyScroll`, +// `firstFocusableIn` / `trapTabFocus`) — one implementation shared with the +// Vue binding so the behaviour cannot drift. This module contributes only the +// React glue: effects, refs, and re-renders. /** * The managed modal *behaviour* as a standalone hook, for a window that needs @@ -200,6 +153,14 @@ export function useModalBehavior(opts: { onClose: () => void; /** Element to receive initial focus on activation (default: first focusable). */ initialFocus?: HTMLElement | null; + /** + * Identity of the content currently hosted inside the dialog. When it + * changes while `active` (the overlay swaps windows without closing — + * `` passes the mounted window's key), initial focus is + * re-applied so focus follows the new content instead of falling to `body` + * with the old one. Irrelevant for a root whose content never swaps. + */ + contentKey?: string | number | null; }): { dialogRef: RefObject; isTop: boolean } { const dialogRef = useRef(null); const ticketRef = useRef(null); @@ -212,25 +173,25 @@ export function useModalBehavior(opts: { initialFocusRef.current = opts.initialFocus; // Re-render on stack changes and re-read `isTop` during render. Deliberately - // NOT useSyncExternalStore: `modalStack.push()` notifies synchronously - // *before* the activation effect below can assign `ticketRef`, so an eager - // snapshot read at notify time would see a stale null and settle on `false`. - // A version bump re-renders instead, and the render-time read happens after - // the ticket is assigned. The explicit bump after assignment covers this - // instance's own push; the subscription covers everyone else's. + // NOT useSyncExternalStore: `sharedOverlayStack.push()` notifies + // synchronously *before* the activation effect below can assign `ticketRef`, + // so an eager snapshot read at notify time would see a stale null and settle + // on `false`. A version bump re-renders instead, and the render-time read + // happens after the ticket is assigned. The explicit bump after assignment + // covers this instance's own push; the subscription covers everyone else's. const [, bumpStackVersion] = useReducer((n: number) => n + 1, 0); - useEffect(() => modalStack.subscribe(bumpStackVersion), []); + useEffect(() => sharedOverlayStack.subscribe(bumpStackVersion), []); const isTop = ticketRef.current?.isTop() ?? false; useEffect(() => { if (!opts.active) return; - const ticket = modalStack.push(); + const ticket = sharedOverlayStack.push(); ticketRef.current = ticket; bumpStackVersion(); const restoreFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null; - lockScroll(); + lockBodyScroll(); const onKeydown = (event: KeyboardEvent) => { if (!ticket.isTop()) return; @@ -240,27 +201,34 @@ export function useModalBehavior(opts: { return; } if (event.key === "Tab" && dialogRef.current) { - trapTab(event, dialogRef.current); + trapTabFocus(event, dialogRef.current); } }; document.addEventListener("keydown", onKeydown, true); - // The portal content committed with this effect's render; focus directly. - const target = - initialFocusRef.current ?? - dialogRef.current?.querySelector(FOCUSABLE_SELECTOR) ?? - dialogRef.current; - target?.focus(); - return () => { document.removeEventListener("keydown", onKeydown, true); - unlockScroll(); + unlockBodyScroll(); ticketRef.current = null; ticket.release(); if (restoreFocusTo?.isConnected) restoreFocusTo.focus(); }; }, [opts.active]); + // Initial focus, separate from activation so it re-runs when the hosted + // content swaps under an open overlay (contentKey change): the old window's + // focused element unmounts and focus would otherwise fall to `body`. Runs + // after the activation effect above (declaration order), against the DOM + // this render committed. + useEffect(() => { + if (!opts.active) return; + const target = + initialFocusRef.current ?? + (dialogRef.current ? firstFocusableIn(dialogRef.current) : null) ?? + dialogRef.current; + target?.focus(); + }, [opts.active, opts.contentKey]); + return { dialogRef, isTop }; } @@ -303,7 +271,11 @@ export interface OverlayOutletProps { readonly to?: Element | null; /** Render in place instead of portaling (tests, inline embedding). */ readonly portalDisabled?: boolean; - /** Close on backdrop click-self (default `true`). */ + /** + * Request close when a press starts **and** releases on the backdrop itself + * (default `true`). A press that starts inside the dialog and slips onto the + * backdrop — a text selection, a missed drag — is not a close request. + */ readonly closeOnBackdrop?: boolean; /** The app's styling for the two host-rendered elements. Headless otherwise. */ readonly backdropClassName?: string; @@ -367,30 +339,44 @@ export function OverlayOutlet({ }: OverlayOutletProps): ReactNode { const entry = useOverlay(host, activeId, onDuplicate ? { onDuplicate } : undefined); - const close = useCallback(() => onClose?.(), [onClose]); - const { dialogRef, isTop } = useModalBehavior({ active: entry !== null, onClose: close }); + const key = + entry === null + ? null + : subjectKey === undefined + ? entry.id + : `${entry.id}:${typeof subjectKey === "function" ? subjectKey(subject) : subjectKey}`; + const close = useCallback(() => onClose?.(), [onClose]); + const { dialogRef, isTop } = useModalBehavior({ + active: entry !== null, + onClose: close, + contentKey: key, + }); + + // A press that starts inside the dialog (text selection, a slipped drag) and + // releases over the backdrop still fires `click` on the backdrop — that is + // not a close request. Close only when the press both started and ended on + // the backdrop itself. + const pressStartedInsidePanelRef = useRef(false); + + const dangling = activeId != null && entry === null ? activeId : null; useEffect(() => { - if (isDevEnv() && activeId != null && entry === null) { + if (isDevEnv() && dangling !== null) { // A dangling active id is data, not a crash (the id may name a window // another deployment ships) — but it is worth a loud dev breadcrumb. console.warn( - `[@modular-react/react] OverlayOutlet: active id "${activeId}" matches no registered ` + + `[@modular-react/react] OverlayOutlet: active id "${dangling}" matches no registered ` + `overlay in slot "${host.slotKey}". Rendering nothing. Register a window under that ` + `id (module slots) or clear the id.`, ); } - }, [activeId, entry, host.slotKey]); + }, [dangling, host.slotKey]); if (entry === null) return empty ?? null; const Component = entry.component as ComponentType>; const content = ; const inner = wrap ? wrap({ entry, subject, close, isTop, children: content }) : content; - const key = - subjectKey === undefined - ? entry.id - : `${entry.id}:${typeof subjectKey === "function" ? subjectKey(subject) : subjectKey}`; const shell = ( @@ -398,8 +384,15 @@ export function OverlayOutlet({ className={backdropClassName} data-modular-overlay-backdrop="" data-overlay-id={entry.id} + onPointerDown={(event) => { + pressStartedInsidePanelRef.current = event.target !== event.currentTarget; + }} onClick={(event) => { - if (closeOnBackdrop && event.target === event.currentTarget) close(); + const pressStartedInsidePanel = pressStartedInsidePanelRef.current; + pressStartedInsidePanelRef.current = false; + if (closeOnBackdrop && event.target === event.currentTarget && !pressStartedInsidePanel) { + close(); + } }} >
{ expect(wrapper.emitted("close")).toBeUndefined(); }); + it("does not emit close when a press starts inside the dialog and releases on the backdrop", async () => { + const wrapper = track(mountOutlet({ activeId: "test-report", subject: step })); + + // Text selection / slipped drag: press starts on the panel, click lands on + // the backdrop — not a close request. + await wrapper.find("[data-modular-overlay-panel]").trigger("pointerdown"); + await wrapper.find("[data-modular-overlay-backdrop]").trigger("click"); + expect(wrapper.emitted("close")).toBeUndefined(); + + // A deliberate backdrop press: starts and releases on the backdrop. + await wrapper.find("[data-modular-overlay-backdrop]").trigger("pointerdown"); + await wrapper.find("[data-modular-overlay-backdrop]").trigger("click"); + expect(wrapper.emitted("close")).toHaveLength(1); + }); + it("emits close on Escape", async () => { const wrapper = track(mountOutlet({ activeId: "test-report", subject: step })); await nextTick(); @@ -269,6 +284,79 @@ describe("OverlayOutlet — managed behaviour", () => { expect(document.activeElement).toBe(opener); }); + it("wraps Tab at the dialog's edges and pulls escaped focus back in", async () => { + const TwoButtons = defineComponent({ + props: { subject: { type: null as never, default: null } }, + setup() { + return () => [h("button", { class: "first" }, "a"), h("button", { class: "second" }, "b")]; + }, + }); + track( + mountOutlet( + { activeId: "w", subject: step, teleportDisabled: false }, + entries({ id: "w", component: TwoButtons }), + ), + ); + await nextTick(); + await nextTick(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("first"); + + const pressTab = (shiftKey = false) => + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey, bubbles: true })); + + // Tab from the last focusable wraps to the first. + (document.querySelector(".second") as HTMLElement).focus(); + pressTab(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("first"); + + // Shift+Tab from the first wraps to the last. + pressTab(true); + expect((document.activeElement as HTMLElement | null)?.className).toBe("second"); + + // Focus that escaped the dialog entirely is pulled back in on Tab. + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + pressTab(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("first"); + outside.remove(); + }); + + it("focuses the panel itself when the window has no focusable content, and keeps it there on Tab", async () => { + track(mountOutlet({ activeId: "merger-verdict", teleportDisabled: false })); + await nextTick(); + await nextTick(); + const panel = document.body.querySelector("[data-modular-overlay-panel]"); + expect(document.activeElement).toBe(panel); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true })); + expect(document.activeElement).toBe(panel); + }); + + it("moves focus into the new window when the active id switches without closing", async () => { + const WinA = defineComponent({ + props: { subject: { type: null as never, default: null } }, + setup: () => () => h("button", { class: "in-a" }, "a"), + }); + const WinB = defineComponent({ + props: { subject: { type: null as never, default: null } }, + setup: () => () => h("button", { class: "in-b" }, "b"), + }); + const wrapper = track( + mountOutlet( + { activeId: "a", subject: step, teleportDisabled: false }, + entries({ id: "a", component: WinA }, { id: "b", component: WinB }), + ), + ); + await nextTick(); + await nextTick(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("in-a"); + + await wrapper.setProps({ activeId: "b" }); + await nextTick(); + await nextTick(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("in-b"); + }); + it("keeps a window's instance state across subject changes without subjectKey, remounts with it", async () => { // Captures its first subject at setup, so the test can tell a reused // instance (stale capture survives) from a remounted one (fresh capture). @@ -324,6 +412,30 @@ describe("useOverlaySubject", () => { }); describe("useModalBehavior (standalone)", () => { + it("gives initial focus to initialFocus when provided", async () => { + const Bespoke = defineComponent({ + props: { active: { type: Boolean, default: false } }, + setup(props) { + const wanted = ref(null); + const { dialogRef } = useModalBehavior({ + active: () => props.active, + onClose: () => {}, + initialFocus: wanted, + }); + return () => + h("div", { ref: dialogRef, tabindex: -1 }, [ + h("button", "first"), + h("button", { ref: wanted, class: "wanted" }, "second"), + ]); + }, + }); + const wrapper = track(mount(Bespoke, { attachTo: document.body })); + await wrapper.setProps({ active: true }); + await nextTick(); + await nextTick(); + expect((document.activeElement as HTMLElement | null)?.className).toBe("wanted"); + }); + it("tracks top-of-stack across bespoke and hosted overlays, and closes top-first", async () => { const active = ref(false); const closed = vi.fn(); diff --git a/packages/vue/src/overlay.ts b/packages/vue/src/overlay.ts index 372afae..2b2a0a5 100644 --- a/packages/vue/src/overlay.ts +++ b/packages/vue/src/overlay.ts @@ -19,10 +19,14 @@ import { type VNode, } from "vue"; import { - createOverlayStack, + firstFocusableIn, isDevEnv, + lockBodyScroll, resolveOverlay, resolveOverlayTitle, + sharedOverlayStack, + trapTabFocus, + unlockBodyScroll, type OnDuplicateComponentId, type OverlayEntry, type OverlayHostHandle, @@ -123,75 +127,18 @@ export function useOverlay( // Managed modal behaviour // --------------------------------------------------------------------------- -// One stack per binding module scope: every overlay in the app — outlet-hosted -// or bespoke via useModalBehavior — shares one ordering, so "the top overlay -// closes first" holds across both. Pure data (no DOM), so module-level -// creation is SSR-safe. -const modalStack = createOverlayStack(); +// The stack instance, scroll lock, and focus semantics live in the engine +// (`sharedOverlayStack`, `lockBodyScroll` / `unlockBodyScroll`, +// `firstFocusableIn` / `trapTabFocus`) — one implementation shared with the +// React binding so the behaviour cannot drift. This module contributes only +// the Vue glue: watchers, refs, and reactivity. // Reactive mirror of stack changes so per-instance `isTop` computeds re-read. const stackVersion = ref(0); -modalStack.subscribe(() => { +sharedOverlayStack.subscribe(() => { stackVersion.value++; }); -// Shared scroll lock: one body-overflow save/restore across however many -// overlays are open, whichever host or composable opened them. -let scrollLockCount = 0; -let prevBodyOverflow = ""; - -function lockScroll(): void { - if (typeof document === "undefined") return; - if (scrollLockCount++ === 0) { - prevBodyOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - } -} - -function unlockScroll(): void { - if (typeof document === "undefined") return; - if (scrollLockCount > 0 && --scrollLockCount === 0) { - document.body.style.overflow = prevBodyOverflow; - } -} - -const FOCUSABLE_SELECTOR = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - "textarea:not([disabled])", - '[tabindex]:not([tabindex="-1"])', -].join(", "); - -function focusableWithin(root: HTMLElement): HTMLElement[] { - return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)); -} - -// Tab-cycle containment: wrap focus at the dialog's edges; if focus escaped -// (or the dialog has no focusable content), pull it back in. -function trapTab(event: KeyboardEvent, root: HTMLElement): void { - const focusables = focusableWithin(root); - if (focusables.length === 0) { - event.preventDefault(); - root.focus(); - return; - } - const first = focusables[0]; - const last = focusables[focusables.length - 1]; - const active = document.activeElement; - const inside = active instanceof HTMLElement && root.contains(active); - if (event.shiftKey) { - if (!inside || active === first) { - event.preventDefault(); - last.focus(); - } - } else if (!inside || active === last) { - event.preventDefault(); - first.focus(); - } -} - /** * The managed modal *behaviour* as a standalone composable, for a window that * needs a bespoke root (a full-bleed detail surface, a hand-styled shell) but @@ -226,6 +173,14 @@ export function useModalBehavior(opts: { onClose: () => void; /** Element to receive initial focus on activation (default: first focusable). */ initialFocus?: MaybeRefOrGetter; + /** + * Identity of the content currently hosted inside the dialog. When it + * changes while `active` (the overlay swaps windows without closing — + * `` passes the mounted window's key), initial focus is + * re-applied so focus follows the new content instead of falling to `body` + * with the old one. Irrelevant for a root whose content never swaps. + */ + contentKey?: MaybeRefOrGetter; }): { dialogRef: Ref; isTop: ComputedRef } { const dialogRef = ref(null); let ticket: OverlayStackTicket | null = null; @@ -245,34 +200,38 @@ export function useModalBehavior(opts: { return; } if (event.key === "Tab" && dialogRef.value) { - trapTab(event, dialogRef.value); + trapTabFocus(event, dialogRef.value); } }; - const activate = () => { - // Client-only: on the server the behaviour is inert (nothing teleports, - // nothing focuses); the app hydrates and activates in the browser. - if (typeof document === "undefined" || ticket) return; - ticket = modalStack.push(); - restoreFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null; - lockScroll(); - document.addEventListener("keydown", onKeydown, true); - // The dialog renders in the same tick the activation flag flips; focus - // after the DOM settles. + const focusIntoDialog = () => { + // The dialog (or its swapped-in content) renders in the same tick the + // trigger flips; focus after the DOM settles. void nextTick(() => { if (!ticket) return; // deactivated before the tick settled const target = toValue(opts.initialFocus) ?? - dialogRef.value?.querySelector(FOCUSABLE_SELECTOR) ?? + (dialogRef.value ? firstFocusableIn(dialogRef.value) : null) ?? dialogRef.value; target?.focus(); }); }; + const activate = () => { + // Client-only: on the server the behaviour is inert (nothing teleports, + // nothing focuses); the app hydrates and activates in the browser. + if (typeof document === "undefined" || ticket) return; + ticket = sharedOverlayStack.push(); + restoreFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : null; + lockBodyScroll(); + document.addEventListener("keydown", onKeydown, true); + focusIntoDialog(); + }; + const deactivate = () => { if (!ticket) return; document.removeEventListener("keydown", onKeydown, true); - unlockScroll(); + unlockBodyScroll(); ticket.release(); ticket = null; if (restoreFocusTo?.isConnected) restoreFocusTo.focus(); @@ -287,6 +246,16 @@ export function useModalBehavior(opts: { }, { immediate: true, flush: "post" }, ); + // Re-apply initial focus when the hosted content swaps under an open + // overlay: the old window's focused element unmounts and focus would + // otherwise fall to `body`. + watch( + () => toValue(opts.contentKey), + () => { + if (ticket) focusIntoDialog(); + }, + { flush: "post" }, + ); onBeforeUnmount(deactivate); return { dialogRef, isTop }; @@ -323,7 +292,10 @@ function keyFor(entry: OverlayEntry, subjectKey: unknown, subject: unkn * the `` `subjectKey` contract. * - `to` — teleport target (default `"body"`); `teleportDisabled` renders in * place (tests, inline embedding). - * - `closeOnBackdrop` — default `true`. + * - `closeOnBackdrop` — default `true`. Close is requested only when a press + * both starts and releases on the backdrop itself; a press that starts + * inside the dialog and slips onto the backdrop (a text selection, a missed + * drag) is not a close request. * - `backdropClass` / `panelClass` — the app's styling for the only two * elements the host renders. Headless: no opinionated CSS is applied; a * bare host is functional but unstyled. @@ -382,6 +354,7 @@ export const OverlayOutlet = defineComponent({ const { dialogRef, isTop } = useModalBehavior({ active: () => entry.value !== null, onClose: close, + contentKey: () => (entry.value ? keyFor(entry.value, props.subjectKey, props.subject) : null), }); if (isDevEnv()) { @@ -402,8 +375,20 @@ export const OverlayOutlet = defineComponent({ ); } + // A press that starts inside the dialog (text selection, a slipped drag) + // and releases over the backdrop still fires `click` on the backdrop — + // that is not a close request. Close only when the press both started and + // ended on the backdrop itself. + let pressStartedInsidePanel = false; + const onBackdropPointerdown = (event: PointerEvent) => { + pressStartedInsidePanel = event.target !== event.currentTarget; + }; const onBackdropClick = (event: MouseEvent) => { - if (props.closeOnBackdrop && event.target === event.currentTarget) close(); + const startedInsidePanel = pressStartedInsidePanel; + pressStartedInsidePanel = false; + if (props.closeOnBackdrop && event.target === event.currentTarget && !startedInsidePanel) { + close(); + } }; return () => { @@ -433,6 +418,7 @@ export const OverlayOutlet = defineComponent({ class: props.backdropClass, "data-modular-overlay-backdrop": "", "data-overlay-id": active.id, + onPointerdown: onBackdropPointerdown, onClick: onBackdropClick, }, [ From 6513ee78fdcc319d2024329f64258d6e547ead9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 15:15:09 +0000 Subject: [PATCH 4/6] docs(overlays): runnable React + Vue overlay-host examples with e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay host was the slice-5 deliverable but had no runnable example — the panels sibling has inspector-panels; overlays had nothing. Add the pick-one, modal mirror of inspector-panels on BOTH bindings (the behaviour is engine-first, so both must observe the same contract), each with a Playwright suite asserting the full managed-modal contract. New examples (examples/{react-router,vue}/overlay-result-windows): - app-shared exports the shared defineOverlayHost handle + WindowMeta. - run-core contributes test-report (dynamic title → aria-label; a nested bespoke useModalBehavior confirm on the shared stack) and run-logs (useOverlaySubject). acme-extras is a consumer window added with no host edit (namespaced id). These are the first Vue example modules to contribute `slots`. - shell mounts one , styles the headless host, and drives activeView from local/reactive state. An in-dialog switcher demonstrates swap-without-close (the backdrop covers the openers behind it). - e2e covers open/close, Escape + focus return, backdrop press-and-release guard, focus trap, window swap, subject injection, consumer contribution, the shared Escape stack, and the dangling-id "data not crash" stance. Register both shells in the CI examples-e2e matrix; link them from docs/overlay-host.md and examples/README.md. fix(vue): make OverlayOutlet/PanelsOutlet subject + class props usable in typed templates. `type: null` with an un-cast `default: null`/`default: undefined` collapsed the inferred $props type to `null`/`undefined`, so the documented `` / `` template usage did not typecheck under vue-tsc. Cast the defaults to the prop's declared type so ExtractPropTypes keeps `unknown`. Runtime unchanged. docs(overlays): fix the Vue #wrap snippet to render `children` through a stable functional component instead of ``, which gets a fresh identity every parent re-render (e.g. when a nested overlay bumps the shared stack) and remounts the window, dropping its state. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRyxejS9jdwipTZGZKGnZ3 --- .github/workflows/ci.yml | 16 ++ docs/overlay-host.md | 24 +- examples/README.md | 8 +- .../overlay-result-windows/README.md | 75 ++++++ .../app-shared/package.json | 23 ++ .../app-shared/src/app-types.ts | 22 ++ .../app-shared/src/index.ts | 3 + .../app-shared/src/overlay.ts | 36 +++ .../app-shared/tsconfig.json | 7 + .../modules/acme-extras/package.json | 29 +++ .../modules/acme-extras/src/index.tsx | 41 +++ .../modules/acme-extras/tsconfig.json | 7 + .../modules/run-core/package.json | 33 +++ .../modules/run-core/src/index.tsx | 135 ++++++++++ .../modules/run-core/tsconfig.json | 7 + .../shell/e2e/smoke.spec.ts | 244 ++++++++++++++++++ .../overlay-result-windows/shell/index.html | 12 + .../overlay-result-windows/shell/package.json | 33 +++ .../shell/playwright.config.ts | 24 ++ .../shell/src/components/Home.tsx | 158 ++++++++++++ .../shell/src/components/Layout.tsx | 27 ++ .../shell/src/components/WindowChrome.tsx | 110 ++++++++ .../overlay-result-windows/shell/src/main.tsx | 27 ++ .../shell/tsconfig.json | 8 + .../shell/vite.config.ts | 22 ++ examples/vue/overlay-result-windows/README.md | 80 ++++++ .../app-shared/package.json | 28 ++ .../app-shared/src/app-types.ts | 22 ++ .../app-shared/src/index.ts | 3 + .../app-shared/src/overlay.ts | 36 +++ .../app-shared/tsconfig.json | 8 + .../modules/acme-extras/package.json | 29 +++ .../acme-extras/src/SecurityReportWindow.vue | 14 + .../modules/acme-extras/src/index.ts | 31 +++ .../modules/acme-extras/tsconfig.json | 8 + .../modules/run-core/package.json | 30 +++ .../modules/run-core/src/RunLogsWindow.vue | 29 +++ .../modules/run-core/src/TestReportWindow.vue | 70 +++++ .../modules/run-core/src/index.ts | 37 +++ .../modules/run-core/tsconfig.json | 8 + .../shell/e2e/smoke.spec.ts | 234 +++++++++++++++++ .../overlay-result-windows/shell/index.html | 13 + .../overlay-result-windows/shell/package.json | 30 +++ .../shell/playwright.config.ts | 24 ++ .../overlay-result-windows/shell/src/App.vue | 9 + .../shell/src/components/Home.vue | 190 ++++++++++++++ .../shell/src/components/ShellLayout.vue | 21 ++ .../shell/src/components/WindowChrome.vue | 92 +++++++ .../overlay-result-windows/shell/src/main.ts | 44 ++++ .../shell/tsconfig.json | 9 + .../shell/vite.config.ts | 15 ++ packages/vue/src/overlay.ts | 12 +- packages/vue/src/panels.ts | 6 +- pnpm-lock.yaml | 209 +++++++++++++++ 54 files changed, 2464 insertions(+), 8 deletions(-) create mode 100644 examples/react-router/overlay-result-windows/README.md create mode 100644 examples/react-router/overlay-result-windows/app-shared/package.json create mode 100644 examples/react-router/overlay-result-windows/app-shared/src/app-types.ts create mode 100644 examples/react-router/overlay-result-windows/app-shared/src/index.ts create mode 100644 examples/react-router/overlay-result-windows/app-shared/src/overlay.ts create mode 100644 examples/react-router/overlay-result-windows/app-shared/tsconfig.json create mode 100644 examples/react-router/overlay-result-windows/modules/acme-extras/package.json create mode 100644 examples/react-router/overlay-result-windows/modules/acme-extras/src/index.tsx create mode 100644 examples/react-router/overlay-result-windows/modules/acme-extras/tsconfig.json create mode 100644 examples/react-router/overlay-result-windows/modules/run-core/package.json create mode 100644 examples/react-router/overlay-result-windows/modules/run-core/src/index.tsx create mode 100644 examples/react-router/overlay-result-windows/modules/run-core/tsconfig.json create mode 100644 examples/react-router/overlay-result-windows/shell/e2e/smoke.spec.ts create mode 100644 examples/react-router/overlay-result-windows/shell/index.html create mode 100644 examples/react-router/overlay-result-windows/shell/package.json create mode 100644 examples/react-router/overlay-result-windows/shell/playwright.config.ts create mode 100644 examples/react-router/overlay-result-windows/shell/src/components/Home.tsx create mode 100644 examples/react-router/overlay-result-windows/shell/src/components/Layout.tsx create mode 100644 examples/react-router/overlay-result-windows/shell/src/components/WindowChrome.tsx create mode 100644 examples/react-router/overlay-result-windows/shell/src/main.tsx create mode 100644 examples/react-router/overlay-result-windows/shell/tsconfig.json create mode 100644 examples/react-router/overlay-result-windows/shell/vite.config.ts create mode 100644 examples/vue/overlay-result-windows/README.md create mode 100644 examples/vue/overlay-result-windows/app-shared/package.json create mode 100644 examples/vue/overlay-result-windows/app-shared/src/app-types.ts create mode 100644 examples/vue/overlay-result-windows/app-shared/src/index.ts create mode 100644 examples/vue/overlay-result-windows/app-shared/src/overlay.ts create mode 100644 examples/vue/overlay-result-windows/app-shared/tsconfig.json create mode 100644 examples/vue/overlay-result-windows/modules/acme-extras/package.json create mode 100644 examples/vue/overlay-result-windows/modules/acme-extras/src/SecurityReportWindow.vue create mode 100644 examples/vue/overlay-result-windows/modules/acme-extras/src/index.ts create mode 100644 examples/vue/overlay-result-windows/modules/acme-extras/tsconfig.json create mode 100644 examples/vue/overlay-result-windows/modules/run-core/package.json create mode 100644 examples/vue/overlay-result-windows/modules/run-core/src/RunLogsWindow.vue create mode 100644 examples/vue/overlay-result-windows/modules/run-core/src/TestReportWindow.vue create mode 100644 examples/vue/overlay-result-windows/modules/run-core/src/index.ts create mode 100644 examples/vue/overlay-result-windows/modules/run-core/tsconfig.json create mode 100644 examples/vue/overlay-result-windows/shell/e2e/smoke.spec.ts create mode 100644 examples/vue/overlay-result-windows/shell/index.html create mode 100644 examples/vue/overlay-result-windows/shell/package.json create mode 100644 examples/vue/overlay-result-windows/shell/playwright.config.ts create mode 100644 examples/vue/overlay-result-windows/shell/src/App.vue create mode 100644 examples/vue/overlay-result-windows/shell/src/components/Home.vue create mode 100644 examples/vue/overlay-result-windows/shell/src/components/ShellLayout.vue create mode 100644 examples/vue/overlay-result-windows/shell/src/components/WindowChrome.vue create mode 100644 examples/vue/overlay-result-windows/shell/src/main.ts create mode 100644 examples/vue/overlay-result-windows/shell/tsconfig.json create mode 100644 examples/vue/overlay-result-windows/shell/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6a7bf6..d47f78e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,6 +215,17 @@ jobs: example_path: examples/tanstack-router/editor-composition runtime_path: packages/tanstack-router-runtime core_path: packages/tanstack-router-core + # Overlay-host examples — exercise the state-keyed overlay + # (`` / `useOverlay` / `useModalBehavior`) in each + # binding. The managed modal behaviour (focus trap, shared Escape + # stack, scroll lock) is engine-first in packages/frontend-core, so + # the React and Vue hosts must observe the identical contract — both + # are covered to catch drift in either binding's glue. + - shell: "@example-rr-overlay-windows/shell" + shell_artifact: "example-rr-overlay-windows-shell" + example_path: examples/react-router/overlay-result-windows + runtime_path: packages/react-router-runtime + core_path: packages/react-router-core # Vue examples — exercise the @modular-vue/* family mounted in # a real vue-router shell: sibling modules, a journey, and a # composition. Same duplicate-module / provider-plumbing regressions @@ -234,6 +245,11 @@ jobs: example_path: examples/vue/editor-composition runtime_path: packages/vue-runtime core_path: packages/vue-core + - shell: "@example-vue-overlay-windows/shell" + shell_artifact: "example-vue-overlay-windows-shell" + example_path: examples/vue/overlay-result-windows + runtime_path: packages/vue-runtime + core_path: packages/vue-core steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/docs/overlay-host.md b/docs/overlay-host.md index 5f5861e..e673624 100644 --- a/docs/overlay-host.md +++ b/docs/overlay-host.md @@ -159,7 +159,13 @@ const ui = useUiStore(); @@ -294,8 +300,20 @@ stack. (`` is implemented on exactly this composable/hook.) ## End-to-end: an app shell over the host -The intended division of labor, using the agent-run "result windows" shape as the -example: +Two runnable examples build exactly this — the agent-run "result windows" shape, +one per binding, with matching Playwright suites that assert the whole behaviour +contract (open/close, Escape, backdrop press-and-release, focus trap + return, +window swap, consumer contribution, the shared stack, and the dangling-id +stance): + +- **React Router** — [`examples/react-router/overlay-result-windows`](../examples/react-router/overlay-result-windows) +- **Vue Router** — [`examples/vue/overlay-result-windows`](../examples/vue/overlay-result-windows) + +They are the pick-one, modal sibling of the [`inspector-panels`](../examples/react-router/inspector-panels) +panels example, and read side by side to show the one engine-first contract +observed by both bindings. + +The intended division of labor, using that same "result windows" shape: 1. The app declares the host (`defineOverlayHost("resultViews")`) and mounts **one** `` in its shell, driving `activeId` from its ui store and diff --git a/examples/README.md b/examples/README.md index 1852c8d..7489278 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,7 @@ examples/ │ ├── customer-onboarding-journey/ Multi-module workflow via @modular-react/journeys (React Router) │ ├── editor-composition/ Multi-module screen via @modular-react/compositions (React Router) │ ├── inspector-panels/ Subject-keyed panels — render-all inspector rail via +│ ├── overlay-result-windows/ State-keyed overlay host — pick-one managed modal via │ ├── remote-capabilities/ Slots/navigation driven by a backend-served remote manifest │ └── active-project-manifest/ Per-project remote manifests swapped at runtime ├── tanstack-router/ @@ -20,7 +21,8 @@ examples/ └── vue/ ├── integration-manager/ Sibling modules sharing a screen (Vue Router) ├── customer-onboarding-journey/ Multi-module workflow via @modular-vue/journeys (Vue Router) - └── editor-composition/ Multi-module screen via @modular-vue/compositions (Vue Router) + ├── editor-composition/ Multi-module screen via @modular-vue/compositions (Vue Router) + └── overlay-result-windows/ State-keyed overlay host — pick-one managed modal via ``` ## Running an example @@ -62,6 +64,10 @@ An editor screen with main canvas, integration source picker, and inspector pane A design board whose inspector rail is driven by **subject-keyed panels**. Selecting a block is the runtime **subject**; each contributed panel decides via its own `when(block)` predicate whether it applies, and `` renders **every** match, ordered — the render-all counterpart to compositions' pick-one zones. A first-party module (`inspector-core`) contributes an always-on `identity` panel plus a frontend-only `frontend-config` panel; a **consumer** module (`acme-extras`) adds a panel for its own `acme-secure` block type with no edit to the host, showing the open-contribution property. Documented in [docs/subject-panels.md](../docs/subject-panels.md). +### `overlay-result-windows` + +An agent run whose result windows are driven by the **state-keyed overlay host** — the pick-one, modal sibling of `inspector-panels`. Which window is open is app state (`activeView`); `` mounts the one matching window inside a framework-managed modal shell (teleport/portal, focus trap + return, body scroll lock, a shared Escape stack, a11y wiring) and hands the body to the app's `wrap` chrome. A first-party module (`run-core`) contributes `test-report` and `run-logs` windows — `test-report` nests a bespoke `useModalBehavior` confirm on the same stack — and a **consumer** module (`acme-extras`) adds its own `acme:security-report` window with no edit to the host. Built for **both** bindings (React Router and Vue Router) since the behaviour is engine-first, with matching e2e suites. Documented in [docs/overlay-host.md](../docs/overlay-host.md). + ### `remote-capabilities` Slots and navigation are driven by a backend-served `RemoteModuleManifest` JSON file instead of being baked into the module source. Useful for tenants that toggle features per environment. diff --git a/examples/react-router/overlay-result-windows/README.md b/examples/react-router/overlay-result-windows/README.md new file mode 100644 index 0000000..8229888 --- /dev/null +++ b/examples/react-router/overlay-result-windows/README.md @@ -0,0 +1,75 @@ +# Overlay result windows — state-keyed overlay host (React Router) + +A runnable demonstration of the **state-keyed overlay host** (`defineOverlayHost` +/ `resolveOverlay` / `useOverlay` / `` / `useOverlaySubject` / +`useModalBehavior`): a pick-**one**, app-state-keyed **modal host** whose +module-contributed _windows_ are selected by a caller-supplied active id and +mounted inside a framework-managed modal shell — portaled, backdrop-closed, +focus-trapped with focus return, scroll-locked, stack-registered, a11y-wired. +See the [overlay host guide](../../../docs/overlay-host.md) for the full pattern. + +The overlay host is the **pick-one, modal sibling** of the render-all, inline +[subject-keyed panels](../inspector-panels) — this example is deliberately the +mirror of the `inspector-panels` one, one primitive over. + +The scenario is an **agent run**: pick a step, open a result window. Which window +is open is app state (`activeView`); the selected step is the **subject** threaded +to it. Windows are contributed by modules — including a **consumer** module that +adds its own window with no edit to the host. + +## Run it + +From the repo root: + +```bash +pnpm install +pnpm --filter "@example-rr-overlay-windows/shell" dev +``` + +Then open the printed URL, pick a step, and open the windows. + +## Layout + +```text +overlay-result-windows/ +├── app-shared/ StepRef subject + WindowMeta + the overlay-host handle +├── modules/ +│ ├── run-core/ First-party: `test-report` (+ a nested useModalBehavior confirm) · `run-logs` +│ └── acme-extras/ Consumer: `acme:security-report` — added with no host edit +└── shell/ Registry wiring + host + wrap chrome + e2e +``` + +- **`app-shared`** owns the `StepRef` subject type and exports the shared + `resultViews = defineOverlayHost("resultViews")` handle, imported by + both the host and every contributor so the subject type is stated once. +- **`run-core`** and **`acme-extras`** each contribute `OverlayEntry` windows + through the ordinary `slots` path — the overlay host adds no new registration + seam (`OverlayEntry` is a superset of `ComponentEntry`). No window carries any + portal / backdrop / Escape / focus code; the managed shell owns all of it. + `acme-extras` is deliberately a **consumer**: it adds a window with a + **namespaced id** the host never knew about. +- **`shell`** holds `activeView` (the open window id) and the selected step in + local state, mounts one ``, and supplies the per-window chrome + (`wrap`) and empty state. The shell never branches on window id. + +## What it demonstrates + +Each behaviour below is asserted in `shell/e2e/smoke.spec.ts`: + +| Behaviour | What you see | +| ----------------------------- | ----------------------------------------------------------------------------------------- | +| **Pick-one, app-state-keyed** | The in-dialog switcher swaps windows without closing; exactly one dialog is ever mounted | +| **Managed a11y** | `role="dialog"`, `aria-modal`, `aria-label` from `title(subject)` — zero app effort | +| **Focus trap + return** | Focus moves into the dialog on open, follows a window swap, returns to the opener | +| **Backdrop + Escape close** | Both _request_ close; app state clears the id (the host never closes itself) | +| **Press-and-release guard** | A press that starts in the dialog and releases on the backdrop does **not** close | +| **Subject injection** | The window reads the step via a `subject` prop **and** via `useOverlaySubject` | +| **Consumer contribution** | `acme:security-report` opens with no host edit — same `slots` path every module uses | +| **Shared stack** | A nested `useModalBehavior` confirm layers on the same stack; Escape closes the top first | +| **Dangling id is data** | An unregistered id renders nothing and dev-warns — it never crashes the app | + +## Test + +```bash +pnpm --filter "@example-rr-overlay-windows/shell" test:e2e +``` diff --git a/examples/react-router/overlay-result-windows/app-shared/package.json b/examples/react-router/overlay-result-windows/app-shared/package.json new file mode 100644 index 0000000..12c5a10 --- /dev/null +++ b/examples/react-router/overlay-result-windows/app-shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@example-rr-overlay-windows/app-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modular-react/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2" + } +} diff --git a/examples/react-router/overlay-result-windows/app-shared/src/app-types.ts b/examples/react-router/overlay-result-windows/app-shared/src/app-types.ts new file mode 100644 index 0000000..7a21478 --- /dev/null +++ b/examples/react-router/overlay-result-windows/app-shared/src/app-types.ts @@ -0,0 +1,22 @@ +import type { OverlayEntry } from "@modular-react/core"; +import type { StepRef, WindowMeta } from "./overlay.js"; + +/** + * Shared registry dependencies. The window modules are dependency-free (they + * contribute static slot entries), so this stays minimal — kept for parity with + * the sibling examples and to show where cross-module services would live. + */ +export interface AppDependencies { + readonly auth: { readonly userId: string }; +} + +/** + * Slot contributions collected from every module. `resultViews` is the slot the + * overlay host is keyed on: each module contributes `OverlayEntry` objects under it through the ordinary `slots` path — the overlay + * host adds no new registration seam (`OverlayEntry` is a superset of + * `ComponentEntry`). The base value is `[]`; modules concatenate onto it. + */ +export interface AppSlots { + readonly resultViews: readonly OverlayEntry[]; +} diff --git a/examples/react-router/overlay-result-windows/app-shared/src/index.ts b/examples/react-router/overlay-result-windows/app-shared/src/index.ts new file mode 100644 index 0000000..190cca2 --- /dev/null +++ b/examples/react-router/overlay-result-windows/app-shared/src/index.ts @@ -0,0 +1,3 @@ +export { resultViews } from "./overlay.js"; +export type { StepRef, WindowMeta } from "./overlay.js"; +export type { AppDependencies, AppSlots } from "./app-types.js"; diff --git a/examples/react-router/overlay-result-windows/app-shared/src/overlay.ts b/examples/react-router/overlay-result-windows/app-shared/src/overlay.ts new file mode 100644 index 0000000..b3dd432 --- /dev/null +++ b/examples/react-router/overlay-result-windows/app-shared/src/overlay.ts @@ -0,0 +1,36 @@ +import { defineOverlayHost } from "@modular-react/core"; + +/** + * The **subject** the overlay host threads to the active window: a reference to + * one step of an agent run. Selection is by **id** (which window is open lives + * in app state), so the subject is payload only — the active window reads it to + * render its body; it never selects the window. It may be `null` while a window + * is open (a window that keys on its own store simply ignores it). + */ +export interface StepRef { + readonly instanceId: string; + readonly stepIndex: number; + readonly label: string; +} + +/** + * App **presentation** metadata carried opaquely on each window entry. `title` + * is behaviour (the host resolves it to the dialog's `aria-label`); everything + * only *your* chrome renders — an icon, a width variant — belongs here in + * `meta`, which the framework never interprets. The shell's `wrap` chrome reads + * these to draw the header and size the dialog. + */ +export interface WindowMeta { + readonly icon: string; + readonly width?: "normal" | "wide"; +} + +/** + * The shared overlay-host handle. Exported once and imported at both the host + * (`shell`, which mounts one ``) and every + * contributor (the window modules), so the `StepRef` subject type is stated in + * exactly one place. Its only runtime field is the slot key modules contribute + * their windows under; the subject type rides along as a phantom so + * `title(subject)` and `useOverlaySubject()` type-check end to end. + */ +export const resultViews = defineOverlayHost("resultViews"); diff --git a/examples/react-router/overlay-result-windows/app-shared/tsconfig.json b/examples/react-router/overlay-result-windows/app-shared/tsconfig.json new file mode 100644 index 0000000..ef847b5 --- /dev/null +++ b/examples/react-router/overlay-result-windows/app-shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/overlay-result-windows/modules/acme-extras/package.json b/examples/react-router/overlay-result-windows/modules/acme-extras/package.json new file mode 100644 index 0000000..f2bdd28 --- /dev/null +++ b/examples/react-router/overlay-result-windows/modules/acme-extras/package.json @@ -0,0 +1,29 @@ +{ + "name": "@example-rr-overlay-windows/acme-extras", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.tsx", + "types": "./src/index.tsx", + "exports": { + ".": { + "import": "./src/index.tsx", + "types": "./src/index.tsx" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@example-rr-overlay-windows/app-shared": "workspace:*", + "@react-router-modules/core": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "typescript": "^6.0.2" + }, + "peerDependencies": { + "react": "^19.0.0" + } +} diff --git a/examples/react-router/overlay-result-windows/modules/acme-extras/src/index.tsx b/examples/react-router/overlay-result-windows/modules/acme-extras/src/index.tsx new file mode 100644 index 0000000..9a1736d --- /dev/null +++ b/examples/react-router/overlay-result-windows/modules/acme-extras/src/index.tsx @@ -0,0 +1,41 @@ +import { defineModule } from "@react-router-modules/core"; +import type { AppDependencies, AppSlots, StepRef } from "@example-rr-overlay-windows/app-shared"; + +/** + * A **consumer** module — the kind a downstream deployment ships. It contributes + * its own window (`acme:security-report`) to the `resultViews` host that the + * first-party `run-core` module knows nothing about, with **no edit to the host + * and no framework change**: the window flows in through the same `slots` path + * every module uses, and inherits the full managed-modal behaviour contract + * (portal, focus trap, Escape, scroll lock, a11y) for free. + * + * Its id is namespaced (`acme:security-report`) so it can never collide with a + * first-party window id — the overlay resolver's duplicate-id check would + * otherwise throw. This is the open-contribution property the overlay host + * exists to provide. + */ +function SecurityReportWindow({ subject }: { subject: StepRef | null }) { + return ( +
+

+ Compliance scan for {subject?.label ?? "the run"} — secrets last rotated 3 + days ago, no findings. +

+
+ ); +} + +export default defineModule({ + id: "acme-extras", + version: "1.0.0", + slots: { + resultViews: [ + { + id: "acme:security-report", + component: SecurityReportWindow, + title: "Security report", + meta: { icon: "🛡️", width: "normal" }, + }, + ], + }, +}); diff --git a/examples/react-router/overlay-result-windows/modules/acme-extras/tsconfig.json b/examples/react-router/overlay-result-windows/modules/acme-extras/tsconfig.json new file mode 100644 index 0000000..8a223dc --- /dev/null +++ b/examples/react-router/overlay-result-windows/modules/acme-extras/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/overlay-result-windows/modules/run-core/package.json b/examples/react-router/overlay-result-windows/modules/run-core/package.json new file mode 100644 index 0000000..cf94baa --- /dev/null +++ b/examples/react-router/overlay-result-windows/modules/run-core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@example-rr-overlay-windows/run-core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.tsx", + "types": "./src/index.tsx", + "exports": { + ".": { + "import": "./src/index.tsx", + "types": "./src/index.tsx" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@example-rr-overlay-windows/app-shared": "workspace:*", + "@modular-react/react": "workspace:*", + "@react-router-modules/core": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^6.0.2" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/examples/react-router/overlay-result-windows/modules/run-core/src/index.tsx b/examples/react-router/overlay-result-windows/modules/run-core/src/index.tsx new file mode 100644 index 0000000..2123961 --- /dev/null +++ b/examples/react-router/overlay-result-windows/modules/run-core/src/index.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { defineModule } from "@react-router-modules/core"; +import { useModalBehavior, useOverlaySubject } from "@modular-react/react"; +import type { AppDependencies, AppSlots, StepRef } from "@example-rr-overlay-windows/app-shared"; + +/** + * First-party window module. Contributes two windows to the `resultViews` + * overlay host through the ordinary `slots` path — the overlay host adds no new + * registration seam (`OverlayEntry` is a superset of `ComponentEntry`). Neither + * window carries any ``/portal/backdrop/Escape/focus code: the managed + * shell owns all of that. A window is just a body plus presentation metadata. + * + * - `test-report` — a dynamic `title(step)` (→ the dialog's `aria-label`) and, + * inside its body, a **nested bespoke overlay** built on `useModalBehavior`. + * That confirm dialog registers on the same shared stack as the hosted + * window, so Escape closes the **top** one first (the confirm), then the + * window — the stacking guarantee, demonstrated with the composable the + * `` is itself built on. + * - `run-logs` — reads the subject through `useOverlaySubject`, the context + * reader that reaches the current step without prop-drilling. + */ + +/** Reads the subject from the injected `subject` prop; hosts a nested confirm. */ +function TestReportWindow({ subject }: { subject: StepRef | null }) { + const [confirmOpen, setConfirmOpen] = useState(false); + + // A bespoke overlay (its own hand-styled root) that still behaves like a + // first-class modal because it shares the one overlay stack: Escape closes it + // before the window beneath it, and its own focus trap / focus return / scroll + // lock coordinate with the hosted window's. + const { dialogRef } = useModalBehavior({ + active: confirmOpen, + onClose: () => setConfirmOpen(false), + }); + + return ( +
+

+ {subject?.label ?? "Unknown step"} passed — 42 assertions, 0 failed. +

+ + + {confirmOpen && + createPortal( +
{ + if (event.target === event.currentTarget) setConfirmOpen(false); + }} + > +
} + role="dialog" + aria-modal="true" + aria-label="Discard run?" + tabIndex={-1} + data-testid="confirm-dialog" + style={{ + background: "#fff", + borderRadius: 8, + padding: "1rem 1.25rem", + maxWidth: 320, + boxShadow: "0 10px 40px rgba(0,0,0,0.3)", + }} + > +

Discard this run? This can't be undone.

+ +
+
, + document.body, + )} +
+ ); +} + +/** Reads the subject from context via `useOverlaySubject` — no prop-drilling. */ +function RunLogsWindow() { + const step = useOverlaySubject(); + return ( +
+
+        {`[step ${step?.stepIndex ?? "?"}] ${step?.label ?? ""}\n> resolving modules… ok\n> mounting shell… ok\n> exit 0`}
+      
+
+ ); +} + +export default defineModule({ + id: "run-core", + version: "1.0.0", + slots: { + resultViews: [ + { + id: "test-report", + component: TestReportWindow, + title: (step: StepRef | null) => + step ? `Test report — step ${step.stepIndex}` : "Test report", + meta: { icon: "🧪", width: "wide" }, + }, + { + id: "run-logs", + component: RunLogsWindow, + title: "Run logs", + meta: { icon: "📜", width: "normal" }, + }, + ], + }, +}); diff --git a/examples/react-router/overlay-result-windows/modules/run-core/tsconfig.json b/examples/react-router/overlay-result-windows/modules/run-core/tsconfig.json new file mode 100644 index 0000000..8a223dc --- /dev/null +++ b/examples/react-router/overlay-result-windows/modules/run-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/overlay-result-windows/shell/e2e/smoke.spec.ts b/examples/react-router/overlay-result-windows/shell/e2e/smoke.spec.ts new file mode 100644 index 0000000..7c6c56e --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/e2e/smoke.spec.ts @@ -0,0 +1,244 @@ +import { test, expect, type ConsoleMessage, type Page } from "@playwright/test"; + +// Smoke + behavioral coverage for the state-keyed overlay host +// (`` / `useOverlay` / `useOverlaySubject` / `useModalBehavior`) +// mounted in a React Router shell. Mirrors the sibling examples' e2e style: a +// page/console-error collector wraps every test so runtime regressions +// (duplicate-module copies, uncaught render errors) surface as failures +// regardless of the specific UI assertion. +// +// Only `console.error` is treated as a failure — the dangling-id test +// deliberately triggers the host's dev `console.warn`, which is expected. + +function attachErrorCollectors(page: Page) { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + page.on("pageerror", (err) => { + pageErrors.push(`${err.message}\n${err.stack ?? ""}`); + }); + page.on("console", (msg: ConsoleMessage) => { + if (msg.type() !== "error") return; + consoleErrors.push(msg.text()); + }); + return { pageErrors, consoleErrors }; +} + +function assertNoErrors({ + pageErrors, + consoleErrors, +}: { + pageErrors: string[]; + consoleErrors: string[]; +}) { + expect(pageErrors, `Unhandled page errors:\n${pageErrors.join("\n---\n")}`).toEqual([]); + expect(consoleErrors, `console.error entries:\n${consoleErrors.join("\n---\n")}`).toEqual([]); +} + +const backdrop = "[data-modular-overlay-backdrop]"; +const panel = "[data-modular-overlay-panel]"; + +/** The id of the currently-open window, or null when closed. */ +async function openWindowId(page: Page): Promise { + const el = page.locator(backdrop); + if ((await el.count()) === 0) return null; + return el.getAttribute("data-overlay-id"); +} + +/** Whether keyboard focus currently sits inside the dialog panel. */ +async function focusInsidePanel(page: Page): Promise { + return page.evaluate((sel) => { + const p = document.querySelector(sel); + return !!p && !!document.activeElement && p.contains(document.activeElement); + }, panel); +} + +test("closed by default: empty placeholder, no dialog", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await expect(page.getByTestId("overlay-closed")).toBeVisible(); + await expect(page.locator(panel)).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("opening a window mounts the managed modal shell (a11y + focus)", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + + const dialog = page.locator(panel); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAttribute("role", "dialog"); + await expect(dialog).toHaveAttribute("aria-modal", "true"); + // `title(subject)` resolved against the selected step (step 0 by default) and + // wired to the dialog's aria-label with zero app effort. + await expect(dialog).toHaveAttribute("aria-label", "Test report — step 0"); + await expect(page.getByTestId("overlay-title")).toHaveText("Test report — step 0"); + expect(await openWindowId(page)).toBe("test-report"); + // Focus was moved into the dialog on open. + expect(await focusInsidePanel(page)).toBe(true); + await expect(page.getByTestId("overlay-closed")).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("Escape requests close and returns focus to the opener", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + await expect(page.locator(panel)).toBeVisible(); + + await page.keyboard.press("Escape"); + + await expect(page.locator(panel)).toHaveCount(0); + await expect(page.getByTestId("overlay-closed")).toBeVisible(); + // Focus returned to the button that opened the window. + const focusedTestId = await page.evaluate(() => + document.activeElement?.getAttribute("data-testid"), + ); + expect(focusedTestId).toBe("open-test-report"); + + assertNoErrors(errors); +}); + +test("backdrop press-and-release closes; the ✕ closes", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + // Backdrop click (top-left corner, clear of the centred panel). + await page.getByTestId("open-run-logs").click(); + await expect(page.locator(panel)).toBeVisible(); + await page.locator(backdrop).click({ position: { x: 6, y: 6 } }); + await expect(page.locator(panel)).toHaveCount(0); + + // The chrome's close button. + await page.getByTestId("open-run-logs").click(); + await expect(page.locator(panel)).toBeVisible(); + await page.getByTestId("overlay-close").click(); + await expect(page.locator(panel)).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("a press that starts inside the dialog and releases on the backdrop does NOT close", async ({ + page, +}) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + const box = await page.locator(panel).boundingBox(); + if (!box) throw new Error("panel has no bounding box"); + + // Press inside the panel, drag onto the backdrop, release there — a text + // selection / missed drag, not a close request. + await page.mouse.move(box.x + 20, box.y + 20); + await page.mouse.down(); + await page.mouse.move(6, 6); + await page.mouse.up(); + + await expect(page.locator(panel)).toBeVisible(); + + assertNoErrors(errors); +}); + +test("one window at a time: switching swaps without closing, keeping a single dialog", async ({ + page, +}) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + expect(await openWindowId(page)).toBe("test-report"); + await expect(page.getByTestId("window-body-test-report")).toBeVisible(); + + // The switcher lives inside the dialog (the backdrop covers the openers + // behind it): switching sets the active id to a sibling — a swap, not a close. + await page.getByTestId("switch-run-logs").click(); + // Still exactly one backdrop / one panel — the host is pick-one. + await expect(page.locator(backdrop)).toHaveCount(1); + await expect(page.locator(panel)).toHaveCount(1); + expect(await openWindowId(page)).toBe("run-logs"); + await expect(page.getByTestId("window-body-run-logs")).toBeVisible(); + await expect(page.getByTestId("window-body-test-report")).toHaveCount(0); + // Focus followed the swapped-in content (re-applied on the content-key change). + expect(await focusInsidePanel(page)).toBe(true); + + assertNoErrors(errors); +}); + +test("the consumer window opens with no host edit", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-security-report").click(); + + expect(await openWindowId(page)).toBe("acme:security-report"); + await expect(page.getByTestId("window-body-acme-security-report")).toBeVisible(); + await expect(page.getByTestId("overlay-title")).toHaveText("Security report"); + + assertNoErrors(errors); +}); + +test("the subject is threaded to the window and swaps with the selected step", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("step-1").click(); + await page.getByTestId("open-run-logs").click(); + // `run-logs` reads the subject via `useOverlaySubject` (no prop-drilling). + await expect(page.getByTestId("window-body-run-logs")).toContainText("step 1"); + await expect(page.getByTestId("window-body-run-logs")).toContainText("Typecheck"); + + // Switch to test-report (inside the dialog): it reads the same subject via its + // injected `subject` prop, and the title re-resolves against the same step. + await page.getByTestId("switch-test-report").click(); + await expect(page.getByTestId("overlay-title")).toHaveText("Test report — step 1"); + await expect(page.getByTestId("window-body-test-report")).toContainText("Typecheck"); + + assertNoErrors(errors); +}); + +test("nested bespoke overlay shares the stack: Escape closes the top first", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + await expect(page.locator(panel)).toBeVisible(); + + // Open the nested confirm (built on `useModalBehavior`) inside the window. + await page.getByTestId("open-confirm").click(); + await expect(page.getByTestId("confirm-dialog")).toBeVisible(); + + // First Escape closes the topmost overlay — the confirm — leaving the window. + await page.keyboard.press("Escape"); + await expect(page.getByTestId("confirm-dialog")).toHaveCount(0); + await expect(page.locator(panel)).toBeVisible(); + + // Second Escape closes the window beneath it. + await page.keyboard.press("Escape"); + await expect(page.locator(panel)).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("a dangling active id is data, not a crash: renders nothing, no error", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-dangling").click(); + + // No window mounts; the empty placeholder stays; the app is still interactive. + await expect(page.locator(panel)).toHaveCount(0); + await expect(page.getByTestId("overlay-closed")).toBeVisible(); + + // A real window still opens afterwards — the dangling id didn't wedge state. + await page.getByTestId("open-test-report").click(); + await expect(page.locator(panel)).toBeVisible(); + + // The dev `console.warn` for the dangling id is expected; only errors fail. + assertNoErrors(errors); +}); diff --git a/examples/react-router/overlay-result-windows/shell/index.html b/examples/react-router/overlay-result-windows/shell/index.html new file mode 100644 index 0000000..802d67f --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/index.html @@ -0,0 +1,12 @@ + + + + + + State-keyed overlay host (React Router) + + +
+ + + diff --git a/examples/react-router/overlay-result-windows/shell/package.json b/examples/react-router/overlay-result-windows/shell/package.json new file mode 100644 index 0000000..c44b6b5 --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/package.json @@ -0,0 +1,33 @@ +{ + "name": "@example-rr-overlay-windows/shell", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "@example-rr-overlay-windows/acme-extras": "workspace:*", + "@example-rr-overlay-windows/app-shared": "workspace:*", + "@example-rr-overlay-windows/run-core": "workspace:*", + "@modular-react/core": "workspace:*", + "@modular-react/react": "workspace:*", + "@react-router-modules/core": "workspace:*", + "@react-router-modules/runtime": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.2", + "vite": "^8.1.3" + } +} diff --git a/examples/react-router/overlay-result-windows/shell/playwright.config.ts b/examples/react-router/overlay-result-windows/shell/playwright.config.ts new file mode 100644 index 0000000..73f4687 --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = 5199; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + timeout: 30_000, + retries: 0, + fullyParallel: false, + reporter: [["list"]], + use: { + headless: true, + baseURL: `http://localhost:${PORT}`, + }, + webServer: { + command: `pnpm exec vite --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}/`, + reuseExistingServer: false, + timeout: 60_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/examples/react-router/overlay-result-windows/shell/src/components/Home.tsx b/examples/react-router/overlay-result-windows/shell/src/components/Home.tsx new file mode 100644 index 0000000..dec710e --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/src/components/Home.tsx @@ -0,0 +1,158 @@ +import { useState } from "react"; +import { OverlayOutlet, useOverlay } from "@modular-react/react"; +import { resultViews, type StepRef, type WindowMeta } from "@example-rr-overlay-windows/app-shared"; +import { WindowChrome, type SwitchTarget } from "./WindowChrome.js"; + +/** + * The host. Two pieces of local app state drive everything: + * + * - `activeView` — the **id** of the open window, or `null` for closed. This is + * the whole selection: the overlay host is pick-one, keyed by app state, not + * by route. The buttons set it; `onClose` (backdrop / Escape / the chrome's ✕) + * clears it. The host never closes itself. + * - `stepIndex` — which run step is selected; the resolved `StepRef` is the + * **subject** threaded to the active window (as a prop and via context). + * + * `` reads the host's windows from the slots context, mounts the + * one whose id equals `activeId` inside the managed modal shell, and hands its + * body to `wrap`. The shell itself never branches on window id — adding a window + * is a new *module*, not an edit here. `useOverlay` is called alongside the + * outlet purely to size the dialog from the active window's `meta.width`. + */ + +const STEPS: readonly StepRef[] = [ + { instanceId: "run-1", stepIndex: 0, label: "Install dependencies" }, + { instanceId: "run-1", stepIndex: 1, label: "Typecheck" }, + { instanceId: "run-1", stepIndex: 2, label: "Unit tests" }, +]; + +const OPENERS: readonly { id: string; label: string; testId: string }[] = [ + { id: "test-report", label: "🧪 Test report", testId: "open-test-report" }, + { id: "run-logs", label: "📜 Run logs", testId: "open-run-logs" }, + { id: "acme:security-report", label: "🛡️ Security report", testId: "open-security-report" }, + // A window id no installed module provides — data, not a crash: the host + // renders nothing and dev-warns rather than throwing. + { id: "does-not-exist", label: "👻 Dangling id", testId: "open-dangling" }, +]; + +// The real windows the in-dialog switcher can jump between (the dangling id is +// an opener only, never a switch target). +const SWITCH_TARGETS: readonly SwitchTarget[] = [ + { id: "test-report", label: "🧪 Report" }, + { id: "run-logs", label: "📜 Logs" }, + { id: "acme:security-report", label: "🛡️ Security" }, +]; + +export function Home() { + const [activeView, setActiveView] = useState(null); + const [stepIndex, setStepIndex] = useState(0); + const selectedStep = STEPS[stepIndex] ?? null; + + // Called alongside the outlet only to read the active window's presentation + // metadata (its width variant) so the shell can size the dialog. The outlet + // resolves the same entry internally to mount it. + const active = useOverlay(resultViews, activeView); + const width = (active?.meta as WindowMeta | undefined)?.width ?? "normal"; + + return ( +
+
+

Agent run — {selectedStep?.instanceId}

+

+ Select a step, then open a result window. Exactly one window is open at a time; which one + is app state. Backdrop click, Esc, or the ✕ closes it. +

+
+ +
+

Steps (the subject)

+
+ {STEPS.map((s) => ( + + ))} +
+
+ +
+

Open a window

+
+ {OPENERS.map((o) => ( + + ))} +
+
+ + (s ? `${s.instanceId}:${s.stepIndex}` : "none")} + onClose={() => setActiveView(null)} + backdropClassName="ovl-backdrop" + panelClassName={width === "wide" ? "ovl-panel ovl-panel--wide" : "ovl-panel"} + empty={ +

+ No window open. +

+ } + wrap={(args) => ( + + )} + /> + + {/* Headless host → the app supplies every pixel. These two classes are the + whole visual contract: the backdrop must position:fixed to overlay, + the panel is the design system's dialog card. */} + +
+ ); +} diff --git a/examples/react-router/overlay-result-windows/shell/src/components/Layout.tsx b/examples/react-router/overlay-result-windows/shell/src/components/Layout.tsx new file mode 100644 index 0000000..373681b --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/src/components/Layout.tsx @@ -0,0 +1,27 @@ +import { Outlet } from "react-router"; + +export function Layout() { + return ( +
+
+ State-keyed overlay host — React Router shell +
+
+ +
+
+ ); +} diff --git a/examples/react-router/overlay-result-windows/shell/src/components/WindowChrome.tsx b/examples/react-router/overlay-result-windows/shell/src/components/WindowChrome.tsx new file mode 100644 index 0000000..134be92 --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/src/components/WindowChrome.tsx @@ -0,0 +1,110 @@ +import { resolveOverlayTitle } from "@modular-react/core"; +import type { OverlayWrapArgs } from "@modular-react/react"; +import type { StepRef, WindowMeta } from "@example-rr-overlay-windows/app-shared"; + +/** A window the header switcher can jump to without closing the overlay. */ +export interface SwitchTarget { + readonly id: string; + readonly label: string; +} + +/** + * The app's chrome around a window body — the whole of the `wrap` render-prop. + * The headless host renders only the backdrop and the dialog panel; everything + * *inside* the dialog (header, icon, title, switcher, close button) is drawn + * here, from the design system. Icon comes from the opaque `entry.meta`; the + * title text is `resolveOverlayTitle(entry, subject)` — the same value the host + * wired to the dialog's `aria-label`, resolved against the current subject. + * + * The switcher jumps between windows **without closing** (it sets the active id + * to a sibling). It lives inside the dialog on purpose: while a window is open + * the backdrop covers the page behind it, so a swap control belongs in the + * chrome — and the shell re-applies focus to the swapped-in content. + * + * `close` requests close (the same request backdrop-click and Escape make); the + * shell never closes itself, so this just calls back into app state. + */ +export function WindowChrome({ + entry, + subject, + close, + children, + targets, + onSwitch, +}: OverlayWrapArgs & { + readonly targets: readonly SwitchTarget[]; + readonly onSwitch: (id: string) => void; +}) { + const meta = entry.meta as WindowMeta | undefined; + return ( +
+
+ + {meta?.icon} + +

+ {resolveOverlayTitle(entry, subject)} +

+ +
+ + + +
{children}
+
+ ); +} diff --git a/examples/react-router/overlay-result-windows/shell/src/main.tsx b/examples/react-router/overlay-result-windows/shell/src/main.tsx new file mode 100644 index 0000000..de094c9 --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/src/main.tsx @@ -0,0 +1,27 @@ +import { createRoot } from "react-dom/client"; +import { createRegistry } from "@react-router-modules/runtime"; +import runCore from "@example-rr-overlay-windows/run-core"; +import acmeExtras from "@example-rr-overlay-windows/acme-extras"; +import type { AppDependencies, AppSlots } from "@example-rr-overlay-windows/app-shared"; + +import { Layout } from "./components/Layout.js"; +import { Home } from "./components/Home.js"; + +// Base slots declare `resultViews: []`; each registered module concatenates its +// own `OverlayEntry` windows onto it. Both window modules are headless — they +// contribute only slots, no routes — so the whole app renders on the index +// route (`Home`), which mounts the single `` overlay host. +const registry = createRegistry({ + services: { auth: { userId: "demo-user" } }, + slots: { resultViews: [] }, +}); + +registry.register(runCore); +registry.register(acmeExtras); + +const { App } = registry.resolve({ + rootComponent: Layout, + indexComponent: Home, +}); + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/react-router/overlay-result-windows/shell/tsconfig.json b/examples/react-router/overlay-result-windows/shell/tsconfig.json new file mode 100644 index 0000000..4a28ed6 --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/overlay-result-windows/shell/vite.config.ts b/examples/react-router/overlay-result-windows/shell/vite.config.ts new file mode 100644 index 0000000..d898fd8 --- /dev/null +++ b/examples/react-router/overlay-result-windows/shell/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + resolve: { + // Dedupe react and the modular packages so the dev server's pre-bundler + // doesn't produce two module copies of the same package (the duplicate- + // module class of regression the runtime guards against). The shared + // overlay stack lives in a module-private singleton in @modular-frontend/ + // core, so a second copy would split the stack and break Escape ordering. + dedupe: [ + "react", + "react-dom", + "react/jsx-runtime", + "react-router", + "@modular-react/core", + "@modular-react/react", + ], + }, + server: { port: 5199 }, +}); diff --git a/examples/vue/overlay-result-windows/README.md b/examples/vue/overlay-result-windows/README.md new file mode 100644 index 0000000..2ec0647 --- /dev/null +++ b/examples/vue/overlay-result-windows/README.md @@ -0,0 +1,80 @@ +# Overlay result windows — state-keyed overlay host (Vue Router) + +A runnable demonstration of the **state-keyed overlay host** (`defineOverlayHost` +/ `resolveOverlay` / `useOverlay` / `` / `useOverlaySubject` / +`useModalBehavior`) on the **Vue** binding: a pick-**one**, app-state-keyed +**modal host** whose module-contributed _windows_ are selected by a +caller-supplied active id and mounted inside a framework-managed modal shell — +teleported, backdrop-closed, focus-trapped with focus return, scroll-locked, +stack-registered, a11y-wired. See the +[overlay host guide](../../../docs/overlay-host.md) for the full pattern. + +The overlay primitive is **engine-first**: the pure resolver and the shared +overlay stack live in `@modular-frontend/core`, and the React and Vue hosts are +thin bindings over it. This example is the **exact mirror** of the +[React `overlay-result-windows`](../../react-router/overlay-result-windows) +example — same windows, same scenario, same e2e assertions — so you can read the +two side by side and see the one contract observed by both bindings. + +The scenario is an **agent run**: pick a step, open a result window. Which window +is open is app state (`activeView`, a `ref`); the selected step is the **subject** +threaded to it. Windows are contributed by modules — including a **consumer** +module that adds its own window with no edit to the host. + +## Run it + +From the repo root: + +```bash +pnpm install +pnpm --filter "@example-vue-overlay-windows/shell" dev +``` + +Then open the printed URL, pick a step, and open the windows. + +## Layout + +```text +overlay-result-windows/ +├── app-shared/ StepRef subject + WindowMeta + the overlay-host handle +├── modules/ +│ ├── run-core/ First-party: `test-report` (+ a nested useModalBehavior confirm) · `run-logs` +│ └── acme-extras/ Consumer: `acme:security-report` — added with no host edit +└── shell/ Registry + router wiring + host + #wrap chrome + e2e +``` + +- **`app-shared`** owns the `StepRef` subject type and exports the shared + `resultViews = defineOverlayHost("resultViews")` handle, imported by + both the host and every contributor so the subject type is stated once. +- **`run-core`** and **`acme-extras`** each contribute `OverlayEntry` windows + (Vue SFCs) through the ordinary `slots` path — the overlay host adds no new + registration seam. No window carries any teleport / backdrop / Escape / focus + code; the managed shell owns all of it. `acme-extras` is deliberately a + **consumer**: it adds a window with a **namespaced id** the host never knew + about. (These are the first Vue example modules to contribute `slots`.) +- **`shell`** holds `activeView` (the open window id) and the selected step in + reactive state, mounts one ``, and supplies the per-window + chrome (`#wrap`) and empty state. The shell never branches on window id. + +## What it demonstrates + +Each behaviour below is asserted in `shell/e2e/smoke.spec.ts` (mirroring the +React example's suite): + +| Behaviour | What you see | +| ----------------------------- | ----------------------------------------------------------------------------------------- | +| **Pick-one, app-state-keyed** | The in-dialog switcher swaps windows without closing; exactly one dialog is ever mounted | +| **Managed a11y** | `role="dialog"`, `aria-modal`, `aria-label` from `title(subject)` — zero app effort | +| **Focus trap + return** | Focus moves into the dialog on open, follows a window swap, returns to the opener | +| **Backdrop + Escape close** | Both _request_ close; app state clears the id (the host never closes itself) | +| **Press-and-release guard** | A press that starts in the dialog and releases on the backdrop does **not** close | +| **Subject injection** | The window reads the step via a `subject` prop **and** via `useOverlaySubject` | +| **Consumer contribution** | `acme:security-report` opens with no host edit — same `slots` path every module uses | +| **Shared stack** | A nested `useModalBehavior` confirm layers on the same stack; Escape closes the top first | +| **Dangling id is data** | An unregistered id renders nothing and dev-warns — it never crashes the app | + +## Test + +```bash +pnpm --filter "@example-vue-overlay-windows/shell" test:e2e +``` diff --git a/examples/vue/overlay-result-windows/app-shared/package.json b/examples/vue/overlay-result-windows/app-shared/package.json new file mode 100644 index 0000000..eff112d --- /dev/null +++ b/examples/vue/overlay-result-windows/app-shared/package.json @@ -0,0 +1,28 @@ +{ + "name": "@example-vue-overlay-windows/app-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@modular-vue/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vue": "^3.5.40", + "vue-tsc": "^3.3.0" + }, + "peerDependencies": { + "vue": "^3.5.40" + } +} diff --git a/examples/vue/overlay-result-windows/app-shared/src/app-types.ts b/examples/vue/overlay-result-windows/app-shared/src/app-types.ts new file mode 100644 index 0000000..8446231 --- /dev/null +++ b/examples/vue/overlay-result-windows/app-shared/src/app-types.ts @@ -0,0 +1,22 @@ +import type { OverlayEntry } from "@modular-vue/core"; +import type { StepRef, WindowMeta } from "./overlay.js"; + +/** + * Shared registry dependencies. The window modules are dependency-free (they + * contribute static slot entries), so this stays minimal — kept for parity with + * the sibling examples and to show where cross-module services would live. + */ +export interface AppDependencies { + readonly auth: { readonly userId: string }; +} + +/** + * Slot contributions collected from every module. `resultViews` is the slot the + * overlay host is keyed on: each module contributes `OverlayEntry` objects under it through the ordinary `slots` path — the overlay + * host adds no new registration seam (`OverlayEntry` is a superset of + * `ComponentEntry`). The base value is `[]`; modules concatenate onto it. + */ +export interface AppSlots { + readonly resultViews: readonly OverlayEntry[]; +} diff --git a/examples/vue/overlay-result-windows/app-shared/src/index.ts b/examples/vue/overlay-result-windows/app-shared/src/index.ts new file mode 100644 index 0000000..190cca2 --- /dev/null +++ b/examples/vue/overlay-result-windows/app-shared/src/index.ts @@ -0,0 +1,3 @@ +export { resultViews } from "./overlay.js"; +export type { StepRef, WindowMeta } from "./overlay.js"; +export type { AppDependencies, AppSlots } from "./app-types.js"; diff --git a/examples/vue/overlay-result-windows/app-shared/src/overlay.ts b/examples/vue/overlay-result-windows/app-shared/src/overlay.ts new file mode 100644 index 0000000..32c8e58 --- /dev/null +++ b/examples/vue/overlay-result-windows/app-shared/src/overlay.ts @@ -0,0 +1,36 @@ +import { defineOverlayHost } from "@modular-vue/core"; + +/** + * The **subject** the overlay host threads to the active window: a reference to + * one step of an agent run. Selection is by **id** (which window is open lives + * in app state), so the subject is payload only — the active window reads it to + * render its body; it never selects the window. It may be `null` while a window + * is open (a window that keys on its own store simply ignores it). + */ +export interface StepRef { + readonly instanceId: string; + readonly stepIndex: number; + readonly label: string; +} + +/** + * App **presentation** metadata carried opaquely on each window entry. `title` + * is behaviour (the host resolves it to the dialog's `aria-label`); everything + * only *your* chrome renders — an icon, a width variant — belongs here in + * `meta`, which the framework never interprets. The shell's `#wrap` chrome reads + * these to draw the header and size the dialog. + */ +export interface WindowMeta { + readonly icon: string; + readonly width?: "normal" | "wide"; +} + +/** + * The shared overlay-host handle. Exported once and imported at both the host + * (`shell`, which mounts one ``) and every + * contributor (the window modules), so the `StepRef` subject type is stated in + * exactly one place. Its only runtime field is the slot key modules contribute + * their windows under; the subject type rides along as a phantom so + * `title(subject)` and `useOverlaySubject()` type-check end to end. + */ +export const resultViews = defineOverlayHost("resultViews"); diff --git a/examples/vue/overlay-result-windows/app-shared/tsconfig.json b/examples/vue/overlay-result-windows/app-shared/tsconfig.json new file mode 100644 index 0000000..554231f --- /dev/null +++ b/examples/vue/overlay-result-windows/app-shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/examples/vue/overlay-result-windows/modules/acme-extras/package.json b/examples/vue/overlay-result-windows/modules/acme-extras/package.json new file mode 100644 index 0000000..d628784 --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/acme-extras/package.json @@ -0,0 +1,29 @@ +{ + "name": "@example-vue-overlay-windows/acme-extras", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@example-vue-overlay-windows/app-shared": "workspace:*", + "@modular-vue/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vue": "^3.5.40", + "vue-tsc": "^3.3.0" + }, + "peerDependencies": { + "vue": "^3.5.40" + } +} diff --git a/examples/vue/overlay-result-windows/modules/acme-extras/src/SecurityReportWindow.vue b/examples/vue/overlay-result-windows/modules/acme-extras/src/SecurityReportWindow.vue new file mode 100644 index 0000000..de25f4f --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/acme-extras/src/SecurityReportWindow.vue @@ -0,0 +1,14 @@ + + + diff --git a/examples/vue/overlay-result-windows/modules/acme-extras/src/index.ts b/examples/vue/overlay-result-windows/modules/acme-extras/src/index.ts new file mode 100644 index 0000000..73b7a83 --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/acme-extras/src/index.ts @@ -0,0 +1,31 @@ +import { defineModule } from "@modular-vue/core"; +import type { AppDependencies, AppSlots } from "@example-vue-overlay-windows/app-shared"; +import SecurityReportWindow from "./SecurityReportWindow.vue"; + +/** + * A **consumer** module — the kind a downstream deployment ships. It contributes + * its own window (`acme:security-report`) to the `resultViews` host that the + * first-party `run-core` module knows nothing about, with **no edit to the host + * and no framework change**: the window flows in through the same `slots` path + * every module uses, and inherits the full managed-modal behaviour contract + * (teleport, focus trap, Escape, scroll lock, a11y) for free. + * + * Its id is namespaced (`acme:security-report`) so it can never collide with a + * first-party window id — the overlay resolver's duplicate-id check would + * otherwise throw. This is the open-contribution property the overlay host + * exists to provide. + */ +export default defineModule({ + id: "acme-extras", + version: "1.0.0", + slots: { + resultViews: [ + { + id: "acme:security-report", + component: SecurityReportWindow, + title: "Security report", + meta: { icon: "🛡️", width: "normal" }, + }, + ], + }, +}); diff --git a/examples/vue/overlay-result-windows/modules/acme-extras/tsconfig.json b/examples/vue/overlay-result-windows/modules/acme-extras/tsconfig.json new file mode 100644 index 0000000..2dcb2a1 --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/acme-extras/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/examples/vue/overlay-result-windows/modules/run-core/package.json b/examples/vue/overlay-result-windows/modules/run-core/package.json new file mode 100644 index 0000000..902f83d --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/run-core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@example-vue-overlay-windows/run-core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "@example-vue-overlay-windows/app-shared": "workspace:*", + "@modular-vue/core": "workspace:*", + "@modular-vue/vue": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vue": "^3.5.40", + "vue-tsc": "^3.3.0" + }, + "peerDependencies": { + "vue": "^3.5.40" + } +} diff --git a/examples/vue/overlay-result-windows/modules/run-core/src/RunLogsWindow.vue b/examples/vue/overlay-result-windows/modules/run-core/src/RunLogsWindow.vue new file mode 100644 index 0000000..44c3ff9 --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/run-core/src/RunLogsWindow.vue @@ -0,0 +1,29 @@ + + + diff --git a/examples/vue/overlay-result-windows/modules/run-core/src/TestReportWindow.vue b/examples/vue/overlay-result-windows/modules/run-core/src/TestReportWindow.vue new file mode 100644 index 0000000..ac8ecdd --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/run-core/src/TestReportWindow.vue @@ -0,0 +1,70 @@ + + + diff --git a/examples/vue/overlay-result-windows/modules/run-core/src/index.ts b/examples/vue/overlay-result-windows/modules/run-core/src/index.ts new file mode 100644 index 0000000..b2cd62c --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/run-core/src/index.ts @@ -0,0 +1,37 @@ +import { defineModule } from "@modular-vue/core"; +import type { AppDependencies, AppSlots, StepRef } from "@example-vue-overlay-windows/app-shared"; +import TestReportWindow from "./TestReportWindow.vue"; +import RunLogsWindow from "./RunLogsWindow.vue"; + +/** + * First-party window module. Contributes two windows to the `resultViews` + * overlay host through the ordinary `slots` path — the overlay host adds no new + * registration seam (`OverlayEntry` is a superset of `ComponentEntry`). Neither + * window carries any teleport / backdrop / Escape / focus code; the managed + * shell owns all of it. A window is just a body plus presentation metadata. + * + * - `test-report` — a dynamic `title(step)` (→ the dialog's `aria-label`) and a + * nested `useModalBehavior` confirm inside its body (the shared-stack demo). + * - `run-logs` — reads the subject through `useOverlaySubject`. + */ +export default defineModule({ + id: "run-core", + version: "1.0.0", + slots: { + resultViews: [ + { + id: "test-report", + component: TestReportWindow, + title: (step: StepRef | null) => + step ? `Test report — step ${step.stepIndex}` : "Test report", + meta: { icon: "🧪", width: "wide" }, + }, + { + id: "run-logs", + component: RunLogsWindow, + title: "Run logs", + meta: { icon: "📜", width: "normal" }, + }, + ], + }, +}); diff --git a/examples/vue/overlay-result-windows/modules/run-core/tsconfig.json b/examples/vue/overlay-result-windows/modules/run-core/tsconfig.json new file mode 100644 index 0000000..2dcb2a1 --- /dev/null +++ b/examples/vue/overlay-result-windows/modules/run-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/examples/vue/overlay-result-windows/shell/e2e/smoke.spec.ts b/examples/vue/overlay-result-windows/shell/e2e/smoke.spec.ts new file mode 100644 index 0000000..5e05c97 --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/e2e/smoke.spec.ts @@ -0,0 +1,234 @@ +import { test, expect, type ConsoleMessage, type Page } from "@playwright/test"; + +// Smoke + behavioral coverage for the state-keyed overlay host +// (`` / `useOverlay` / `useOverlaySubject` / `useModalBehavior`) +// mounted in a Vue Router shell. Mirrors the React `overlay-result-windows` +// example row-for-row on the same primitive — the behaviour is engine-first, so +// both bindings must observe the identical managed-modal contract. A +// page/console-error collector wraps every test so runtime regressions surface +// as failures regardless of the specific UI assertion. +// +// Only `console.error` is treated as a failure — the dangling-id test +// deliberately triggers the host's dev `console.warn`, which is expected. + +function attachErrorCollectors(page: Page) { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + page.on("pageerror", (err) => { + pageErrors.push(`${err.message}\n${err.stack ?? ""}`); + }); + page.on("console", (msg: ConsoleMessage) => { + if (msg.type() !== "error") return; + consoleErrors.push(msg.text()); + }); + return { pageErrors, consoleErrors }; +} + +function assertNoErrors({ + pageErrors, + consoleErrors, +}: { + pageErrors: string[]; + consoleErrors: string[]; +}) { + expect(pageErrors, `Unhandled page errors:\n${pageErrors.join("\n---\n")}`).toEqual([]); + expect(consoleErrors, `console.error entries:\n${consoleErrors.join("\n---\n")}`).toEqual([]); +} + +const backdrop = "[data-modular-overlay-backdrop]"; +const panel = "[data-modular-overlay-panel]"; + +/** The id of the currently-open window, or null when closed. */ +async function openWindowId(page: Page): Promise { + const el = page.locator(backdrop); + if ((await el.count()) === 0) return null; + return el.getAttribute("data-overlay-id"); +} + +/** Whether keyboard focus currently sits inside the dialog panel. */ +async function focusInsidePanel(page: Page): Promise { + return page.evaluate((sel) => { + const p = document.querySelector(sel); + return !!p && !!document.activeElement && p.contains(document.activeElement); + }, panel); +} + +test("closed by default: empty placeholder, no dialog", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await expect(page.getByTestId("overlay-closed")).toBeVisible(); + await expect(page.locator(panel)).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("opening a window mounts the managed modal shell (a11y + focus)", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + + const dialog = page.locator(panel); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAttribute("role", "dialog"); + await expect(dialog).toHaveAttribute("aria-modal", "true"); + await expect(dialog).toHaveAttribute("aria-label", "Test report — step 0"); + await expect(page.getByTestId("overlay-title")).toHaveText("Test report — step 0"); + expect(await openWindowId(page)).toBe("test-report"); + expect(await focusInsidePanel(page)).toBe(true); + await expect(page.getByTestId("overlay-closed")).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("Escape requests close and returns focus to the opener", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + await expect(page.locator(panel)).toBeVisible(); + + await page.keyboard.press("Escape"); + + await expect(page.locator(panel)).toHaveCount(0); + await expect(page.getByTestId("overlay-closed")).toBeVisible(); + const focusedTestId = await page.evaluate(() => + document.activeElement?.getAttribute("data-testid"), + ); + expect(focusedTestId).toBe("open-test-report"); + + assertNoErrors(errors); +}); + +test("backdrop press-and-release closes; the ✕ closes", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-run-logs").click(); + await expect(page.locator(panel)).toBeVisible(); + await page.locator(backdrop).click({ position: { x: 6, y: 6 } }); + await expect(page.locator(panel)).toHaveCount(0); + + await page.getByTestId("open-run-logs").click(); + await expect(page.locator(panel)).toBeVisible(); + await page.getByTestId("overlay-close").click(); + await expect(page.locator(panel)).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("a press that starts inside the dialog and releases on the backdrop does NOT close", async ({ + page, +}) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + const box = await page.locator(panel).boundingBox(); + if (!box) throw new Error("panel has no bounding box"); + + await page.mouse.move(box.x + 20, box.y + 20); + await page.mouse.down(); + await page.mouse.move(6, 6); + await page.mouse.up(); + + await expect(page.locator(panel)).toBeVisible(); + + assertNoErrors(errors); +}); + +test("one window at a time: switching swaps without closing, keeping a single dialog", async ({ + page, +}) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + expect(await openWindowId(page)).toBe("test-report"); + await expect(page.getByTestId("window-body-test-report")).toBeVisible(); + + // The switcher lives inside the dialog (the backdrop covers the openers + // behind it): switching sets the active id to a sibling — a swap, not a close. + await page.getByTestId("switch-run-logs").click(); + await expect(page.locator(backdrop)).toHaveCount(1); + await expect(page.locator(panel)).toHaveCount(1); + expect(await openWindowId(page)).toBe("run-logs"); + await expect(page.getByTestId("window-body-run-logs")).toBeVisible(); + await expect(page.getByTestId("window-body-test-report")).toHaveCount(0); + expect(await focusInsidePanel(page)).toBe(true); + + assertNoErrors(errors); +}); + +test("the consumer window opens with no host edit", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-security-report").click(); + + expect(await openWindowId(page)).toBe("acme:security-report"); + await expect(page.getByTestId("window-body-acme-security-report")).toBeVisible(); + await expect(page.getByTestId("overlay-title")).toHaveText("Security report"); + + assertNoErrors(errors); +}); + +test("the subject is threaded to the window and swaps with the selected step", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("step-1").click(); + await page.getByTestId("open-run-logs").click(); + // `run-logs` reads the subject via `useOverlaySubject` (no prop-drilling). + await expect(page.getByTestId("window-body-run-logs")).toContainText("step 1"); + await expect(page.getByTestId("window-body-run-logs")).toContainText("Typecheck"); + + // Switch to test-report (inside the dialog): it reads the same subject via its + // injected `subject` prop, and the title re-resolves against the same step. + await page.getByTestId("switch-test-report").click(); + await expect(page.getByTestId("overlay-title")).toHaveText("Test report — step 1"); + await expect(page.getByTestId("window-body-test-report")).toContainText("Typecheck"); + + assertNoErrors(errors); +}); + +test("nested bespoke overlay shares the stack: Escape closes the top first", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-test-report").click(); + await expect(page.locator(panel)).toBeVisible(); + + // Open the nested confirm (built on `useModalBehavior`) inside the window. + await page.getByTestId("open-confirm").click(); + await expect(page.getByTestId("confirm-dialog")).toBeVisible(); + + // First Escape closes the topmost overlay — the confirm — leaving the window. + await page.keyboard.press("Escape"); + await expect(page.getByTestId("confirm-dialog")).toHaveCount(0); + await expect(page.locator(panel)).toBeVisible(); + + // Second Escape closes the window beneath it. + await page.keyboard.press("Escape"); + await expect(page.locator(panel)).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("a dangling active id is data, not a crash: renders nothing, no error", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("open-dangling").click(); + + await expect(page.locator(panel)).toHaveCount(0); + await expect(page.getByTestId("overlay-closed")).toBeVisible(); + + // A real window still opens afterwards — the dangling id didn't wedge state. + await page.getByTestId("open-test-report").click(); + await expect(page.locator(panel)).toBeVisible(); + + // The dev `console.warn` for the dangling id is expected; only errors fail. + assertNoErrors(errors); +}); diff --git a/examples/vue/overlay-result-windows/shell/index.html b/examples/vue/overlay-result-windows/shell/index.html new file mode 100644 index 0000000..1efa58e --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/index.html @@ -0,0 +1,13 @@ + + + + + + + State-keyed overlay host (Vue Router) + + +
+ + + diff --git a/examples/vue/overlay-result-windows/shell/package.json b/examples/vue/overlay-result-windows/shell/package.json new file mode 100644 index 0000000..fbf1090 --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/package.json @@ -0,0 +1,30 @@ +{ + "name": "@example-vue-overlay-windows/shell", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "@example-vue-overlay-windows/acme-extras": "workspace:*", + "@example-vue-overlay-windows/app-shared": "workspace:*", + "@example-vue-overlay-windows/run-core": "workspace:*", + "@modular-vue/core": "workspace:*", + "@modular-vue/runtime": "workspace:*", + "@modular-vue/vue": "workspace:*", + "vue": "^3.5.40", + "vue-router": "^5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@vitejs/plugin-vue": "^6.0.0", + "typescript": "^6.0.2", + "vite": "^8.1.3", + "vue-tsc": "^3.3.0" + } +} diff --git a/examples/vue/overlay-result-windows/shell/playwright.config.ts b/examples/vue/overlay-result-windows/shell/playwright.config.ts new file mode 100644 index 0000000..bf8a307 --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = 5204; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + timeout: 30_000, + retries: 0, + fullyParallel: false, + reporter: [["list"]], + use: { + headless: true, + baseURL: `http://localhost:${PORT}`, + }, + webServer: { + command: `pnpm exec vite --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}/`, + reuseExistingServer: false, + timeout: 60_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/examples/vue/overlay-result-windows/shell/src/App.vue b/examples/vue/overlay-result-windows/shell/src/App.vue new file mode 100644 index 0000000..85f7c1c --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/src/App.vue @@ -0,0 +1,9 @@ + + + diff --git a/examples/vue/overlay-result-windows/shell/src/components/Home.vue b/examples/vue/overlay-result-windows/shell/src/components/Home.vue new file mode 100644 index 0000000..1cdd318 --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/src/components/Home.vue @@ -0,0 +1,190 @@ + + + + + + diff --git a/examples/vue/overlay-result-windows/shell/src/components/ShellLayout.vue b/examples/vue/overlay-result-windows/shell/src/components/ShellLayout.vue new file mode 100644 index 0000000..3bf6d0a --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/src/components/ShellLayout.vue @@ -0,0 +1,21 @@ + + + diff --git a/examples/vue/overlay-result-windows/shell/src/components/WindowChrome.vue b/examples/vue/overlay-result-windows/shell/src/components/WindowChrome.vue new file mode 100644 index 0000000..a199ad6 --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/src/components/WindowChrome.vue @@ -0,0 +1,92 @@ + + + diff --git a/examples/vue/overlay-result-windows/shell/src/main.ts b/examples/vue/overlay-result-windows/shell/src/main.ts new file mode 100644 index 0000000..5c299a3 --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/src/main.ts @@ -0,0 +1,44 @@ +import { createApp } from "vue"; +import { createRouter, createWebHistory } from "vue-router"; +import { createModularApp, createRegistry } from "@modular-vue/runtime"; +import type { AppDependencies, AppSlots } from "@example-vue-overlay-windows/app-shared"; +import runCore from "@example-vue-overlay-windows/run-core"; +import acmeExtras from "@example-vue-overlay-windows/acme-extras"; +import App from "./App.vue"; +import ShellLayout from "./components/ShellLayout.vue"; +import Home from "./components/Home.vue"; + +// Base slots declare `resultViews: []`; each registered module concatenates its +// own `OverlayEntry` windows onto it. Both window modules are headless — they +// contribute only slots, no routes — so the whole app renders on the index +// route (`Home`), which mounts the single `` overlay host. +const registry = createRegistry({ + stores: {}, + services: { auth: { userId: "demo-user" } }, + slots: { resultViews: [] }, +}); + +registry.register(runCore); +registry.register(acmeExtras); + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/", + name: "root", + component: ShellLayout, + children: [{ path: "", name: "home", component: Home }], + }, + ], +}); + +const manifest = createModularApp(registry, { router, parentRouteName: "root" }); + +const app = createApp(App); +app.use(router); +// The manifest is itself a Vue plugin: installing it wires the modular contexts +// (slots, modules, shared deps) app-wide, so `` / `useOverlay` +// can inject the slots source and resolve the host's contributed windows. +app.use(manifest); +app.mount("#app"); diff --git a/examples/vue/overlay-result-windows/shell/tsconfig.json b/examples/vue/overlay-result-windows/shell/tsconfig.json new file mode 100644 index 0000000..ad0de3c --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/examples/vue/overlay-result-windows/shell/vite.config.ts b/examples/vue/overlay-result-windows/shell/vite.config.ts new file mode 100644 index 0000000..1df2a41 --- /dev/null +++ b/examples/vue/overlay-result-windows/shell/vite.config.ts @@ -0,0 +1,15 @@ +import vue from "@vitejs/plugin-vue"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [vue()], + resolve: { + // Dedupe vue and the modular packages so the dev server's pre-bundler + // doesn't produce two module copies of the same package. The shared overlay + // stack lives in a module-private singleton in @modular-frontend/core, so a + // second copy would split the stack and break Escape ordering across + // hosted windows and bespoke `useModalBehavior` overlays. + dedupe: ["vue", "vue-router", "@modular-vue/core", "@modular-vue/vue", "@modular-vue/runtime"], + }, + server: { port: 5204 }, +}); diff --git a/packages/vue/src/overlay.ts b/packages/vue/src/overlay.ts index 2b2a0a5..38e568d 100644 --- a/packages/vue/src/overlay.ts +++ b/packages/vue/src/overlay.ts @@ -326,7 +326,13 @@ export const OverlayOutlet = defineComponent({ props: { host: { type: Object as PropType>, required: true }, activeId: { type: String as PropType, default: null }, - subject: { type: null as unknown as PropType, default: null }, + // `type: null` accepts any runtime value (subject / class bindings are + // opaque to the host). The `default` is cast to the prop's declared type so + // `ExtractPropTypes` keeps `unknown` — an un-cast `default: null` / + // `default: undefined` collapses the inferred `$props` type to `null` / + // `undefined`, which makes `` + // reject real values in a typed template (vue-tsc). + subject: { type: null as unknown as PropType, default: null as unknown }, subjectKey: { type: null as unknown as PropType string | number)>, default: undefined, @@ -335,8 +341,8 @@ export const OverlayOutlet = defineComponent({ to: { type: [String, Object] as PropType, default: "body" }, teleportDisabled: { type: Boolean, default: false }, closeOnBackdrop: { type: Boolean, default: true }, - backdropClass: { type: null as unknown as PropType, default: undefined }, - panelClass: { type: null as unknown as PropType, default: undefined }, + backdropClass: { type: null as unknown as PropType, default: undefined as unknown }, + panelClass: { type: null as unknown as PropType, default: undefined as unknown }, }, emits: ["close"], setup(props, { slots, emit }) { diff --git a/packages/vue/src/panels.ts b/packages/vue/src/panels.ts index 627f248..748fe3e 100644 --- a/packages/vue/src/panels.ts +++ b/packages/vue/src/panels.ts @@ -206,7 +206,11 @@ export const PanelsOutlet = defineComponent({ name: "PanelsOutlet", props: { group: { type: Object as PropType>, required: true }, - subject: { type: null as unknown as PropType, default: null }, + // The `default` is cast to the prop's declared type so `ExtractPropTypes` + // keeps `unknown`; an un-cast `default: null` collapses the inferred + // `$props["subject"]` to `null`, which makes `` + // reject a real subject in a typed template (vue-tsc). See OverlayOutlet. + subject: { type: null as unknown as PropType, default: null as unknown }, subjectKey: { type: null as unknown as PropType string | number)>, default: undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7499590..1b553e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1087,6 +1087,115 @@ importers: specifier: 8.1.5 version: 8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + examples/react-router/overlay-result-windows/app-shared: + dependencies: + '@modular-react/core': + specifier: workspace:* + version: link:../../../../packages/core + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/react-router/overlay-result-windows/modules/acme-extras: + dependencies: + '@example-rr-overlay-windows/app-shared': + specifier: workspace:* + version: link:../../app-shared + '@react-router-modules/core': + specifier: workspace:* + version: link:../../../../../packages/react-router-core + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: ^19.0.0 + version: 19.2.6 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/react-router/overlay-result-windows/modules/run-core: + dependencies: + '@example-rr-overlay-windows/app-shared': + specifier: workspace:* + version: link:../../app-shared + '@modular-react/react': + specifier: workspace:* + version: link:../../../../../packages/react + '@react-router-modules/core': + specifier: workspace:* + version: link:../../../../../packages/react-router-core + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: ^19.0.0 + version: 19.2.6 + react-dom: + specifier: ^19.0.0 + version: 19.2.6(react@19.2.6) + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/react-router/overlay-result-windows/shell: + dependencies: + '@example-rr-overlay-windows/acme-extras': + specifier: workspace:* + version: link:../modules/acme-extras + '@example-rr-overlay-windows/app-shared': + specifier: workspace:* + version: link:../app-shared + '@example-rr-overlay-windows/run-core': + specifier: workspace:* + version: link:../modules/run-core + '@modular-react/core': + specifier: workspace:* + version: link:../../../../packages/core + '@modular-react/react': + specifier: workspace:* + version: link:../../../../packages/react + '@react-router-modules/core': + specifier: workspace:* + version: link:../../../../packages/react-router-core + '@react-router-modules/runtime': + specifier: workspace:* + version: link:../../../../packages/react-router-runtime + react: + specifier: ^19.0.0 + version: 19.2.6 + react-dom: + specifier: ^19.0.0 + version: 19.2.6(react@19.2.6) + react-router: + specifier: ^7.0.0 + version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.59.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + examples/react-router/remote-capabilities/app-shared: dependencies: '@modular-react/core': @@ -2766,6 +2875,106 @@ importers: specifier: ^3.3.0 version: 3.3.7(typescript@6.0.3) + examples/vue/overlay-result-windows/app-shared: + dependencies: + '@modular-vue/core': + specifier: workspace:* + version: link:../../../../packages/vue-core + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vue: + specifier: ^3.5.40 + version: 3.5.40(typescript@6.0.3) + vue-tsc: + specifier: ^3.3.0 + version: 3.3.7(typescript@6.0.3) + + examples/vue/overlay-result-windows/modules/acme-extras: + dependencies: + '@example-vue-overlay-windows/app-shared': + specifier: workspace:* + version: link:../../app-shared + '@modular-vue/core': + specifier: workspace:* + version: link:../../../../../packages/vue-core + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vue: + specifier: ^3.5.40 + version: 3.5.40(typescript@6.0.3) + vue-tsc: + specifier: ^3.3.0 + version: 3.3.7(typescript@6.0.3) + + examples/vue/overlay-result-windows/modules/run-core: + dependencies: + '@example-vue-overlay-windows/app-shared': + specifier: workspace:* + version: link:../../app-shared + '@modular-vue/core': + specifier: workspace:* + version: link:../../../../../packages/vue-core + '@modular-vue/vue': + specifier: workspace:* + version: link:../../../../../packages/vue + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vue: + specifier: ^3.5.40 + version: 3.5.40(typescript@6.0.3) + vue-tsc: + specifier: ^3.3.0 + version: 3.3.7(typescript@6.0.3) + + examples/vue/overlay-result-windows/shell: + dependencies: + '@example-vue-overlay-windows/acme-extras': + specifier: workspace:* + version: link:../modules/acme-extras + '@example-vue-overlay-windows/app-shared': + specifier: workspace:* + version: link:../app-shared + '@example-vue-overlay-windows/run-core': + specifier: workspace:* + version: link:../modules/run-core + '@modular-vue/core': + specifier: workspace:* + version: link:../../../../packages/vue-core + '@modular-vue/runtime': + specifier: workspace:* + version: link:../../../../packages/vue-runtime + '@modular-vue/vue': + specifier: workspace:* + version: link:../../../../packages/vue + vue: + specifier: ^3.5.40 + version: 3.5.40(typescript@6.0.3) + vue-router: + specifier: ^5.0.0 + version: 5.2.0(@vue/compiler-sfc@3.5.40)(esbuild@0.28.1)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.40(typescript@6.0.3)))(rolldown@1.1.4)(rollup@4.60.3)(vite@8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) + devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.59.1 + '@vitejs/plugin-vue': + specifier: ^6.0.0 + version: 6.0.8(vite@8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + vue-tsc: + specifier: ^3.3.0 + version: 3.3.7(typescript@6.0.3) + packages/angular: devDependencies: '@angular/compiler': From ed1354ca6605c36d4703e754ab9c9a9d71abc5c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 15:25:50 +0000 Subject: [PATCH 5/6] fix(overlays): expose ariaLabelledby on both overlay hosts Addresses the review comment: when a window ships no `title`, the dialog set only `aria-label` (from the resolved title), so a title-less window ended up with an unnamed dialog. Add an optional `ariaLabelledby` prop to `` on both bindings, forwarded to the dialog's `aria-labelledby`, so a heading the window renders in `wrap`/`#wrap` can name the modal. `aria-label` is preserved (ARIA resolves `aria-labelledby` first when both are present). Tests on both bindings assert the attribute forwards for a title-less window (aria-label absent). Docs updated (props lists + a11y guarantee). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRyxejS9jdwipTZGZKGnZ3 --- docs/overlay-host.md | 12 ++++++++---- packages/react/src/overlay.test.tsx | 9 +++++++++ packages/react/src/overlay.tsx | 13 ++++++++++++- packages/vue/src/overlay.test.ts | 11 +++++++++++ packages/vue/src/overlay.ts | 9 ++++++++- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/docs/overlay-host.md b/docs/overlay-host.md index e673624..5c2d40b 100644 --- a/docs/overlay-host.md +++ b/docs/overlay-host.md @@ -175,7 +175,9 @@ const ui = useUiStore(); Props: `host`, `activeId`, `subject`, `subjectKey`, `onDuplicate`, `to` (teleport target, default `"body"`), `teleportDisabled`, `closeOnBackdrop` (default `true`), -`backdropClass`, `panelClass`. Emits `close`. Also exported: `useOverlay(host, +`backdropClass`, `panelClass`, `ariaLabelledby` (the `id` of a heading the window +renders in `#wrap`, for a title-less window — forwarded to the dialog's +`aria-labelledby`). Emits `close`. Also exported: `useOverlay(host, activeId, opts?)` (a `computed` over the slots context — every argument is a `MaybeRefOrGetter`), `useOverlaySubject()` (reads the provided subject inside the window, no prop-drilling; throws outside an outlet), `overlaySubjectKey`. @@ -208,8 +210,9 @@ import { resultViews } from "../overlay-hosts"; ``` Same surface with props instead of slots (`empty` / `wrap` are props), `to` / -`portalDisabled` for the portal target, and `useOverlay` / `useOverlaySubject` / -`OverlaySubjectContext` as the hook-shaped reads. +`portalDisabled` for the portal target, `ariaLabelledby` for a title-less +window, and `useOverlay` / `useOverlaySubject` / `OverlaySubjectContext` as the +hook-shaped reads. ## What the managed shell guarantees (both bindings) @@ -230,7 +233,8 @@ Same surface with props instead of slots (`empty` / `wrap` are props), `to` / - **Scroll**: body scroll locked while any overlay is open, restored when the last closes. - **A11y**: `role="dialog"`, `aria-modal="true"`, `aria-label` from `title` resolved - against the subject, `tabindex="-1"` on the panel. + against the subject (or `aria-labelledby` pointed at a heading the window renders + in `wrap` when it ships no `title`), `tabindex="-1"` on the panel. - **Stable e2e hooks**: `data-modular-overlay-backdrop`, `data-modular-overlay-panel`, `data-overlay-id=""` — no configuration, no per-app drift. - **Containment**: the window body renders inside `ModuleErrorBoundary` (label diff --git a/packages/react/src/overlay.test.tsx b/packages/react/src/overlay.test.tsx index 81ad553..a5642fe 100644 --- a/packages/react/src/overlay.test.tsx +++ b/packages/react/src/overlay.test.tsx @@ -114,6 +114,15 @@ describe("OverlayOutlet — shell and a11y", () => { ).toBe("test-report"); }); + it("forwards ariaLabelledby to the dialog for a title-less window (no aria-label)", () => { + // `merger-verdict` has no `title`, so `aria-label` is absent; the app names + // the dialog with a heading it renders in `wrap`, via `ariaLabelledby`. + renderOutlet({ activeId: "merger-verdict", ariaLabelledby: "wrap-heading" }); + const panel = document.querySelector("[data-modular-overlay-panel]")!; + expect(panel.getAttribute("aria-label")).toBeNull(); + expect(panel.getAttribute("aria-labelledby")).toBe("wrap-heading"); + }); + it("passes entry props through with the injected subject winning", () => { const slots = slotsOf({ id: "w", diff --git a/packages/react/src/overlay.tsx b/packages/react/src/overlay.tsx index 4fcf314..9c7c426 100644 --- a/packages/react/src/overlay.tsx +++ b/packages/react/src/overlay.tsx @@ -280,6 +280,14 @@ export interface OverlayOutletProps { /** The app's styling for the two host-rendered elements. Headless otherwise. */ readonly backdropClassName?: string; readonly panelClassName?: string; + /** + * `id` of an element that names the dialog — forwarded to the dialog's + * `aria-labelledby`. Use when a window omits `title` (so no `aria-label` is + * set) but renders its own heading in `wrap`: point this at that heading's + * `id` so the modal is still named for assistive tech. Per ARIA, + * `aria-labelledby` wins over `aria-label` when both are present. + */ + readonly ariaLabelledby?: string; /** Rendered (in place, not portaled) when no window is active. */ readonly empty?: ReactNode; /** @@ -296,7 +304,8 @@ export interface OverlayOutletProps { * shell: portaled (default `document.body`), backdrop click-self → `onClose`, * focus trap + focus return, body scroll lock, shared-stack registration * (Escape closes the top overlay first), `role="dialog"` / `aria-modal` / - * `aria-label` (from the entry's `title`, resolved against the subject). + * `aria-label` (from the entry's `title`, resolved against the subject) or + * `aria-labelledby` (pointed at a heading the window renders in `wrap`). * * The backdrop and dialog carry stable, namespaced hooks for e2e suites: * `data-modular-overlay-backdrop`, `data-modular-overlay-panel`, and @@ -334,6 +343,7 @@ export function OverlayOutlet({ closeOnBackdrop = true, backdropClassName, panelClassName, + ariaLabelledby, empty, wrap, }: OverlayOutletProps): ReactNode { @@ -400,6 +410,7 @@ export function OverlayOutlet({ role="dialog" aria-modal="true" aria-label={resolveOverlayTitle(entry, subject)} + aria-labelledby={ariaLabelledby} tabIndex={-1} className={panelClassName} data-modular-overlay-panel="" diff --git a/packages/vue/src/overlay.test.ts b/packages/vue/src/overlay.test.ts index 76748fa..6eb475a 100644 --- a/packages/vue/src/overlay.test.ts +++ b/packages/vue/src/overlay.test.ts @@ -137,6 +137,17 @@ describe("OverlayOutlet — shell and a11y", () => { ); }); + it("forwards ariaLabelledby to the dialog for a title-less window (no aria-label)", () => { + // `merger-verdict` has no `title`, so `aria-label` is absent; the app names + // the dialog with a heading it renders in `#wrap`, via `ariaLabelledby`. + const wrapper = track( + mountOutlet({ activeId: "merger-verdict", ariaLabelledby: "wrap-heading" }), + ); + const panel = wrapper.find("[data-modular-overlay-panel]"); + expect(panel.attributes("aria-label")).toBeUndefined(); + expect(panel.attributes("aria-labelledby")).toBe("wrap-heading"); + }); + it("passes entry props through with the injected subject winning", () => { seen.length = 0; const slots = entries({ diff --git a/packages/vue/src/overlay.ts b/packages/vue/src/overlay.ts index 38e568d..feaf3c5 100644 --- a/packages/vue/src/overlay.ts +++ b/packages/vue/src/overlay.ts @@ -279,7 +279,8 @@ function keyFor(entry: OverlayEntry, subjectKey: unknown, subject: unkn * shell: teleported (default `body`), backdrop click-self → `close`, focus * trap + focus return, body scroll lock, shared-stack registration (Escape * closes the top overlay first), `role="dialog"` / `aria-modal` / - * `aria-label` (from the entry's `title`, resolved against the subject). + * `aria-label` (from the entry's `title`, resolved against the subject) or + * `aria-labelledby` (pointed at a heading the window renders in `#wrap`). * * - `host` — the {@link OverlayHostHandle} from `defineOverlayHost`. * - `activeId` — the active window id from app state; `null` renders the @@ -343,6 +344,11 @@ export const OverlayOutlet = defineComponent({ closeOnBackdrop: { type: Boolean, default: true }, backdropClass: { type: null as unknown as PropType, default: undefined as unknown }, panelClass: { type: null as unknown as PropType, default: undefined as unknown }, + // `id` of an element that names the dialog — forwarded to `aria-labelledby`. + // Use when a window omits `title` (so no `aria-label` is set) but renders + // its own heading in `#wrap`: point this at that heading's `id` so the modal + // is still named for assistive tech. + ariaLabelledby: { type: String, default: undefined }, }, emits: ["close"], setup(props, { slots, emit }) { @@ -435,6 +441,7 @@ export const OverlayOutlet = defineComponent({ role: "dialog", "aria-modal": "true", "aria-label": resolveOverlayTitle(active, props.subject), + "aria-labelledby": props.ariaLabelledby, tabindex: -1, class: props.panelClass, "data-modular-overlay-panel": "", From 6fc63b8b2eaf6c164d5bc5cbbbed5757bedb3a2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 15:32:50 +0000 Subject: [PATCH 6/6] docs(overlays): drop stale collapseEntriesById reference in overlay-dom docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review comment: the module docstring referenced a `collapseEntriesById` argument that no function in this file takes — a stale leftover from an earlier draft. Remove the parenthetical; the surrounding description of the shared focusable/Tab/scroll-lock/sharedOverlayStack semantics is unchanged. Comment-only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MRyxejS9jdwipTZGZKGnZ3 --- packages/frontend-core/src/overlay-dom.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/frontend-core/src/overlay-dom.ts b/packages/frontend-core/src/overlay-dom.ts index e9d9e10..88cb782 100644 --- a/packages/frontend-core/src/overlay-dom.ts +++ b/packages/frontend-core/src/overlay-dom.ts @@ -1,8 +1,7 @@ /** * Client-only DOM behaviour shared by the bindings' overlay hosts — the single * implementation of the modal behaviour *semantics* that must not drift - * between React and Vue (the `collapseEntriesById` argument, applied to focus - * and scroll): which elements count as focusable, how Tab wraps at the + * between React and Vue: which elements count as focusable, how Tab wraps at the * dialog's edges, how the body scroll lock is counted, and the one app-wide * {@link sharedOverlayStack} every overlay registers on. *