modal_stack 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,311 @@
1
+ /*
2
+ * modal_stack — Tailwind v4 preset
3
+ *
4
+ * Tailwind v4 exposes its design tokens as native CSS variables via the
5
+ * `@theme` directive (`--color-*`, `--radius-*`, `--shadow-*`,
6
+ * `--ease-*`, `--container-*`, …). This preset chains on those so the
7
+ * modal stack picks up your theme automatically. Fallbacks match the
8
+ * Tailwind v4 defaults, so the preset still renders correctly when a
9
+ * project hasn't redefined `@theme` (or isn't running v4 yet).
10
+ *
11
+ * Override `--modal-stack-*` on `:root` for modal-specific tweaks
12
+ * without touching the gem.
13
+ *
14
+ * Variants: [data-variant="modal" | "drawer" | "bottom_sheet" | "confirmation"]
15
+ * Drawer side: [data-side="left" | "right" | "top" | "bottom"]
16
+ * Sizes: [data-modal-stack-size="sm" | "md" | "lg" | "xl"]
17
+ *
18
+ * Entry / exit transitions:
19
+ * - @starting-style + transition-behavior: allow-discrete (Chrome 117+,
20
+ * Safari 17.4+, Firefox 129+).
21
+ * - The runtime sets [data-leaving] before unmounting a layer so the
22
+ * same transition rules apply on exit.
23
+ */
24
+
25
+ :root {
26
+ --modal-stack-z-base: 1000;
27
+ --modal-stack-duration: 220ms;
28
+ --modal-stack-ease: var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1));
29
+ --modal-stack-radius: var(--radius-2xl, 14px);
30
+ --modal-stack-bg: var(--color-white, #ffffff);
31
+ --modal-stack-fg: var(--color-slate-900, #0f172a);
32
+ --modal-stack-shadow: var(--shadow-2xl, 0 30px 60px -20px rgba(15, 23, 42, 0.35));
33
+ --modal-stack-backdrop: rgba(15, 23, 42, 0.55);
34
+ /* `none` by default — `backdrop-filter: blur()` even at radius 0
35
+ * still allocates a filter layer on the compositor, and animating
36
+ * a non-zero radius costs ~190ms/frame on Hi-DPI displays. Opt in
37
+ * with `:root { --modal-stack-backdrop-filter: blur(8px); }` —
38
+ * the filter is applied statically when the dialog opens (no
39
+ * per-frame compositor cost). */
40
+ --modal-stack-backdrop-filter: none;
41
+ --modal-stack-panel-padding: 24px;
42
+ --modal-stack-size-sm: var(--container-sm, 24rem); /* 384px */
43
+ --modal-stack-size-md: 34rem; /* 544px — no v4 container alias */
44
+ --modal-stack-size-lg: var(--container-3xl, 48rem); /* 768px */
45
+ --modal-stack-size-xl: var(--container-5xl, 64rem); /* 1024px */
46
+ --modal-stack-drawer-width: var(--container-md, 28rem); /* 448px */
47
+ --modal-stack-drawer-height: 22rem; /* 352px */
48
+ }
49
+
50
+ /* --- Body lock when the stack is open ------------------------------- */
51
+
52
+ body[data-modal-stack-locked] {
53
+ overflow: hidden;
54
+ /* Compensate for the scrollbar width to avoid a layout shift. */
55
+ padding-right: var(--modal-stack-scrollbar-width, 0);
56
+ }
57
+
58
+ /* --- The single dialog root ---------------------------------------- */
59
+
60
+ #modal-stack-root {
61
+ position: fixed;
62
+ inset: 0;
63
+ width: 100vw;
64
+ height: 100vh;
65
+ margin: 0;
66
+ padding: 0;
67
+ border: 0;
68
+ background: transparent;
69
+ max-width: none;
70
+ max-height: none;
71
+ overflow: visible;
72
+ z-index: var(--modal-stack-z-base);
73
+ opacity: 0;
74
+ transition:
75
+ opacity var(--modal-stack-duration) var(--modal-stack-ease),
76
+ overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
77
+ display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
78
+ }
79
+
80
+ #modal-stack-root[open] {
81
+ opacity: 1;
82
+ }
83
+
84
+ @starting-style {
85
+ #modal-stack-root[open] {
86
+ opacity: 0;
87
+ }
88
+ }
89
+
90
+ #modal-stack-root::backdrop {
91
+ background: rgba(15, 23, 42, 0);
92
+ transition:
93
+ background var(--modal-stack-duration) var(--modal-stack-ease),
94
+ overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
95
+ display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
96
+ }
97
+
98
+ #modal-stack-root[open]::backdrop {
99
+ background: var(--modal-stack-backdrop);
100
+ /* Static (not in the transition list above). Default is `none` so
101
+ * Chrome skips the filter pass entirely. Override --modal-stack-
102
+ * backdrop-filter to opt in. */
103
+ backdrop-filter: var(--modal-stack-backdrop-filter);
104
+ }
105
+
106
+ @starting-style {
107
+ #modal-stack-root[open]::backdrop {
108
+ background: rgba(15, 23, 42, 0);
109
+ }
110
+ }
111
+
112
+ /* --- Layer (one per modal, vertically stacked Z-axis) -------------- */
113
+
114
+ [data-modal-stack-target="layer"] {
115
+ position: absolute;
116
+ top: 50%;
117
+ left: 50%;
118
+ width: var(--modal-stack-size-md);
119
+ max-width: 92vw;
120
+ max-height: min(85vh, 720px);
121
+ overflow-y: auto;
122
+ background: var(--modal-stack-bg);
123
+ color: var(--modal-stack-fg);
124
+ border-radius: var(--modal-stack-radius);
125
+ box-shadow: var(--modal-stack-shadow);
126
+ padding: 0;
127
+ transform: translate(-50%, -50%);
128
+ transition:
129
+ transform var(--modal-stack-duration) var(--modal-stack-ease),
130
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
131
+ }
132
+
133
+ @starting-style {
134
+ [data-modal-stack-target="layer"] {
135
+ opacity: 0;
136
+ transform: translate(-50%, -44%) scale(0.97);
137
+ }
138
+ }
139
+
140
+ [data-modal-stack-target="layer"][data-leaving] {
141
+ opacity: 0;
142
+ transform: translate(-50%, -56%) scale(0.97);
143
+ pointer-events: none;
144
+ }
145
+
146
+ [data-modal-stack-target="layer"][inert] {
147
+ opacity: 0.5;
148
+ }
149
+
150
+ /* --- Sizes via [data-modal-stack-size] ----------------------------- */
151
+
152
+ [data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
153
+ [data-modal-stack-target="layer"][data-modal-stack-size="md"] { width: var(--modal-stack-size-md); }
154
+ [data-modal-stack-target="layer"][data-modal-stack-size="lg"] { width: var(--modal-stack-size-lg); }
155
+ [data-modal-stack-target="layer"][data-modal-stack-size="xl"] { width: var(--modal-stack-size-xl); }
156
+
157
+ /* --- Subtle depth offset for stacked layers ----------------------- */
158
+
159
+ [data-modal-stack-target="layer"][data-depth="2"] {
160
+ transform: translate(-50%, -53%) scale(1.015);
161
+ }
162
+
163
+ [data-modal-stack-target="layer"][data-depth="3"] {
164
+ transform: translate(-50%, -56%) scale(1.03);
165
+ }
166
+
167
+ [data-modal-stack-target="layer"][data-depth="4"] {
168
+ transform: translate(-50%, -59%) scale(1.045);
169
+ }
170
+
171
+ /* --- Drawer ------------------------------------------------------- */
172
+
173
+ [data-modal-stack-target="layer"][data-variant="drawer"] {
174
+ left: 0;
175
+ top: 0;
176
+ max-width: 100vw;
177
+ max-height: 100vh;
178
+ }
179
+
180
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"],
181
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
182
+ top: 0;
183
+ height: 100vh;
184
+ max-height: none;
185
+ width: var(--modal-stack-drawer-width);
186
+ max-width: 90vw;
187
+ }
188
+
189
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
190
+ left: auto;
191
+ right: 0;
192
+ transform: translateX(0);
193
+ border-radius: var(--modal-stack-radius) 0 0 var(--modal-stack-radius);
194
+ }
195
+
196
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
197
+ left: 0;
198
+ transform: translateX(0);
199
+ border-radius: 0 var(--modal-stack-radius) var(--modal-stack-radius) 0;
200
+ }
201
+
202
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
203
+ left: 0;
204
+ top: 0;
205
+ width: 100vw;
206
+ max-width: none;
207
+ height: var(--modal-stack-drawer-height);
208
+ max-height: 85vh;
209
+ transform: translateY(0);
210
+ border-radius: 0 0 var(--modal-stack-radius) var(--modal-stack-radius);
211
+ }
212
+
213
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
214
+ left: 0;
215
+ top: auto;
216
+ bottom: 0;
217
+ width: 100vw;
218
+ max-width: none;
219
+ height: var(--modal-stack-drawer-height);
220
+ max-height: 85vh;
221
+ transform: translateY(0);
222
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
223
+ }
224
+
225
+ @starting-style {
226
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"] {
227
+ opacity: 0;
228
+ transform: translateX(100%);
229
+ }
230
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"] {
231
+ opacity: 0;
232
+ transform: translateX(-100%);
233
+ }
234
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"] {
235
+ opacity: 0;
236
+ transform: translateY(-100%);
237
+ }
238
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"] {
239
+ opacity: 0;
240
+ transform: translateY(100%);
241
+ }
242
+ }
243
+
244
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="right"][data-leaving] {
245
+ opacity: 0;
246
+ transform: translateX(100%);
247
+ }
248
+
249
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="left"][data-leaving] {
250
+ opacity: 0;
251
+ transform: translateX(-100%);
252
+ }
253
+
254
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="top"][data-leaving] {
255
+ opacity: 0;
256
+ transform: translateY(-100%);
257
+ }
258
+
259
+ [data-modal-stack-target="layer"][data-variant="drawer"][data-side="bottom"][data-leaving] {
260
+ opacity: 0;
261
+ transform: translateY(100%);
262
+ }
263
+
264
+ /* --- Bottom sheet ------------------------------------------------- */
265
+
266
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
267
+ top: auto;
268
+ bottom: 0;
269
+ left: 0;
270
+ width: 100vw;
271
+ max-width: none;
272
+ transform: translate(0, 0);
273
+ border-radius: var(--modal-stack-radius) var(--modal-stack-radius) 0 0;
274
+ max-height: 90vh;
275
+ }
276
+
277
+ @starting-style {
278
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"] {
279
+ opacity: 0;
280
+ transform: translateY(100%);
281
+ }
282
+ }
283
+
284
+ [data-modal-stack-target="layer"][data-variant="bottom_sheet"][data-leaving] {
285
+ opacity: 0;
286
+ transform: translateY(100%);
287
+ }
288
+
289
+ /* --- Confirmation ------------------------------------------------- */
290
+
291
+ [data-modal-stack-target="layer"][data-variant="confirmation"] {
292
+ width: var(--modal-stack-size-sm);
293
+ }
294
+
295
+ /* --- Panel slot (the modal_stack_container output) --------------- */
296
+
297
+ .modal-stack__panel {
298
+ padding: var(--modal-stack-panel-padding);
299
+ display: grid;
300
+ gap: 14px;
301
+ }
302
+
303
+ /* --- Reduced motion ---------------------------------------------- */
304
+
305
+ @media (prefers-reduced-motion: reduce) {
306
+ #modal-stack-root,
307
+ #modal-stack-root::backdrop,
308
+ [data-modal-stack-target="layer"] {
309
+ transition-duration: 1ms !important;
310
+ }
311
+ }
@@ -18,7 +18,9 @@
18
18
  --modal-stack-fg: #1a1a1a;
