diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 20:44:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 20:44:40 +0900 |
| commit | a8c9be77cf5dd6249d8ea531c4ce79d823a6f600 (patch) | |
| tree | 4a6bbb086751ebca5aab55de41087d8c2da9205a | |
| parent | 51da5df8fc70efc2febda238faeaeacb21713699 (diff) | |
| download | dispatch-web-a8c9be77cf5dd6249d8ea531c4ce79d823a6f600.tar.gz dispatch-web-a8c9be77cf5dd6249d8ea531c4ce79d823a6f600.zip | |
fix(heartbeat): make PromptEditor modal truly fullscreen
| -rw-r--r-- | src/adapters/portal.test.ts | 49 | ||||
| -rw-r--r-- | src/adapters/portal.ts | 28 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.svelte | 8 |
3 files changed, 85 insertions, 0 deletions
diff --git a/src/adapters/portal.test.ts b/src/adapters/portal.test.ts new file mode 100644 index 0000000..792d3b4 --- /dev/null +++ b/src/adapters/portal.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { portal } from "./portal"; + +describe("portal action", () => { + afterEach(() => { + // Strip any leftover teleported nodes between tests. + document.querySelectorAll("body > :not(script)").forEach((n) => { + if (n instanceof HTMLElement) n.remove(); + }); + }); + + it("teleports the node to document.body (escaping an ancestor with transform)", () => { + // Simulate the sidebar: a transformed ancestor establishes a containing + // block for `position: fixed`. + const ancestor = document.createElement("div"); + ancestor.style.transform = "translateX(0)"; + document.body.appendChild(ancestor); + + const node = document.createElement("div"); + node.setAttribute("data-testid", "modal"); + ancestor.appendChild(node); + expect(node.parentNode).toBe(ancestor); + + const action = portal(node); + + // After the action, the node is a direct child of <body>, not the ancestor. + expect(node.parentNode).toBe(document.body); + expect(ancestor.contains(node)).toBe(false); + + action.destroy(); + + // On destroy the node is removed from <body>. + expect(document.body.contains(node)).toBe(false); + }); + + it("is a no-op (does not throw) when document is unavailable (SSR guard)", () => { + const originalDocument = globalThis.document; + // @ts-expect-error — deliberately undefined to exercise the SSR guard. + globalThis.document = undefined; + try { + const stub = {} as HTMLElement; + const action = portal(stub); + // Must not throw, and returns a destroy that is safe to call. + action.destroy(); + } finally { + globalThis.document = originalDocument; + } + }); +}); diff --git a/src/adapters/portal.ts b/src/adapters/portal.ts new file mode 100644 index 0000000..10aec30 --- /dev/null +++ b/src/adapters/portal.ts @@ -0,0 +1,28 @@ +/** + * A Svelte `use:` action that teleports a node to `document.body`, escaping any + * ancestor that establishes a containing block for `position: fixed` (most + * commonly an ancestor with a `transform`, `filter`, `perspective`, or + * `will-change` — e.g. the sidebar's `transform: translateX(...)` container). + * + * Without this, a `position: fixed` modal rendered inside such an ancestor is + * positioned relative to the ANCESTOR, not the viewport (so it only covers the + * sidebar area instead of the full screen). Moving the node to `document.body` + * restores viewport-relative `fixed` positioning. Svelte still owns the node's + * lifecycle (children, bindings, events); we just relocate it + remove it on + * destroy as hygiene. + * + * No-op safely when there is no `document` (SSR / jsdom guards). + */ +export function portal(node: HTMLElement): { destroy(): void } { + if (typeof document === "undefined") { + return { destroy() {} }; + } + document.body.appendChild(node); + return { + destroy() { + if (node.parentNode === document.body) { + document.body.removeChild(node); + } + }, + }; +} diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte index 2377428..867c32a 100644 --- a/src/features/heartbeat/ui/PromptEditor.svelte +++ b/src/features/heartbeat/ui/PromptEditor.svelte @@ -9,6 +9,7 @@ type LoadSystemPromptVariables, } from "../../system-prompt"; import type { SaveHeartbeatConfig } from "../logic/types"; + import { portal } from "../../../adapters/portal"; let { systemPrompt, @@ -141,8 +142,15 @@ <svelte:window onkeydown={onKeydown} /> +<!-- Teleported to <body> (use:portal) so `position: fixed` resolves against the + VIEWPORT, not the sidebar's `transform: translateX(...)` container — an + ancestor transform establishes a containing block for `fixed`, which would + otherwise clip this overlay to the sidebar area. (RunModal/SystemPromptBuilder + avoid this by rendering at the composition root; this modal lives inside + HeartbeatView, so it must escape its ancestor.) --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div + use:portal class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="dialog" aria-modal="true" |
