modal_stack 0.2.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +136 -52
  4. data/app/assets/javascripts/modal_stack.js +612 -63
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
  6. data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
  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 +54 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
  12. data/app/javascript/modal_stack/install.js +7 -1
  13. data/app/javascript/modal_stack/orchestrator.js +132 -3
  14. data/app/javascript/modal_stack/orchestrator.test.js +264 -2
  15. data/app/javascript/modal_stack/runtime.js +222 -13
  16. data/app/javascript/modal_stack/runtime.test.js +151 -0
  17. data/app/javascript/modal_stack/state.js +338 -39
  18. data/app/javascript/modal_stack/state.test.js +400 -13
  19. data/app/views/modal_stack/_dialog.html.erb +1 -0
  20. data/app/views/modal_stack/_panel.html.erb +4 -0
  21. data/lib/generators/modal_stack/install/install_generator.rb +18 -4
  22. data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
  23. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  24. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  25. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  26. data/lib/modal_stack/capybara.rb +21 -0
  27. data/lib/modal_stack/configuration.rb +43 -17
  28. data/lib/modal_stack/controller_extensions.rb +8 -1
  29. data/lib/modal_stack/engine.rb +2 -0
  30. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  33. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  34. data/lib/modal_stack/version.rb +1 -1
  35. data/lib/modal_stack.rb +5 -1
  36. metadata +11 -3
