modal_stack 0.1.1 → 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 +43 -0
- data/README.md +73 -26
- data/app/assets/javascripts/modal_stack.js +230 -41
- 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 +52 -13
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/orchestrator.js +136 -4
- data/app/javascript/modal_stack/orchestrator.test.js +218 -2
- data/app/javascript/modal_stack/runtime.js +91 -10
- data/app/javascript/modal_stack/runtime.test.js +138 -1
- data/app/javascript/modal_stack/state.js +142 -8
- data/app/javascript/modal_stack/state.test.js +89 -5
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +19 -6
- data/lib/modal_stack/configuration.rb +44 -5
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +26 -6
- 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); }
|
|
@@ -6,6 +6,8 @@ export class ModalStackController extends Controller {
|
|
|
6
6
|
static values = {
|
|
7
7
|
stackId: String,
|
|
8
8
|
baseUrl: String,
|
|
9
|
+
maxDepth: { type: Number, default: 0 },
|
|
10
|
+
maxDepthStrategy: { type: String, default: "warn" },
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
connect() {
|
|
@@ -20,6 +22,10 @@ export class ModalStackController extends Controller {
|
|
|
20
22
|
stackId,
|
|
21
23
|
baseUrl,
|
|
22
24
|
restoreFrom: snapshot,
|
|
25
|
+
// Stimulus Number values default to 0, but state.js treats null as
|
|
26
|
+
// "no cap" — so map 0/missing to null here.
|
|
27
|
+
maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
|
|
28
|
+
maxDepthStrategy: this.maxDepthStrategyValue || "warn",
|
|
23
29
|
});
|
|
24
30
|
|
|
25
31
|
this._onPopstate = (event) =>
|
|
@@ -73,6 +79,10 @@ export class ModalStackController extends Controller {
|
|
|
73
79
|
return this.orchestrator.closeAll();
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
prefetch(url) {
|
|
83
|
+
return this.orchestrator.prefetch(url);
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
#topLayer() {
|
|
77
87
|
const layers = this.orchestrator.layers;
|
|
78
88
|
return layers[layers.length - 1] ?? null;
|
|
@@ -89,28 +99,57 @@ export class ModalStackController extends Controller {
|
|
|
89
99
|
}
|
|
90
100
|
const StreamActions = Turbo.StreamActions || (Turbo.StreamActions = {});
|
|
91
101
|
const orchestrator = this.orchestrator;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
const dialog = this.element;
|
|
103
|
+
|
|
104
|
+
// Wraps a stream-action body so a malformed payload (bad data-*, fetch
|
|
105
|
+
// 500, etc.) doesn't bubble up and break the page. The error is logged
|
|
106
|
+
// and re-emitted as `modal_stack:error` so apps can surface UI feedback.
|
|
107
|
+
const guarded = (action, fn) =>
|
|
108
|
+
function guardedStreamAction() {
|
|
109
|
+
try {
|
|
110
|
+
const result = fn.call(this, orchestrator);
|
|
111
|
+
if (result && typeof result.catch === "function") {
|
|
112
|
+
result.catch((err) => emitStreamError(dialog, action, err));
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
emitStreamError(dialog, action, err);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
StreamActions.modal_push = guarded("modal_push", function (orch) {
|
|
120
|
+
return orch.push(layerFromStreamElement(this), {
|
|
95
121
|
fragment: this.templateContent.cloneNode(true),
|
|
96
122
|
});
|
|
97
|
-
};
|
|
123
|
+
});
|
|
98
124
|
|
|
99
|
-
StreamActions.modal_pop = function
|
|
100
|
-
|
|
101
|
-
};
|
|
125
|
+
StreamActions.modal_pop = guarded("modal_pop", function (orch) {
|
|
126
|
+
return orch.pop();
|
|
127
|
+
});
|
|
102
128
|
|
|
103
|
-
StreamActions.modal_replace = function
|
|
104
|
-
|
|
129
|
+
StreamActions.modal_replace = guarded("modal_replace", function (orch) {
|
|
130
|
+
return orch.replaceTop(layerPatchFromStreamElement(this), {
|
|
105
131
|
fragment: this.templateContent.cloneNode(true),
|
|
106
132
|
historyMode: this.dataset.historyMode || "replace",
|
|
107
133
|
});
|
|
108
|
-
};
|
|
134
|
+
});
|
|
109
135
|
|
|
110
|
-
StreamActions.modal_close_all = function
|
|
111
|
-
|
|
112
|
-
};
|
|
136
|
+
StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
|
|
137
|
+
return orch.closeAll();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function emitStreamError(dialog, action, error) {
|
|
143
|
+
if (typeof console !== "undefined" && console.error) {
|
|
144
|
+
console.error(`[modal_stack] stream action "${action}" failed:`, error);
|
|
113
145
|
}
|
|
146
|
+
dialog.dispatchEvent(
|
|
147
|
+
new CustomEvent("modal_stack:error", {
|
|
148
|
+
bubbles: true,
|
|
149
|
+
cancelable: false,
|
|
150
|
+
detail: { action, error },
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
114
153
|
}
|
|
115
154
|
|
|
116
155
|
function layerFromStreamElement(el) {
|
|
@@ -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() {
|