katalyst-kpop 2.0.9 → 3.0.0.beta.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -43
  3. data/app/assets/builds/katalyst/kpop.esm.js +590 -0
  4. data/app/assets/builds/katalyst/kpop.js +466 -515
  5. data/app/assets/builds/katalyst/kpop.min.js +2 -1
  6. data/app/assets/builds/katalyst/kpop.min.js.map +1 -0
  7. data/app/assets/builds/katalyst/kpop.umd.js +5890 -0
  8. data/app/assets/config/kpop.js +1 -1
  9. data/app/assets/stylesheets/katalyst/kpop/_frame.scss +104 -0
  10. data/app/assets/stylesheets/katalyst/kpop/_modal.scss +95 -0
  11. data/app/assets/stylesheets/katalyst/kpop/_scrim.scss +33 -3
  12. data/app/assets/stylesheets/katalyst/kpop/_side_panel.scss +64 -0
  13. data/app/assets/stylesheets/katalyst/kpop/_variables.scss +25 -0
  14. data/app/assets/stylesheets/katalyst/kpop.scss +6 -1
  15. data/app/components/concerns/kpop/has_html_attributes.rb +78 -0
  16. data/app/components/kpop/frame_component.html.erb +14 -0
  17. data/app/components/kpop/frame_component.rb +46 -0
  18. data/app/components/kpop/modal/title_component.html.erb +6 -0
  19. data/app/components/kpop/modal/title_component.rb +28 -0
  20. data/app/components/kpop/modal_component.html.erb +8 -0
  21. data/app/components/kpop/modal_component.rb +39 -0
  22. data/app/components/scrim_component.rb +32 -0
  23. data/app/helpers/kpop_helper.rb +12 -35
  24. data/app/javascript/kpop/application.js +13 -0
  25. data/app/javascript/kpop/controllers/frame_controller.js +178 -0
  26. data/app/javascript/kpop/controllers/modal_controller.js +30 -0
  27. data/app/{assets/javascripts → javascript/kpop}/controllers/scrim_controller.js +76 -72
  28. data/app/javascript/kpop/debug.js +3 -0
  29. data/app/javascript/kpop/modals/content_modal.js +46 -0
  30. data/app/javascript/kpop/modals/frame_modal.js +41 -0
  31. data/app/javascript/kpop/modals/modal.js +69 -0
  32. data/app/javascript/kpop/modals/stream_modal.js +49 -0
  33. data/app/javascript/kpop/turbo_actions.js +33 -0
  34. data/app/javascript/kpop/utils/stream_renderer.js +15 -0
  35. data/app/views/layouts/kpop.html.erb +1 -1
  36. data/config/importmap.rb +1 -4
  37. data/lib/katalyst/kpop/engine.rb +13 -12
  38. data/lib/katalyst/kpop/matchers/base.rb +18 -0
  39. data/lib/katalyst/kpop/matchers/capybara_matcher.rb +46 -0
  40. data/lib/katalyst/kpop/matchers/capybara_parser.rb +17 -0
  41. data/lib/katalyst/kpop/matchers/chained_matcher.rb +40 -0
  42. data/lib/katalyst/kpop/matchers/frame_matcher.rb +16 -0
  43. data/lib/katalyst/kpop/matchers/modal_matcher.rb +20 -0
  44. data/lib/katalyst/kpop/matchers/redirect_matcher.rb +28 -0
  45. data/lib/katalyst/kpop/matchers/response_matcher.rb +33 -0
  46. data/lib/katalyst/kpop/matchers/stream_matcher.rb +16 -0
  47. data/lib/katalyst/kpop/matchers/title_finder.rb +16 -0
  48. data/lib/katalyst/kpop/matchers/title_matcher.rb +28 -0
  49. data/lib/katalyst/kpop/matchers.rb +79 -0
  50. data/lib/katalyst/kpop/turbo.rb +50 -0
  51. data/lib/katalyst/kpop/version.rb +1 -1
  52. data/lib/katalyst/kpop.rb +4 -0
  53. metadata +88 -15
  54. data/app/assets/builds/katalyst/kpop.css +0 -117
  55. data/app/assets/javascripts/controllers/kpop_controller.js +0 -72
  56. data/app/assets/javascripts/katalyst/kpop.js +0 -9
  57. data/app/assets/stylesheets/katalyst/kpop/_index.scss +0 -2
  58. data/app/assets/stylesheets/katalyst/kpop/_kpop.scss +0 -133
  59. data/app/helpers/kpop/modal.rb +0 -98
  60. data/app/helpers/scrim_helper.rb +0 -13
@@ -1,564 +1,484 @@
1
- function camelize(value) {
2
- return value.replace(/(?:[_-])([a-z0-9])/g, ((_, char) => char.toUpperCase()));
3
- }
1
+ import { Controller } from '@hotwired/stimulus';
2
+ import { Turbo } from '@hotwired/turbo-rails';
4
3
 
