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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +113 -32
  4. data/app/assets/javascripts/modal_stack.js +488 -50
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
  6. data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
  9. data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
  10. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +50 -0
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +53 -7
  13. data/app/javascript/modal_stack/orchestrator.test.js +96 -0
  14. data/app/javascript/modal_stack/runtime.js +167 -5
  15. data/app/javascript/modal_stack/runtime.test.js +83 -0
  16. data/app/javascript/modal_stack/state.js +319 -34
  17. data/app/javascript/modal_stack/state.test.js +394 -9
  18. data/app/views/modal_stack/_dialog.html.erb +1 -0
  19. data/app/views/modal_stack/_panel.html.erb +4 -0
  20. data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
  21. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  22. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  23. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  24. data/lib/modal_stack/capybara.rb +21 -0
  25. data/lib/modal_stack/configuration.rb +37 -16
  26. data/lib/modal_stack/engine.rb +2 -0
  27. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  28. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +1 -1
  29. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  30. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  31. data/lib/modal_stack/version.rb +1 -1
  32. data/lib/modal_stack.rb +5 -1
  33. metadata +9 -2
@@ -10,7 +10,7 @@
10
10
  * :root { --modal-stack-radius: 4px; }
11
11
  *
12
12
  * Variants: [data-variant="modal" | "drawer" | "bottom_sheet" | "confirmation"]
13
- * Drawer side: [data-side="left" | "right"]
13
+ * Drawer side: [data-side="left" | "right" | "top" | "bottom"]
14
14
  * Sizes: [data-modal-stack-size="sm" | "md" | "lg" | "xl"]
15
15
  */
16
16
 
@@ -32,6 +32,7 @@
32
32
  --modal-stack-size-lg: 800px;
33
33
  --modal-stack-size-xl: 1140px;
34
34
  --modal-stack-drawer-width: 400px;
35
+ --modal-stack-drawer-height: 22rem; /* 352px */
35
36
  }
36
37
 
37
38
  body[data-modal-stack-locked] {
@@ -94,6 +95,7 @@ body[data-modal-stack-locked] {
94
95
  max-width: 92vw;
95
96
  max-height: min(85vh, 720px);
96
97
  overflow-y: auto;
98
+ overscroll-behavior: contain;
97
99
  background: var(--modal-stack-bg);
98
100
  color: var(--modal-stack-fg);
99
101
  border-radius: var(--modal-stack-radius);
@@ -114,7 +116,7 @@ body[data-modal-stack-locked] {
114
116
 
115
117
  [data-modal-stack-target="layer"][data-leaving] {
116
118
  opacity: 0;
117
- transform: translate(-50%, -56%) scale(0.97);
119
+ transform: translate(-50%, -44%) scale(0.97);
118
120
  pointer-events: none;
119
121
  }
120
122
 
@@ -142,7 +144,14 @@ body[data-modal-stack-locked] {
142
144
  /* --- Drawer (Bootstrap offcanvas-flavored) ----------------------- */
143
145
 
144
146
  [data-modal-stack-target="layer"][data-variant="drawer"] {
147
+ left: 0;
145
148
  top: 0;
149
+ max-width: 100vw;
150
+ max-height: 100vh;
151
+ }
152
+
153
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"],
154
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
146
155
  height: 100vh;
147
156
  max-height: none;
148
157
  width: var(--modal-stack-drawer-width);
@@ -162,6 +171,30 @@ body[data-modal-stack-locked] {
162
171
  border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
163
172
  }
164
173
 
174
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
175
+ left: 0;
176
+ top: 0;
177
+ width: 100vw;
178
+ max-width: none;
179
+ height: var(--modal-stack-drawer-height);
180
+ max-height: 85vh;
181
+ transform: translateY(0);
182
+ border-radius: 0 0 var(--modal-stack-radius) var(--modal-stack-radius);
183
+ }
184
+
185
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
186
+ left: 0;
187
+ top: auto;
188
+ bottom: 0;
189
+ width: 100vw;
190
+ max-width: none;
191
+ height: var(--modal-stack-drawer-height);
192
+ max-height: 85vh;
193
+ transform: translateY(0);
194
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
195
+ padding-bottom: env(safe-area-inset-bottom, 0);
196
+ }
197
+
165
198
  @starting-style {
166
199
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
167
200
  opacity: 0;
@@ -171,6 +204,14 @@ body[data-modal-stack-locked] {
171
204
  opacity: 0;
172
205
  transform: translateX(-100%);
173
206
  }
207
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
208
+ opacity: 0;
209
+ transform: translateY(-100%);
210
+ }
211
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
212
+ opacity: 0;
213
+ transform: translateY(100%);
214
+ }
174
215
  }
