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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +136 -52
- data/app/assets/javascripts/modal_stack.js +612 -63
- data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +54 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +132 -3
- data/app/javascript/modal_stack/orchestrator.test.js +264 -2
- data/app/javascript/modal_stack/runtime.js +222 -13
- data/app/javascript/modal_stack/runtime.test.js +151 -0
- data/app/javascript/modal_stack/state.js +338 -39
- data/app/javascript/modal_stack/state.test.js +400 -13
- data/app/views/modal_stack/_dialog.html.erb +1 -0
- data/app/views/modal_stack/_panel.html.erb +4 -0
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
- data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
- data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
- data/lib/generators/modal_stack/views/views_generator.rb +50 -0
- data/lib/modal_stack/capybara.rb +21 -0
- data/lib/modal_stack/configuration.rb +43 -17
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/engine.rb +2 -0
- data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
- data/lib/modal_stack/turbo_streams_extension.rb +56 -0
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +5 -1
- 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
|
-
|
|
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
|
-
|
|
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%, -
|
|
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
|
+
}
|