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
@@ -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
 
@@ -23,13 +23,16 @@
23
23
  --modal-stack-fg: var(--bs-body-color, #212529);
24
24
  --modal-stack-shadow: var(--bs-box-shadow-lg, 0 1rem 3rem rgba(0, 0, 0, 0.175));
25
25
  --modal-stack-backdrop: rgba(var(--bs-backdrop-color, 0, 0, 0), var(--bs-backdrop-opacity, 0.5));
26
- --modal-stack-backdrop-blur: 0;
26
+ /* Default `none` so Chrome skips the filter pass. Opt in with
27
+ * `:root { --modal-stack-backdrop-filter: blur(8px); }`. */
28
+ --modal-stack-backdrop-filter: none;
27
29
  --modal-stack-panel-padding: var(--bs-modal-padding, 1rem);
28
30
  --modal-stack-size-sm: 300px;
29
31
  --modal-stack-size-md: 500px;
30
32
  --modal-stack-size-lg: 800px;
31
33
  --modal-stack-size-xl: 1140px;
32
34
  --modal-stack-drawer-width: 400px;
35
+ --modal-stack-drawer-height: 22rem; /* 352px */
33
36
  }
34
37
 
35
38
  body[data-modal-stack-locked] {
@@ -65,23 +68,22 @@ body[data-modal-stack-locked] {
65
68
 
66
69
  #modal-stack-root::backdrop {
67
70
  background: rgba(0, 0, 0, 0);
68
- backdrop-filter: blur(0);
69
71
  transition:
70
72
  background var(--modal-stack-duration) var(--modal-stack-ease),
71
- backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
72
73
  overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
73
74
  display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
74
75
  }
75
76
 
76
77
  #modal-stack-root[open]::backdrop {
77
78
  background: var(--modal-stack-backdrop);
78
- backdrop-filter: blur(var(--modal-stack-backdrop-blur));
79
+ /* Static. Default is `none` (no filter pass). Opt in with
80
+ * --modal-stack-backdrop-filter on :root. */
81
+ backdrop-filter: var(--modal-stack-backdrop-filter);
79
82
  }
80
83
 
81
84
  @starting-style {
82
85
  #modal-stack-root[open]::backdrop {
83
86
  background: rgba(0, 0, 0, 0);
84
- backdrop-filter: blur(0);
85
87
  }
86
88
  }
87
89
 
