modal_stack 0.1.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 +7 -0
- data/CHANGELOG.md +37 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +748 -0
- data/Rakefile +12 -0
- data/app/assets/javascripts/modal_stack.js +756 -0
- data/app/assets/stylesheets/modal_stack/bootstrap.css +232 -0
- data/app/assets/stylesheets/modal_stack/tailwind.css +303 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +219 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +149 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +34 -0
- data/app/javascript/modal_stack/index.js +15 -0
- data/app/javascript/modal_stack/install.js +15 -0
- data/app/javascript/modal_stack/orchestrator.js +98 -0
- data/app/javascript/modal_stack/orchestrator.test.js +260 -0
- data/app/javascript/modal_stack/runtime.js +217 -0
- data/app/javascript/modal_stack/runtime.test.js +134 -0
- data/app/javascript/modal_stack/state.js +315 -0
- data/app/javascript/modal_stack/state.test.js +508 -0
- data/app/views/layouts/modal.html.erb +6 -0
- data/lib/generators/modal_stack/install/install_generator.rb +224 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +57 -0
- data/lib/modal_stack/capybara/minitest.rb +9 -0
- data/lib/modal_stack/capybara/rspec.rb +9 -0
- data/lib/modal_stack/capybara.rb +85 -0
- data/lib/modal_stack/configuration.rb +90 -0
- data/lib/modal_stack/controller_extensions.rb +73 -0
- data/lib/modal_stack/engine.rb +44 -0
- data/lib/modal_stack/helpers/modal_link_helper.rb +65 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +45 -0
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +36 -0
- data/lib/modal_stack/initializer_version_check.rb +33 -0
- data/lib/modal_stack/turbo_streams_extension.rb +73 -0
- data/lib/modal_stack/version.rb +5 -0
- data/lib/modal_stack.rb +36 -0
- metadata +130 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
export const VARIANTS = Object.freeze([
|
|
2
|
+
"modal",
|
|
3
|
+
"drawer",
|
|
4
|
+
"bottom_sheet",
|
|
5
|
+
"confirmation",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const SNAPSHOT_VERSION = 1;
|
|
9
|
+
const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
|
|
10
|
+
const DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
|
|
11
|
+
|
|
12
|
+
function normalizeLayerOptions({ variant, size, side, width, height }) {
|
|
13
|
+
// A drawer must always carry a side so CSS can position it.
|
|
14
|
+
const normalizedSide = variant === "drawer" ? (side ?? "right") : (side ?? null);
|
|
15
|
+
if (variant === "drawer" && !DRAWER_SIDES.includes(normalizedSide)) {
|
|
16
|
+
throw new Error(`unknown drawer side: ${normalizedSide}`);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
size: size ?? null,
|
|
20
|
+
side: normalizedSide,
|
|
21
|
+
width: width ?? null,
|
|
22
|
+
height: height ?? null,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function freezeLayer({ id, url, variant, dismissible, size, side, width, height }) {
|
|
27
|
+
const normalized = normalizeLayerOptions({ variant, size, side, width, height });
|
|
28
|
+
return Object.freeze({
|
|
29
|
+
id,
|
|
30
|
+
url,
|
|
31
|
+
variant,
|
|
32
|
+
dismissible: !!dismissible,
|
|
33
|
+
size: normalized.size,
|
|
34
|
+
side: normalized.side,
|
|
35
|
+
width: normalized.width,
|
|
36
|
+
height: normalized.height,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createStack({ stackId, baseUrl }) {
|
|
41
|
+
if (!stackId) throw new Error("stackId required");
|
|
42
|
+
if (!baseUrl) throw new Error("baseUrl required");
|
|
43
|
+
return Object.freeze({ stackId, baseUrl, layers: Object.freeze([]) });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function topLayer(state) {
|
|
47
|
+
return state.layers[state.layers.length - 1] ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function push(state, layer) {
|
|
51
|
+
if (!layer?.id) throw new Error("layer.id required");
|
|
52
|
+
if (!layer?.url) throw new Error("layer.url required");
|
|
53
|
+
const variant = layer.variant ?? "modal";
|
|
54
|
+
if (!VARIANTS.includes(variant)) {
|
|
55
|
+
throw new Error(`unknown variant: ${variant}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const newLayer = freezeLayer({
|
|
59
|
+
id: layer.id,
|
|
60
|
+
url: layer.url,
|
|
61
|
+
variant,
|
|
62
|
+
dismissible: layer.dismissible ?? true,
|
|
63
|
+
size: layer.size,
|
|
64
|
+
side: layer.side,
|
|
65
|
+
width: layer.width,
|
|
66
|
+
height: layer.height,
|
|
67
|
+
});
|
|
68
|
+
const previousTop = topLayer(state);
|
|
69
|
+
const layers = Object.freeze([...state.layers, newLayer]);
|
|
70
|
+
const depth = layers.length;
|
|
71
|
+
|
|
72
|
+
// mountLayer runs first so the dialog (or the previous layer) doesn't
|
|
73
|
+
// flash an empty / interactive intermediate state while we're still
|
|
74
|
+
// loading the new content. When the orchestrator has pre-fetched the
|
|
75
|
+
// fragment, mountLayer is a sync DOM append.
|
|
76
|
+
const commands = [];
|
|
77
|
+
commands.push({
|
|
78
|
+
type: "mountLayer",
|
|
79
|
+
layerId: newLayer.id,
|
|
80
|
+
url: newLayer.url,
|
|
81
|
+
depth,
|
|
82
|
+
variant: newLayer.variant,
|
|
83
|
+
dismissible: newLayer.dismissible,
|
|
84
|
+
...(newLayer.size ? { size: newLayer.size } : {}),
|
|
85
|
+
...(newLayer.side ? { side: newLayer.side } : {}),
|
|
86
|
+
...(newLayer.width ? { width: newLayer.width } : {}),
|
|
87
|
+
...(newLayer.height ? { height: newLayer.height } : {}),
|
|
88
|
+
});
|
|
89
|
+
if (depth === 1) {
|
|
90
|
+
commands.push({ type: "showDialog" });
|
|
91
|
+
commands.push({ type: "lockScroll" });
|
|
92
|
+
} else {
|
|
93
|
+
commands.push({ type: "inertLayer", layerId: previousTop.id, value: true });
|
|
94
|
+
}
|
|
95
|
+
commands.push({
|
|
96
|
+
type: "pushHistory",
|
|
97
|
+
url: newLayer.url,
|
|
98
|
+
historyState: { stackId: state.stackId, layerId: newLayer.id, depth },
|
|
99
|
+
});
|
|
100
|
+
commands.push({ type: "persistSnapshot" });
|
|
101
|
+
|
|
102
|
+
return { state: { ...state, layers }, commands };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function pop(state) {
|
|
106
|
+
if (state.layers.length === 0) return { state, commands: [] };
|
|
107
|
+
|
|
108
|
+
const newLayers = Object.freeze(state.layers.slice(0, -1));
|
|
109
|
+
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
110
|
+
const commands = [
|
|
111
|
+
{ type: "unmountTopLayer" },
|
|
112
|
+
{ type: "historyBack", n: 1 },
|
|
113
|
+
];
|
|
114
|
+
if (newTop) {
|
|
115
|
+
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
116
|
+
commands.push({ type: "persistSnapshot" });
|
|
117
|
+
} else {
|
|
118
|
+
commands.push({ type: "closeDialog" });
|
|
119
|
+
commands.push({ type: "unlockScroll" });
|
|
120
|
+
commands.push({ type: "clearSnapshot" });
|
|
121
|
+
}
|
|
122
|
+
return { state: { ...state, layers: newLayers }, commands };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
|
|
126
|
+
if (state.layers.length === 0) {
|
|
127
|
+
throw new Error("replaceTop requires at least one layer");
|
|
128
|
+
}
|
|
129
|
+
if (historyMode !== "push" && historyMode !== "replace") {
|
|
130
|
+
throw new Error(`unknown historyMode: ${historyMode}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const top = topLayer(state);
|
|
134
|
+
const next = freezeLayer({
|
|
135
|
+
id: patch.id ?? top.id,
|
|
136
|
+
url: patch.url ?? top.url,
|
|
137
|
+
variant: patch.variant ?? top.variant,
|
|
138
|
+
dismissible: patch.dismissible ?? top.dismissible,
|
|
139
|
+
size: patch.size ?? top.size,
|
|
140
|
+
side: patch.side ?? top.side,
|
|
141
|
+
width: patch.width ?? top.width,
|
|
142
|
+
height: patch.height ?? top.height,
|
|
143
|
+
});
|
|
144
|
+
const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
|
|
145
|
+
const depth = newLayers.length;
|
|
146
|
+
|
|
147
|
+
const historyCmd = {
|
|
148
|
+
type: historyMode === "push" ? "pushHistory" : "replaceHistory",
|
|
149
|
+
url: next.url,
|
|
150
|
+
historyState: { stackId: state.stackId, layerId: next.id, depth },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
state: { ...state, layers: newLayers },
|
|
155
|
+
commands: [
|
|
156
|
+
{
|
|
157
|
+
type: "morphTopLayer",
|
|
158
|
+
layerId: next.id,
|
|
159
|
+
url: next.url,
|
|
160
|
+
depth,
|
|
161
|
+
variant: next.variant,
|
|
162
|
+
dismissible: next.dismissible,
|
|
163
|
+
...(next.size ? { size: next.size } : {}),
|
|
164
|
+
...(next.side ? { side: next.side } : {}),
|
|
165
|
+
...(next.width ? { width: next.width } : {}),
|
|
166
|
+
...(next.height ? { height: next.height } : {}),
|
|
167
|
+
},
|
|
168
|
+
historyCmd,
|
|
169
|
+
{ type: "persistSnapshot" },
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function closeAll(state) {
|
|
175
|
+
if (state.layers.length === 0) return { state, commands: [] };
|
|
176
|
+
const n = state.layers.length;
|
|
177
|
+
return {
|
|
178
|
+
state: { ...state, layers: Object.freeze([]) },
|
|
179
|
+
commands: [
|
|
180
|
+
{ type: "unmountAllLayers" },
|
|
181
|
+
{ type: "closeDialog" },
|
|
182
|
+
{ type: "unlockScroll" },
|
|
183
|
+
{ type: "historyBack", n },
|
|
184
|
+
{ type: "clearSnapshot" },
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function handlePopstate(state, { historyState, locationHref }) {
|
|
190
|
+
const isOurs =
|
|
191
|
+
historyState && historyState.stackId === state.stackId;
|
|
192
|
+
|
|
193
|
+
if (!isOurs) {
|
|
194
|
+
if (state.layers.length === 0) return { state, commands: [] };
|
|
195
|
+
return {
|
|
196
|
+
state: { ...state, layers: Object.freeze([]) },
|
|
197
|
+
commands: [
|
|
198
|
+
{ type: "unmountAllLayers" },
|
|
199
|
+
{ type: "closeDialog" },
|
|
200
|
+
{ type: "unlockScroll" },
|
|
201
|
+
{ type: "clearSnapshot" },
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const targetDepth = historyState.depth ?? 0;
|
|
207
|
+
const currentDepth = state.layers.length;
|
|
208
|
+
const targetLayerId = historyState.layerId ?? null;
|
|
209
|
+
|
|
210
|
+
if (targetDepth < currentDepth) {
|
|
211
|
+
const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
|
|
212
|
+
const newTop = newLayers[newLayers.length - 1] ?? null;
|
|
213
|
+
const commands = [];
|
|
214
|
+
for (let i = 0; i < currentDepth - targetDepth; i++) {
|
|
215
|
+
commands.push({ type: "unmountTopLayer" });
|
|
216
|
+
}
|
|
217
|
+
if (newTop) {
|
|
218
|
+
commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
|
|
219
|
+
commands.push({ type: "persistSnapshot" });
|
|
220
|
+
} else {
|
|
221
|
+
commands.push({ type: "closeDialog" });
|
|
222
|
+
commands.push({ type: "unlockScroll" });
|
|
223
|
+
commands.push({ type: "clearSnapshot" });
|
|
224
|
+
}
|
|
225
|
+
return { state: { ...state, layers: newLayers }, commands };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (targetDepth > currentDepth) {
|
|
229
|
+
return {
|
|
230
|
+
state,
|
|
231
|
+
commands: [
|
|
232
|
+
{ type: "rebuildFromSnapshot", targetDepth, targetLayerId },
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const top = topLayer(state);
|
|
238
|
+
if (top && targetLayerId && top.id !== targetLayerId) {
|
|
239
|
+
const updatedTop = freezeLayer({
|
|
240
|
+
id: targetLayerId,
|
|
241
|
+
url: locationHref ?? top.url,
|
|
242
|
+
variant: top.variant,
|
|
243
|
+
dismissible: top.dismissible,
|
|
244
|
+
size: top.size,
|
|
245
|
+
side: top.side,
|
|
246
|
+
width: top.width,
|
|
247
|
+
height: top.height,
|
|
248
|
+
});
|
|
249
|
+
const newLayers = Object.freeze([
|
|
250
|
+
...state.layers.slice(0, -1),
|
|
251
|
+
updatedTop,
|
|
252
|
+
]);
|
|
253
|
+
return {
|
|
254
|
+
state: { ...state, layers: newLayers },
|
|
255
|
+
commands: [
|
|
256
|
+
{
|
|
257
|
+
type: "morphTopLayer",
|
|
258
|
+
layerId: targetLayerId,
|
|
259
|
+
url: updatedTop.url,
|
|
260
|
+
depth: currentDepth,
|
|
261
|
+
variant: updatedTop.variant,
|
|
262
|
+
dismissible: updatedTop.dismissible,
|
|
263
|
+
...(updatedTop.size ? { size: updatedTop.size } : {}),
|
|
264
|
+
...(updatedTop.side ? { side: updatedTop.side } : {}),
|
|
265
|
+
...(updatedTop.width ? { width: updatedTop.width } : {}),
|
|
266
|
+
...(updatedTop.height ? { height: updatedTop.height } : {}),
|
|
267
|
+
},
|
|
268
|
+
{ type: "persistSnapshot" },
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return { state, commands: [] };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function snapshot(state, { now = Date.now } = {}) {
|
|
277
|
+
return JSON.stringify({
|
|
278
|
+
v: SNAPSHOT_VERSION,
|
|
279
|
+
stackId: state.stackId,
|
|
280
|
+
baseUrl: state.baseUrl,
|
|
281
|
+
layers: state.layers,
|
|
282
|
+
savedAt: now(),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function restore(
|
|
287
|
+
serialized,
|
|
288
|
+
{ stackId, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now } = {},
|
|
289
|
+
) {
|
|
290
|
+
if (typeof serialized !== "string" || serialized.length === 0) return null;
|
|
291
|
+
let parsed;
|
|
292
|
+
try {
|
|
293
|
+
parsed = JSON.parse(serialized);
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
if (parsed?.v !== SNAPSHOT_VERSION) return null;
|
|
298
|
+
if (typeof parsed.stackId !== "string") return null;
|
|
299
|
+
if (typeof parsed.baseUrl !== "string") return null;
|
|
300
|
+
if (!Array.isArray(parsed.layers)) return null;
|
|
301
|
+
if (typeof parsed.savedAt !== "number") return null;
|
|
302
|
+
if (stackId && parsed.stackId !== stackId) return null;
|
|
303
|
+
if (now() - parsed.savedAt > maxAgeMs) return null;
|
|
304
|
+
|
|
305
|
+
for (const l of parsed.layers) {
|
|
306
|
+
if (!l || typeof l.id !== "string" || typeof l.url !== "string") return null;
|
|
307
|
+
if (!VARIANTS.includes(l.variant)) return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return Object.freeze({
|
|
311
|
+
stackId: parsed.stackId,
|
|
312
|
+
baseUrl: parsed.baseUrl,
|
|
313
|
+
layers: Object.freeze(parsed.layers.map(freezeLayer)),
|
|
314
|
+
});
|
|
315
|
+
}
|