19
19
  --modal-stack-shadow: 0 24px 60px -16px rgba(0, 0, 0, 0.32);
20
20
  --modal-stack-backdrop: rgba(0, 0, 0, 0.5);
21
- --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;
@@ -60,23 +62,22 @@ body[data-modal-stack-locked] {
60
62
 
61
63
  #modal-stack-root::backdrop {
62
64
  background: rgba(0, 0, 0, 0);
63
- backdrop-filter: blur(0);
64
65
  transition:
65
66
  background var(--modal-stack-duration) var(--modal-stack-ease),
66
- backdrop-filter var(--modal-stack-duration) var(--modal-stack-ease),
67
67
  overlay var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete,
68
68
  display var(--modal-stack-duration) var(--modal-stack-ease) allow-discrete;
69
69
  }
70
70
 
71
71
  #modal-stack-root[open]::backdrop {
72
72
  background: var(--modal-stack-backdrop);
73
- backdrop-filter: blur(var(--modal-stack-backdrop-blur));
73
+ /* Static. Default is `none` (no filter pass). Opt in with
74
+ * --modal-stack-backdrop-filter on :root. */
75
+ backdrop-filter: var(--modal-stack-backdrop-filter);
74
76
  }
75
77
 
76
78
  @starting-style {
77
79
  #modal-stack-root[open]::backdrop {
78
80
  background: rgba(0, 0, 0, 0);
79
- backdrop-filter: blur(0);
80
81
  }
