katalyst-kpop 2.0.8 → 3.0.0.beta.1

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