175
216
 
176
217
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
@@ -183,6 +224,16 @@ body[data-modal-stack-locked] {
183
224
  transform: translateX(-100%);
184
225
  }
185
226
 
227
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"][data-leaving] {
228
+ opacity: 0;
229
+ transform: translateY(-100%);
230
+ }
231
+
232
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"][data-leaving] {
233
+ opacity: 0;
234
+ transform: translateY(100%);
235
+ }
236
+
186
237
  /* --- Bottom sheet ------------------------------------------------ */
187
238
 
188
239
  [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
@@ -194,6 +245,7 @@ body[data-modal-stack-locked] {
194
245
  transform: translate(0, 0);
195
246
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
196
247
  max-height: 90vh;
248
+ padding-bottom: env(safe-area-inset-bottom, 0);
197
249
  }
198
250
 
199
251
  @starting-style {
@@ -222,10 +274,68 @@ body[data-modal-stack-locked] {
222
274
  gap: 0.5rem;
223
275
  }
224
276
 
277
+ /* Path frames are transparent layout wrappers — CSS transitions can hook
278
+ on [data-modal-stack-frame][data-transition][data-direction] to
279
+ animate slide/fade. */
280
+ [data-modal-stack-frame] {
281
+ display: contents;
282
+ }
283
+
284
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="slide"]),
285
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="fade"]) {
286
+ overflow: hidden;
287
+ }
288
+
289
+ [data-modal-stack-frame][data-transition="slide"] {
290
+ display: block;
291
+ translate: 0 0;
292
+ transition: translate var(--modal-stack-duration) var(--modal-stack-ease);
293
+ }
294
+
295
+ [data-modal-stack-frame][data-transition="fade"] {
296
+ display: block;
297
+ opacity: 1;
298
+ transition: opacity var(--modal-stack-duration) var(--modal-stack-ease);
299
+ }
300
+
301
+ @starting-style {
302
+ [data-modal-stack-frame][data-transition="slide"][data-direction="forward"] {
303
+ translate: 100% 0;
304
+ }
305
+ [data-modal-stack-frame][data-transition="slide"][data-direction="back"] {
306
+ translate: -100% 0;
307
+ }
308
+ [data-modal-stack-frame][data-transition="fade"] {
309
+ opacity: 0;
310
+ }
311
+ }
312
+
313
+ .modal-stack__panel-back {
314
+ font: inherit;
315
+ color: inherit;
316
+ background: transparent;
317
+ border: 0;
318
+ padding: 0;
319
+ cursor: pointer;
320
+ align-self: start;
321
+ justify-self: start;
322
+ }
323
+
324
+ .modal-stack__panel-back:focus-visible {
325
+ outline: 2px solid currentColor;
326
+ outline-offset: 4px;
327
+ border-radius: 2px;
328
+ }
329
+
330
+ [data-modal-stack-target="layer"][data-frame-depth="1"] .modal-stack__panel-back {
331
+ display: none;
332
+ }
333
+
225
334
  @media (prefers-reduced-motion: reduce) {
226
335
  #modal-stack-root,
227
336
  #modal-stack-root::backdrop,
228
- [data-modal-stack-target="layer"] {
337
+ [data-modal-stack-target="layer"],
338
+ [data-modal-stack-frame][data-transition] {
229
339
  transition-duration: 1ms !important;
230
340
  }
231
341
  }
@@ -118,6 +118,7 @@ body[data-modal-stack-locked] {
118
118
  max-width: 92vw;
119
119
  max-height: min(85vh, 720px);
120
120
  overflow-y: auto;
121
+ overscroll-behavior: contain;
121
122
  background: var(--modal-stack-bg);
122
123
  color: var(--modal-stack-fg);
123
124
  border-radius: var(--modal-stack-radius);
@@ -138,7 +139,7 @@ body[data-modal-stack-locked] {
138
139
 
139
140
  [data-modal-stack-target="layer"][data-leaving] {
140
141
  opacity: 0;
141
- transform: translate(-50%, -56%) scale(0.97);
142
+ transform: translate(-50%, -44%) scale(0.97);
142
143
  pointer-events: none;
143
144
  }
144
145
 
@@ -219,6 +220,7 @@ body[data-modal-stack-locked] {
219
220
  max-height: 85vh;
220
221
  transform: translateY(0);
221
222
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
223
+ padding-bottom: env(safe-area-inset-bottom, 0);
222
224
  }
223
225
 
224
226
  @starting-style {
@@ -271,6 +273,7 @@ body[data-modal-stack-locked] {
271
273
  transform: translate(0, 0);
272
274
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
273
275
  max-height: 90vh;
276
+ padding-bottom: env(safe-area-inset-bottom, 0);
274
277
  }
275
278
 
276
279
  @starting-style {
@@ -299,12 +302,70 @@ body[data-modal-stack-locked] {
299
302
  gap: 14px;
300
303
  }
301
304
 
305
+ /* Path frames are transparent layout wrappers — CSS transitions can hook
306
+ on [data-modal-stack-frame][data-transition][data-direction] to
307
+ animate slide/fade. */
308
+ [data-modal-stack-frame] {
309
+ display: contents;
310
+ }
311
+
312
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="slide"]),
313
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="fade"]) {
314
+ overflow: hidden;
315
+ }
316
+
317
+ [data-modal-stack-frame][data-transition="slide"] {
318
+ display: block;
319
+ translate: 0 0;
320
+ transition: translate var(--modal-stack-duration) var(--modal-stack-ease);
321
+ }
322
+
323
+ [data-modal-stack-frame][data-transition="fade"] {
324
+ display: block;
325
+ opacity: 1;
326
+ transition: opacity var(--modal-stack-duration) var(--modal-stack-ease);
327
+ }
328
+
329
+ @starting-style {
330
+ [data-modal-stack-frame][data-transition="slide"][data-direction="forward"] {
331
+ translate: 100% 0;
332
+ }
333
+ [data-modal-stack-frame][data-transition="slide"][data-direction="back"] {
334
+ translate: -100% 0;
335
+ }
336
+ [data-modal-stack-frame][data-transition="fade"] {
337
+ opacity: 0;
338
+ }
339
+ }
340
+
341
+ .modal-stack__panel-back {
342
+ font: inherit;
343
+ color: inherit;
344
+ background: transparent;
345
+ border: 0;
346
+ padding: 0;
347
+ cursor: pointer;
348
+ align-self: start;
349
+ justify-self: start;
350
+ }
351
+
352
+ .modal-stack__panel-back:focus-visible {
353
+ outline: 2px solid currentColor;
354
+ outline-offset: 4px;
355
+ border-radius: 2px;
356
+ }
357
+
358
+ [data-modal-stack-target="layer"][data-frame-depth="1"] .modal-stack__panel-back {
359
+ display: none;
360
+ }
361
+
302
362
  /* --- Reduced motion ---------------------------------------------- */
303
363
 
304
364
  @media (prefers-reduced-motion: reduce) {
305
365
  #modal-stack-root,
306
366
  #modal-stack-root::backdrop,
307
- [data-modal-stack-target="layer"] {
367
+ [data-modal-stack-target="layer"],
368
+ [data-modal-stack-frame][data-transition] {
308
369
  transition-duration: 1ms !important;
309
370
  }
310
371
  }
@@ -119,6 +119,7 @@ body[data-modal-stack-locked] {
119
119
  max-width: 92vw;
120
120
  max-height: min(85vh, 720px);
121
121
  overflow-y: auto;
122
+ overscroll-behavior: contain;
122
123
  background: var(--modal-stack-bg);
123
124
  color: var(--modal-stack-fg);
124
125
  border-radius: var(--modal-stack-radius);
@@ -139,7 +140,7 @@ body[data-modal-stack-locked] {
139
140
 
140
141
  [data-modal-stack-target="layer"][data-leaving] {
141
142
  opacity: 0;
142
- transform: translate(-50%, -56%) scale(0.97);
143
+ transform: translate(-50%, -44%) scale(0.97);
143
144
  pointer-events: none;
144
145
  }
145
146
 
@@ -220,6 +221,7 @@ body[data-modal-stack-locked] {
220
221
  max-height: 85vh;
221
222
  transform: translateY(0);
222
223
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
224
+ padding-bottom: env(safe-area-inset-bottom, 0);
223
225
  }
224
226
 
225
227
  @starting-style {
@@ -272,6 +274,7 @@ body[data-modal-stack-locked] {
272
274
  transform: translate(0, 0);
273
275
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
274
276
  max-height: 90vh;
277
+ padding-bottom: env(safe-area-inset-bottom, 0);
275
278
  }
276
279
 
277
280
  @starting-style {
@@ -300,12 +303,70 @@ body[data-modal-stack-locked] {
300
303
  gap: 14px;
301
304
  }
302
305
 
306
+ /* Path frames are transparent layout wrappers — CSS transitions can hook
307
+ on [data-modal-stack-frame][data-transition][data-direction] to
308
+ animate slide/fade. */
309
+ [data-modal-stack-frame] {
310
+ display: contents;
311
+ }
312
+
313
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="slide"]),
314
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="fade"]) {
315
+ overflow: hidden;
316
+ }
317
+
318
+ [data-modal-stack-frame][data-transition="slide"] {
319
+ display: block;
320
+ translate: 0 0;
321
+ transition: translate var(--modal-stack-duration) var(--modal-stack-ease);
322
+ }
323
+
324
+ [data-modal-stack-frame][data-transition="fade"] {
325
+ display: block;
326
+ opacity: 1;
327
+ transition: opacity var(--modal-stack-duration) var(--modal-stack-ease);
328
+ }
329
+
330
+ @starting-style {
331
+ [data-modal-stack-frame][data-transition="slide"][data-direction="forward"] {
332
+ translate: 100% 0;
333
+ }
334
+ [data-modal-stack-frame][data-transition="slide"][data-direction="back"] {
335
+ translate: -100% 0;
336
+ }
337
+ [data-modal-stack-frame][data-transition="fade"] {
338
+ opacity: 0;
339
+ }
340
+ }
341
+
342
+ .modal-stack__panel-back {
343
+ font: inherit;
344
+ color: inherit;
345
+ background: transparent;
346
+ border: 0;
347
+ padding: 0;
348
+ cursor: pointer;
349
+ align-self: start;
350
+ justify-self: start;
351
+ }
352
+
353
+ .modal-stack__panel-back:focus-visible {
354
+ outline: 2px solid currentColor;
355
+ outline-offset: 4px;
356
+ border-radius: 2px;
357
+ }
358
+
359
+ [data-modal-stack-target="layer"][data-frame-depth="1"] .modal-stack__panel-back {
360
+ display: none;
361
+ }
362
+
303
363
  /* --- Reduced motion ---------------------------------------------- */
304
364
 
305
365
  @media (prefers-reduced-motion: reduce) {
306
366
  #modal-stack-root,
307
367
  #modal-stack-root::backdrop,
308
- [data-modal-stack-target="layer"] {
368
+ [data-modal-stack-target="layer"],
369
+ [data-modal-stack-frame][data-transition] {
309
370
  transition-duration: 1ms !important;
310
371
  }
311
372
  }
@@ -5,7 +5,7 @@
5
5
  * --modal-stack-* tokens on :root to retheme.
6
6
  *
7
7
  * Variants: [data-variant="modal" | "drawer" | "bottom_sheet" | "confirmation"]
8
- * Drawer side: [data-side="left" | "right"]
8
+ * Drawer side: [data-side="left" | "right" | "top" | "bottom"]
9
9
  * Sizes: [data-modal-stack-size="sm" | "md" | "lg" | "xl"]
10
10
  */
11
11
 
@@ -27,6 +27,7 @@
27
27
  --modal-stack-size-lg: 720px;
28
28
  --modal-stack-size-xl: 960px;
29
29
  --modal-stack-drawer-width: 400px;
30
+ --modal-stack-drawer-height: 22rem; /* 352px */
30
31
  }
31
32
 
32
33
  body[data-modal-stack-locked] {
@@ -89,6 +90,7 @@ body[data-modal-stack-locked] {
89
90
  max-width: 92vw;
90
91
  max-height: min(85vh, 720px);
91
92
  overflow-y: auto;
93
+ overscroll-behavior: contain;
92
94
  background: var(--modal-stack-bg);
93
95
  color: var(--modal-stack-fg);
94
96
  border-radius: var(--modal-stack-radius);
@@ -109,7 +111,7 @@ body[data-modal-stack-locked] {
109
111
 
110
112
  [data-modal-stack-target="layer"][data-leaving] {
111
113
  opacity: 0;
112
- transform: translate(-50%, -56%) scale(0.97);
114
+ transform: translate(-50%, -44%) scale(0.97);
113
115
  pointer-events: none;
114
116
  }
115
117
 
@@ -135,7 +137,14 @@ body[data-modal-stack-locked] {
135
137
  }
136
138
 
137
139
  [data-modal-stack-target="layer"][data-variant="drawer"] {
140
+ left: 0;
138
141
  top: 0;
142
+ max-width: 100vw;
143
+ max-height: 100vh;
144
+ }
145
+
146
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"],
147
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
139
148
  height: 100vh;
140
149
  max-height: none;
141
150
  width: var(--modal-stack-drawer-width);
@@ -155,6 +164,30 @@ body[data-modal-stack-locked] {
155
164
  border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
156
165
  }
157
166
 
167
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
168
+ left: 0;
169
+ top: 0;
170
+ width: 100vw;
171
+ max-width: none;
172
+ height: var(--modal-stack-drawer-height);
173
+ max-height: 85vh;
174
+ transform: translateY(0);
175
+ border-radius: 0 0 var(--modal-stack-radius) var(--modal-stack-radius);
176
+ }
177
+
178
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
179
+ left: 0;
180
+ top: auto;
181
+ bottom: 0;
182
+ width: 100vw;
183
+ max-width: none;
184
+ height: var(--modal-stack-drawer-height);
185
+ max-height: 85vh;
186
+ transform: translateY(0);
187
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
188
+ padding-bottom: env(safe-area-inset-bottom, 0);
189
+ }
190
+
158
191
  @starting-style {
159
192
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
160
193
  opacity: 0;
@@ -164,6 +197,14 @@ body[data-modal-stack-locked] {
164
197
  opacity: 0;
165
198
  transform: translateX(-100%);
166
199
  }
200
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
201
+ opacity: 0;
202
+ transform: translateY(-100%);
203
+ }
204
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
205
+ opacity: 0;
206
+ transform: translateY(100%);
207
+ }
167
208
  }
168
209
 
169
210
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
@@ -176,6 +217,16 @@ body[data-modal-stack-locked] {
176
217
  transform: translateX(-100%);
177
218
  }
178
219
 
220
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"][data-leaving] {
221
+ opacity: 0;
222
+ transform: translateY(-100%);
223
+ }
224
+
225
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"][data-leaving] {
226
+ opacity: 0;
227
+ transform: translateY(100%);
228
+ }
229
+
179
230
  [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
180
231
  top: auto;
181
232
  bottom: 0;
@@ -185,6 +236,7 @@ body[data-modal-stack-locked] {
185
236
  transform: translate(0, 0);
186
237
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
187
238
  max-height: 90vh;
239
+ padding-bottom: env(safe-area-inset-bottom, 0);
188
240
  }
189
241
 
190
242
  @starting-style {
@@ -209,10 +261,76 @@ body[data-modal-stack-locked] {
209
261
  gap: 12px;
210
262
  }
211
263
 
264
+ /* Path frames live inside the layer as transparent wrappers. Switching
265
+ to `display: contents` keeps the wrapper layout-invisible so adding
266
+ the modal_path feature doesn't change visuals for layers that never
267
+ navigate forward. Frame-level CSS transitions (slide / fade) can hook
268
+ on [data-modal-stack-frame][data-transition][data-direction]. */
269
+ [data-modal-stack-frame] {
270
+ display: contents;
271
+ }
272
+
273
+ /* Frame transitions — the JS runtime sets [data-transition] + [data-direction]
274
+ on new frame wrappers and removes them after the animation. While the
275
+ attribute is present, the frame is promoted from display:contents to a
276
+ block context so translate/opacity apply. The :has() rule clips the layer
277
+ so off-screen frames don't overflow the panel area. */
278
+
279
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="slide"]),
280
+ [data-modal-stack-target="layer"]:has([data-modal-stack-frame][data-transition="fade"]) {
281
+ overflow: hidden;
282
+ }
283
+
284
+ [data-modal-stack-frame][data-transition="slide"] {
285
+ display: block;
286
+ translate: 0 0;
287
+ transition: translate var(--modal-stack-duration) var(--modal-stack-ease);
288
+ }
289
+
290
+ [data-modal-stack-frame][data-transition="fade"] {
291
+ display: block;
292
+ opacity: 1;
293
+ transition: opacity var(--modal-stack-duration) var(--modal-stack-ease);
294
+ }
295
+
296
+ @starting-style {
297
+ [data-modal-stack-frame][data-transition="slide"][data-direction="forward"] {
298
+ translate: 100% 0;
299
+ }
300
+ [data-modal-stack-frame][data-transition="slide"][data-direction="back"] {
301
+ translate: -100% 0;
302
+ }
303
+ [data-modal-stack-frame][data-transition="fade"] {
304
+ opacity: 0;
305
+ }
306
+ }
307
+
308
+ .modal-stack__panel-back {
309
+ font: inherit;
310
+ color: inherit;
311
+ background: transparent;
312
+ border: 0;
313
+ padding: 0;
314
+ cursor: pointer;
315
+ align-self: start;
316
+ justify-self: start;
317
+ }
318
+
319
+ .modal-stack__panel-back:focus-visible {
320
+ outline: 2px solid currentColor;
321
+ outline-offset: 4px;
322
+ border-radius: 2px;
323
+ }
324
+
325
+ [data-modal-stack-target="layer"][data-frame-depth="1"] .modal-stack__panel-back {
326
+ display: none;
327
+ }
328
+
212
329
  @media (prefers-reduced-motion: reduce) {
213
330
  #modal-stack-root,
214
331
  #modal-stack-root::backdrop,
215
- [data-modal-stack-target="layer"] {
332
+ [data-modal-stack-target="layer"],
333
+ [data-modal-stack-frame][data-transition] {
216
334
  transition-duration: 1ms !important;
217
335
  }
218
336
  }
@@ -0,0 +1,32 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Backed by `<%= modal_back_link %>`. Triggers orchestrator.pathBack on
4
+ // click; falls back to a no-op when no modal-stack controller is on the
5
+ // page (e.g. server-side rendered link viewed outside a modal).
6
+ export class ModalStackBackLinkController extends Controller {
7
+ static values = {
8
+ steps: { type: Number, default: 1 },
9
+ };
10
+
11
+ trigger(event) {
12
+ const stackController = this.#stackController();
13
+ if (!stackController) return;
14
+
15
+ if (event) {
16
+ event.preventDefault();
17
+ event.stopPropagation();
18
+ }
19
+ stackController.orchestrator.pathBack({
20
+ steps: this.stepsValue > 0 ? this.stepsValue : 1,
21
+ });
22
+ }
23
+
24
+ #stackController() {
25
+ const stack = document.querySelector('[data-controller~="modal-stack"]');
26
+ if (!stack) return null;
27
+ return this.application.getControllerForElementAndIdentifier(
28
+ stack,
29
+ "modal-stack",
30
+ );
31
+ }
32
+ }
@@ -83,6 +83,17 @@ export class ModalStackController extends Controller {
83
83
  return this.orchestrator.prefetch(url);
84
84
  }