81
82
  }
82
83
 
@@ -96,8 +97,7 @@ body[data-modal-stack-locked] {
96
97
  transform: translate(-50%, -50%);
97
98
  transition:
98
99
  transform var(--modal-stack-duration) var(--modal-stack-ease),
99
- opacity var(--modal-stack-duration) var(--modal-stack-ease),
100
- filter var(--modal-stack-duration) var(--modal-stack-ease);
100
+ opacity var(--modal-stack-duration) var(--modal-stack-ease);
101
101
  }
102
102
 
103
103
  @starting-style {
@@ -115,7 +115,6 @@ body[data-modal-stack-locked] {
115
115
 
116
116
  [data-modal-stack-target="layer"][inert] {
117
117
  opacity: 0.5;
118
- filter: blur(0.5px);
119
118
  }
120
119
 
121
120
  [data-modal-stack-target="layer"][data-modal-stack-size="sm"] { width: var(--modal-stack-size-sm); }
@@ -6,6 +6,8 @@ export class ModalStackController extends Controller {
6
6
  static values = {
7
7
  stackId: String,
8
8
  baseUrl: String,
9
+ maxDepth: { type: Number, default: 0 },
10
+ maxDepthStrategy: { type: String, default: "warn" },
9
11
  };
10
12
 
11
13
  connect() {
@@ -20,6 +22,10 @@ export class ModalStackController extends Controller {
20
22
  stackId,
21
23
  baseUrl,
22
24
  restoreFrom: snapshot,
25
+ // Stimulus Number values default to 0, but state.js treats null as
26
+ // "no cap" — so map 0/missing to null here.
27
+ maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
28
+ maxDepthStrategy: this.maxDepthStrategyValue || "warn",
23
29
  });
24
30
 
25
31
  this._onPopstate = (event) =>
@@ -73,6 +79,10 @@ export class ModalStackController extends Controller {
73
79
  return this.orchestrator.closeAll();
74
80
  }
75
81
 
82
+ prefetch(url) {
83
+ return this.orchestrator.prefetch(url);
84
+ }
85
+
76
86
  #topLayer() {
77
87
  const layers = this.orchestrator.layers;
78
88
  return layers[layers.length - 1] ?? null;
@@ -89,28 +99,57 @@ export class ModalStackController extends Controller {
89
99
  }
90
100
  const StreamActions = Turbo.StreamActions || (Turbo.StreamActions = {});
91
101
  const orchestrator = this.orchestrator;
92
-
93
- StreamActions.modal_push = function modalPush() {
94
- orchestrator.push(layerFromStreamElement(this), {
102
+ const dialog = this.element;
103
+
104
+ // Wraps a stream-action body so a malformed payload (bad data-*, fetch
105
+ // 500, etc.) doesn't bubble up and break the page. The error is logged
106
+ // and re-emitted as `modal_stack:error` so apps can surface UI feedback.
107
+ const guarded = (action, fn) =>
108
+ function guardedStreamAction() {
109
+ try {
110
+ const result = fn.call(this, orchestrator);
111
+ if (result && typeof result.catch === "function") {
112
+ result.catch((err) => emitStreamError(dialog, action, err));
113
+ }
114
+ } catch (err) {
115
+ emitStreamError(dialog, action, err);
116
+ }
117
+ };
118
+
119
+ StreamActions.modal_push = guarded("modal_push", function (orch) {
120
+ return orch.push(layerFromStreamElement(this), {
95
121
  fragment: this.templateContent.cloneNode(true),
96
122
  });
97
- };
123
+ });
98
124
 
99
- StreamActions.modal_pop = function modalPop() {
100
- orchestrator.pop();
101
- };
125
+ StreamActions.modal_pop = guarded("modal_pop", function (orch) {
126
+ return orch.pop();
127
+ });
102
128
 
103
- StreamActions.modal_replace = function modalReplace() {
104
- orchestrator.replaceTop(layerPatchFromStreamElement(this), {
129
+ StreamActions.modal_replace = guarded("modal_replace", function (orch) {
130
+ return orch.replaceTop(layerPatchFromStreamElement(this), {
105
131
  fragment: this.templateContent.cloneNode(true),
106
132
  historyMode: this.dataset.historyMode || "replace",
107
133
  });
108
- };
134
+ });
109
135
 
110
- StreamActions.modal_close_all = function modalCloseAll() {
111
- orchestrator.closeAll();
112
- };
136
+ StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
137
+ return orch.closeAll();
138
+ });
139
+ }
140
+ }
141
+
142
+ function emitStreamError(dialog, action, error) {
143
+ if (typeof console !== "undefined" && console.error) {
144
+ console.error(`[modal_stack] stream action "${action}" failed:`, error);
113
145
  }
146
+ dialog.dispatchEvent(
147
+ new CustomEvent("modal_stack:error", {
148
+ bubbles: true,
149
+ cancelable: false,
150
+ detail: { action, error },
151
+ }),
152
+ );
114
153
  }
115
154
 
116
155
  function layerFromStreamElement(el) {
@@ -1,14 +1,21 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export class ModalStackLinkController extends Controller {
4
- open(event) {
5
- const stack = document.querySelector('[data-controller~="modal-stack"]');
6
- if (!stack) return;
4
+ connect() {
5
+ if (this.element.dataset.modalStackLinkPrefetch === "false") return;
6
+ this._onIntent = () => this.#warm();
7
+ this.element.addEventListener("pointerenter", this._onIntent);
8
+ this.element.addEventListener("focus", this._onIntent);
9
+ }
7
10
 
8
- const controller = this.application.getControllerForElementAndIdentifier(
9
- stack,
10
- "modal-stack",
11
- );
11
+ disconnect() {
12
+ if (!this._onIntent) return;
13
+ this.element.removeEventListener("pointerenter", this._onIntent);
14
+ this.element.removeEventListener("focus", this._onIntent);
15
+ }
16
+
17
+ open(event) {
18
+ const controller = this.#stackController();
12
19
  if (!controller) return;
13
20
 
14
21
  event.preventDefault();
@@ -24,6 +31,21 @@ export class ModalStackLinkController extends Controller {
24
31
  dismissible: ds.modalStackLinkDismissible !== "false",
25
32
  });
26
33
  }
34
+
35
+ #warm() {
36
+ const controller = this.#stackController();
37
+ if (!controller || typeof controller.prefetch !== "function") return;
38
+ controller.prefetch(this.element.href);
39
+ }
40
+
41
+ #stackController() {
42
+ const stack = document.querySelector('[data-controller~="modal-stack"]');
43
+ if (!stack) return null;
44
+ return this.application.getControllerForElementAndIdentifier(
45
+ stack,
46
+ "modal-stack",
47
+ );
48
+ }
27
49
  }
28
50
 
29
51
  function generateLayerId() {