modal_stack 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +113 -32
- data/app/assets/javascripts/modal_stack.js +488 -50
- data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
- data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +50 -0
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +53 -7
- data/app/javascript/modal_stack/orchestrator.test.js +96 -0
- data/app/javascript/modal_stack/runtime.js +167 -5
- data/app/javascript/modal_stack/runtime.test.js +83 -0
- data/app/javascript/modal_stack/state.js +319 -34
- data/app/javascript/modal_stack/state.test.js +394 -9
- data/app/views/modal_stack/_dialog.html.erb +1 -0
- data/app/views/modal_stack/_panel.html.erb +4 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
- data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
- data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
- data/lib/generators/modal_stack/views/views_generator.rb +50 -0
- data/lib/modal_stack/capybara.rb +21 -0
- data/lib/modal_stack/configuration.rb +37 -16
- data/lib/modal_stack/engine.rb +2 -0
- data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +1 -1
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
- data/lib/modal_stack/turbo_streams_extension.rb +56 -0
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +5 -1
- metadata +9 -2
|
@@ -1,6 +1,33 @@
|
|
|
1
|
-
// app/javascript/modal_stack/controllers/
|
|
1
|
+
// app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js
|
|
2
2
|
import { Controller } from "@hotwired/stimulus";
|
|
3
3
|
|
|
4
|
+
class ModalStackBackLinkController extends Controller {
|
|
5
|
+
static values = {
|
|
6
|
+
steps: { type: Number, default: 1 }
|
|
7
|
+
};
|
|
8
|
+
trigger(event) {
|
|
9
|
+
const stackController = this.#stackController();
|
|
10
|
+
if (!stackController)
|
|
11
|
+
return;
|
|
12
|
+
if (event) {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
event.stopPropagation();
|
|
15
|
+
}
|
|
16
|
+
stackController.orchestrator.pathBack({
|
|
17
|
+
steps: this.stepsValue > 0 ? this.stepsValue : 1
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
#stackController() {
|
|
21
|
+
const stack = document.querySelector('[data-controller~="modal-stack"]');
|
|
22
|
+
if (!stack)
|
|
23
|
+
return null;
|
|
24
|
+
return this.application.getControllerForElementAndIdentifier(stack, "modal-stack");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// app/javascript/modal_stack/controllers/modal_stack_controller.js
|
|
29
|
+
import { Controller as Controller2 } from "@hotwired/stimulus";
|
|
30
|
+
|
|
4
31
|
// app/javascript/modal_stack/state.js
|
|
5
32
|
var VARIANTS = Object.freeze([
|
|
6
33
|
"modal",
|
|
@@ -8,7 +35,8 @@ var VARIANTS = Object.freeze([
|
|
|
8
35
|
"bottom_sheet",
|
|
9
36
|
"confirmation"
|
|
10
37
|
]);
|
|
11
|
-
var
|
|
38
|
+
var TRANSITIONS = Object.freeze(["slide", "fade", "none"]);
|
|
39
|
+
var SNAPSHOT_VERSION = 2;
|
|
12
40
|
var DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
|
|
13
41
|
var DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
|
|
14
42
|
var MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
|
|
@@ -33,17 +61,30 @@ function normalizeLayerOptions({ variant, size, side, width, height }) {
|
|
|
33
61
|
height: height ?? null
|
|
34
62
|
};
|
|
35
63
|
}
|
|
36
|
-
function
|
|
64
|
+
function freezeFrame({ url, stale = false }) {
|
|
65
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
66
|
+
throw new Error("frame.url required");
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze({ url, stale: !!stale });
|
|
69
|
+
}
|
|
70
|
+
function freezeFrames(frames) {
|
|
71
|
+
return Object.freeze(frames.map(freezeFrame));
|
|
72
|
+
}
|
|
73
|
+
function freezeLayer({ id, url, variant, dismissible, size, side, width, height, frames }) {
|
|
37
74
|
const normalized = normalizeLayerOptions({ variant, size, side, width, height });
|
|
75
|
+
const framesArray = Array.isArray(frames) && frames.length > 0 ? frames : [{ url, stale: false }];
|
|
76
|
+
const frozenFrames = freezeFrames(framesArray);
|
|
77
|
+
const topUrl = frozenFrames[frozenFrames.length - 1].url;
|
|
38
78
|
return Object.freeze({
|
|
39
79
|
id,
|
|
40
|
-
url,
|
|
80
|
+
url: topUrl,
|
|
41
81
|
variant,
|
|
42
82
|
dismissible: !!dismissible,
|
|
43
83
|
size: normalized.size,
|
|
44
84
|
side: normalized.side,
|
|
45
85
|
width: normalized.width,
|
|
46
|
-
height: normalized.height
|
|
86
|
+
height: normalized.height,
|
|
87
|
+
frames: frozenFrames
|
|
47
88
|
});
|
|
48
89
|
}
|
|
49
90
|
function createStack({ stackId, baseUrl }) {
|
|
@@ -56,6 +97,20 @@ function createStack({ stackId, baseUrl }) {
|
|
|
56
97
|
function topLayer(state) {
|
|
57
98
|
return state.layers[state.layers.length - 1] ?? null;
|
|
58
99
|
}
|
|
100
|
+
function totalFrameCount(layers) {
|
|
101
|
+
let n = 0;
|
|
102
|
+
for (const l of layers)
|
|
103
|
+
n += l.frames.length;
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
function validateTransition(value) {
|
|
107
|
+
if (value == null)
|
|
108
|
+
return null;
|
|
109
|
+
if (!TRANSITIONS.includes(value)) {
|
|
110
|
+
throw new Error(`unknown transition: ${value}`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
59
114
|
function push(state, layer, options = {}) {
|
|
60
115
|
if (!layer?.id)
|
|
61
116
|
throw new Error("layer.id required");
|
|
@@ -116,26 +171,134 @@ function push(state, layer, options = {}) {
|
|
|
116
171
|
commands.push({
|
|
117
172
|
type: "pushHistory",
|
|
118
173
|
url: newLayer.url,
|
|
119
|
-
historyState: {
|
|
174
|
+
historyState: {
|
|
175
|
+
stackId: state.stackId,
|
|
176
|
+
layerId: newLayer.id,
|
|
177
|
+
depth,
|
|
178
|
+
frameIndex: 0
|
|
179
|
+
}
|
|
120
180
|
});
|
|
121
181
|
commands.push({ type: "persistSnapshot" });
|
|
122
182
|
return { state: { ...state, layers }, commands };
|
|
123
183
|
}
|
|
184
|
+
function pathTo(state, frame, options = {}) {
|
|
185
|
+
if (state.layers.length === 0) {
|
|
186
|
+
throw new Error("pathTo requires at least one layer");
|
|
187
|
+
}
|
|
188
|
+
if (typeof frame?.url !== "string" || frame.url.length === 0) {
|
|
189
|
+
throw new Error("pathTo requires a frame.url");
|
|
190
|
+
}
|
|
191
|
+
const transition = validateTransition(options.transition ?? null);
|
|
192
|
+
const top = topLayer(state);
|
|
193
|
+
const previousFrameIndex = top.frames.length - 1;
|
|
194
|
+
const newFrame = freezeFrame({ url: frame.url, stale: !!frame.stale });
|
|
195
|
+
const newFrames = [...top.frames, newFrame];
|
|
196
|
+
const newTop = freezeLayer({
|
|
197
|
+
id: top.id,
|
|
198
|
+
url: newFrame.url,
|
|
199
|
+
variant: top.variant,
|
|
200
|
+
dismissible: top.dismissible,
|
|
201
|
+
size: top.size,
|
|
202
|
+
side: top.side,
|
|
203
|
+
width: top.width,
|
|
204
|
+
height: top.height,
|
|
205
|
+
frames: newFrames
|
|
206
|
+
});
|
|
207
|
+
const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
|
|
208
|
+
const depth = newLayers.length;
|
|
209
|
+
const newFrameIndex = newFrames.length - 1;
|
|
210
|
+
return {
|
|
211
|
+
state: { ...state, layers: newLayers },
|
|
212
|
+
commands: [
|
|
213
|
+
{
|
|
214
|
+
type: "mountFrame",
|
|
215
|
+
layerId: newTop.id,
|
|
216
|
+
fromFrameIndex: previousFrameIndex,
|
|
217
|
+
toFrameIndex: newFrameIndex,
|
|
218
|
+
url: newFrame.url,
|
|
219
|
+
stale: newFrame.stale,
|
|
220
|
+
...transition ? { transition } : {}
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
type: "pushHistory",
|
|
224
|
+
url: newFrame.url,
|
|
225
|
+
historyState: {
|
|
226
|
+
stackId: state.stackId,
|
|
227
|
+
layerId: newTop.id,
|
|
228
|
+
depth,
|
|
229
|
+
frameIndex: newFrameIndex
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{ type: "persistSnapshot" }
|
|
233
|
+
]
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function pathBack(state, options = {}) {
|
|
237
|
+
if (state.layers.length === 0) {
|
|
238
|
+
throw new Error("pathBack requires at least one layer");
|
|
239
|
+
}
|
|
240
|
+
const requestedSteps = options.steps == null ? 1 : Math.floor(options.steps);
|
|
241
|
+
if (!Number.isFinite(requestedSteps) || requestedSteps < 1) {
|
|
242
|
+
throw new Error("pathBack: steps must be a positive integer");
|
|
243
|
+
}
|
|
244
|
+
const transition = validateTransition(options.transition ?? null);
|
|
245
|
+
const top = topLayer(state);
|
|
246
|
+
const fromFrameIndex = top.frames.length - 1;
|
|
247
|
+
const maxSteps = top.frames.length - 1;
|
|
248
|
+
const effectiveSteps = Math.min(requestedSteps, maxSteps);
|
|
249
|
+
if (effectiveSteps === 0)
|
|
250
|
+
return { state, commands: [] };
|
|
251
|
+
const toFrameIndex = fromFrameIndex - effectiveSteps;
|
|
252
|
+
const newFrames = top.frames.slice(0, toFrameIndex + 1);
|
|
253
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
254
|
+
const newTop = freezeLayer({
|
|
255
|
+
id: top.id,
|
|
256
|
+
url: targetFrame.url,
|
|
257
|
+
variant: top.variant,
|
|
258
|
+
dismissible: top.dismissible,
|
|
259
|
+
size: top.size,
|
|
260
|
+
side: top.side,
|
|
261
|
+
width: top.width,
|
|
262
|
+
height: top.height,
|
|
263
|
+
frames: newFrames
|
|
264
|
+
});
|
|
265
|
+
const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
|
|
266
|
+
return {
|
|
267
|
+
state: { ...state, layers: newLayers },
|
|
268
|
+
commands: [
|
|
269
|
+
{
|
|
270
|
+
type: "unmountFrame",
|
|
271
|
+
layerId: newTop.id,
|
|
272
|
+
fromFrameIndex,
|
|
273
|
+
toFrameIndex,
|
|
274
|
+
url: targetFrame.url,
|
|
275
|
+
stale: targetFrame.stale,
|
|
276
|
+
...transition ? { transition } : {}
|
|
277
|
+
},
|
|
278
|
+
{ type: "historyBack", n: effectiveSteps },
|
|
279
|
+
{ type: "persistSnapshot" }
|
|
280
|
+
]
|
|
281
|
+
};
|
|
282
|
+
}
|
|
124
283
|
function pop(state) {
|
|
125
284
|
if (state.layers.length === 0)
|
|
126
285
|
return { state, commands: [] };
|
|
286
|
+
const popped = topLayer(state);
|
|
287
|
+
const framesToWalkBack = popped.frames.length;
|
|
127
288
|
const newLayers = Object.freeze(state.layers.slice(0, -1));
|
|
128
289
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
129
290
|
const commands = [];
|
|
130
291
|
if (newTop) {
|
|
131
292
|
commands.push({ type: "unmountTopLayer" });
|
|
132
|
-
commands.push({ type: "
|
|
293
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
294
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
133
295
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
134
296
|
commands.push({ type: "persistSnapshot" });
|
|
135
297
|
} else {
|
|
136
298
|
commands.push({ type: "closeDialog" });
|
|
137
299
|
commands.push({ type: "unmountTopLayer" });
|
|
138
|
-
commands.push({ type: "
|
|
300
|
+
commands.push({ type: "clearFrameCache", layerId: popped.id });
|
|
301
|
+
commands.push({ type: "historyBack", n: framesToWalkBack });
|
|
139
302
|
commands.push({ type: "unlockScroll" });
|
|
140
303
|
commands.push({ type: "clearSnapshot" });
|
|
141
304
|
}
|
|
@@ -149,6 +312,7 @@ function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
149
312
|
throw new Error(`unknown historyMode: ${historyMode}`);
|
|
150
313
|
}
|
|
151
314
|
const top = topLayer(state);
|
|
315
|
+
const framesToCollapse = top.frames.length - 1;
|
|
152
316
|
const next = freezeLayer({
|
|
153
317
|
id: patch.id ?? top.id,
|
|
154
318
|
url: patch.url ?? top.url,
|
|
@@ -157,44 +321,56 @@ function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
|
157
321
|
size: patch.size ?? top.size,
|
|
158
322
|
side: patch.side ?? top.side,
|
|
159
323
|
width: patch.width ?? top.width,
|
|
160
|
-
height: patch.height ?? top.height
|
|
324
|
+
height: patch.height ?? top.height,
|
|
325
|
+
frames: undefined
|
|
161
326
|
});
|
|
162
327
|
const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
|
|
163
328
|
const depth = newLayers.length;
|
|
164
329
|
const historyCmd = {
|
|
165
330
|
type: historyMode === "push" ? "pushHistory" : "replaceHistory",
|
|
166
331
|
url: next.url,
|
|
167
|
-
historyState: {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
type: "morphTopLayer",
|
|
174
|
-
layerId: next.id,
|
|
175
|
-
url: next.url,
|
|
176
|
-
depth,
|
|
177
|
-
variant: next.variant,
|
|
178
|
-
dismissible: next.dismissible,
|
|
179
|
-
...next.size ? { size: next.size } : {},
|
|
180
|
-
...next.side ? { side: next.side } : {},
|
|
181
|
-
...next.width ? { width: next.width } : {},
|
|
182
|
-
...next.height ? { height: next.height } : {}
|
|
183
|
-
},
|
|
184
|
-
historyCmd,
|
|
185
|
-
{ type: "persistSnapshot" }
|
|
186
|
-
]
|
|
332
|
+
historyState: {
|
|
333
|
+
stackId: state.stackId,
|
|
334
|
+
layerId: next.id,
|
|
335
|
+
depth,
|
|
336
|
+
frameIndex: 0
|
|
337
|
+
}
|
|
187
338
|
};
|
|
339
|
+
const commands = [];
|
|
340
|
+
if (framesToCollapse > 0) {
|
|
341
|
+
commands.push({ type: "clearFrameCache", layerId: top.id });
|
|
342
|
+
commands.push({ type: "historyBack", n: framesToCollapse });
|
|
343
|
+
}
|
|
344
|
+
commands.push({
|
|
345
|
+
type: "morphTopLayer",
|
|
346
|
+
layerId: next.id,
|
|
347
|
+
url: next.url,
|
|
348
|
+
depth,
|
|
349
|
+
variant: next.variant,
|
|
350
|
+
dismissible: next.dismissible,
|
|
351
|
+
...next.size ? { size: next.size } : {},
|
|
352
|
+
...next.side ? { side: next.side } : {},
|
|
353
|
+
...next.width ? { width: next.width } : {},
|
|
354
|
+
...next.height ? { height: next.height } : {}
|
|
355
|
+
});
|
|
356
|
+
commands.push(historyCmd);
|
|
357
|
+
commands.push({ type: "persistSnapshot" });
|
|
358
|
+
return { state: { ...state, layers: newLayers }, commands };
|
|
188
359
|
}
|
|
189
360
|
function closeAll(state) {
|
|
190
361
|
if (state.layers.length === 0)
|
|
191
362
|
return { state, commands: [] };
|
|
192
|
-
const n = state.layers
|
|
363
|
+
const n = totalFrameCount(state.layers);
|
|
364
|
+
const cacheClears = state.layers.map((l) => ({
|
|
365
|
+
type: "clearFrameCache",
|
|
366
|
+
layerId: l.id
|
|
367
|
+
}));
|
|
193
368
|
return {
|
|
194
369
|
state: { ...state, layers: Object.freeze([]) },
|
|
195
370
|
commands: [
|
|
196
371
|
{ type: "closeDialog" },
|
|
197
372
|
{ type: "unmountAllLayers" },
|
|
373
|
+
...cacheClears,
|
|
198
374
|
{ type: "unlockScroll" },
|
|
199
375
|
{ type: "historyBack", n },
|
|
200
376
|
{ type: "clearSnapshot" }
|
|
@@ -206,11 +382,16 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
206
382
|
if (!isOurs) {
|
|
207
383
|
if (state.layers.length === 0)
|
|
208
384
|
return { state, commands: [] };
|
|
385
|
+
const cacheClears = state.layers.map((l) => ({
|
|
386
|
+
type: "clearFrameCache",
|
|
387
|
+
layerId: l.id
|
|
388
|
+
}));
|
|
209
389
|
return {
|
|
210
390
|
state: { ...state, layers: Object.freeze([]) },
|
|
211
391
|
commands: [
|
|
212
392
|
{ type: "closeDialog" },
|
|
213
393
|
{ type: "unmountAllLayers" },
|
|
394
|
+
...cacheClears,
|
|
214
395
|
{ type: "unlockScroll" },
|
|
215
396
|
{ type: "clearSnapshot" }
|
|
216
397
|
]
|
|
@@ -219,15 +400,20 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
219
400
|
const targetDepth = historyState.depth ?? 0;
|
|
220
401
|
const currentDepth = state.layers.length;
|
|
221
402
|
const targetLayerId = historyState.layerId ?? null;
|
|
403
|
+
const targetFrameIndex = historyState.frameIndex ?? 0;
|
|
222
404
|
if (targetDepth < currentDepth) {
|
|
405
|
+
const droppedLayers = state.layers.slice(targetDepth);
|
|
223
406
|
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
224
407
|
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
225
408
|
const commands = [];
|
|
226
409
|
if (!newTop)
|
|
227
410
|
commands.push({ type: "closeDialog" });
|
|
228
|
-
for (let i = 0;i <
|
|
411
|
+
for (let i = 0;i < droppedLayers.length; i++) {
|
|
229
412
|
commands.push({ type: "unmountTopLayer" });
|
|
230
413
|
}
|
|
414
|
+
for (const dropped of droppedLayers) {
|
|
415
|
+
commands.push({ type: "clearFrameCache", layerId: dropped.id });
|
|
416
|
+
}
|
|
231
417
|
if (newTop) {
|
|
232
418
|
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
233
419
|
commands.push({ type: "persistSnapshot" });
|
|
@@ -246,6 +432,51 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
246
432
|
};
|
|
247
433
|
}
|
|
248
434
|
const top = topLayer(state);
|
|
435
|
+
if (top && targetLayerId && top.id === targetLayerId) {
|
|
436
|
+
const currentFrameIndex = top.frames.length - 1;
|
|
437
|
+
if (targetFrameIndex === currentFrameIndex) {
|
|
438
|
+
return { state, commands: [] };
|
|
439
|
+
}
|
|
440
|
+
if (targetFrameIndex < currentFrameIndex) {
|
|
441
|
+
const newFrames = top.frames.slice(0, targetFrameIndex + 1);
|
|
442
|
+
const targetFrame = newFrames[newFrames.length - 1];
|
|
443
|
+
const updatedTop = freezeLayer({
|
|
444
|
+
id: top.id,
|
|
445
|
+
url: targetFrame.url,
|
|
446
|
+
variant: top.variant,
|
|
447
|
+
dismissible: top.dismissible,
|
|
448
|
+
size: top.size,
|
|
449
|
+
side: top.side,
|
|
450
|
+
width: top.width,
|
|
451
|
+
height: top.height,
|
|
452
|
+
frames: newFrames
|
|
453
|
+
});
|
|
454
|
+
const newLayers = Object.freeze([
|
|
455
|
+
...state.layers.slice(0, -1),
|
|
456
|
+
updatedTop
|
|
457
|
+
]);
|
|
458
|
+
return {
|
|
459
|
+
state: { ...state, layers: newLayers },
|
|
460
|
+
commands: [
|
|
461
|
+
{
|
|
462
|
+
type: "unmountFrame",
|
|
463
|
+
layerId: top.id,
|
|
464
|
+
fromFrameIndex: currentFrameIndex,
|
|
465
|
+
toFrameIndex: targetFrameIndex,
|
|
466
|
+
url: targetFrame.url,
|
|
467
|
+
stale: targetFrame.stale
|
|
468
|
+
},
|
|
469
|
+
{ type: "persistSnapshot" }
|
|
470
|
+
]
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
state,
|
|
475
|
+
commands: [
|
|
476
|
+
{ type: "rebuildFromSnapshot", targetDepth, targetLayerId }
|
|
477
|
+
]
|
|
478
|
+
};
|
|
479
|
+
}
|
|
249
480
|
if (top && targetLayerId && top.id !== targetLayerId) {
|
|
250
481
|
const updatedTop = freezeLayer({
|
|
251
482
|
id: targetLayerId,
|
|
@@ -264,6 +495,7 @@ function handlePopstate(state, { historyState, locationHref }) {
|
|
|
264
495
|
return {
|
|
265
496
|
state: { ...state, layers: newLayers },
|
|
266
497
|
commands: [
|
|
498
|
+
{ type: "clearFrameCache", layerId: top.id },
|
|
267
499
|
{
|
|
268
500
|
type: "morphTopLayer",
|
|
269
501
|
layerId: targetLayerId,
|
|
@@ -287,10 +519,23 @@ function snapshot(state, { now = Date.now } = {}) {
|
|
|
287
519
|
v: SNAPSHOT_VERSION,
|
|
288
520
|
stackId: state.stackId,
|
|
289
521
|
baseUrl: state.baseUrl,
|
|
290
|
-
layers: state.layers,
|
|
522
|
+
layers: state.layers.map(serializeLayer),
|
|
291
523
|
savedAt: now()
|
|
292
524
|
});
|
|
293
525
|
}
|
|
526
|
+
function serializeLayer(layer) {
|
|
527
|
+
return {
|
|
528
|
+
id: layer.id,
|
|
529
|
+
url: layer.url,
|
|
530
|
+
variant: layer.variant,
|
|
531
|
+
dismissible: layer.dismissible,
|
|
532
|
+
size: layer.size,
|
|
533
|
+
side: layer.side,
|
|
534
|
+
width: layer.width,
|
|
535
|
+
height: layer.height,
|
|
536
|
+
frames: layer.frames.map((f) => ({ url: f.url, stale: f.stale }))
|
|
537
|
+
};
|
|
538
|
+
}
|
|
294
539
|
function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {}) {
|
|
295
540
|
if (typeof serialized !== "string" || serialized.length === 0)
|
|
296
541
|
return null;
|
|
@@ -300,7 +545,7 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
300
545
|
} catch {
|
|
301
546
|
return null;
|
|
302
547
|
}
|
|
303
|
-
if (parsed?.v !== SNAPSHOT_VERSION)
|
|
548
|
+
if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION)
|
|
304
549
|
return null;
|
|
305
550
|
if (typeof parsed.stackId !== "string")
|
|
306
551
|
return null;
|
|
@@ -319,6 +564,14 @@ function restore(serialized, { stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Dat
|
|
|
319
564
|
return null;
|
|
320
565
|
if (!VARIANTS.includes(l.variant))
|
|
321
566
|
return null;
|
|
567
|
+
if (l.frames !== undefined) {
|
|
568
|
+
if (!Array.isArray(l.frames) || l.frames.length === 0)
|
|
569
|
+
return null;
|
|
570
|
+
for (const f of l.frames) {
|
|
571
|
+
if (!f || typeof f.url !== "string")
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
322
575
|
}
|
|
323
576
|
return Object.freeze({
|
|
324
577
|
stackId: parsed.stackId,
|
|
@@ -383,21 +636,41 @@ class Orchestrator {
|
|
|
383
636
|
}
|
|
384
637
|
return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
|
|
385
638
|
}
|
|
639
|
+
async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
|
|
640
|
+
let resolvedStale = frame?.stale === true;
|
|
641
|
+
if (fragment == null && html == null && frame?.url) {
|
|
642
|
+
const meta = await this.#prefetchWithMeta(frame.url);
|
|
643
|
+
fragment = meta.fragment;
|
|
644
|
+
if (frame.stale !== true && meta.stale === true)
|
|
645
|
+
resolvedStale = true;
|
|
646
|
+
}
|
|
647
|
+
return this.#dispatch(pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }), { html, fragment });
|
|
648
|
+
}
|
|
649
|
+
pathBack({ steps = 1, transition = null } = {}) {
|
|
650
|
+
return this.#dispatch(pathBack(this.state, { steps, transition }));
|
|
651
|
+
}
|
|
386
652
|
async#prefetch(url) {
|
|
387
|
-
|
|
388
|
-
|
|
653
|
+
const meta = await this.#prefetchWithMeta(url);
|
|
654
|
+
return meta.fragment;
|
|
655
|
+
}
|
|
656
|
+
async#prefetchWithMeta(url) {
|
|
657
|
+
if (typeof this.runtime.fetchFragment !== "function") {
|
|
658
|
+
return { fragment: null, stale: false };
|
|
659
|
+
}
|
|
389
660
|
const cached = this.#fragmentCache.get(url);
|
|
390
661
|
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
391
|
-
return cloneFragment(cached.fragment);
|
|
662
|
+
return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
|
|
392
663
|
}
|
|
393
664
|
const existing = this.#inflight.get(url);
|
|
394
665
|
if (existing) {
|
|
395
666
|
const entry2 = await existing.promise;
|
|
396
|
-
return cloneFragment(entry2.fragment);
|
|
667
|
+
return { fragment: cloneFragment(entry2.fragment), stale: entry2.stale === true };
|
|
397
668
|
}
|
|
398
669
|
const controller = supportsAbort() ? new AbortController : null;
|
|
399
|
-
const fetchPromise = this.runtime.fetchFragment(url, controller ? { signal: controller.signal } : undefined).then((
|
|
400
|
-
const
|
|
670
|
+
const fetchPromise = this.runtime.fetchFragment(url, controller ? { signal: controller.signal } : undefined).then((result) => {
|
|
671
|
+
const fragment = result?.fragment ?? result;
|
|
672
|
+
const stale = result?.stale === true;
|
|
673
|
+
const entry2 = { fragment, stale, ts: Date.now() };
|
|
401
674
|
this.#fragmentCache.set(url, entry2);
|
|
402
675
|
return entry2;
|
|
403
676
|
}).finally(() => {
|
|
@@ -405,7 +678,7 @@ class Orchestrator {
|
|
|
405
678
|
});
|
|
406
679
|
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
407
680
|
const entry = await fetchPromise;
|
|
408
|
-
return cloneFragment(entry.fragment);
|
|
681
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
409
682
|
}
|
|
410
683
|
#invalidatePrefetch() {
|
|
411
684
|
for (const { controller } of this.#inflight.values()) {
|
|
@@ -437,7 +710,7 @@ class Orchestrator {
|
|
|
437
710
|
async#dispatch({ state, commands }, payload = {}) {
|
|
438
711
|
this.state = state;
|
|
439
712
|
for (const cmd of commands) {
|
|
440
|
-
if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
|
|
713
|
+
if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer" || cmd.type === "mountFrame") {
|
|
441
714
|
if (payload.html != null)
|
|
442
715
|
cmd.html = payload.html;
|
|
443
716
|
if (payload.fragment != null)
|
|
@@ -477,8 +750,10 @@ function supportsAbort() {
|
|
|
477
750
|
// app/javascript/modal_stack/runtime.js
|
|
478
751
|
var SNAPSHOT_KEY = "modalStackSnapshot";
|
|
479
752
|
var FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
753
|
+
var STALE_HEADER = "X-Modal-Stack-Stale";
|
|
480
754
|
var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
481
755
|
var LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
|
|
756
|
+
var FRAME_SELECTOR = "[data-modal-stack-frame]";
|
|
482
757
|
var DURATION_CSS_VAR = "--modal-stack-duration";
|
|
483
758
|
var LEAVE_TIMEOUT_FLOOR_MS = 300;
|
|
484
759
|
var LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
@@ -504,6 +779,7 @@ class BrowserRuntime {
|
|
|
504
779
|
this.fetcher = fetcher;
|
|
505
780
|
this.store = store;
|
|
506
781
|
this.document = documentRef;
|
|
782
|
+
this._frameCache = new Map;
|
|
507
783
|
}
|
|
508
784
|
showDialog() {
|
|
509
785
|
if (!this.dialog.open)
|
|
@@ -544,7 +820,10 @@ class BrowserRuntime {
|
|
|
544
820
|
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
545
821
|
const layer = this.document.createElement("div");
|
|
546
822
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
547
|
-
layer
|
|
823
|
+
this.#applyFrameDepth(layer, 0);
|
|
824
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
825
|
+
wrapper.append(...frag.childNodes);
|
|
826
|
+
layer.appendChild(wrapper);
|
|
548
827
|
this.dialog.appendChild(layer);
|
|
549
828
|
}
|
|
550
829
|
async morphTopLayer({ layerId, url, depth, variant, dismissible, size, side, width, height, html, fragment }) {
|
|
@@ -553,7 +832,61 @@ class BrowserRuntime {
|
|
|
553
832
|
if (!layer)
|
|
554
833
|
return;
|
|
555
834
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
556
|
-
layer
|
|
835
|
+
this.#applyFrameDepth(layer, 0);
|
|
836
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
837
|
+
wrapper.append(...frag.childNodes);
|
|
838
|
+
layer.replaceChildren(wrapper);
|
|
839
|
+
}
|
|
840
|
+
async mountFrame({ layerId, fromFrameIndex, toFrameIndex, url, html, fragment, transition }) {
|
|
841
|
+
const layer = this.#findLayer(layerId);
|
|
842
|
+
if (!layer)
|
|
843
|
+
return;
|
|
844
|
+
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
845
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
846
|
+
if (oldFrame) {
|
|
847
|
+
const cached = this.document.createDocumentFragment();
|
|
848
|
+
cached.append(...oldFrame.childNodes);
|
|
849
|
+
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
850
|
+
}
|
|
851
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "forward" });
|
|
852
|
+
newFrame.append(...frag.childNodes);
|
|
853
|
+
layer.appendChild(newFrame);
|
|
854
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
855
|
+
if (oldFrame)
|
|
856
|
+
oldFrame.remove();
|
|
857
|
+
if (transition)
|
|
858
|
+
this.#cleanupFrameTransition(newFrame);
|
|
859
|
+
}
|
|
860
|
+
async unmountFrame({ layerId, fromFrameIndex, toFrameIndex, url, stale, transition }) {
|
|
861
|
+
const layer = this.#findLayer(layerId);
|
|
862
|
+
if (!layer)
|
|
863
|
+
return;
|
|
864
|
+
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
865
|
+
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
866
|
+
if (!restored) {
|
|
867
|
+
const result = await this.fetchFragment(url);
|
|
868
|
+
restored = result.fragment;
|
|
869
|
+
this._frameCache.set(cacheKey, cloneFragment2(restored, this.document));
|
|
870
|
+
} else {
|
|
871
|
+
restored = cloneFragment2(restored, this.document);
|
|
872
|
+
}
|
|
873
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "back" });
|
|
874
|
+
newFrame.append(...restored.childNodes);
|
|
875
|
+
layer.appendChild(newFrame);
|
|
876
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
877
|
+
this.#purgeFrameCacheAbove(layerId, toFrameIndex);
|
|
878
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
879
|
+
if (oldFrame)
|
|
880
|
+
oldFrame.remove();
|
|
881
|
+
if (transition)
|
|
882
|
+
this.#cleanupFrameTransition(newFrame);
|
|
883
|
+
}
|
|
884
|
+
clearFrameCache({ layerId }) {
|
|
885
|
+
const prefix = `${layerId}#`;
|
|
886
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
887
|
+
if (key.startsWith(prefix))
|
|
888
|
+
this._frameCache.delete(key);
|
|
889
|
+
}
|
|
557
890
|
}
|
|
558
891
|
async unmountTopLayer() {
|
|
559
892
|
const layer = this.#topLayer();
|
|
@@ -620,6 +953,49 @@ class BrowserRuntime {
|
|
|
620
953
|
#findLayer(layerId) {
|
|
621
954
|
return this.dialog.querySelector(`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`);
|
|
622
955
|
}
|
|
956
|
+
#findFrame(layer, frameIndex) {
|
|
957
|
+
return layer.querySelector(`${FRAME_SELECTOR}[data-frame-index="${escapeAttr(String(frameIndex))}"]`);
|
|
958
|
+
}
|
|
959
|
+
#frameKey(layerId, frameIndex) {
|
|
960
|
+
return `${layerId}#${frameIndex}`;
|
|
961
|
+
}
|
|
962
|
+
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
963
|
+
const prefix = `${layerId}#`;
|
|
964
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
965
|
+
if (!key.startsWith(prefix))
|
|
966
|
+
continue;
|
|
967
|
+
const idx = Number(key.slice(prefix.length));
|
|
968
|
+
if (Number.isFinite(idx) && idx > frameIndex) {
|
|
969
|
+
this._frameCache.delete(key);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
#cleanupFrameTransition(frameEl) {
|
|
974
|
+
let done = false;
|
|
975
|
+
const cleanup = () => {
|
|
976
|
+
if (done)
|
|
977
|
+
return;
|
|
978
|
+
done = true;
|
|
979
|
+
frameEl.removeAttribute("data-transition");
|
|
980
|
+
frameEl.removeAttribute("data-direction");
|
|
981
|
+
};
|
|
982
|
+
frameEl.addEventListener("transitionend", cleanup, { once: true });
|
|
983
|
+
setTimeout(cleanup, this.#leaveTimeoutMs());
|
|
984
|
+
}
|
|
985
|
+
#createFrameWrapper({ frameIndex, transition = null, direction = null }) {
|
|
986
|
+
const el = this.document.createElement("div");
|
|
987
|
+
el.dataset.modalStackFrame = "";
|
|
988
|
+
el.dataset.frameIndex = String(frameIndex);
|
|
989
|
+
if (transition)
|
|
990
|
+
el.dataset.transition = transition;
|
|
991
|
+
if (direction)
|
|
992
|
+
el.dataset.direction = direction;
|
|
993
|
+
return el;
|
|
994
|
+
}
|
|
995
|
+
#applyFrameDepth(layer, topFrameIndex) {
|
|
996
|
+
layer.dataset.frameIndex = String(topFrameIndex);
|
|
997
|
+
layer.dataset.frameDepth = String(topFrameIndex + 1);
|
|
998
|
+
}
|
|
623
999
|
#topLayer() {
|
|
624
1000
|
const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
|
|
625
1001
|
return layers[layers.length - 1] ?? null;
|
|
@@ -666,14 +1042,16 @@ class BrowserRuntime {
|
|
|
666
1042
|
throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
|
|
667
1043
|
}
|
|
668
1044
|
const html = await resp.text();
|
|
669
|
-
|
|
1045
|
+
const stale = parseStaleHeader(resp);
|
|
1046
|
+
return { fragment: parseFragment(html, this.document), stale };
|
|
670
1047
|
}
|
|
671
1048
|
async#resolveFragment({ url, html, fragment }) {
|
|
672
1049
|
if (fragment)
|
|
673
1050
|
return fragment;
|
|
674
1051
|
if (html != null)
|
|
675
1052
|
return parseFragment(html, this.document);
|
|
676
|
-
|
|
1053
|
+
const result = await this.fetchFragment(url);
|
|
1054
|
+
return result.fragment;
|
|
677
1055
|
}
|
|
678
1056
|
}
|
|
679
1057
|
function parseFragment(html, doc) {
|
|
@@ -683,6 +1061,25 @@ function parseFragment(html, doc) {
|
|
|
683
1061
|
fragment.append(...parsed.body.childNodes);
|
|
684
1062
|
return fragment;
|
|
685
1063
|
}
|
|
1064
|
+
function parseStaleHeader(resp) {
|
|
1065
|
+
const headers = resp?.headers;
|
|
1066
|
+
const value = typeof headers?.get === "function" ? headers.get(STALE_HEADER) ?? headers.get(STALE_HEADER.toLowerCase()) : null;
|
|
1067
|
+
if (!value)
|
|
1068
|
+
return false;
|
|
1069
|
+
const normalized = String(value).trim().toLowerCase();
|
|
1070
|
+
return normalized === "true" || normalized === "1";
|
|
1071
|
+
}
|
|
1072
|
+
function cloneFragment2(fragment, doc) {
|
|
1073
|
+
if (typeof fragment?.cloneNode === "function") {
|
|
1074
|
+
return fragment.cloneNode(true);
|
|
1075
|
+
}
|
|
1076
|
+
const clone = doc.createDocumentFragment();
|
|
1077
|
+
if (fragment?.childNodes) {
|
|
1078
|
+
for (const node of fragment.childNodes)
|
|
1079
|
+
clone.appendChild(node.cloneNode(true));
|
|
1080
|
+
}
|
|
1081
|
+
return clone;
|
|
1082
|
+
}
|
|
686
1083
|
function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
|
|
687
1084
|
return new Promise((resolve) => {
|
|
688
1085
|
let done = false;
|
|
@@ -714,11 +1111,11 @@ function escapeAttr(value) {
|
|
|
714
1111
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
715
1112
|
return CSS.escape(value);
|
|
716
1113
|
}
|
|
717
|
-
return String(value).replace(/["\\]/g, "\\$&");
|
|
1114
|
+
return String(value).replace(/["\\[\]]/g, "\\$&");
|
|
718
1115
|
}
|
|
719
1116
|
|
|
720
1117
|
// app/javascript/modal_stack/controllers/modal_stack_controller.js
|
|
721
|
-
class ModalStackController extends
|
|
1118
|
+
class ModalStackController extends Controller2 {
|
|
722
1119
|
static values = {
|
|
723
1120
|
stackId: String,
|
|
724
1121
|
baseUrl: String,
|
|
@@ -783,6 +1180,14 @@ class ModalStackController extends Controller {
|
|
|
783
1180
|
prefetch(url) {
|
|
784
1181
|
return this.orchestrator.prefetch(url);
|
|
785
1182
|
}
|
|
1183
|
+
pathBack(event) {
|
|
1184
|
+
if (event) {
|
|
1185
|
+
event.preventDefault();
|
|
1186
|
+
event.stopPropagation();
|
|
1187
|
+
}
|
|
1188
|
+
const steps = readSteps(event);
|
|
1189
|
+
return this.orchestrator.pathBack({ steps });
|
|
1190
|
+
}
|
|
786
1191
|
#topLayer() {
|
|
787
1192
|
const layers = this.orchestrator.layers;
|
|
788
1193
|
return layers[layers.length - 1] ?? null;
|
|
@@ -823,6 +1228,19 @@ class ModalStackController extends Controller {
|
|
|
823
1228
|
StreamActions.modal_close_all = guarded("modal_close_all", function(orch) {
|
|
824
1229
|
return orch.closeAll();
|
|
825
1230
|
});
|
|
1231
|
+
StreamActions.modal_path_to = guarded("modal_path_to", function(orch) {
|
|
1232
|
+
return orch.pathTo(frameFromStreamElement(this), {
|
|
1233
|
+
fragment: this.templateContent.cloneNode(true),
|
|
1234
|
+
transition: this.dataset.transition || null
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
StreamActions.modal_path_back = guarded("modal_path_back", function(orch) {
|
|
1238
|
+
const steps = parsePositiveInt(this.dataset.steps, 1);
|
|
1239
|
+
return orch.pathBack({
|
|
1240
|
+
steps,
|
|
1241
|
+
transition: this.dataset.transition || null
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
826
1244
|
}
|
|
827
1245
|
}
|
|
828
1246
|
function emitStreamError(dialog, action, error) {
|
|
@@ -874,11 +1292,29 @@ function generateLayerId() {
|
|
|
874
1292
|
}
|
|
875
1293
|
return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
876
1294
|
}
|
|
1295
|
+
function frameFromStreamElement(el) {
|
|
1296
|
+
return {
|
|
1297
|
+
url: el.dataset.url || window.location.href,
|
|
1298
|
+
stale: el.dataset.stale === "true" || el.dataset.stale === "1"
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
function parsePositiveInt(raw, fallback) {
|
|
1302
|
+
const n = Number.parseInt(raw, 10);
|
|
1303
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
1304
|
+
}
|
|
1305
|
+
function readSteps(event) {
|
|
1306
|
+
const params = event?.params;
|
|
1307
|
+
if (params && Number.isFinite(params.steps) && params.steps > 0) {
|
|
1308
|
+
return params.steps;
|
|
1309
|
+
}
|
|
1310
|
+
const target = event?.currentTarget ?? event?.target;
|
|
1311
|
+
return parsePositiveInt(target?.dataset?.steps, 1);
|
|
1312
|
+
}
|
|
877
1313
|
|
|
878
1314
|
// app/javascript/modal_stack/controllers/modal_stack_link_controller.js
|
|
879
|
-
import { Controller as
|
|
1315
|
+
import { Controller as Controller3 } from "@hotwired/stimulus";
|
|
880
1316
|
|
|
881
|
-
class ModalStackLinkController extends
|
|
1317
|
+
class ModalStackLinkController extends Controller3 {
|
|
882
1318
|
connect() {
|
|
883
1319
|
if (this.element.dataset.modalStackLinkPrefetch === "false")
|
|
884
1320
|
return;
|
|
@@ -936,10 +1372,12 @@ function install(application) {
|
|
|
936
1372
|
}
|
|
937
1373
|
application.register("modal-stack", ModalStackController);
|
|
938
1374
|
application.register("modal-stack-link", ModalStackLinkController);
|
|
1375
|
+
application.register("modal-stack-back-link", ModalStackBackLinkController);
|
|
939
1376
|
return application;
|
|
940
1377
|
}
|
|
941
1378
|
export {
|
|
942
1379
|
install,
|
|
943
1380
|
ModalStackLinkController,
|
|
944
|
-
ModalStackController
|
|
1381
|
+
ModalStackController,
|
|
1382
|
+
ModalStackBackLinkController
|
|
945
1383
|
};
|