modal_stack 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +748 -0
  6. data/Rakefile +12 -0
  7. data/app/assets/javascripts/modal_stack.js +756 -0
  8. data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
  9. data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
  10. data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
  12. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
  13. data/app/javascript/modal_stack/index.js +15 -0
  14. data/app/javascript/modal_stack/install.js +15 -0
  15. data/app/javascript/modal_stack/orchestrator.js +98 -0
  16. data/app/javascript/modal_stack/orchestrator.test.js +260 -0
  17. data/app/javascript/modal_stack/runtime.js +217 -0
  18. data/app/javascript/modal_stack/runtime.test.js +134 -0
  19. data/app/javascript/modal_stack/state.js +315 -0
  20. data/app/javascript/modal_stack/state.test.js +508 -0
  21. data/app/views/layouts/modal.html.erb +6 -0
  22. data/lib/generators/modal_stack/install/install_generator.rb +224 -0
  23. data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
  24. data/lib/modal_stack/capybara/minitest.rb +9 -0
  25. data/lib/modal_stack/capybara/rspec.rb +9 -0
  26. data/lib/modal_stack/capybara.rb +85 -0
  27. data/lib/modal_stack/configuration.rb +90 -0
  28. data/lib/modal_stack/controller_extensions.rb +73 -0
  29. data/lib/modal_stack/engine.rb +44 -0
  30. data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
  33. data/lib/modal_stack/initializer_version_check.rb +33 -0
  34. data/lib/modal_stack/turbo_streams_extension.rb +73 -0
  35. data/lib/modal_stack/version.rb +5 -0
  36. data/lib/modal_stack.rb +36 -0
  37. metadata +130 -0