5
- function namespaceCamelize(value) {
6
- return camelize(value.replace(/--/g, "-").replace(/__/g, "_"));
7
- }
4
+ class Modal {
5
+ constructor(id) {
6
+ this.id = id;
7
+ }
8
8
 
9
- function capitalize(value) {
10
- return value.charAt(0).toUpperCase() + value.slice(1);
11
- }
9
+ async open() {
10
+ this.debug("open");
11
+ }
12
12
 
13
- function dasherize(value) {
14
- return value.replace(/([A-Z])/g, ((_, char) => `-${char.toLowerCase()}`));
15
- }
13
+ async dismiss() {
14
+ this.debug(`dismiss`);
15
+ }
16
16
 
17
- function readInheritableStaticArrayValues(constructor, propertyName) {
18
- const ancestors = getAncestorsForConstructor(constructor);
19
- return Array.from(ancestors.reduce(((values, constructor) => {
20
- getOwnStaticArrayValues(constructor, propertyName).forEach((name => values.add(name)));
21
- return values;
22
- }), new Set));
23
- }
17
+ beforeVisit(frame, e) {
18
+ this.debug(`before-visit`, e.detail.url);
19
+ }
24
20
 
25
- function readInheritableStaticObjectPairs(constructor, propertyName) {
26
- const ancestors = getAncestorsForConstructor(constructor);
27
- return ancestors.reduce(((pairs, constructor) => {
28
- pairs.push(...getOwnStaticObjectPairs(constructor, propertyName));
29
- return pairs;
30
- }), []);
31
- }
21
+ popstate(frame, e) {
22
+ this.debug(`popstate`, e.state);
23
+ }
24
+
25
+ async pop(event, callback) {
26
+ this.debug(`pop`);
27
+
28
+ const promise = new Promise((resolve) => {
29
+ window.addEventListener(
30
+ event,
31
+ () => {
32
+ resolve();
33
+ },
34
+ { once: true }
35
+ );
36
+ });
37
+
38
+ callback();
32
39
 
33
- function getAncestorsForConstructor(constructor) {
34
- const ancestors = [];
35
- while (constructor) {
36
- ancestors.push(constructor);
37
- constructor = Object.getPrototypeOf(constructor);
40
+ return promise;
38
41
  }
39
- return ancestors.reverse();
40
- }
41
42
 
42
- function getOwnStaticArrayValues(constructor, propertyName) {
43
- const definition = constructor[propertyName];
44
- return Array.isArray(definition) ? definition : [];
45
- }
43
+ get frameElement() {
44
+ return document.getElementById(this.id);
45
+ }
46
46
 
47
- function getOwnStaticObjectPairs(constructor, propertyName) {
48
- const definition = constructor[propertyName];
49
- return definition ? Object.keys(definition).map((key => [ key, definition[key] ])) : [];
50
- }
47
+ get modalElement() {
48
+ return this.frameElement?.querySelector("[data-controller*='kpop--modal']");
49
+ }
51
50
 
52
- (() => {
53
- function extendWithReflect(constructor) {
54
- function extended() {
55
- return Reflect.construct(constructor, arguments, new.target);
56
- }
57
- extended.prototype = Object.create(constructor.prototype, {
58
- constructor: {
59
- value: extended
60
- }
61
- });
62
- Reflect.setPrototypeOf(extended, constructor);
63
- return extended;
51
+ get currentLocationValue() {
52
+ return this.modalElement?.dataset["kpop-ModalCurrentLocationValue"] || "/";
64
53
  }
65
- function testReflectExtension() {
66
- const a = function() {
67
- this.a.call(this);
68
- };
69
- const b = extendWithReflect(a);
70
- b.prototype.a = function() {};
71
- return new b;
72
- }
73
- try {
74
- testReflectExtension();
75
- return extendWithReflect;
76
- } catch (error) {
77
- return constructor => class extended extends constructor {};
78
- }
79
- })();
80
-
81
- ({
82
- controllerAttribute: "data-controller",
83
- actionAttribute: "data-action",
84
- targetAttribute: "data-target",
85
- targetAttributeForScope: identifier => `data-${identifier}-target`,
86
- outletAttributeForScope: (identifier, outlet) => `data-${identifier}-${outlet}-outlet`,
87
- keyMappings: Object.assign(Object.assign({
88
- enter: "Enter",
89
- tab: "Tab",
90
- esc: "Escape",
91
- space: " ",
92
- up: "ArrowUp",
93
- down: "ArrowDown",
94
- left: "ArrowLeft",
95
- right: "ArrowRight",
96
- home: "Home",
97
- end: "End"
98
- }, objectFromEntries("abcdefghijklmnopqrstuvwxyz".split("").map((c => [ c, c ])))), objectFromEntries("0123456789".split("").map((n => [ n, n ]))))
99
- });
100
-
101
- function objectFromEntries(array) {
102
- return array.reduce(((memo, [k, v]) => Object.assign(Object.assign({}, memo), {
103
- [k]: v
104
- })), {});
105
- }
106
54
 
107
- function ClassPropertiesBlessing(constructor) {
108
- const classes = readInheritableStaticArrayValues(constructor, "classes");
109
- return classes.reduce(((properties, classDefinition) => Object.assign(properties, propertiesForClassDefinition(classDefinition))), {});
110
- }
55
+ get fallbackLocationValue() {
56
+ return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"] || "/";
57
+ }
111
58
 
112
- function propertiesForClassDefinition(key) {
113
- return {
114
- [`${key}Class`]: {
115
- get() {
116
- const {classes: classes} = this;
117
- if (classes.has(key)) {
118
- return classes.get(key);
119
- } else {
120
- const attribute = classes.getAttributeName(key);
121
- throw new Error(`Missing attribute "${attribute}"`);
122
- }
123
- }
124
- },
125
- [`${key}Classes`]: {
126
- get() {
127
- return this.classes.getAll(key);
128
- }
129
- },
130
- [`has${capitalize(key)}Class`]: {
131
- get() {
132
- return this.classes.has(key);
133
- }
134
- }
135
- };
136
- }
59
+ get isCurrentLocation() {
60
+ return (
61
+ window.history.state?.turbo && Turbo.session.location.href === this.src
62
+ );
63
+ }
137
64
 
138
- function OutletPropertiesBlessing(constructor) {
139
- const outlets = readInheritableStaticArrayValues(constructor, "outlets");
140
- return outlets.reduce(((properties, outletDefinition) => Object.assign(properties, propertiesForOutletDefinition(outletDefinition))), {});
65
+ debug(event, ...args) {
66
+ }
141
67
  }
142
68
 
143
- function propertiesForOutletDefinition(name) {
144
- const camelizedName = namespaceCamelize(name);
145
- return {
146
- [`${camelizedName}Outlet`]: {
147
- get() {
148
- const outlet = this.outlets.find(name);
149
- if (outlet) {
150
- const outletController = this.application.getControllerForElementAndIdentifier(outlet, name);
151
- if (outletController) {
152
- return outletController;
153
- } else {
154
- throw new Error(`Missing "data-controller=${name}" attribute on outlet element for "${this.identifier}" controller`);
155
- }
156
- }
157
- throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`);
158
- }
159
- },
160
- [`${camelizedName}Outlets`]: {
161
- get() {
162
- const outlets = this.outlets.findAll(name);
163
- if (outlets.length > 0) {
164
- return outlets.map((outlet => {
165
- const controller = this.application.getControllerForElementAndIdentifier(outlet, name);
166
- if (controller) {
167
- return controller;
168
- } else {
169
- console.warn(`The provided outlet element is missing the outlet controller "${name}" for "${this.identifier}"`, outlet);
170
- }
171
- })).filter((controller => controller));
172
- }
173
- return [];
174
- }
175
- },
176
- [`${camelizedName}OutletElement`]: {
177
- get() {
178
- const outlet = this.outlets.find(name);
179
- if (outlet) {
180
- return outlet;
181
- } else {
182
- throw new Error(`Missing outlet element "${name}" for "${this.identifier}" controller`);
183
- }
184
- }
185
- },
186
- [`${camelizedName}OutletElements`]: {
187
- get() {
188
- return this.outlets.findAll(name);
189
- }
190
- },
191
- [`has${capitalize(camelizedName)}Outlet`]: {
192
- get() {
193
- return this.outlets.has(name);
194
- }
195
- }
196
- };
197
- }
69
+ class ContentModal extends Modal {
70
+ constructor(id, src = null) {
71
+ super(id);
198
72
 
199
- function TargetPropertiesBlessing(constructor) {
200
- const targets = readInheritableStaticArrayValues(constructor, "targets");
201
- return targets.reduce(((properties, targetDefinition) => Object.assign(properties, propertiesForTargetDefinition(targetDefinition))), {});
202
- }
73
+ if (src) this.src = src;
74
+ }
203
75
 
204
- function propertiesForTargetDefinition(name) {
205
- return {
206
- [`${name}Target`]: {
207
- get() {
208
- const target = this.targets.find(name);
209
- if (target) {
210
- return target;
211
- } else {
212
- throw new Error(`Missing target element "${name}" for "${this.identifier}" controller`);
213
- }
214
- }
215
- },
216
- [`${name}Targets`]: {
217
- get() {
218
- return this.targets.findAll(name);
219
- }
220
- },
221
- [`has${capitalize(name)}Target`]: {
222
- get() {
223
- return this.targets.has(name);
224
- }
225
- }
226
- };
227
- }
76
+ async dismiss() {
77
+ await super.dismiss();
228
78
 
229
- function ValuePropertiesBlessing(constructor) {
230
- const valueDefinitionPairs = readInheritableStaticObjectPairs(constructor, "values");
231
- const propertyDescriptorMap = {
232
- valueDescriptorMap: {
233
- get() {
234
- return valueDefinitionPairs.reduce(((result, valueDefinitionPair) => {
235
- const valueDescriptor = parseValueDefinitionPair(valueDefinitionPair, this.identifier);
236
- const attributeName = this.data.getAttributeNameForKey(valueDescriptor.key);
237
- return Object.assign(result, {
238
- [attributeName]: valueDescriptor
239
- });
240
- }), {});
241
- }
79
+ if (this.visitStarted) {
80
+ this.debug("skipping dismiss, visit started");
81
+ return;
242
82
  }
243
- };
244
- return valueDefinitionPairs.reduce(((properties, valueDefinitionPair) => Object.assign(properties, propertiesForValueDefinitionPair(valueDefinitionPair))), propertyDescriptorMap);
245
- }
246
-
247
- function propertiesForValueDefinitionPair(valueDefinitionPair, controller) {
248
- const definition = parseValueDefinitionPair(valueDefinitionPair, controller);
249
- const {key: key, name: name, reader: read, writer: write} = definition;
250
- return {
251
- [name]: {
252
- get() {
253
- const value = this.data.get(key);
254
- if (value !== null) {
255
- return read(value);
256
- } else {
257
- return definition.defaultValue;
258
- }
259
- },
260
- set(value) {
261
- if (value === undefined) {
262
- this.data.delete(key);
263
- } else {
264
- this.data.set(key, write(value));
265
- }
266
- }
267
- },
268
- [`has${capitalize(name)}`]: {
269
- get() {
270
- return this.data.has(key) || definition.hasCustomDefaultValue;
271
- }
83
+ if (!this.isCurrentLocation) {
84
+ this.debug("skipping dismiss, not current location");
85
+ return;
272
86
  }
273
- };
274
- }
275
87
 
276
- function parseValueDefinitionPair([token, typeDefinition], controller) {
277
- return valueDescriptorForTokenAndTypeDefinition({
278
- controller: controller,
279
- token: token,
280
- typeDefinition: typeDefinition
281
- });
282
- }
88
+ return this.pop("turbo:load", () => {
89
+ this.debug("turbo-visit", this.fallbackLocationValue);
90
+ Turbo.visit(this.fallbackLocationValue, { action: "replace" });
91
+ });
283
92
 
284
- function parseValueTypeConstant(constant) {
285
- switch (constant) {
286
- case Array:
287
- return "array";
93
+ // no specific close action required, this is turbo's responsibility
94
+ }
288
95
 
289
- case Boolean:
290
- return "boolean";
96
+ beforeVisit(frame, e) {
97
+ super.beforeVisit(frame, e);
291
98
 
292
- case Number:
293
- return "number";
99
+ this.visitStarted = true;
294
100
 
295
- case Object:
296
- return "object";
101
+ frame.scrimOutlet.hide({ animate: false });
102
+ }
297
103
 
298
- case String:
299
- return "string";
104
+ get src() {
105
+ return new URL(
106
+ this.currentLocationValue.toString(),
107
+ document.baseURI
108
+ ).toString();
300
109
  }
301
110
  }
302
111
 
303
- function parseValueTypeDefault(defaultValue) {
304
- switch (typeof defaultValue) {
305
- case "boolean":
306
- return "boolean";
112
+ class FrameModal extends Modal {
113
+ constructor(id, src) {
114
+ super(id);
115
+ this.src = src;
116
+ }
307
117
 
308
- case "number":
309
- return "number";
118
+ async dismiss() {
119
+ await super.dismiss();
310
120
 
311
- case "string":
312
- return "string";
121
+ if (!this.isCurrentLocation) {
122
+ this.debug("skipping dismiss, not current location");
123
+ }
124
+
125
+ await this.pop("turbo:load", () => window.history.back());
126
+
127
+ // no specific close action required, this is turbo's responsibility
313
128
  }
314
- if (Array.isArray(defaultValue)) return "array";
315
- if (Object.prototype.toString.call(defaultValue) === "[object Object]") return "object";
316
- }
317
129
 
318
- function parseValueTypeObject(payload) {
319
- const typeFromObject = parseValueTypeConstant(payload.typeObject.type);
320
- if (!typeFromObject) return;
321
- const defaultValueType = parseValueTypeDefault(payload.typeObject.default);
322
- if (typeFromObject !== defaultValueType) {
323
- const propertyPath = payload.controller ? `${payload.controller}.${payload.token}` : payload.token;
324
- throw new Error(`The specified default value for the Stimulus Value "${propertyPath}" must match the defined type "${typeFromObject}". The provided default value of "${payload.typeObject.default}" is of type "${defaultValueType}".`);
130
+ beforeVisit(frame, e) {
131
+ super.beforeVisit(frame, e);
132
+
133
+ e.preventDefault();
134
+
135
+ frame.dismiss({ animate: false }).then(() => {
136
+ Turbo.visit(e.detail.url);
137
+
138
+ this.debug("before-visit-end");
139
+ });
325
140
  }
326
- return typeFromObject;
327
- }
328
141
 
329
- function parseValueTypeDefinition(payload) {
330
- const typeFromObject = parseValueTypeObject({
331
- controller: payload.controller,
332
- token: payload.token,
333
- typeObject: payload.typeDefinition
334
- });
335
- const typeFromDefaultValue = parseValueTypeDefault(payload.typeDefinition);
336
- const typeFromConstant = parseValueTypeConstant(payload.typeDefinition);
337
- const type = typeFromObject || typeFromDefaultValue || typeFromConstant;
338
- if (type) return type;
339
- const propertyPath = payload.controller ? `${payload.controller}.${payload.typeDefinition}` : payload.token;
340
- throw new Error(`Unknown value type "${propertyPath}" for "${payload.token}" value`);
341
- }
142
+ popstate(frame, e) {
143
+ super.popstate(frame, e);
342
144
 
343
- function defaultValueForDefinition(typeDefinition) {
344
- const constant = parseValueTypeConstant(typeDefinition);
345
- if (constant) return defaultValuesByType[constant];
346
- const defaultValue = typeDefinition.default;
347
- if (defaultValue !== undefined) return defaultValue;
348
- return typeDefinition;
145
+ // Turbo will restore modal state, but we need to reset the scrim
146
+ frame.scrimOutlet.hide({ animate: false });
147
+ }
349
148
  }
350
149
 
351
- function valueDescriptorForTokenAndTypeDefinition(payload) {
352
- const key = `${dasherize(payload.token)}-value`;
353
- const type = parseValueTypeDefinition(payload);
354
- return {
355
- type: type,
356
- key: key,
357
- name: camelize(key),
358
- get defaultValue() {
359
- return defaultValueForDefinition(payload.typeDefinition);
360
- },
361
- get hasCustomDefaultValue() {
362
- return parseValueTypeDefault(payload.typeDefinition) !== undefined;
363
- },
364
- reader: readers[type],
365
- writer: writers[type] || writers.default
150
+ class Kpop__FrameController extends Controller {
151
+ static outlets = ["scrim"];
152
+ static targets = ["modal"];
153
+ static values = {
154
+ open: Boolean,
366
155
  };
367
- }
368
156
 
369
- const defaultValuesByType = {
370
- get array() {
371
- return [];
372
- },
373
- boolean: false,
374
- number: 0,
375
- get object() {
376
- return {};
377
- },
378
- string: ""
379
- };
157
+ connect() {
158
+ this.debug("connect", this.element.src);
380
159
 
381
- const readers = {
382
- array(value) {
383
- const array = JSON.parse(value);
384
- if (!Array.isArray(array)) {
385
- throw new TypeError(`expected value of type "array" but instead got value "${value}" of type "${parseValueTypeDefault(array)}"`);
386
- }
387
- return array;
388
- },
389
- boolean(value) {
390
- return !(value == "0" || String(value).toLowerCase() == "false");
391
- },
392
- number(value) {
393
- return Number(value);
394
- },
395
- object(value) {
396
- const object = JSON.parse(value);
397
- if (object === null || typeof object != "object" || Array.isArray(object)) {
398
- throw new TypeError(`expected value of type "object" but instead got value "${value}" of type "${parseValueTypeDefault(object)}"`);
160
+ this.element.kpop = this;
161
+
162
+ // restoration visit
163
+ if (this.element.src && this.element.complete) {
164
+ this.debug("new frame modal", this.element.src);
165
+ this.open(new FrameModal(this.element.id, this.element.src), {
166
+ animate: false,
167
+ });
168
+ } else {
169
+ const element = this.element.querySelector(
170
+ "[data-controller*='kpop--modal']"
171
+ );
172
+ if (element) {
173
+ this.debug("new content modal", window.location.pathname);
174
+ this.open(new ContentModal(this.element.id), { animate: false });
175
+ }
399
176
  }
400
- return object;
401
- },
402
- string(value) {
403
- return value;
404
177
  }
405
- };
406
178
 
407
- const writers = {
408
- default: writeString,
409
- array: writeJSON,
410
- object: writeJSON
411
- };
179
+ disconnect() {
180
+ this.debug("disconnect");
412
181
 
413
- function writeJSON(value) {
414
- return JSON.stringify(value);
415
- }
182
+ delete this.element.kpop;
183
+ delete this.modal;
184
+ }
416
185
 
417
- function writeString(value) {
418
- return `${value}`;
419
- }
186
+ scrimOutletConnected(scrim) {
187
+ this.debug("scrim-connected");
420
188
 
421
- class Controller {
422
- constructor(context) {
423
- this.context = context;
189
+ this.scrimConnected = true;
190
+
191
+ if (this.openValue) scrim.show({ animate: false });
424
192
  }
425
- static get shouldLoad() {
426
- return true;
193
+
194
+ openValueChanged(open) {
195
+ this.debug("open-changed", open);
196
+
197
+ this.element.parentElement.style.display = open ? "flex" : "none";
427
198
  }
428
- static afterLoad(_identifier, _application) {
429
- return;
199
+
200
+ async open(modal, { animate = true } = {}) {
201
+ if (this.isOpen) {
202
+ this.debug("skip open as already open");
203
+ return false;
204
+ }
205
+
206
+ return (this.opening ||= this.#nextFrame(() =>
207
+ this.#open(modal, { animate })
208
+ ));
430
209
  }
431
- get application() {
432
- return this.context.application;
210
+
211
+ async dismiss({ animate = true, reason = "" } = {}) {
212
+ if (!this.isOpen) {
213
+ this.debug("skip dismiss as already closed");
214
+ return false;
215
+ }
216
+
217
+ return (this.dismissing ||= this.#nextFrame(() =>
218
+ this.#dismiss({ animate, reason })
219
+ ));
433
220
  }
434
- get scope() {
435
- return this.context.scope;
221
+
222
+ // EVENTS
223
+
224
+ popstate(event) {
225
+ this.modal?.popstate(this, event);
226
+ }
227
+
228
+ beforeFrameRender(event) {
229
+ this.debug("before-frame-render", event.detail.newFrame.baseURI);
230
+
231
+ event.preventDefault();
232
+
233
+ this.dismiss({ animate: true, reason: "before-frame-render" }).then(() => {
234
+ this.debug("resume-frame-render", event.detail.newFrame.baseURI);
235
+ event.detail.resume();
236
+ });
237
+ }
238
+
239
+ beforeStreamRender(event) {
240
+ this.debug("before-stream-render", event.detail);
241
+
242
+ const resume = event.detail.render;
243
+
244
+ // Defer rendering until dismiss is complete.
245
+ // Dismiss may change history so we need to wait for it to complete to avoid
246
+ // losing DOM changes on restoration visits.
247
+ event.detail.render = (stream) => {
248
+ (this.dismissing || Promise.resolve()).then(() => {
249
+ this.debug("stream-render", stream);
250
+ resume(stream);
251
+ });
252
+ };
436
253
  }
437
- get element() {
438
- return this.scope.element;
254
+
255
+ beforeVisit(e) {
256
+ this.debug("before-visit", e.detail.url);
257
+
258
+ // ignore visits to the current frame, these fire when the frame navigates
259
+ if (e.detail.url === this.element.src) return;
260
+
261
+ // ignore unless we're open
262
+ if (!this.isOpen) return;
263
+
264
+ this.modal.beforeVisit(this, e);
439
265
  }
440
- get identifier() {
441
- return this.scope.identifier;
266
+
267
+ frameLoad(event) {
268
+ this.debug("frame-load");
269
+
270
+ return this.open(new FrameModal(this.element.id, this.element.src), {
271
+ animate: true,
272
+ });
442
273
  }
443
- get targets() {
444
- return this.scope.targets;
274
+
275
+ get isOpen() {
276
+ return this.openValue && !this.dismissing;
445
277
  }
446
- get outlets() {
447
- return this.scope.outlets;
278
+
279
+ async #open(modal, { animate = true } = {}) {
280
+ this.debug("open-start", { animate });
281
+
282
+ const scrim = this.scrimConnected && this.scrimOutlet;
283
+
284
+ this.modal = modal;
285
+ this.openValue = true;
286
+
287
+ await modal.open({ animate });
288
+ await scrim?.show({ animate });
289
+
290
+ delete this.opening;
291
+
292
+ this.debug("open-end");
448
293
  }
449
- get classes() {
450
- return this.scope.classes;
294
+
295
+ async #dismiss({ animate = true, reason = "" } = {}) {
296
+ this.debug("dismiss-start", { animate, reason });
297
+
298
+ if (!this.modal) {
299
+ console.warn("modal missing on dismiss");
300
+ }
301
+
302
+ await this.scrimOutlet.hide({ animate });
303
+ await this.modal?.dismiss();
304
+
305
+ this.openValue = false;
306
+ this.modal = null;
307
+ delete this.dismissing;
308
+
309
+ this.debug("dismiss-end");
451
310
  }
452
- get data() {
453
- return this.scope.data;
311
+
312
+ async #nextFrame(callback) {
313
+ // return Promise.resolve().then(callback);
314
+ return new Promise(window.requestAnimationFrame).then(callback);
454
315
  }
455
- initialize() {}
456
- connect() {}
457
- disconnect() {}
458
- dispatch(eventName, {target: target = this.element, detail: detail = {}, prefix: prefix = this.identifier, bubbles: bubbles = true, cancelable: cancelable = true} = {}) {
459
- const type = prefix ? `${prefix}:${eventName}` : eventName;
460
- const event = new CustomEvent(type, {
461
- detail: detail,
462
- bubbles: bubbles,
463
- cancelable: cancelable
464
- });
465
- target.dispatchEvent(event);
466
- return event;
316
+
317
+ debug(event, ...args) {
467
318
  }
468
319
  }
469
320
 
470
- Controller.blessings = [ ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing, OutletPropertiesBlessing ];
321
+ class Kpop__ModalController extends Controller {
322
+ static values = {
323
+ fallback_location: String,
324
+ layout: String,
325
+ };
326
+
327
+ connect() {
328
+ this.debug("connect");
471
329
 
472
- Controller.targets = [];
330
+ if (this.layoutValue) {
331
+ document.querySelector("#kpop").classList.toggle(this.layoutValue, true);
332
+ }
333
+ }
334
+
335
+ disconnect() {
336
+ this.debug("disconnect");
473
337
 
474
- Controller.outlets = [];
338
+ if (this.layoutValue) {
339
+ document.querySelector("#kpop").classList.toggle(this.layoutValue, false);
340
+ }
341
+ }
475
342
 
476
- Controller.values = {};
343
+ debug(event, ...args) {
344
+ }
345
+ }
477
346
 
347
+ /**
348
+ * Scrim controller wraps an element that creates a whole page layer.
349
+ * It is intended to be used behind a modal or nav drawer.
350
+ *
351
+ * If the Scrim element receives a click event, it automatically triggers "scrim:hide".
352
+ *
353
+ * You can show and hide the scrim programmatically by calling show/hide on the controller, e.g. using an outlet.
354
+ *
355
+ * If you need to respond to the scrim showing or hiding you should subscribe to "scrim:show" and "scrim:hide".
356
+ */
478
357
  class ScrimController extends Controller {
479
- static values={
358
+ static values = {
480
359
  open: Boolean,
481
360
  captive: Boolean,
482
- zIndex: Number
361
+ zIndex: Number,
483
362
  };
484
- static showScrim({dismiss: dismiss = true, zIndex: zIndex = undefined, top: top = undefined} = {}) {
485
- return window.dispatchEvent(new CustomEvent("scrim:request:show", {
486
- cancelable: true,
487
- detail: {
488
- captive: !dismiss,
489
- zIndex: zIndex,
490
- top: top
491
- }
492
- }));
493
- }
494
- static hideScrim() {
495
- return window.dispatchEvent(new CustomEvent("scrim:request:hide", {
496
- cancelable: true
497
- }));
498
- }
363
+
499
364
  connect() {
365
+
500
366
  this.defaultZIndexValue = this.zIndexValue;
501
367
  this.defaultCaptiveValue = this.captiveValue;
368
+
369
+ this.element.scrim = this;
502
370
  }
503
- show(request) {
504
- if (this.openValue) this.hide(request);
505
- if (this.openValue) return;
371
+
372
+ disconnect() {
373
+
374
+ delete this.element.scrim;
375
+ }
376
+
377
+ async show({
378
+ captive = this.defaultCaptiveValue,
379
+ zIndex = this.defaultZIndexValue,
380
+ top = window.scrollY,
381
+ animate = true,
382
+ } = {}) {
383
+
384
+ // hide the scrim before opening the new one if it's already open
385
+ if (this.openValue) {
386
+ await this.hide({ animate });
387
+ }
388
+
389
+ // update internal state
506
390
  this.openValue = true;
507
- const event = this.dispatch("show", {
508
- bubbles: true,
509
- cancelable: true
510
- });
511
- if (event.defaultPrevented) {
512
- this.openValue = false;
513
- request.preventDefault();
514
- return;
391
+
392
+ // notify listeners of pending request
393
+ this.dispatch("show", { bubbles: true });
394
+
395
+ // update state, perform style updates
396
+ this.#show(captive, zIndex, top);
397
+
398
+ if (animate) {
399
+ // animate opening
400
+ // this will trigger an animationEnd event via CSS that completes the open
401
+ this.element.dataset.showAnimating = "";
402
+
403
+ await new Promise((resolve) => {
404
+ this.element.addEventListener("animationend", () => resolve(), {
405
+ once: true,
406
+ });
407
+ });
408
+
409
+ delete this.element.dataset.showAnimating;
515
410
  }
516
- this.#show(request.detail);
517
411
  }
518
- hide(request) {
519
- if (!this.openValue) return;
520
- this.openValue = false;
521
- const event = this.dispatch("hide", {
522
- bubbles: true,
523
- cancelable: true
524
- });
525
- if (event.defaultPrevented) {
526
- this.openValue = true;
527
- request.preventDefault();
528
- return;
412
+
413
+ async hide({ animate = true } = {}) {
414
+ if (!this.openValue || this.element.dataset.hideAnimating) return;
415
+
416
+ // notify listeners of pending request
417
+ this.dispatch("hide", { bubbles: true });
418
+
419
+ if (animate) {
420
+ // set animation state
421
+ // this will trigger an animationEnd event via CSS that completes the hide
422
+ this.element.dataset.hideAnimating = "";
423
+
424
+ await new Promise((resolve) => {
425
+ this.element.addEventListener("animationend", () => resolve(), {
426
+ once: true,
427
+ });
428
+ });
429
+
430
+ delete this.element.dataset.hideAnimating;
529
431
  }
432
+
530
433
  this.#hide();
434
+
435
+ this.openValue = false;
531
436
  }
437
+
532
438
  dismiss(event) {
533
- if (!this.captiveValue) this.hide(event);
439
+
440
+ if (!this.captiveValue) this.dispatch("dismiss", { bubbles: true });
534
441
  }
442
+
535
443
  escape(event) {
536
- if (event.key === "Escape" && !this.captiveValue && !event.defaultPrevented) this.hide(event);
537
- }
538
- disconnect() {
539
- super.disconnect();
444
+ if (
445
+ event.key === "Escape" &&
446
+ !this.captiveValue &&
447
+ !event.defaultPrevented
448
+ ) {
449
+ this.dispatch("dismiss", { bubbles: true });
450
+ }
540
451
  }
541
- #show({captive: captive = this.defaultCaptiveValue, zIndex: zIndex = this.defaultZIndexValue, top: top = window.scrollY}) {
452
+
453
+ /**
454
+ * Clips body to viewport size and sets the z-index
455
+ */
456
+ #show(captive, zIndex, top) {
542
457
  this.captiveValue = captive;
543
458
  this.zIndexValue = zIndex;
544
459
  this.scrollY = top;
460
+
545
461
  this.previousPosition = document.body.style.position;
546
462
  this.previousTop = document.body.style.top;
463
+
547
464
  this.element.style.zIndex = this.zIndexValue;
548
465
  document.body.style.top = `-${top}px`;
549
466
  document.body.style.position = "fixed";
550
467
  }
468
+
469
+ /**
470
+ * Unclips body from viewport size and unsets the z-index
471
+ */
551
472
  #hide() {
552
473
  this.captiveValue = this.defaultCaptiveValue;
553
474
  this.zIndexValue = this.defaultZIndexValue;
475
+
554
476
  resetStyle(this.element, "z-index", null);
555
477
  resetStyle(document.body, "position", null);
556
478
  resetStyle(document.body, "top", null);
557
- window.scrollTo({
558
- left: 0,
559
- top: this.scrollY,
560
- behavior: "instant"
561
- });
479
+
480
+ window.scrollTo({ left: 0, top: this.scrollY, behavior: "instant" });
481
+
562
482
  delete this.scrollY;
563
483
  delete this.previousPosition;
564
484
  delete this.previousTop;
@@ -573,67 +493,98 @@ function resetStyle(element, property, previousValue) {
573
493
  }
574
494
  }
575
495
 
576
- class KpopController extends Controller {
577
- static targets=[ "content", "closeButton" ];
578
- static values={
579
- open: Boolean
580
- };
581
- contentTargetConnected(target) {
582
- target.setAttribute("data-turbo-temporary", "");
583
- if (this.openValue) return;
584
- if (ScrimController.showScrim({
585
- dismiss: this.hasCloseButtonTarget
586
- })) {
587
- this.openValue = true;
588
- } else {
589
- this.#clear();
590
- }
496
+ class StreamModal extends Modal {
497
+ constructor(id, action) {
498
+ super(id);
499
+
500
+ this.action = action;
591
501
  }
592
- contentTargetDisconnected() {
593
- if (this.hasContentTarget) return;
594
- this.openValue = false;
595
- ScrimController.hideScrim();
502
+
503
+ async open() {
504
+ await super.open();
505
+
506
+ window.history.pushState({ kpop: true, id: this.id }, "", window.location);
596
507
  }
597
- openValueChanged(open) {
598
- this.element.style.display = open ? "flex" : "none";
599
- }
600
- dismiss() {
601
- if (!this.hasContentTarget || !this.openValue) return;
602
- const dismissUrl = this.contentTarget.dataset.dismissUrl;
603
- const dismissAction = this.contentTarget.dataset.dismissAction;
604
- if (dismissUrl) {
605
- if (dismissAction === "replace") {
606
- if (isSameUrl(document.referrer, dismissUrl)) {
607
- history.back();
608
- } else {
609
- history.replaceState({}, "", dismissUrl);
610
- }
611
- } else {
612
- window.location.href = dismissUrl;
613
- }
508
+
509
+ async dismiss() {
510
+ await super.dismiss();
511
+
512
+ if (this.isCurrentLocation) {
513
+ await this.pop("popstate", () => window.history.back());
614
514
  }
615
- this.#clear();
515
+
516
+ this.frameElement.innerHTML = "";
616
517
  }
617
- #clear() {
618
- this.element.removeAttribute("src");
619
- this.element.innerHTML = "";
518
+
519
+ beforeVisit(frame, e) {
520
+ super.beforeVisit(frame, e);
521
+
522
+ e.preventDefault();
523
+
524
+ frame.dismiss({ animate: false }).then(() => {
525
+ Turbo.visit(e.detail.url);
526
+
527
+ this.debug("before-visit-end");
528
+ });
529
+ }
530
+
531
+ popstate(frame, e) {
532
+ super.popstate(frame, e);
533
+
534
+ frame.dismiss({ animate: true, reason: "popstate" });
535
+ }
536
+
537
+ get isCurrentLocation() {
538
+ return window.history.state?.kpop && window.history.state?.id === this.id;
620
539
  }
621
540
  }
622
541
 
623
- function isSameUrl(previous, next) {
624
- try {
625
- return `${new URL(previous)}` === `${new URL(next, location.href)}`;
626
- } catch {
627
- return false;
542
+ class StreamRenderer {
543
+ constructor(frame, action) {
544
+ this.frame = frame;
545
+ this.action = action;
628
546
  }
547
+
548
+ render() {
549
+ this.frame.src = "";
550
+ this.frame.innerHTML = "";
551
+ this.frame.append(this.action.templateContent);
552
+ }
553
+ }
554
+
555
+ function kpop(action) {
556
+ return action.targetElements[0]?.kpop;
629
557
  }
630
558
 
631
- const Definitions = [ {
632
- identifier: "kpop",
633
- controllerConstructor: KpopController
634
- }, {
635
- identifier: "scrim",
636
- controllerConstructor: ScrimController
637
- } ];
559
+ Turbo.StreamActions.kpop_open = function () {
560
+ const animate = !kpop(this).openValue;
561
+
562
+ kpop(this)
563
+ ?.dismiss({ animate, reason: "before-turbo-stream" })
564
+ .then(() => {
565
+ new StreamRenderer(this.targetElements[0], this).render();
566
+ kpop(this)?.open(new StreamModal(this.target, this), { animate });
567
+ });
568
+ };
569
+
570
+ Turbo.StreamActions.kpop_dismiss = function () {
571
+ kpop(this)?.dismiss({ reason: "turbo_stream.kpop.dismiss" });
572
+ };
573
+
574
+ Turbo.StreamActions.kpop_redirect_to = function () {
575
+ if (this.dataset.turboFrame === this.target) {
576
+ this.targetElements[0].src = this.getAttribute("href");
577
+ } else {
578
+ Turbo.visit(this.getAttribute("href"), {
579
+ action: this.dataset.turboAction,
580
+ });
581
+ }
582
+ };
583
+
584
+ const Definitions = [
585
+ { identifier: "kpop--frame", controllerConstructor: Kpop__FrameController },
586
+ { identifier: "kpop--modal", controllerConstructor: Kpop__ModalController },
587
+ { identifier: "scrim", controllerConstructor: ScrimController },
588
+ ];
638
589
 
639
- export { KpopController, ScrimController, Definitions as default };
590
+ export { Definitions as default };