@@ -93,6 +95,7 @@ body[data-modal-stack-locked] {
93
95
  max-width: 92vw;
94
96
  max-height: min(85vh, 720px);
95
97
  overflow-y: auto;
98
+ overscroll-behavior: contain;
96
99
  background: var(--modal-stack-bg);
97
100
  color: var(--modal-stack-fg);
98
101
  border-radius: var(--modal-stack-radius);
@@ -101,8 +104,7 @@ body[data-modal-stack-locked] {
101
104
  transform: translate(-50%, -50%);
102
105
  transition:
103
106
  transform var(--modal-stack-duration) var(--modal-stack-ease),
104
- opacity var(--modal-stack-duration) var(--modal-stack-ease),
105
- filter var(--modal-stack-duration) var(--modal-stack-ease);
107
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
106
108
  }
107
109
 
108
110
  @starting-style {
@@ -114,13 +116,12 @@ 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
 
121
123
  [data-modal-stack-target="layer"][inert] {
122
124
  opacity: 0.5;
123
- filter: blur(0.5px);
124
125
  }
125
126
 
126
127
  [data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
@@ -143,7 +144,14 @@ body[data-modal-stack-locked] {
143
144
  /* --- Drawer (Bootstrap offcanvas-flavored) ----------------------- */
144
145
 
145
146
  [data-modal-stack-target="layer"][data-variant="drawer"] {
147
+ left: 0;
146
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"] {
147
155
  height: 100vh;
148
156
  max-height: none;
149
157
  width: var(--modal-stack-drawer-width);
@@ -163,6 +171,30 @@ body[data-modal-stack-locked] {
163
171
  border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
164
172
  }
165
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
+
166
198
  @starting-style {
167
199
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
168
200
  opacity: 0;
@@ -172,6 +204,14 @@ body[data-modal-stack-locked] {
172
204
  opacity: 0;
173
205
  transform: translateX(-100%);
174
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
+ }
175
215
  }
176
216
 
177
217
  [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
@@ -184,6 +224,16 @@ body[data-modal-stack-locked] {
184
224
  transform: translateX(-100%);
185
225
  }
186
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
+
187
237
  /* --- Bottom sheet ------------------------------------------------ */
188
238
 
189
239
  [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
@@ -195,6 +245,7 @@ body[data-modal-stack-locked] {
195
245
  transform: translate(0, 0);
196
246
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
197
247
  max-height: 90vh;
248
+ padding-bottom: env(safe-area-inset-bottom, 0);
198
249
  }
199
250
 
200
251
  @starting-style {
@@ -223,10 +274,68 @@ body[data-modal-stack-locked] {
223
274
  gap: 0.5rem;
224
275
  }
225
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
+
226
334
  @media (prefers-reduced-motion: reduce) {
227
335
  #modal-stack-root,
228
336
  #modal-stack-root::backdrop,
229
- [data-modal-stack-target="layer"] {
337
+ [data-modal-stack-target="layer"],
338
+ [data-modal-stack-frame][data-transition] {
230
339
  transition-duration: 1ms !important;
231
340
  }
232
341
  }
@@ -1,11 +1,14 @@
1
1
  /*
2
- * modal_stack — Tailwind preset
2
+ * modal_stack — Tailwind v3 preset
3
+ *
4
+ * Tailwind v3 doesn't expose its design tokens as native CSS variables,
5
+ * so this preset ships static values aligned with the Tailwind defaults
6
+ * (slate text, white surface, rounded-2xl-ish radius, container sizes).
7
+ * Override the `--modal-stack-*` tokens on `:root` to retheme without
8
+ * touching the gem.
3
9
  *
4
10
  * Structural CSS for the <dialog id="modal-stack-root"> + layered
5
11
  * `[data-modal-stack-target="layer"]` setup driven by the JS runtime.
6
- * Visual tokens are exposed as CSS custom properties on `:root` so they
7
- * can be overridden globally, per scope, or via Tailwind's
8
- * `:root { --modal-stack-* }` declaration.
9
12
  *
10
13
  * Variants: [data-variant="modal" | "drawer" | "bottom_sheet" | "confirmation"]
11
14
  * Drawer side: [data-side="left" | "right" | "top" | "bottom"]
@@ -27,7 +30,13 @@
27
30
  --modal-stack-fg: #0f172a;
28
31
  --modal-stack-shadow: 0 30px 60px -20px rgba(15, 23, 42, 0.35);
29
32
  --modal-stack-backdrop: rgba(15, 23, 42, 0.55);
30
- --modal-stack-backdrop-blur: 2px;
33
+ /* `none` by default — `backdrop-filter: blur()` even at radius 0
34
+ * still allocates a filter layer on the compositor, and animating
35
+ * a non-zero radius costs ~190ms/frame on Hi-DPI displays. Opt in
36
+ * with `:root { --modal-stack-backdrop-filter: blur(8px); }` —
37
+ * the filter is applied statically when the dialog opens (no
38
+ * per-frame compositor cost). */
39
+ --modal-stack-backdrop-filter: none;
31
40
  --modal-stack-panel-padding: 24px;
32
41
  --modal-stack-size-sm: 24rem; /* 384px */
33
42
  --modal-stack-size-md: 34rem; /* 544px */
@@ -79,23 +88,23 @@ body[data-modal-stack-locked] {
79
88
 
80
89
  #modal-stack-root::backdrop {
81
90
  background: rgba(15, 23, 42, 0);
82
- backdrop-filter: blur(0);
83
91
  transition:
84
92
  background var(--modal-stack-duration) var(--modal-stack-ease),
85
- backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
86
93
  overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
87
94
  display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
88
95
  }
89
96
 
90
97
  #modal-stack-root[open]::backdrop {
91
98
  background: var(--modal-stack-backdrop);
92
- backdrop-filter: blur(var(--modal-stack-backdrop-blur));
99
+ /* Static (not in the transition list above). Default is `none` so
100
+ * Chrome skips the filter pass entirely. Override --modal-stack-
101
+ * backdrop-filter to opt in. */
102
+ backdrop-filter: var(--modal-stack-backdrop-filter);
93
103
  }
94
104
 
95
105
  @starting-style {
96
106
  #modal-stack-root[open]::backdrop {
97
107
  background: rgba(15, 23, 42, 0);
98
- backdrop-filter: blur(0);
99
108
  }
100
109
  }
101
110
 
@@ -109,6 +118,7 @@ body[data-modal-stack-locked] {
109
118
  max-width: 92vw;
110
119
  max-height: min(85vh, 720px);
111
120
  overflow-y: auto;
121
+ overscroll-behavior: contain;
112
122
  background: var(--modal-stack-bg);
113
123
  color: var(--modal-stack-fg);
114
124
  border-radius: var(--modal-stack-radius);
@@ -117,8 +127,7 @@ body[data-modal-stack-locked] {
117
127
  transform: translate(-50%, -50%);
118
128
  transition:
119
129
  transform var(--modal-stack-duration) var(--modal-stack-ease),
120
- opacity var(--modal-stack-duration) var(--modal-stack-ease),
121
- filter var(--modal-stack-duration) var(--modal-stack-ease);
130
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
122
131
  }
123
132
 
124
133
  @starting-style {
@@ -130,13 +139,12 @@ body[data-modal-stack-locked] {
130
139
 
131
140
  [data-modal-stack-target="layer"][data-leaving] {
132
141
  opacity: 0;
133
- transform: translate(-50%, -56%) scale(0.97);
142
+ transform: translate(-50%, -44%) scale(0.97);
134
143
  pointer-events: none;
135
144
  }
136
145
 
137
146
  [data-modal-stack-target="layer"][inert] {
138
147
  opacity: 0.5;
139
- filter: blur(0.5px);
140
148
  }
141
149
 
142
150
  /* --- Sizes via [data-modal-stack-size] ----------------------------- */
@@ -212,6 +220,7 @@ body[data-modal-stack-locked] {
212
220
  max-height: 85vh;
213
221
  transform: translateY(0);
214
222
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
223
+ padding-bottom: env(safe-area-inset-bottom, 0);
215
224
  }
216
225
 
217
226
  @starting-style {
@@ -264,6 +273,7 @@ body[data-modal-stack-locked] {
264
273
  transform: translate(0, 0);
265
274
  border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
266
275
  max-height: 90vh;
276
+ padding-bottom: env(safe-area-inset-bottom, 0);
267
277
  }
268
278
 
269
279
  @starting-style {
@@ -292,12 +302,70 @@ body[data-modal-stack-locked] {
292
302
  gap: 14px;
293
303
  }
294
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
+
295
362
  /* --- Reduced motion ---------------------------------------------- */
296
363
 
297
364
  @media (prefers-reduced-motion: reduce) {
298
365
  #modal-stack-root,
299
366
  #modal-stack-root::backdrop,
300
- [data-modal-stack-target="layer"] {
367
+ [data-modal-stack-target="layer"],
368
+ [data-modal-stack-frame][data-transition] {
301
369
  transition-duration: 1ms !important;
302
370
  }
303
371
  }