katalyst-kpop 2.0.9 → 3.0.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
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 -519
  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 +76 -72
  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,564 +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;
465
+ }
466
+
467
+ disconnect() {
468
+
469
+ delete this.element.scrim;
502
470
  }
503
- show(request) {
504
- if (this.openValue) this.hide(request);
505
- if (this.openValue) return;
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({
558
- left: 0,
559
- top: this.scrollY,
560
- behavior: "instant"
561
- });
574
+
575
+ window.scrollTo({ left: 0, top: this.scrollY, behavior: "instant" });
576
+
562
577
  delete this.scrollY;
563
578
  delete this.previousPosition;
564
579
  delete this.previousTop;
@@ -573,67 +588,12 @@ function resetStyle(element, property, previousValue) {
573
588
  }
574
589
  }
575
590
 
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
- }
591
- }
592
- contentTargetDisconnected() {
593
- if (this.hasContentTarget) return;
594
- this.openValue = false;
595
- ScrimController.hideScrim();
596
- }
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
- }
614
- }
615
- this.#clear();
616
- }
617
- #clear() {
618
- this.element.removeAttribute("src");
619
- this.element.innerHTML = "";
620
- }
621
- }
622
-
623
- function isSameUrl(previous, next) {
624
- try {
625
- return `${new URL(previous)}` === `${new URL(next, location.href)}`;
626
- } catch {
627
- return false;
628
- }
629
- }
630
-
631
- const Definitions = [ {
632
- identifier: "kpop",
633
- controllerConstructor: KpopController
634
- }, {
635
- identifier: "scrim",
636
- controllerConstructor: ScrimController
637
- } ];
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
+ ];
638
598
 
639
- export { KpopController, ScrimController, Definitions as default };
599
+ export { Definitions as default };