85
85
 
86
+ // Stimulus action — wire up via data-action="click->modal-stack#pathBack"
87
+ // on any button/link inside a modal panel.
88
+ pathBack(event) {
89
+ if (event) {
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ }
93
+ const steps = readSteps(event);
94
+ return this.orchestrator.pathBack({ steps });
95
+ }
96
+
86
97
  #topLayer() {
87
98
  const layers = this.orchestrator.layers;
88
99
  return layers[layers.length - 1] ?? null;
@@ -136,6 +147,21 @@ export class ModalStackController extends Controller {
136
147
  StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
137
148
  return orch.closeAll();
138
149
  });
150
+
151
+ StreamActions.modal_path_to = guarded("modal_path_to", function (orch) {
152
+ return orch.pathTo(frameFromStreamElement(this), {
153
+ fragment: this.templateContent.cloneNode(true),
154
+ transition: this.dataset.transition || null,
155
+ });
156
+ });
157
+
158
+ StreamActions.modal_path_back = guarded("modal_path_back", function (orch) {
159
+ const steps = parsePositiveInt(this.dataset.steps, 1);
160
+ return orch.pathBack({
161
+ steps,
162
+ transition: this.dataset.transition || null,
163
+ });
164
+ });
139
165
  }
140
166
  }
141
167
 