@@ -0,0 +1,372 @@
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
+ overscroll-behavior: contain;
123
+ background: var(--modal-stack-bg);
124
+ color: var(--modal-stack-fg);
125
+ border-radius: var(--modal-stack-radius);
126
+ box-shadow: var(--modal-stack-shadow);
127
+ padding: 0;
128
+ transform: translate(-50%, -50%);
129
+ transition:
130
+ transform var(--modal-stack-duration) var(--modal-stack-ease),
131
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
132
+ }
133
+
134
+ @starting-style {
135
+ [data-modal-stack-target="layer"] {
136
+ opacity: 0;
137
+ transform: translate(-50%, -44%) scale(0.97);
138
+ }
139
+ }
140
+
141
+ [data-modal-stack-target="layer"][data-leaving] {
142
+ opacity: 0;
143
+ transform: translate(-50%, -44%) scale(0.97);
144
+ pointer-events: none;
145
+ }
146
+
147
+ [data-modal-stack-target="layer"][inert] {
148
+ opacity: 0.5;
149
+ }
150
+
151
+ /* --- Sizes via [data-modal-stack-size] ----------------------------- */
152
+
153
+ [data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
154
+ [data-modal-stack-target="layer"][data-modal-stack-size="md"] { width: var(--modal-stack-size-md); }
155
+ [data-modal-stack-target="layer"][data-modal-stack-size="lg"] { width: var(--modal-stack-size-lg); }
156
+ [data-modal-stack-target="layer"][data-modal-stack-size="xl"] { width: var(--modal-stack-size-xl); }
157
+
158
+ /* --- Subtle depth offset for stacked layers ----------------------- */
159
+
160
+ [data-modal-stack-target="layer"][data-depth="2"] {
161
+ transform: translate(-50%, -53%) scale(1.015);
162
+ }
163
+
164
+ [data-modal-stack-target="layer"][data-depth="3"] {
165
+ transform: translate(-50%, -56%) scale(1.03);
166
+ }
167
+
168
+ [data-modal-stack-target="layer"][data-depth="4"] {
169
+ transform: translate(-50%, -59%) scale(1.045);
170
+ }
171
+
172
+ /* --- Drawer ------------------------------------------------------- */
173
+
174
+ [data-modal-stack-target="layer"][data-variant="drawer"] {
175
+ left: 0;
176
+ top: 0;
177
+ max-width: 100vw;
178
+ max-height: 100vh;
179
+ }
180
+
181
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"],
182
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
183
+ top: 0;
184
+ height: 100vh;
185
+ max-height: none;
186
+ width: var(--modal-stack-drawer-width);
187
+ max-width: 90vw;
188
+ }
189
+
190
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
191
+ left: auto;
192
+ right: 0;
193
+ transform: translateX(0);
194
+ border-radius: var(--modal-stack-radius) 0 0 var(--modal-stack-radius);
195
+ }
196
+
197
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
198
+ left: 0;
199
+ transform: translateX(0);
200
+ border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
201
+ }
202
+
203
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
204
+ left: 0;
205
+ top: 0;
206
+ width: 100vw;
207
+ max-width: none;
208
+ height: var(--modal-stack-drawer-height);
209
+ max-height: 85vh;
210
+ transform: translateY(0);
211
+ border-radius: 0 0 var(--modal-stack-radius) var(--modal-stack-radius);
212
+ }
213
+
214
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
215
+ left: 0;
216
+ top: auto;
217
+ bottom: 0;
218
+ width: 100vw;
219
+ max-width: none;
220
+ height: var(--modal-stack-drawer-height);
221
+ max-height: 85vh;
222
+ transform: translateY(0);
223
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
224
+ padding-bottom: env(safe-area-inset-bottom, 0);
225
+ }
226
+
227
+ @starting-style {
228
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
229
+ opacity: 0;
230
+ transform: translateX(100%);
231
+ }
232
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
233
+ opacity: 0;
234
+ transform: translateX(-100%);
235
+ }
236
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
237
+ opacity: 0;
238
+ transform: translateY(-100%);
239
+ }
240
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
241
+ opacity: 0;
242
+ transform: translateY(100%);
243
+ }
244
+ }
245
+
246
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
247
+ opacity: 0;
248
+ transform: translateX(100%);
249
+ }
250
+
251
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"][data-leaving] {
252
+ opacity: 0;
253
+ transform: translateX(-100%);
254
+ }
255
+
256
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"][data-leaving] {
257
+ opacity: 0;
258
+ transform: translateY(-100%);
259
+ }
260
+
261
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"][data-leaving] {
262
+ opacity: 0;
263
+ transform: translateY(100%);
264
+ }
265
+
266
+ /* --- Bottom sheet ------------------------------------------------- */
267
+
268
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
269
+ top: auto;
270
+ bottom: 0;
271
+ left: 0;
272
+ width: 100vw;
273
+ max-width: none;
274
+ transform: translate(0, 0);
275
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
276
+ max-height: 90vh;
277
+ padding-bottom: env(safe-area-inset-bottom, 0);
278
+ }
279
+
280
+ @starting-style {
281
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
282
+ opacity: 0;
283
+ transform: translateY(100%);
284
+ }
285
+ }
286
+
287
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"][data-leaving] {
288
+ opacity: 0;
289
+ transform: translateY(100%);
290
+ }
291
+
292
+ /* --- Confirmation ------------------------------------------------- */
293
+
294
+ [data-modal-stack-target="layer"][data-variant="confirmation"] {
295
+ width: var(--modal-stack-size-sm);
296
+ }
297
+
298
+ /* --- Panel slot (the modal_stack_container output) --------------- */
299
+
300
+ .modal-stack__panel {
301
+ padding: var(--modal-stack-panel-padding);
302
+ display: grid;
303
+ gap: 14px;
304
+ }
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
+
363
+ /* --- Reduced motion ---------------------------------------------- */
364
+
365
+ @media (prefers-reduced-motion: reduce) {
366
+ #modal-stack-root,
367
+ #modal-stack-root::backdrop,
368
+ [data-modal-stack-target="layer"],
369
+ [data-modal-stack-frame][data-transition] {
370
+ transition-duration: 1ms !important;
371
+ }
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
 
@@ -18,13 +18,16 @@
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
- --modal-stack-backdrop-blur: 0;
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;
25
27
  --modal-stack-size-lg: 720px;
26
28
  --modal-stack-size-xl: 960px;
27
29
  --modal-stack-drawer-width: 400px;
30
+ --modal-stack-drawer-height: 22rem; /* 352px */
28
31
  }