@@ -0,0 +1,260 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { Orchestrator } from "./orchestrator.js";
3
+ import { snapshot } from "./state.js";
4
+
5
+ const STACK_ID = "stack-abc";
6
+ const BASE_URL = "/projects";
7
+
8
+ function recordingRuntime() {
9
+ const calls = [];
10
+ const handlerNames = [
11
+ "showDialog",
12
+ "lockScroll",
13
+ "inertLayer",
14
+ "mountLayer",
15
+ "morphTopLayer",
16
+ "unmountTopLayer",
17
+ "unmountAllLayers",
18
+ "closeDialog",
19
+ "unlockScroll",
20
+ "pushHistory",
21
+ "replaceHistory",
22
+ "historyBack",
23
+ "rebuildFromSnapshot",
24
+ "persistSnapshot",
25
+ "clearSnapshot",
26
+ ];
27
+ const runtime = { _calls: calls };
28
+ for (const name of handlerNames) {
29
+ runtime[name] = (cmd) => {
30
+ calls.push({ type: name, ...cmd });
31
+ };
32
+ }
33
+ return runtime;
34
+ }
35
+
36
+ let runtime;
37
+ let orchestrator;
38
+
39
+ beforeEach(() => {
40
+ runtime = recordingRuntime();
41
+ orchestrator = new Orchestrator({
42
+ runtime,
43
+ stackId: STACK_ID,
44
+ baseUrl: BASE_URL,
45
+ });
46
+ });
47
+
48
+ describe("constructor", () => {
49
+ test("requires runtime", () => {
50
+ expect(() => new Orchestrator({ stackId: "x", baseUrl: "/" })).toThrow(
51
+ /runtime required/,
52
+ );
53
+ });
54
+
55
+ test("starts empty", () => {
56
+ expect(orchestrator.layers).toEqual([]);
57
+ expect(orchestrator.depth).toBe(0);
58
+ });
59
+
60
+ test("restoreFrom seeds state when valid", async () => {
61
+ const seed = new Orchestrator({
62
+ runtime,
63
+ stackId: STACK_ID,
64
+ baseUrl: BASE_URL,
65
+ });
66
+ await seed.push({ id: "L1", url: "/x" });
67
+ const json = snapshot(seed.state);
68
+
69
+ const restored = new Orchestrator({
70
+ runtime: recordingRuntime(),
71
+ stackId: STACK_ID,
72
+ baseUrl: BASE_URL,
73
+ restoreFrom: json,
74
+ });
75
+ expect(restored.depth).toBe(1);
76
+ expect(restored.layers[0].id).toBe("L1");
77
+ });
78
+
79
+ test("restoreFrom is ignored when stackId mismatches", async () => {
80
+ const seed = new Orchestrator({
81
+ runtime,
82
+ stackId: STACK_ID,
83
+ baseUrl: BASE_URL,
84
+ });
85
+ await seed.push({ id: "L1", url: "/x" });
86
+ const json = snapshot(seed.state);
87
+
88
+ const restored = new Orchestrator({
89
+ runtime: recordingRuntime(),
90
+ stackId: "other",
91
+ baseUrl: BASE_URL,
92
+ restoreFrom: json,
93
+ });
94
+ expect(restored.depth).toBe(0);
95
+ });
96
+ });
97
+
98
+ describe("push", () => {
99
+ test("dispatches commands in order, persists snapshot last", async () => {
100
+ await orchestrator.push({ id: "L1", url: "/x" });
101
+ const types = runtime._calls.map((c) => c.type);
102
+ expect(types).toEqual([
103
+ "mountLayer",
104
+ "showDialog",
105
+ "lockScroll",
106
+ "pushHistory",
107
+ "persistSnapshot",
108
+ ]);
109
+ });
110
+
111
+ test("persistSnapshot receives serialized state", async () => {
112
+ let captured = null;
113
+ runtime.persistSnapshot = (json) => {
114
+ captured = json;
115
+ };
116
+ await orchestrator.push({ id: "L1", url: "/x" });
117
+ expect(typeof captured).toBe("string");
118
+ const parsed = JSON.parse(captured);
119
+ expect(parsed.layers).toHaveLength(1);
120
+ expect(parsed.layers[0].id).toBe("L1");
121
+ });
122
+
123
+ test("throws when runtime is missing a handler", async () => {
124
+ delete runtime.mountLayer;
125
+ await expect(orchestrator.push({ id: "L1", url: "/x" })).rejects.toThrow(
126
+ /mountLayer/,
127
+ );
128
+ });
129
+ });
130
+
131
+ describe("pop", () => {
132
+ test("guards the popstate that history.back triggers", async () => {
133
+ await orchestrator.push({ id: "L1", url: "/x" });
134
+ await orchestrator.pop();
135
+
136
+ const popCalls = runtime._calls.map((c) => c.type);
137
+ expect(popCalls).toContain("historyBack");
138
+
139
+ runtime._calls.length = 0;
140
+ await orchestrator.onPopstate({
141
+ historyState: null,
142
+ locationHref: BASE_URL,
143
+ });
144
+ expect(runtime._calls).toEqual([]);
145
+ });
146
+
147
+ test("subsequent popstate (not from us) is processed normally", async () => {
148
+ await orchestrator.push({ id: "L1", url: "/x" });
149
+ await orchestrator.pop();
150
+ await orchestrator.onPopstate({
151
+ historyState: null,
152
+ locationHref: BASE_URL,
153
+ });
154
+
155
+ runtime._calls.length = 0;
156
+ await orchestrator.push({ id: "L2", url: "/y" });
157
+ runtime._calls.length = 0;
158
+ await orchestrator.onPopstate({
159
+ historyState: null,
160
+ locationHref: BASE_URL,
161
+ });
162
+
163
+ const types = runtime._calls.map((c) => c.type);
164
+ expect(types).toContain("closeDialog");
165
+ });
166
+ });
167
+
168
+ describe("replaceTop", () => {
169
+ test("default replaces history without consuming guard", async () => {
170
+ await orchestrator.push({ id: "L1", url: "/x" });
171
+ runtime._calls.length = 0;
172
+
173
+ await orchestrator.replaceTop({ url: "/x/y" });
174
+ const types = runtime._calls.map((c) => c.type);
175
+ expect(types).toEqual([
176
+ "morphTopLayer",
177
+ "replaceHistory",
178
+ "persistSnapshot",
179
+ ]);
180
+ });
181
+
182
+ test("historyMode push emits pushHistory and updates layer id", async () => {
183
+ await orchestrator.push({ id: "L1", url: "/wizard/1" });
184
+ runtime._calls.length = 0;
185
+
186
+ await orchestrator.replaceTop(
187
+ { id: "L1b", url: "/wizard/2" },
188
+ { historyMode: "push" },
189
+ );
190
+
191
+ const types = runtime._calls.map((c) => c.type);
192
+ expect(types).toEqual([
193
+ "morphTopLayer",
194
+ "pushHistory",
195
+ "persistSnapshot",
196
+ ]);
197
+ expect(orchestrator.layers[0].id).toBe("L1b");
198
+ });
199
+ });
200
+
201
+ describe("closeAll", () => {
202
+ test("clears layers and increments guard once for the historyBack call", async () => {
203
+ await orchestrator.push({ id: "L1", url: "/x" });
204
+ await orchestrator.push({ id: "L2", url: "/y" });
205
+ runtime._calls.length = 0;
206
+
207
+ await orchestrator.closeAll();
208
+ expect(orchestrator.depth).toBe(0);
209
+ const types = runtime._calls.map((c) => c.type);
210
+ expect(types).toEqual([
211
+ "unmountAllLayers",
212
+ "closeDialog",
213
+ "unlockScroll",
214
+ "historyBack",
215
+ "clearSnapshot",
216
+ ]);
217
+
218
+ runtime._calls.length = 0;
219
+ await orchestrator.onPopstate({
220
+ historyState: null,
221
+ locationHref: BASE_URL,
222
+ });
223
+ expect(runtime._calls).toEqual([]);
224
+ });
225
+ });
226
+
227
+ describe("onPopstate", () => {
228
+ test("forward navigation requests rebuild from snapshot", async () => {
229
+ await orchestrator.push({ id: "L1", url: "/x" });
230
+ runtime._calls.length = 0;
231
+
232
+ await orchestrator.onPopstate({
233
+ historyState: { stackId: STACK_ID, layerId: "L2", depth: 2 },
234
+ locationHref: "/y",
235
+ });
236
+
237
+ const rebuild = runtime._calls.find((c) => c.type === "rebuildFromSnapshot");
238
+ expect(rebuild).toMatchObject({ targetDepth: 2, targetLayerId: "L2" });
239
+ });
240
+
241
+ test("popstate from a different stack tears down without history changes", async () => {
242
+ await orchestrator.push({ id: "L1", url: "/x" });
243
+ runtime._calls.length = 0;
244
+
245
+ await orchestrator.onPopstate({
246
+ historyState: { stackId: "other", layerId: "Z", depth: 9 },
247
+ locationHref: "/elsewhere",
248
+ });
249
+
250
+ const types = runtime._calls.map((c) => c.type);
251
+ expect(types).toEqual([
252
+ "unmountAllLayers",
253
+ "closeDialog",
254
+ "unlockScroll",
255
+ "clearSnapshot",
256
+ ]);
257
+ expect(types).not.toContain("historyBack");
258
+ });
259
+ });
260
+
@@ -0,0 +1,217 @@
1
+ export const SNAPSHOT_KEY = "modalStackSnapshot";
2
+ export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
3
+
4
+ const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
5
+ // Hard cap: never wait longer than this for an exit transition to fire,
6
+ // even if the host CSS forgot to transition the leaving state.
7
+ const LEAVE_TIMEOUT_MS = 600;
8
+
9
+ export class BrowserRuntime {
10
+ constructor({
11
+ dialog,
12
+ body = globalThis.document?.body,
13
+ history = globalThis.history,
14
+ fetcher = globalThis.fetch?.bind(globalThis),
15
+ store = globalThis.sessionStorage,
16
+ documentRef = globalThis.document,
17
+ } = {}) {
18
+ if (!dialog) throw new Error("BrowserRuntime: dialog element required");
19
+ if (!fetcher) throw new Error("BrowserRuntime: fetch implementation required");
20
+ if (!documentRef) throw new Error("BrowserRuntime: document reference required");
21
+ this.dialog = dialog;
22
+ this.body = body;
23
+ this.history = history;
24
+ this.fetcher = fetcher;
25
+ this.store = store;
26
+ this.document = documentRef;
27
+ }
28
+
29
+ showDialog() {
30
+ if (!this.dialog.open) this.dialog.showModal();
31
+ }
32
+
33
+ closeDialog() {
34
+ if (this.dialog.open) this.dialog.close();
35
+ }
36
+
37
+ lockScroll() {
38
+ if (this.body) this.body.dataset.modalStackLocked = "";
39
+ }
40
+
41
+ unlockScroll() {
42
+ if (this.body) delete this.body.dataset.modalStackLocked;
43
+ }
44
+
45
+ inertLayer({ layerId, value }) {
46
+ const layer = this.#findLayer(layerId);
47
+ if (!layer) return;
48
+ if (value) layer.setAttribute("inert", "");
49
+ else layer.removeAttribute("inert");
50
+ }
51
+
52
+ async mountLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
53
+ const frag = await this.#resolveFragment({ url, html, fragment });
54
+ const layer = this.document.createElement("div");
55
+ this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
56
+ layer.append(...frag.childNodes);
57
+ this.dialog.appendChild(layer);
58
+ }
59
+
60
+ async morphTopLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
61
+ const frag = await this.#resolveFragment({ url, html, fragment });
62
+ const layer = this.#topLayer();
63
+ if (!layer) return;
64
+ this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
65
+ layer.replaceChildren(...frag.childNodes);
66
+ }
67
+
68
+ async unmountTopLayer() {
69
+ const layer = this.#topLayer();
70
+ if (!layer) return;
71
+ await animateOut(layer);
72
+ }
73
+
74
+ async unmountAllLayers() {
75
+ const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
76
+ await Promise.all(layers.map(animateOut));
77
+ }
78
+
79
+ pushHistory({ url, historyState }) {
80
+ this.history.pushState(historyState, "", url);
81
+ }
82
+
83
+ replaceHistory({ url, historyState }) {
84
+ this.history.replaceState(historyState, "", url);
85
+ }
86
+
87
+ historyBack({ n }) {
88
+ this.history.go(-n);
89
+ }
90
+
91
+ rebuildFromSnapshot() {
92
+ // Forward navigation reconstruction is M2 territory (nested deep-linking).
93
+ this.dialog.dispatchEvent(
94
+ new CustomEvent("modal_stack:rebuild-requested", { bubbles: true }),
95
+ );
96
+ }
97
+
98
+ persistSnapshot(json) {
99
+ if (!this.store) return;
100
+ try {
101
+ this.store.setItem(SNAPSHOT_KEY, json);
102
+ } catch {
103
+ // sessionStorage may be full or unavailable (private mode, quota) — best effort.
104
+ }
105
+ }
106
+
107
+ clearSnapshot() {
108
+ if (!this.store) return;
109
+ try {
110
+ this.store.removeItem(SNAPSHOT_KEY);
111
+ } catch {
112
+ // ignore
113
+ }
114
+ }
115
+
116
+ readSnapshot() {
117
+ if (!this.store) return null;
118
+ try {
119
+ return this.store.getItem(SNAPSHOT_KEY);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ #findLayer(layerId) {
126
+ return this.dialog.querySelector(
127
+ `${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`,
128
+ );
129
+ }
130
+
131
+ #topLayer() {
132
+ const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
133
+ return layers[layers.length - 1] ?? null;
134
+ }
135
+
136
+ #applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height }) {
137
+ layer.dataset.modalStackTarget = "layer";
138
+ layer.dataset.layerId = layerId;
139
+ layer.dataset.depth = String(depth);
140
+ layer.dataset.variant = variant;
141
+ layer.dataset.dismissible = String(dismissible);
142
+ if (size) layer.dataset.modalStackSize = size;
143
+ else delete layer.dataset.modalStackSize;
144
+ if (side) layer.dataset.side = side;
145
+ else delete layer.dataset.side;
146
+ if (width) {
147
+ layer.dataset.modalStackWidth = width;
148
+ layer.style.width = width;
149
+ } else {
150
+ delete layer.dataset.modalStackWidth;
151
+ layer.style.removeProperty("width");
152
+ }
153
+ if (height) {
154
+ layer.dataset.modalStackHeight = height;
155
+ layer.style.height = height;
156
+ } else {
157
+ delete layer.dataset.modalStackHeight;
158
+ layer.style.removeProperty("height");
159
+ }
160
+ }
161
+
162
+ async fetchFragment(url) {
163
+ const resp = await this.fetcher(url, {
164
+ headers: {
165
+ Accept: "text/html, text/vnd.turbo-stream.html",
166
+ [FRAGMENT_HEADER]: "1",
167
+ },
168
+ credentials: "same-origin",
169
+ });
170
+ if (!resp.ok) {
171
+ throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
172
+ }
173
+ const html = await resp.text();
174
+ return parseFragment(html, this.document);
175
+ }
176
+
177
+ async #resolveFragment({ url, html, fragment }) {
178
+ if (fragment) return fragment;
179
+ if (html != null) return parseFragment(html, this.document);
180
+ return this.fetchFragment(url);
181
+ }
182
+ }
183
+
184
+ function parseFragment(html, doc) {
185
+ const parser = new DOMParser();
186
+ const parsed = parser.parseFromString(html, "text/html");
187
+ const fragment = doc.createDocumentFragment();
188
+ fragment.append(...parsed.body.childNodes);
189
+ return fragment;
190
+ }
191
+
192
+ // Marks the layer with [data-leaving] so the host CSS can transition it
193
+ // out, then awaits transitionend (with a hard timeout) before removing
194
+ // the element from the DOM. If the host CSS doesn't define an exit
195
+ // transition, the timeout still fires and the layer is removed cleanly.
196
+ function animateOut(layer) {
197
+ return new Promise((resolve) => {
198
+ let done = false;
199
+ const finish = () => {
200
+ if (done) return;
201
+ done = true;
202
+ layer.removeEventListener("transitionend", finish);
203
+ layer.remove();
204
+ resolve();
205
+ };
206
+ layer.addEventListener("transitionend", finish, { once: true });
207
+ layer.dataset.leaving = "";
208
+ setTimeout(finish, LEAVE_TIMEOUT_MS);
209
+ });
210
+ }
211
+
212
+ function escapeAttr(value) {
213
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
214
+ return CSS.escape(value);
215
+ }
216
+ return String(value).replace(/["\\]/g, "\\$&");
217
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BrowserRuntime, FRAGMENT_HEADER, SNAPSHOT_KEY } from "./runtime.js";
3
+
4
+ function fakeStore() {
5
+ const map = new Map();
6
+ return {
7
+ map,
8
+ getItem: (k) => (map.has(k) ? map.get(k) : null),
9
+ setItem: (k, v) => map.set(k, v),
10
+ removeItem: (k) => map.delete(k),
11
+ };
12
+ }
13
+
14
+ function noopRuntimeArgs(overrides = {}) {
15
+ return {
16
+ dialog: {},
17
+ fetcher: () => Promise.resolve(new Response("")),
18
+ documentRef: {},
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("BrowserRuntime constructor", () => {
24
+ test("requires dialog", () => {
25
+ expect(() => new BrowserRuntime(noopRuntimeArgs({ dialog: null }))).toThrow(
26
+ /dialog/,
27
+ );
28
+ });
29
+
30
+ test("requires fetcher", () => {
31
+ expect(
32
+ () => new BrowserRuntime(noopRuntimeArgs({ fetcher: null })),
33
+ ).toThrow(/fetch/);
34
+ });
35
+
36
+ test("requires document", () => {
37
+ expect(
38
+ () => new BrowserRuntime(noopRuntimeArgs({ documentRef: null })),
39
+ ).toThrow(/document/);
40
+ });
41
+ });
42
+
43
+ describe("snapshot storage", () => {
44
+ test("persistSnapshot writes under SNAPSHOT_KEY", () => {
45
+ const store = fakeStore();
46
+ const rt = new BrowserRuntime(noopRuntimeArgs({ store }));
47
+ rt.persistSnapshot('{"hello":1}');
48
+ expect(store.map.get(SNAPSHOT_KEY)).toBe('{"hello":1}');
49
+ });
50
+
51
+ test("clearSnapshot removes the key", () => {
52
+ const store = fakeStore();
53
+ store.map.set(SNAPSHOT_KEY, "x");
54
+ const rt = new BrowserRuntime(noopRuntimeArgs({ store }));
55
+ rt.clearSnapshot();
56
+ expect(store.map.has(SNAPSHOT_KEY)).toBe(false);
57
+ });
58
+
59
+ test("readSnapshot returns the stored value or null", () => {
60
+ const store = fakeStore();
61
+ const rt = new BrowserRuntime(noopRuntimeArgs({ store }));
62
+ expect(rt.readSnapshot()).toBeNull();
63
+ store.map.set(SNAPSHOT_KEY, "{}");
64
+ expect(rt.readSnapshot()).toBe("{}");
65
+ });
66
+
67
+ test("storage failures are swallowed (best-effort)", () => {
68
+ const failing = {
69
+ getItem: () => {
70
+ throw new Error("quota");
71
+ },
72
+ setItem: () => {
73
+ throw new Error("quota");
74
+ },
75
+ removeItem: () => {
76
+ throw new Error("quota");
77
+ },
78
+ };
79
+ const rt = new BrowserRuntime(noopRuntimeArgs({ store: failing }));
80
+ expect(() => rt.persistSnapshot("x")).not.toThrow();
81
+ expect(() => rt.clearSnapshot()).not.toThrow();
82
+ expect(rt.readSnapshot()).toBeNull();
83
+ });
84
+
85
+ test("operates as a no-op when no store is provided", () => {
86
+ const rt = new BrowserRuntime(noopRuntimeArgs({ store: null }));
87
+ expect(() => rt.persistSnapshot("x")).not.toThrow();
88
+ expect(() => rt.clearSnapshot()).not.toThrow();
89
+ expect(rt.readSnapshot()).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe("history wiring", () => {
94
+ test("pushHistory / replaceHistory / historyBack delegate to history", () => {
95
+ const calls = [];
96
+ const history = {
97
+ pushState: (s, t, u) => calls.push(["push", s, t, u]),
98
+ replaceState: (s, t, u) => calls.push(["replace", s, t, u]),
99
+ go: (n) => calls.push(["go", n]),
100
+ };
101
+ const rt = new BrowserRuntime(noopRuntimeArgs({ history }));
102
+ rt.pushHistory({ url: "/a", historyState: { x: 1 } });
103
+ rt.replaceHistory({ url: "/b", historyState: { y: 2 } });
104
+ rt.historyBack({ n: 3 });
105
+ expect(calls).toEqual([
106
+ ["push", { x: 1 }, "", "/a"],
107
+ ["replace", { y: 2 }, "", "/b"],
108
+ ["go", -3],
109
+ ]);
110
+ });
111
+ });
112
+
113
+ describe("fetch headers", () => {
114
+ test("sends Accept and X-Modal-Stack-Request headers", async () => {
115
+ let captured = null;
116
+ const fetcher = (url, opts) => {
117
+ captured = { url, opts };
118
+ return Promise.resolve(new Response("", { status: 500 }));
119
+ };
120
+ const rt = new BrowserRuntime(noopRuntimeArgs({ fetcher }));
121
+ await expect(
122
+ rt.mountLayer({
123
+ layerId: "L",
124
+ url: "/x",
125
+ depth: 1,
126
+ variant: "modal",
127
+ dismissible: true,
128
+ }),
129
+ ).rejects.toThrow(/500/);
130
+ expect(captured.opts.headers[FRAGMENT_HEADER]).toBe("1");
131
+ expect(captured.opts.headers.Accept).toContain("text/html");
132
+ expect(captured.opts.credentials).toBe("same-origin");
133
+ });
134
+ });