@@ -186,3 +212,27 @@ function generateLayerId() {
186
212
  }
187
213
  return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
188
214
  }
215
+
216
+ function frameFromStreamElement(el) {
217
+ return {
218
+ url: el.dataset.url || window.location.href,
219
+ stale: el.dataset.stale === "true" || el.dataset.stale === "1",
220
+ };
221
+ }
222
+
223
+ function parsePositiveInt(raw, fallback) {
224
+ const n = Number.parseInt(raw, 10);
225
+ return Number.isFinite(n) && n > 0 ? n : fallback;
226
+ }
227
+
228
+ // Steps for pathBack come from either Stimulus action params
229
+ // (data-modal-stack-steps-param) or a plain data-steps attribute on
230
+ // the action target, e.g. <button data-modal-stack-steps-param="2">.
231
+ function readSteps(event) {
232
+ const params = event?.params;
233
+ if (params && Number.isFinite(params.steps) && params.steps > 0) {
234
+ return params.steps;
235
+ }
236
+ const target = event?.currentTarget ?? event?.target;
237
+ return parsePositiveInt(target?.dataset?.steps, 1);
238
+ }
@@ -1,3 +1,4 @@
1
+ import { ModalStackBackLinkController } from "./controllers/modal_stack_back_link_controller.js";
1
2
  import { ModalStackController } from "./controllers/modal_stack_controller.js";
2
3
  import { ModalStackLinkController } from "./controllers/modal_stack_link_controller.js";
3
4
 
@@ -9,7 +10,12 @@ export function install(application) {
9
10
  }
10
11
  application.register("modal-stack", ModalStackController);
11
12
  application.register("modal-stack-link", ModalStackLinkController);
13
+ application.register("modal-stack-back-link", ModalStackBackLinkController);
12
14
  return application;
13
15
  }
14
16
 
15
- export { ModalStackController, ModalStackLinkController };
17
+ export {
18
+ ModalStackBackLinkController,
19
+ ModalStackController,
20
+ ModalStackLinkController,
21
+ };