modal_stack 0.2.0 → 0.3.0
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 +26 -0
- data/README.md +23 -20
- data/app/assets/javascripts/modal_stack.js +131 -20
- data/app/assets/stylesheets/modal_stack/bootstrap.css +7 -8
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +19 -12
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +311 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +7 -8
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +4 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/orchestrator.js +84 -1
- data/app/javascript/modal_stack/orchestrator.test.js +168 -2
- data/app/javascript/modal_stack/runtime.js +55 -8
- data/app/javascript/modal_stack/runtime.test.js +68 -0
- data/app/javascript/modal_stack/state.js +21 -7
- data/app/javascript/modal_stack/state.test.js +6 -4
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +12 -5
- data/lib/modal_stack/configuration.rb +7 -2
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +14 -2
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +1 -1
- metadata +4 -3
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* modal_stack — Tailwind v4 preset
|
|
3
|
+
*
|
|
4
|
+
* Tailwind v4 exposes its design tokens as native CSS variables via the
|
|
5
|
+
* `@theme` directive (`--color-*`, `--radius-*`, `--shadow-*`,
|
|
6
|
+
* `--ease-*`, `--container-*`, …). This preset chains on those so the
|
|
7
|
+
* modal stack picks up your theme automatically. Fallbacks match the
|
|
8
|
+
* Tailwind v4 defaults, so the preset still renders correctly when a
|
|
9
|
+
* project hasn't redefined `@theme` (or isn't running v4 yet).
|
|
10
|
+
*
|
|
11
|
+
* Override `--modal-stack-*` on `:root` for modal-specific tweaks
|
|
12
|
+
* without touching the gem.
|
|
13
|
+
*
|
|
14
|
+
* Variants: [data-variant="modal" | "drawer" | "bottom_sheet" | "confirmation"]
|
|
15
|
+
* Drawer side: [data-side="left" | "right" | "top" | "bottom"]
|
|
16
|
+
* Sizes: [data-modal-stack-size="sm" | "md" | "lg" | "xl"]
|
|
17
|
+
*
|
|
18
|
+
* Entry / exit transitions:
|
|
19
|
+
* - @starting-style + transition-behavior: allow-discrete (Chrome 117+,
|
|
20
|
+
* Safari 17.4+, Firefox 129+).
|
|
21
|
+
* - The runtime sets [data-leaving] before unmounting a layer so the
|
|
22
|
+
* same transition rules apply on exit.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
:root {
|
|
26
|
+
--modal-stack-z-base: 1000;
|
|
27
|
+
--modal-stack-duration: 220ms;
|
|
28
|
+
--modal-stack-ease: var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1));
|
|
29
|
+
--modal-stack-radius: var(--radius-2xl, 14px);
|
|
30
|
+
--modal-stack-bg: var(--color-white, #ffffff);
|
|
31
|
+
--modal-stack-fg: var(--color-slate-900, #0f172a);
|
|
32
|
+
--modal-stack-shadow: var(--shadow-2xl, 0 30px 60px -20px rgba(15, 23, 42, 0.35));
|
|
33
|
+
--modal-stack-backdrop: rgba(15, 23, 42, 0.55);
|
|
34
|
+
/* `none` by default — `backdrop-filter: blur()` even at radius 0
|
|
35
|
+
* still allocates a filter layer on the compositor, and animating
|
|
36
|
+
* a non-zero radius costs ~190ms/frame on Hi-DPI displays. Opt in
|
|
37
|
+
* with `:root { --modal-stack-backdrop-filter: blur(8px); }` —
|
|
38
|
+
* the filter is applied statically when the dialog opens (no
|
|
39
|
+
* per-frame compositor cost). */
|
|
40
|
+
--modal-stack-backdrop-filter: none;
|
|
41
|
+
--modal-stack-panel-padding: 24px;
|
|
42
|
+
--modal-stack-size-sm: var(--container-sm, 24rem); /* 384px */
|
|
43
|
+
--modal-stack-size-md: 34rem; /* 544px — no v4 container alias */
|
|
44
|
+
--modal-stack-size-lg: var(--container-3xl, 48rem); /* 768px */
|
|
45
|
+
--modal-stack-size-xl: var(--container-5xl, 64rem); /* 1024px */
|
|
46
|
+
--modal-stack-drawer-width: var(--container-md, 28rem); /* 448px */
|
|
47
|
+
--modal-stack-drawer-height: 22rem; /* 352px */
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* --- Body lock when the stack is open ------------------------------- */
|
|
51
|
+
|
|
52
|
+
body[data-modal-stack-locked] {
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
/* Compensate for the scrollbar width to avoid a layout shift. */
|
|
55
|
+
padding-right: var(--modal-stack-scrollbar-width, 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* --- The single dialog root ---------------------------------------- */
|
|
59
|
+
|
|
60
|
+
#modal-stack-root {
|
|
61
|
+
position: fixed;
|
|
62
|
+
inset: 0;
|
|
63
|
+
width: 100vw;
|
|
64
|
+
height: 100vh;
|
|
65
|
+
margin: 0;
|
|
66
|
+
padding: 0;
|
|
67
|
+
border: 0;
|
|
68
|
+
background: transparent;
|
|
69
|
+
max-width: none;
|
|
70
|
+
max-height: none;
|
|
71
|
+
overflow: visible;
|
|
72
|
+
z-index: var(--modal-stack-z-base);
|
|
73
|
+
opacity: 0;
|
|
74
|
+
transition:
|
|
75
|
+
opacity var(--modal-stack-duration) var(--modal-stack-ease),
|
|
76
|
+
overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
|
|
77
|
+
display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#modal-stack-root[open] {
|
|
81
|
+
opacity: 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@starting-style {
|
|
85
|
+
#modal-stack-root[open] {
|
|
86
|
+
opacity: 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#modal-stack-root::backdrop {
|
|
91
|
+
background: rgba(15, 23, 42, 0);
|
|
92
|
+
transition:
|
|
93
|
+
background var(--modal-stack-duration) var(--modal-stack-ease),
|
|
94
|
+
overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
|
|
95
|
+
display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#modal-stack-root[open]::backdrop {
|
|
99
|
+
background: var(--modal-stack-backdrop);
|
|
100
|
+
/* Static (not in the transition list above). Default is `none` so
|
|
101
|
+
* Chrome skips the filter pass entirely. Override --modal-stack-
|
|
102
|
+
* backdrop-filter to opt in. */
|
|
103
|
+
backdrop-filter: var(--modal-stack-backdrop-filter);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@starting-style {
|
|
107
|
+
#modal-stack-root[open]::backdrop {
|
|
108
|
+
background: rgba(15, 23, 42, 0);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* --- Layer (one per modal, vertically stacked Z-axis) -------------- */
|
|
113
|
+
|
|
114
|
+
[data-modal-stack-target="layer"] {
|
|
115
|
+
position: absolute;
|
|
116
|
+
top: 50%;
|
|
117
|
+
left: 50%;
|
|
118
|
+
width: var(--modal-stack-size-md);
|
|
119
|
+
max-width: 92vw;
|
|
120
|
+
max-height: min(85vh, 720px);
|
|
121
|
+
overflow-y: auto;
|
|
122
|
+
background: var(--modal-stack-bg);
|
|
123
|
+
color: var(--modal-stack-fg);
|
|
124
|
+
border-radius: var(--modal-stack-radius);
|
|
125
|
+
box-shadow: var(--modal-stack-shadow);
|
|
126
|
+
padding: 0;
|
|
127
|
+
transform: translate(-50%, -50%);
|
|
128
|
+
transition:
|
|
129
|
+
transform var(--modal-stack-duration) var(--modal-stack-ease),
|
|
130
|
+
opacity var(--modal-stack-duration) var(--modal-stack-ease);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@starting-style {
|
|
134
|
+
[data-modal-stack-target="layer"] {
|
|
135
|
+
opacity: 0;
|
|
136
|
+
transform: translate(-50%, -44%) scale(0.97);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
[data-modal-stack-target="layer"][data-leaving] {
|
|
141
|
+
opacity: 0;
|
|
142
|
+
transform: translate(-50%, -56%) scale(0.97);
|
|
143
|
+
pointer-events: none;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
[data-modal-stack-target="layer"][inert] {
|
|
147
|
+
opacity: 0.5;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* --- Sizes via [data-modal-stack-size] ----------------------------- */
|
|
151
|
+
|
|
152
|
+
[data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
|
|
153
|
+
[data-modal-stack-target="layer"][data-modal-stack-size="md"] { width: var(--modal-stack-size-md); }
|
|
154
|
+
[data-modal-stack-target="layer"][data-modal-stack-size="lg"] { width: var(--modal-stack-size-lg); }
|
|
155
|
+
[data-modal-stack-target="layer"][data-modal-stack-size="xl"] { width: var(--modal-stack-size-xl); }
|
|
156
|
+
|
|
157
|
+
/* --- Subtle depth offset for stacked layers ----------------------- */
|
|
158
|
+
|
|
159
|
+
[data-modal-stack-target="layer"][data-depth="2"] {
|
|
160
|
+
transform: translate(-50%, -53%) scale(1.015);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
[data-modal-stack-target="layer"][data-depth="3"] {
|
|
164
|
+
transform: translate(-50%, -56%) scale(1.03);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
[data-modal-stack-target="layer"][data-depth="4"] {
|
|
168
|
+
transform: translate(-50%, -59%) scale(1.045);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* --- Drawer ------------------------------------------------------- */
|
|
172
|
+
|
|
173
|
+
[data-modal-stack-target="layer"][data-variant="drawer"] {
|
|
174
|
+
left: 0;
|
|
175
|
+
top: 0;
|
|
176
|
+
max-width: 100vw;
|
|
177
|
+
max-height: 100vh;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"],
|
|
181
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
|
|
182
|
+
top: 0;
|
|
183
|
+
height: 100vh;
|
|
184
|
+
max-height: none;
|
|
185
|
+
width: var(--modal-stack-drawer-width);
|
|
186
|
+
max-width: 90vw;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
|
|
190
|
+
left: auto;
|
|
191
|
+
right: 0;
|
|
192
|
+
transform: translateX(0);
|
|
193
|
+
border-radius: var(--modal-stack-radius) 0 0 var(--modal-stack-radius);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
|
|
197
|
+
left: 0;
|
|
198
|
+
transform: translateX(0);
|
|
199
|
+
border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
|
|
203
|
+
left: 0;
|
|
204
|
+
top: 0;
|
|
205
|
+
width: 100vw;
|
|
206
|
+
max-width: none;
|
|
207
|
+
height: var(--modal-stack-drawer-height);
|
|
208
|
+
max-height: 85vh;
|
|
209
|
+
transform: translateY(0);
|
|
210
|
+
border-radius: 0 0 var(--modal-stack-radius) var(--modal-stack-radius);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
|
|
214
|
+
left: 0;
|
|
215
|
+
top: auto;
|
|
216
|
+
bottom: 0;
|
|
217
|
+
width: 100vw;
|
|
218
|
+
max-width: none;
|
|
219
|
+
height: var(--modal-stack-drawer-height);
|
|
220
|
+
max-height: 85vh;
|
|
221
|
+
transform: translateY(0);
|
|
222
|
+
border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@starting-style {
|
|
226
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
|
|
227
|
+
opacity: 0;
|
|
228
|
+
transform: translateX(100%);
|
|
229
|
+
}
|
|
230
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
|
|
231
|
+
opacity: 0;
|
|
232
|
+
transform: translateX(-100%);
|
|
233
|
+
}
|
|
234
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
|
|
235
|
+
opacity: 0;
|
|
236
|
+
transform: translateY(-100%);
|
|
237
|
+
}
|
|
238
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
|
|
239
|
+
opacity: 0;
|
|
240
|
+
transform: translateY(100%);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
|
|
245
|
+
opacity: 0;
|
|
246
|
+
transform: translateX(100%);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"][data-leaving] {
|
|
250
|
+
opacity: 0;
|
|
251
|
+
transform: translateX(-100%);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"][data-leaving] {
|
|
255
|
+
opacity: 0;
|
|
256
|
+
transform: translateY(-100%);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
[data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"][data-leaving] {
|
|
260
|
+
opacity: 0;
|
|
261
|
+
transform: translateY(100%);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* --- Bottom sheet ------------------------------------------------- */
|
|
265
|
+
|
|
266
|
+
[data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
|
|
267
|
+
top: auto;
|
|
268
|
+
bottom: 0;
|
|
269
|
+
left: 0;
|
|
270
|
+
width: 100vw;
|
|
271
|
+
max-width: none;
|
|
272
|
+
transform: translate(0, 0);
|
|
273
|
+
border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
|
|
274
|
+
max-height: 90vh;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
@starting-style {
|
|
278
|
+
[data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
|
|
279
|
+
opacity: 0;
|
|
280
|
+
transform: translateY(100%);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
[data-modal-stack-target="layer"][data-variant="bottom_sheet"][data-leaving] {
|
|
285
|
+
opacity: 0;
|
|
286
|
+
transform: translateY(100%);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* --- Confirmation ------------------------------------------------- */
|
|
290
|
+
|
|
291
|
+
[data-modal-stack-target="layer"][data-variant="confirmation"] {
|
|
292
|
+
width: var(--modal-stack-size-sm);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* --- Panel slot (the modal_stack_container output) --------------- */
|
|
296
|
+
|
|
297
|
+
.modal-stack__panel {
|
|
298
|
+
padding: var(--modal-stack-panel-padding);
|
|
299
|
+
display: grid;
|
|
300
|
+
gap: 14px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* --- Reduced motion ---------------------------------------------- */
|
|
304
|
+
|
|
305
|
+
@media (prefers-reduced-motion: reduce) {
|
|
306
|
+
#modal-stack-root,
|
|
307
|
+
#modal-stack-root::backdrop,
|
|
308
|
+
[data-modal-stack-target="layer"] {
|
|
309
|
+
transition-duration: 1ms !important;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
--modal-stack-fg: #1a1a1a;
|
|
19
19
|
--modal-stack-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.32);
|
|
20
20
|
--modal-stack-backdrop: rgba(0, 0, 0, 0.5);
|
|
21
|
-
|
|
21
|
+
/* Default `none` so Chrome skips the filter pass. Opt in with
|
|
22
|
+
* `:root { --modal-stack-backdrop-filter: blur(8px); }`. */
|
|
23
|
+
--modal-stack-backdrop-filter: none;
|
|
22
24
|
--modal-stack-panel-padding: 20px;
|
|
23
25
|
--modal-stack-size-sm: 320px;
|
|
24
26
|
--modal-stack-size-md: 480px;
|
|
@@ -60,23 +62,22 @@ body[data-modal-stack-locked] {
|
|
|
60
62
|
|
|
61
63
|
#modal-stack-root::backdrop {
|
|
62
64
|
background: rgba(0, 0, 0, 0);
|
|
63
|
-
backdrop-filter: blur(0);
|
|
64
65
|
transition:
|
|
65
66
|
background var(--modal-stack-duration) var(--modal-stack-ease),
|
|
66
|
-
backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
|
|
67
67
|
overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
|
|
68
68
|
display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
#modal-stack-root[open]::backdrop {
|
|
72
72
|
background: var(--modal-stack-backdrop);
|
|
73
|
-
|
|
73
|
+
/* Static. Default is `none` (no filter pass). Opt in with
|
|
74
|
+
* --modal-stack-backdrop-filter on :root. */
|
|
75
|
+
backdrop-filter: var(--modal-stack-backdrop-filter);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
@starting-style {
|
|
77
79
|
#modal-stack-root[open]::backdrop {
|
|
78
80
|
background: rgba(0, 0, 0, 0);
|
|
79
|
-
backdrop-filter: blur(0);
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -96,8 +97,7 @@ body[data-modal-stack-locked] {
|
|
|
96
97
|
transform: translate(-50%, -50%);
|
|
97
98
|
transition:
|
|
98
99
|
transform var(--modal-stack-duration) var(--modal-stack-ease),
|
|
99
|
-
opacity var(--modal-stack-duration) var(--modal-stack-ease)
|
|
100
|
-
filter var(--modal-stack-duration) var(--modal-stack-ease);
|
|
100
|
+
opacity var(--modal-stack-duration) var(--modal-stack-ease);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
@starting-style {
|
|
@@ -115,7 +115,6 @@ body[data-modal-stack-locked] {
|
|
|
115
115
|
|
|
116
116
|
[data-modal-stack-target="layer"][inert] {
|
|
117
117
|
opacity: 0.5;
|
|
118
|
-
filter: blur(0.5px);
|
|
119
118
|
}
|
|
120
119
|
|
|
121
120
|
[data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
|
|
@@ -79,6 +79,10 @@ export class ModalStackController extends Controller {
|
|
|
79
79
|
return this.orchestrator.closeAll();
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
prefetch(url) {
|
|
83
|
+
return this.orchestrator.prefetch(url);
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
#topLayer() {
|
|
83
87
|
const layers = this.orchestrator.layers;
|
|
84
88
|
return layers[layers.length - 1] ?? null;
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
3
|
export class ModalStackLinkController extends Controller {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
connect() {
|
|
5
|
+
if (this.element.dataset.modalStackLinkPrefetch === "false") return;
|
|
6
|
+
this._onIntent = () => this.#warm();
|
|
7
|
+
this.element.addEventListener("pointerenter", this._onIntent);
|
|
8
|
+
this.element.addEventListener("focus", this._onIntent);
|
|
9
|
+
}
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
);
|
|
11
|
+
disconnect() {
|
|
12
|
+
if (!this._onIntent) return;
|
|
13
|
+
this.element.removeEventListener("pointerenter", this._onIntent);
|
|
14
|
+
this.element.removeEventListener("focus", this._onIntent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
open(event) {
|
|
18
|
+
const controller = this.#stackController();
|
|
12
19
|
if (!controller) return;
|
|
13
20
|
|
|
14
21
|
event.preventDefault();
|
|
@@ -24,6 +31,21 @@ export class ModalStackLinkController extends Controller {
|
|
|
24
31
|
dismissible: ds.modalStackLinkDismissible !== "false",
|
|
25
32
|
});
|
|
26
33
|
}
|
|
34
|
+
|
|
35
|
+
#warm() {
|
|
36
|
+
const controller = this.#stackController();
|
|
37
|
+
if (!controller || typeof controller.prefetch !== "function") return;
|
|
38
|
+
controller.prefetch(this.element.href);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#stackController() {
|
|
42
|
+
const stack = document.querySelector('[data-controller~="modal-stack"]');
|
|
43
|
+
if (!stack) return null;
|
|
44
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
45
|
+
stack,
|
|
46
|
+
"modal-stack",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
27
49
|
}
|
|
28
50
|
|
|
29
51
|
function generateLayerId() {
|
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
snapshot,
|
|
10
10
|
} from "./state.js";
|
|
11
11
|
|
|
12
|
+
// How long a successful prefetch is reused before being refetched. Short
|
|
13
|
+
// enough that stale server-rendered HTML doesn't linger; long enough to
|
|
14
|
+
// absorb back/forward bounces and rapid double-clicks.
|
|
15
|
+
const PREFETCH_TTL_MS = 30_000;
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* Owns the current `Stack`, calls the pure reducer, and executes the emitted
|
|
14
19
|
* commands against an injected runtime. The only stateful piece is
|
|
@@ -22,9 +27,16 @@ import {
|
|
|
22
27
|
* @property {string|null} [restoreFrom] Serialized snapshot from sessionStorage
|
|
23
28
|
* @property {number|null} [maxDepth] null disables the cap
|
|
24
29
|
* @property {"raise"|"warn"|"silent"} [maxDepthStrategy]
|
|
30
|
+
* @property {number} [prefetchTtlMs] Override the prefetch cache TTL (testing)
|
|
25
31
|
*/
|
|
26
32
|
export class Orchestrator {
|
|
27
33
|
#expectedPopstates = 0;
|
|
34
|
+
// url → { fragment, ts }. Fragment is the canonical copy; consumers
|
|
35
|
+
// always receive a `cloneNode(true)` so the cached entry stays intact.
|
|
36
|
+
#fragmentCache = new Map();
|
|
37
|
+
// url → { controller, promise }. Lets concurrent prefetches dedupe onto
|
|
38
|
+
// the same in-flight request, and gives `closeAll` a way to cancel them.
|
|
39
|
+
#inflight = new Map();
|
|
28
40
|
|
|
29
41
|
/** @param {OrchestratorOptions} options */
|
|
30
42
|
constructor({
|
|
@@ -34,11 +46,13 @@ export class Orchestrator {
|
|
|
34
46
|
restoreFrom = null,
|
|
35
47
|
maxDepth = null,
|
|
36
48
|
maxDepthStrategy = "warn",
|
|
49
|
+
prefetchTtlMs = PREFETCH_TTL_MS,
|
|
37
50
|
}) {
|
|
38
51
|
if (!runtime) throw new Error("runtime required");
|
|
39
52
|
this.runtime = runtime;
|
|
40
53
|
this.maxDepth = maxDepth;
|
|
41
54
|
this.maxDepthStrategy = maxDepthStrategy;
|
|
55
|
+
this.prefetchTtlMs = prefetchTtlMs;
|
|
42
56
|
this.state = createStack({ stackId, baseUrl });
|
|
43
57
|
|
|
44
58
|
if (restoreFrom) {
|
|
@@ -92,10 +106,64 @@ export class Orchestrator {
|
|
|
92
106
|
|
|
93
107
|
async #prefetch(url) {
|
|
94
108
|
if (typeof this.runtime.fetchFragment !== "function") return null;
|
|
95
|
-
|
|
109
|
+
|
|
110
|
+
const cached = this.#fragmentCache.get(url);
|
|
111
|
+
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
112
|
+
return cloneFragment(cached.fragment);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const existing = this.#inflight.get(url);
|
|
116
|
+
if (existing) {
|
|
117
|
+
const entry = await existing.promise;
|
|
118
|
+
return cloneFragment(entry.fragment);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const controller = supportsAbort() ? new AbortController() : null;
|
|
122
|
+
const fetchPromise = this.runtime
|
|
123
|
+
.fetchFragment(url, controller ? { signal: controller.signal } : undefined)
|
|
124
|
+
.then((fragment) => {
|
|
125
|
+
const entry = { fragment, ts: Date.now() };
|
|
126
|
+
this.#fragmentCache.set(url, entry);
|
|
127
|
+
return entry;
|
|
128
|
+
})
|
|
129
|
+
.finally(() => {
|
|
130
|
+
this.#inflight.delete(url);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
134
|
+
const entry = await fetchPromise;
|
|
135
|
+
return cloneFragment(entry.fragment);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Aborts every in-flight prefetch and forgets any cached fragments.
|
|
139
|
+
// Called when we tear the stack down (closeAll / cross-stack popstate)
|
|
140
|
+
// because the URLs in flight are no longer relevant. In-flight callers
|
|
141
|
+
// see an AbortError; caller code (controllers) already wraps push/pop
|
|
142
|
+
// in try/catch via `guarded()`.
|
|
143
|
+
#invalidatePrefetch() {
|
|
144
|
+
for (const { controller } of this.#inflight.values()) {
|
|
145
|
+
try {
|
|
146
|
+
controller?.abort();
|
|
147
|
+
} catch {
|
|
148
|
+
// ignore — abort is best-effort
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.#inflight.clear();
|
|
152
|
+
this.#fragmentCache.clear();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Warm the prefetch cache for `url` without mutating the stack. Safe
|
|
156
|
+
// to call repeatedly for the same URL (deduped via #inflight) and from
|
|
157
|
+
// hover/focus handlers; failures are swallowed since this is best-effort.
|
|
158
|
+
prefetch(url) {
|
|
159
|
+
if (!url || typeof this.runtime.fetchFragment !== "function") {
|
|
160
|
+
return Promise.resolve(null);
|
|
161
|
+
}
|
|
162
|
+
return this.#prefetch(url).catch(() => null);
|
|
96
163
|
}
|
|
97
164
|
|
|
98
165
|
closeAll() {
|
|
166
|
+
this.#invalidatePrefetch();
|
|
99
167
|
return this.#dispatch(closeAll(this.state));
|
|
100
168
|
}
|
|
101
169
|
|
|
@@ -104,6 +172,9 @@ export class Orchestrator {
|
|
|
104
172
|
this.#expectedPopstates -= 1;
|
|
105
173
|
return Promise.resolve();
|
|
106
174
|
}
|
|
175
|
+
// A popstate arriving while we have prefetches in flight means the
|
|
176
|
+
// user navigated away from any URL we were preloading; drop them.
|
|
177
|
+
this.#invalidatePrefetch();
|
|
107
178
|
return this.#dispatch(
|
|
108
179
|
handlePopstate(this.state, { historyState, locationHref }),
|
|
109
180
|
);
|
|
@@ -145,3 +216,15 @@ export class Orchestrator {
|
|
|
145
216
|
await handler.call(this.runtime, cmd);
|
|
146
217
|
}
|
|
147
218
|
}
|
|
219
|
+
|
|
220
|
+
function cloneFragment(fragment) {
|
|
221
|
+
if (!fragment) return fragment;
|
|
222
|
+
if (typeof fragment.cloneNode === "function") {
|
|
223
|
+
return fragment.cloneNode(true);
|
|
224
|
+
}
|
|
225
|
+
return fragment;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function supportsAbort() {
|
|
229
|
+
return typeof globalThis.AbortController === "function";
|
|
230
|
+
}
|