29
32
 
30
33
  body[data-modal-stack-locked] {
@@ -60,23 +63,22 @@ body[data-modal-stack-locked] {
60
63
 
61
64
  #modal-stack-root::backdrop {
62
65
  background: rgba(0, 0, 0, 0);
63
- backdrop-filter: blur(0);
64
66
  transition:
65
67
  background var(--modal-stack-duration) var(--modal-stack-ease),
66
- backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
67
68
  overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
68
69
  display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
69
70
  }
70
71
 
71
72
  #modal-stack-root[open]::backdrop {
72
73
  background: var(--modal-stack-backdrop);
73
- backdrop-filter: blur(var(--modal-stack-backdrop-blur));
74
+ /* Static. Default is `none` (no filter pass). Opt in with
75
+ * --modal-stack-backdrop-filter on :root. */
76
+ backdrop-filter: var(--modal-stack-backdrop-filter);
74
77
  }
75
78
 
76
79
  @starting-style {
77
80
  #modal-stack-root[open]::backdrop {
78
81
  background: rgba(0, 0, 0, 0);
79
- backdrop-filter: blur(0);
80
82
  }
81
83
  }
82
84
 
@@ -88,6 +90,7 @@ body[data-modal-stack-locked] {
88
90
  max-width: 92vw;
89
91
  max-height: min(85vh, 720px);
90
92
  overflow-y: auto;
93
+ overscroll-behavior: contain;
91
94
  background: var(--modal-stack-bg);
92
95
  color: var(--modal-stack-fg);
93
96
  border-radius: var(--modal-stack-radius);
@@ -96,8 +99,7 @@ body[data-modal-stack-locked] {
96
99
  transform: translate(-50%, -50%);
97
100
  transition:
98
101
  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);
102
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
101
103
  }
102
104
 
103
105
  @starting-style {
@@ -109,13 +111,12 @@ 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
 
116
118
  [data-modal-stack-target="layer"][inert] {
117
119
  opacity: 0.5;
118
- filter: blur(0.5px);
119
120
  }
120
121
 
121
122
  [data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
@@ -136,7 +137,14 @@ body[data-modal-stack-locked] {
136
137
  }
137
138
 
138
139
  [data-modal-stack-target="layer"][data-variant="drawer"] {
140
+ left: 0;
139
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"] {
140
148
  height: 100vh;
141
149
  max-height: none;
142
150
  width: var(--modal-stack-drawer-width);
@@ -156,6 +164,30 @@ body[data-modal-stack-locked] {
156
164
  border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
157
165
  }
158
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
+
159
191
  @starting-style {
160
192
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
161
193
  opacity: 0;
@@ -165,6 +197,14 @@ body[data-modal-stack-locked] {
165
197
  opacity: 0;
166
198
  transform: translateX(-100%);
167
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
+ }
168
208
  }
169
209
 
170
210
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
@@ -177,6 +217,16 @@ body[data-modal-stack-locked] {
177
217
  transform: translateX(-100%);
178
218
  }
179
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
+
180
230
  [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
181
231
  top: auto;
182
232
  bottom: 0;
@@ -186,6 +236,7 @@ body[data-modal-stack-locked] {
186
236
  transform: translate(0, 0);
187
237
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
188
238
  max-height: 90vh;
239
+ padding-bottom: env(safe-area-inset-bottom, 0);
189
240
  }
190
241
 
191
242
  @starting-style {
@@ -210,10 +261,76 @@ body[data-modal-stack-locked] {
210
261
  gap: 12px;
211
262
  }
212
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
+
213
329
  @media (prefers-reduced-motion: reduce) {
214
330
  #modal-stack-root,
215
331
  #modal-stack-root::backdrop,
216
- [data-modal-stack-target="layer"] {
332
+ [data-modal-stack-target="layer"],
333
+ [data-modal-stack-frame][data-transition] {
217
334
  transition-duration: 1ms !important;
218
335
  }
219
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
+ }