katalyst-kpop 3.4.0 → 4.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +92 -74
  3. data/app/assets/builds/katalyst/kpop.esm.js +463 -457
  4. data/app/assets/builds/katalyst/kpop.js +463 -457
  5. data/app/assets/builds/katalyst/kpop.min.js +1 -1
  6. data/app/assets/builds/katalyst/kpop.min.js.map +1 -1
  7. data/app/assets/stylesheets/katalyst/kpop.css +69 -0
  8. data/app/components/kpop/frame_component.html.erb +3 -14
  9. data/app/components/kpop/frame_component.rb +15 -11
  10. data/app/components/kpop/modal_component.html.erb +7 -6
  11. data/app/components/kpop/modal_component.rb +9 -32
  12. data/app/controllers/concerns/katalyst/kpop/frame_request.rb +67 -8
  13. data/app/javascript/kpop/application.js +68 -7
  14. data/app/javascript/kpop/controllers/frame_controller.js +96 -66
  15. data/app/javascript/kpop/modals/content_modal.js +2 -58
  16. data/app/javascript/kpop/modals/frame_modal.js +19 -76
  17. data/app/javascript/kpop/modals/modal.js +96 -49
  18. data/app/javascript/kpop/modals/stream_modal.js +11 -62
  19. data/app/javascript/kpop/utils/debug.js +22 -0
  20. data/app/javascript/kpop/utils/link_observer.js +151 -0
  21. data/app/javascript/kpop/utils/ruleset.js +43 -0
  22. data/app/javascript/kpop/utils/stream_actions.js +21 -0
  23. data/app/views/layouts/kpop/frame.html.erb +3 -1
  24. data/app/views/layouts/kpop/stream.html.erb +3 -0
  25. data/lib/katalyst/kpop/engine.rb +1 -8
  26. data/lib/katalyst/kpop/matchers/modal_matcher.rb +1 -1
  27. data/lib/katalyst/kpop/matchers/src_matcher.rb +33 -0
  28. data/lib/katalyst/kpop/matchers.rb +11 -40
  29. metadata +8 -19
  30. data/app/assets/stylesheets/katalyst/kpop/_frame.scss +0 -90
  31. data/app/assets/stylesheets/katalyst/kpop/_modal.scss +0 -88
  32. data/app/assets/stylesheets/katalyst/kpop/_scrim.scss +0 -46
  33. data/app/assets/stylesheets/katalyst/kpop/_side_panel.scss +0 -64
  34. data/app/assets/stylesheets/katalyst/kpop/_variables.scss +0 -24
  35. data/app/assets/stylesheets/katalyst/kpop.scss +0 -6
  36. data/app/components/kpop/modal/footer_component.rb +0 -21
  37. data/app/components/kpop/modal/header_component.rb +0 -21
  38. data/app/components/kpop/modal/title_component.html.erb +0 -6
  39. data/app/components/kpop/modal/title_component.rb +0 -28
  40. data/app/components/scrim_component.rb +0 -32
  41. data/app/helpers/kpop_helper.rb +0 -32
  42. data/app/javascript/kpop/controllers/modal_controller.js +0 -30
  43. data/app/javascript/kpop/controllers/scrim_controller.js +0 -159
  44. data/app/javascript/kpop/debug.js +0 -3
  45. data/app/javascript/kpop/turbo_actions.js +0 -46
  46. data/app/javascript/kpop/utils/stream_renderer.js +0 -15
  47. data/lib/katalyst/kpop/turbo.rb +0 -49
@@ -1,135 +1,153 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
  import { Turbo as Turbo$1 } from '@hotwired/turbo-rails';
3
3
 
4
- class Modal {
5
- constructor(id) {
6
- this.id = id;
7
- }
4
+ let enabled = false;
8
5
 
9
- async open() {
10
- this.debug("open");
6
+ const debug = function (receiver) {
7
+ if (enabled) {
8
+ return console.debug.bind(console, "[%s] %s", receiver);
9
+ } else {
10
+ return noop;
11
11
  }
12
+ };
12
13
 
13
- async dismiss() {
14
- this.debug(`dismiss`);
15
- }
14
+ const noop = () => {};
16
15
 
17
- beforeVisit(frame, e) {
18
- this.debug(`before-visit`, e.detail.url);
19
- }
16
+ Object.defineProperty(debug, "enabled", {
17
+ get: function () {
18
+ return enabled;
19
+ },
20
+ set: function (debug) {
21
+ enabled = debug;
22
+ },
23
+ });
20
24
 
21
- popstate(frame, e) {
22
- this.debug(`popstate`, e.state);
25
+ class Modal {
26
+ constructor(frame, dialog, src = null) {
27
+ this.frame = frame;
28
+ this.element = dialog;
29
+ this.uri = new URL(src || dialog.dataset.src, window.location.origin);
23
30
  }
24
31
 
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();
39
-
40
- return promise;
32
+ connect() {
33
+ this.element.addEventListener("cancel", this.cancel);
34
+ this.element.addEventListener("close", this.close);
35
+ this.element.addEventListener("mousedown", this.scrim);
41
36
  }
42
37
 
43
- get frameElement() {
44
- return document.getElementById(this.id);
38
+ disconnect() {
39
+ this.element.removeEventListener("cancel", this.cancel);
40
+ this.element.removeEventListener("close", this.close);
41
+ this.element.removeEventListener("mousedown", this.scrim);
45
42
  }
46
43
 
47
- get controller() {
48
- return this.frameElement?.kpop;
44
+ get src() {
45
+ return this.uri.pathname + this.uri.search + this.uri.hash;
49
46
  }
50
47
 
51
- get modalElement() {
52
- return this.frameElement?.querySelector("[data-controller*='kpop--modal']");
53
- }
48
+ cancel = (e) => {
49
+ this.debug("event:cancel", e);
54
50
 
55
- get currentLocationValue() {
56
- return this.modalElement?.dataset["kpop-ModalCurrentLocationValue"] || "/";
57
- }
51
+ e.preventDefault();
58
52
 
59
- get fallbackLocationValue() {
60
- return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"];
61
- }
53
+ this.frame.dismiss({ animate: true, reason: "dialog:cancel" });
54
+ };
62
55
 
63
- get isCurrentLocation() {
64
- return (
65
- window.history.state?.turbo && Turbo$1.session.location.href === this.src
66
- );
67
- }
56
+ close = (e) => {
57
+ this.debug("event:close", e);
68
58
 
69
- static debug(event, ...args) {
70
- }
59
+ this.frame.clear({ reason: "dialog:close" });
60
+ };
71
61
 
72
- debug(event, ...args) {
73
- }
74
- }
62
+ scrim = (e) => {
63
+ if (e.target.tagName === "DIALOG") {
64
+ this.debug("event:scrim", e);
75
65
 
76
- class ContentModal extends Modal {
77
- static connect(frame, element) {
78
- frame.open(new ContentModal(element.id), { animate: false });
79
- }
66
+ this.frame.dismiss({ animate: true, reason: "dialog:scrim" });
67
+ }
68
+ };
80
69
 
81
- constructor(id, src = null) {
82
- super(id);
70
+ async open({ animate = true } = {}) {
71
+ this.debug("open-start", animate);
83
72
 
84
- if (src) this.src = src;
73
+ await animation(this.element, animate, () => this.element.showModal());
74
+
75
+ this.debug("open-end");
85
76
  }
86
77
 
87
78
  /**
88
- * When the modal is dismissed we can't rely on a back navigation to close the
89
- * modal as the user may have navigated to a different location. Instead we
90
- * remove the content from the dom and replace the current history state with
91
- * the fallback location, if set.
92
- *
93
- * If there is no fallback location, we may be showing a stream modal that was
94
- * injected and cached by turbo. In this case, we clear the frame element and
95
- * do not change history.
79
+ * Modals are closed by animating out the modal then removing the modal
80
+ * element from the wrapping frame.
96
81
  *
97
82
  * @returns {Promise<void>}
98
83
  */
99
- async dismiss() {
100
- const fallbackLocation = this.fallbackLocationValue;
101
-
102
- await super.dismiss();
84
+ async dismiss({ animate = true } = {}) {
85
+ this.debug("dismiss-start", animate);
103
86
 
104
- if (this.visitStarted) {
105
- this.debug("skipping dismiss, visit started");
106
- return;
107
- }
108
- if (!this.isCurrentLocation) {
109
- this.debug("skipping dismiss, not current location");
110
- return;
111
- }
87
+ await animation(this.element, animate, () =>
88
+ this.element.removeAttribute("open"),
89
+ );
112
90
 
113
- this.frameElement.innerHTML = "";
91
+ this.debug("dismiss-end");
114
92
 
115
- if (fallbackLocation) {
116
- window.history.replaceState(window.history.state, "", fallbackLocation);
117
- }
93
+ this.element.close();
118
94
  }
119
95
 
96
+ /**
97
+ * When user navigates from inside a modal, dismiss the modal first so
98
+ * that the modal does not appear in the history stack.
99
+ *
100
+ * @param frame FrameController
101
+ * @param e Turbo navigation event
102
+ */
120
103
  beforeVisit(frame, e) {
121
- super.beforeVisit(frame, e);
104
+ this.debug(`before-visit`, e.detail.url);
122
105
 
123
- this.visitStarted = true;
106
+ this.frame.clear();
107
+ }
124
108
 
125
- frame.scrimOutlet.hide({ animate: false });
109
+ static get debug() {
110
+ return debug(this.name);
126
111
  }
127
112
 
128
- get src() {
129
- return new URL(
130
- this.currentLocationValue.toString(),
131
- document.baseURI,
132
- ).toString();
113
+ get debug() {
114
+ return debug(this.constructor.name);
115
+ }
116
+ }
117
+
118
+ function animation(el, animate, trigger) {
119
+ if (!animate) return trigger();
120
+
121
+ const duration = animationDuration(el);
122
+
123
+ return new Promise((resolve) => {
124
+ const resolver = () => {
125
+ el.removeEventListener("animationend", resolver, { once: true });
126
+ clearTimeout(timeout);
127
+ el.toggleAttribute("animate", false);
128
+ resolve();
129
+ };
130
+
131
+ el.addEventListener("animationend", resolver, { once: true });
132
+ const timeout = setTimeout(resolver, duration);
133
+
134
+ el.toggleAttribute("animate", animate);
135
+ trigger();
136
+ });
137
+ }
138
+
139
+ function animationDuration(el, defaultValue = "0.2s") {
140
+ const value =
141
+ getComputedStyle(el).getPropertyValue("--animation-duration") ||
142
+ defaultValue;
143
+ const num = parseFloat(value);
144
+ if (value.endsWith("ms")) return num;
145
+ return num * 1000;
146
+ }
147
+
148
+ class ContentModal extends Modal {
149
+ static connect(frame, dialog) {
150
+ frame.open(new ContentModal(frame, dialog), { animate: false });
133
151
  }
134
152
  }
135
153
 
@@ -138,25 +156,27 @@ class FrameModal extends Modal {
138
156
  * When the FrameController detects a frame element on connect, it runs this
139
157
  * method to sanity check the frame src and restore the modal state.
140
158
  *
141
- * @param frame FrameController
142
- * @param element TurboFrame element
159
+ * @param {Kpop__FrameController} frame
160
+ * @param {HTMLDialogElement} dialog
161
+ * @param {String} src
143
162
  */
144
- static connect(frame, element) {
145
- const modal = new FrameModal(element.id, element.src);
146
-
147
- // state reconciliation for turbo restore of invalid frames
148
- if (modal.isCurrentLocation) {
149
- // restoration visit
150
- this.debug("restore", element.src);
151
- return frame.open(modal, { animate: false });
152
- } else {
153
- console.warn(
154
- "kpop: restored frame src doesn't match window href",
155
- modal.src,
156
- window.location.href,
157
- );
158
- return frame.clear();
159
- }
163
+ static connect(frame, dialog, src) {
164
+ // restoration visit
165
+ this.debug("restore", src);
166
+ return frame.open(new FrameModal(frame, dialog, src), { animate: false });
167
+ }
168
+
169
+ /**
170
+ * When the FrameController detects a frame load event, it runs this
171
+ * method to open the modal.
172
+ *
173
+ * @param {Kpop__FrameController} frame
174
+ * @param {HTMLDialogElement} dialog
175
+ * @param {String} src
176
+ */
177
+ static load(frame, dialog, src) {
178
+ this.debug("load", src);
179
+ return frame.open(new FrameModal(frame, dialog, src), { animate: true });
160
180
  }
161
181
 
162
182
  /**
@@ -179,71 +199,12 @@ class FrameModal extends Modal {
179
199
  element.src = "";
180
200
  }
181
201
 
182
- if (element.src === location) {
183
- this.debug("skipping navigate as already on location");
184
- return;
185
- }
186
-
187
- if (element.src && element.src !== window.location.href) {
188
- console.warn(
189
- "kpop: frame src doesn't match window",
190
- element.src,
191
- window.location.href,
192
- location,
193
- );
194
- frame.clear();
195
- }
196
-
197
202
  this.debug("navigate to", location);
198
203
  resolve();
199
204
  }
200
-
201
- constructor(id, src) {
202
- super(id);
203
- this.src = src;
204
- }
205
-
206
- /**
207
- * FrameModals are closed by running pop state and awaiting the turbo:load
208
- * event that follows on history restoration.
209
- *
210
- * @returns {Promise<void>}
211
- */
212
- async dismiss() {
213
- await super.dismiss();
214
-
215
- if (!this.isCurrentLocation) {
216
- this.debug("skipping dismiss, not current location");
217
- } else {
218
- await this.pop("turbo:load", () => window.history.back());
219
- }
220
-
221
- // no specific close action required, this is turbo's responsibility
222
- }
223
-
224
- /**
225
- * When user navigates from inside a Frame modal, dismiss the modal first so
226
- * that the modal does not appear in the history stack.
227
- *
228
- * @param frame FrameController
229
- * @param e Turbo navigation event
230
- */
231
- beforeVisit(frame, e) {
232
- super.beforeVisit(frame, e);
233
-
234
- e.preventDefault();
235
-
236
- frame.dismiss({ animate: false }).then(() => {
237
- Turbo$1.visit(e.detail.url);
238
-
239
- this.debug("before-visit-end");
240
- });
241
- }
242
205
  }
243
206
 
244
207
  class Kpop__FrameController extends Controller {
245
- static outlets = ["scrim"];
246
- static targets = ["modal"];
247
208
  static values = {
248
209
  open: Boolean,
249
210
  };
@@ -256,12 +217,14 @@ class Kpop__FrameController extends Controller {
256
217
  // allow our code to intercept frame navigation requests before dom changes
257
218
  installNavigationInterception(this);
258
219
 
259
- if (this.element.src && this.element.complete) {
220
+ const dialog = this.element.querySelector("dialog");
221
+
222
+ if (this.element.src && dialog) {
260
223
  this.debug("new frame modal", this.element.src);
261
- FrameModal.connect(this, this.element);
262
- } else if (this.modalElements.length > 0) {
263
- this.debug("new content modal", window.location.pathname);
264
- ContentModal.connect(this, this.element);
224
+ FrameModal.connect(this, dialog, this.element.src).then(() => {});
225
+ } else if (dialog) {
226
+ this.debug("new content modal", dialog);
227
+ ContentModal.connect(this, dialog);
265
228
  } else {
266
229
  this.debug("no modal");
267
230
  this.clear();
@@ -275,24 +238,18 @@ class Kpop__FrameController extends Controller {
275
238
  delete this.modal;
276
239
  }
277
240
 
278
- scrimOutletConnected(scrim) {
279
- this.debug("scrim-connected");
280
-
281
- this.scrimConnected = true;
282
-
283
- if (this.openValue) {
284
- scrim.show({ animate: false });
285
- } else {
286
- scrim.hide({ animate: false });
287
- }
288
- }
289
-
290
241
  openValueChanged(open) {
291
242
  this.debug("open-changed", open);
292
-
293
- this.element.parentElement.style.display = open ? "flex" : "none";
294
243
  }
295
244
 
245
+ /**
246
+ * Animate an attached modal into the foreground. Returns a promise that
247
+ * resolves when the animation is complete.
248
+ *
249
+ * @param modal
250
+ * @param animate
251
+ * @returns {Promise<Boolean>}
252
+ */
296
253
  async open(modal, { animate = true } = {}) {
297
254
  if (this.isOpen) {
298
255
  this.debug("skip open as already open");
@@ -302,12 +259,25 @@ class Kpop__FrameController extends Controller {
302
259
 
303
260
  await this.dismissing;
304
261
 
305
- return (this.opening ||= this.#nextFrame(() =>
306
- this.#open(modal, { animate }),
307
- ));
262
+ return (this.opening ||= Promise.resolve().then(() => {
263
+ modal.connect();
264
+ return this.#open(modal, { animate });
265
+ }));
308
266
  }
309
267
 
268
+ /**
269
+ * Cause a modal to hide. Returns a promise that will resolve when the
270
+ * animation (if requested) is finished.
271
+ *
272
+ * If the modal is already animating out, returns the existing promise instead.
273
+ *
274
+ * @param {Boolean} animate
275
+ * @param {String} reason
276
+ * @returns {Promise}
277
+ */
310
278
  async dismiss({ animate = true, reason = "" } = {}) {
279
+ this.debug("event:dismiss", reason);
280
+
311
281
  if (!this.isOpen) {
312
282
  this.debug("skip dismiss as already closed");
313
283
  return false;
@@ -315,36 +285,30 @@ class Kpop__FrameController extends Controller {
315
285
 
316
286
  await this.opening;
317
287
 
318
- return (this.dismissing ||= this.#nextFrame(() =>
319
- this.#dismiss({ animate, reason }),
320
- ));
288
+ return (this.dismissing ||= this.#dismiss({ animate, reason }));
321
289
  }
322
290
 
323
- async clear() {
291
+ /**
292
+ * Clean up after a modal is finished dismissing.
293
+ */
294
+ clear({ reason = "" } = {}) {
295
+ this.debug("event:clear", reason);
296
+
324
297
  // clear the src from the frame (if any)
325
298
  this.element.src = "";
299
+ this.element.innerHTML = "";
326
300
 
327
- // remove any open modal(s)
328
- this.modalElements.forEach((element) => element.remove());
329
-
330
- // mark the modal as hidden (will hide scrim on connect)
301
+ // mark the modal as hidden
331
302
  this.openValue = false;
332
303
 
333
- // close the scrim, if connected
334
- if (this.scrimConnected) {
335
- return this.scrimOutlet.hide({ animate: false });
336
- }
337
-
338
304
  // unset modal
339
- this.modal = null;
305
+ if (this.modal) this.modal.disconnect();
306
+ delete this.modal;
307
+ delete this.dismissing;
340
308
  }
341
309
 
342
310
  // EVENTS
343
311
 
344
- popstate(event) {
345
- this.modal?.popstate(this, event);
346
- }
347
-
348
312
  /**
349
313
  * Incoming frame render, dismiss the current modal (if any) first.
350
314
  *
@@ -388,53 +352,72 @@ class Kpop__FrameController extends Controller {
388
352
  // ignore visits to the current frame, these fire when the frame navigates
389
353
  if (e.detail.url === this.element.src) return;
390
354
 
355
+ const url = new URL(e.detail.url.toString(), document.baseURI);
356
+ if (url.pathname === "/resume_historical_location") {
357
+ e.preventDefault();
358
+ return this.dismiss();
359
+ }
360
+
391
361
  // ignore unless we're open
392
362
  if (!this.isOpen) return;
393
363
 
394
364
  this.modal.beforeVisit(this, e);
395
365
  }
396
366
 
397
- frameLoad(event) {
367
+ frameLoad(e) {
398
368
  this.debug("frame-load");
399
369
 
400
- const modal = new FrameModal(this.element.id, this.element.src);
401
-
402
- window.addEventListener(
403
- "turbo:visit",
404
- (e) => {
405
- this.open(modal, { animate: true });
406
- },
407
- { once: true },
370
+ FrameModal.load(this, e.target.firstElementChild, e.target.src).then(
371
+ () => {},
408
372
  );
409
373
  }
410
374
 
411
- get isOpen() {
412
- return this.openValue && !this.dismissing;
375
+ /**
376
+ * Outgoing fetch request. Capture the initiator so we can return focus if it causes a modal to show.
377
+ */
378
+ beforeFetchRequest() {
379
+ const focusElement = document.activeElement;
380
+
381
+ if (focusElement === document.body) {
382
+ delete this.lastFetchFocusRef;
383
+ } else {
384
+ this.lastFetchFocusRef = new WeakRef(focusElement);
385
+ }
413
386
  }
414
387
 
415
- get modalElements() {
416
- return this.element.querySelectorAll("[data-controller*='kpop--modal']");
388
+ get isOpen() {
389
+ return this.openValue && !this.dismissing;
417
390
  }
418
391
 
419
392
  async #open(modal, { animate = true } = {}) {
420
393
  this.debug("open-start", { animate });
421
394
 
422
- const scrim = this.scrimConnected && this.scrimOutlet;
395
+ this.previousFocusRef =
396
+ document.activeElement === document.body
397
+ ? this.lastFetchFocusRef
398
+ : new WeakRef(document.activeElement);
399
+ this.debug("capture focus", this.previousFocusRef?.deref());
423
400
 
424
401
  this.modal = modal;
425
402
  this.openValue = true;
426
403
 
404
+ // Set turbo-frame[src] without causing a load event
405
+ this.element.delegate.sourceURL = this.modal.src;
406
+
427
407
  await modal.open({ animate });
428
- await scrim?.show({ animate });
429
408
 
430
409
  delete this.opening;
431
410
 
432
411
  this.debug("open-end");
433
412
 
413
+ autofocus(this.modal?.element)?.focus();
414
+
434
415
  // Detect https://github.com/hotwired/turbo-rails/issues/580
435
416
  if (Turbo.session.view.forceReloaded) {
436
417
  console.error("Turbo-Frame response is incompatible with current page");
437
418
  }
419
+
420
+ return true;
438
421
  }
439
422
 
440
423
  async #dismiss({ animate = true, reason = "" } = {}) {
@@ -450,12 +433,13 @@ class Kpop__FrameController extends Controller {
450
433
  console.warn("modal missing on dismiss");
451
434
  }
452
435
 
453
- await this.scrimOutlet.hide({ animate });
454
- await this.modal?.dismiss();
436
+ await this.modal?.dismiss({ animate });
455
437
 
456
- this.openValue = false;
457
- this.modal = null;
458
- delete this.dismissing;
438
+ this.clear();
439
+
440
+ this.previousFocusRef?.deref()?.focus();
441
+ this.debug("restore focus", this.previousFocusRef?.deref());
442
+ delete this.previousFocusRef;
459
443
 
460
444
  this.debug("dismiss-end");
461
445
  }
@@ -464,7 +448,8 @@ class Kpop__FrameController extends Controller {
464
448
  return new Promise(window.requestAnimationFrame).then(callback);
465
449
  }
466
450
 
467
- debug(event, ...args) {
451
+ get debug() {
452
+ return debug("FrameController");
468
453
  }
469
454
  }
470
455
 
@@ -512,296 +497,317 @@ function installNavigationInterception(controller) {
512
497
  };
513
498
  }
514
499
 
515
- class Kpop__ModalController extends Controller {
516
- static values = {
517
- fallback_location: String,
518
- layout: String,
519
- };
520
-
521
- connect() {
522
- this.debug("connect");
500
+ function autofocus(container) {
501
+ if (!container) return null;
523
502
 
524
- if (this.layoutValue) {
525
- document.querySelector("#kpop").classList.toggle(this.layoutValue, true);
526
- }
527
- }
528
-
529
- disconnect() {
530
- this.debug("disconnect");
531
-
532
- if (this.layoutValue) {
533
- document.querySelector("#kpop").classList.toggle(this.layoutValue, false);
534
- }
535
- }
536
-
537
- debug(event, ...args) {
538
- }
503
+ return (
504
+ container.querySelector("[autofocus]") ??
505
+ container.querySelector("button:not([disabled])")
506
+ );
539
507
  }
540
508
 
541
509
  /**
542
- * Scrim controller wraps an element that creates a whole page layer.
543
- * It is intended to be used behind a modal or nav drawer.
544
- *
545
- * If the Scrim element receives a click event, it automatically triggers "scrim:hide".
546
- *
547
- * You can show and hide the scrim programmatically by calling show/hide on the controller, e.g. using an outlet.
548
- *
549
- * If you need to respond to the scrim showing or hiding you should subscribe to "scrim:show" and "scrim:hide".
510
+ * Based on Turbo's LinkObserver, checks links on mouse-over and focus to see
511
+ * whether they should open in modals. If they should, then sets the
512
+ * data-turbo-frame attribute so it will be prefetched and opened in the context
513
+ * of the kpop turbo frame.
550
514
  */
551
- class ScrimController extends Controller {
552
- static values = {
553
- open: Boolean,
554
- captive: Boolean,
555
- zIndex: Number,
556
- };
557
-
558
- connect() {
515
+ class LinkObserver {
516
+ started = false;
559
517
 
560
- this.defaultZIndexValue = this.zIndexValue;
561
- this.defaultCaptiveValue = this.captiveValue;
562
-
563
- this.element.scrim = this;
518
+ constructor(delegate, eventTarget) {
519
+ this.delegate = delegate;
520
+ this.eventTarget = eventTarget;
564
521
  }
565
522
 
566
- disconnect() {
523
+ start() {
524
+ if (this.started) return;
525
+ if (this.eventTarget.readyState === "loading") {
526
+ this.eventTarget.addEventListener("DOMContentLoaded", this.#enable, {
527
+ once: true,
528
+ });
529
+ } else {
530
+ this.#enable();
531
+ }
532
+ }
567
533
 
568
- delete this.element.scrim;
534
+ stop() {
535
+ if (!this.started) return;
536
+ this.eventTarget.removeEventListener("mouseenter", this.#addKpopLink, {
537
+ capture: true,
538
+ passive: true,
539
+ });
540
+ this.eventTarget.removeEventListener(
541
+ "turbo:before-prefetch",
542
+ this.#addKpopLink,
543
+ {
544
+ capture: true,
545
+ passive: true,
546
+ },
547
+ );
548
+ this.eventTarget.removeEventListener("focusin", this.#addKpopLink, {
549
+ capture: true,
550
+ passive: true,
551
+ });
552
+ this.eventTarget.removeEventListener("mouseleave", this.#removeKpopLink, {
553
+ capture: true,
554
+ passive: true,
555
+ });
556
+ this.eventTarget.removeEventListener("focusout", this.#removeKpopLink, {
557
+ capture: true,
558
+ passive: true,
559
+ });
560
+ this.started = false;
569
561
  }
570
562
 
571
- async show({
572
- captive = this.defaultCaptiveValue,
573
- zIndex = this.defaultZIndexValue,
574
- top = window.scrollY,
575
- animate = true,
576
- } = {}) {
563
+ #enable = () => {
564
+ if (this.started) return;
565
+ this.started = true;
566
+ this.eventTarget.addEventListener("mouseenter", this.#addKpopLink, {
567
+ capture: true,
568
+ passive: true,
569
+ });
570
+ this.eventTarget.addEventListener("focusin", this.#addKpopLink, {
571
+ capture: true,
572
+ passive: true,
573
+ });
574
+ this.eventTarget.addEventListener(
575
+ "turbo:before-prefetch",
576
+ this.#addKpopLink,
577
+ {
578
+ capture: true,
579
+ passive: true,
580
+ },
581
+ );
582
+ this.eventTarget.addEventListener("mouseleave", this.#removeKpopLink, {
583
+ capture: true,
584
+ passive: true,
585
+ });
586
+ this.eventTarget.addEventListener("focusout", this.#removeKpopLink, {
587
+ capture: true,
588
+ passive: true,
589
+ });
590
+ };
577
591
 
578
- // hide the scrim before opening the new one if it's already open
579
- if (this.openValue) {
580
- await this.hide({ animate });
592
+ #addKpopLink = (event) => {
593
+ const target = event.target;
594
+ const isLink =
595
+ target.matches &&
596
+ target.matches(
597
+ "a[href]:not([target^=_]):not([download]):not([data-turbo-frame]",
598
+ );
599
+ if (isLink && this.#isPrefetchable(target)) {
600
+ const link = target;
601
+ const location = getLocationForLink(link);
602
+ if (this.delegate.isModalLink(link, location)) {
603
+ link.dataset.turboFrame = "kpop";
604
+ }
581
605
  }
606
+ };
582
607
 
583
- // update internal state
584
- this.openValue = true;
585
-
586
- // notify listeners of pending request
587
- this.dispatch("show", { bubbles: true });
588
-
589
- // update state, perform style updates
590
- this.#show(captive, zIndex, top);
591
-
592
- if (animate) {
593
- // animate opening
594
- // this will trigger an animationEnd event via CSS that completes the open
595
- this.element.dataset.showAnimating = "";
596
-
597
- await new Promise((resolve) => {
598
- this.element.addEventListener("animationend", () => resolve(), {
599
- once: true,
600
- });
601
- });
602
-
603
- delete this.element.dataset.showAnimating;
608
+ #removeKpopLink = (event) => {
609
+ const target = event.target;
610
+ const isLink =
611
+ target.matches && target.matches("a[href][data-turbo-frame='kpop']");
612
+ if (isLink) {
613
+ delete target.dataset.turboFrame;
604
614
  }
605
- }
606
-
607
- async hide({ animate = true } = {}) {
608
- if (!this.openValue || this.element.dataset.hideAnimating) return;
615
+ };
609
616
 
610
- // notify listeners of pending request
611
- this.dispatch("hide", { bubbles: true });
617
+ #isPrefetchable(link) {
618
+ const href = link.getAttribute("href");
619
+ if (!href) return false;
620
+ if (unfetchableLink(link)) return false;
621
+ if (linkToTheSamePage(link)) return false;
622
+ if (linkOptsOut(link)) return false;
623
+ if (nonSafeLink(link)) return false;
624
+ return true;
625
+ }
626
+ }
612
627
 
613
- if (animate) {
614
- // set animation state
615
- // this will trigger an animationEnd event via CSS that completes the hide
616
- this.element.dataset.hideAnimating = "";
628
+ function getLocationForLink(link) {
629
+ return new URL(link.getAttribute("href").toString(), document.baseURI);
630
+ }
617
631
 
618
- await new Promise((resolve) => {
619
- this.element.addEventListener("animationend", () => resolve(), {
620
- once: true,
621
- });
622
- });
632
+ const unfetchableLink = (link) =>
633
+ link.origin !== document.location.origin ||
634
+ !["http:", "https:"].includes(link.protocol) ||
635
+ link.hasAttribute("target");
623
636
 
624
- delete this.element.dataset.hideAnimating;
625
- }
637
+ const linkToTheSamePage = (link) =>
638
+ link.pathname + link.search ===
639
+ document.location.pathname + document.location.search ||
640
+ link.href.startsWith("#");
626
641
 
627
- this.#hide();
642
+ const linkOptsOut = (link) => {
643
+ return link.getAttribute("data-turbo") === "false";
644
+ };
628
645
 
629
- this.openValue = false;
630
- }
646
+ const nonSafeLink = (link) => {
647
+ const turboMethod = link.getAttribute("data-turbo-method");
648
+ if (turboMethod && turboMethod.toLowerCase() !== "get") return true;
649
+ if (isUJS(link)) return true;
650
+ if (link.hasAttribute("data-turbo-confirm")) return true;
651
+ if (link.hasAttribute("data-turbo-stream")) return true;
652
+ return false;
653
+ };
631
654
 
632
- dismiss(event) {
655
+ const isUJS = (link) =>
656
+ link.hasAttribute("data-remote") ||
657
+ link.hasAttribute("data-behavior") ||
658
+ link.hasAttribute("data-confirm") ||
659
+ link.hasAttribute("data-method");
633
660
 
634
- if (!this.captiveValue) this.dispatch("dismiss", { bubbles: true });
635
- }
661
+ /**
662
+ * Similar to Hotwire's PathConfiguration.json, this class compiles a list of
663
+ * rules to check link hrefs against so that we can identify links that
664
+ * should open in a KPOP modal.
665
+ *
666
+ * Unlike Hotwire Native, we can't intercept 303s in the browser before they
667
+ * load. Browser sandbox prevents us from inspecting the location of redirect
668
+ * requests so we can only intercept links that match modals directly.
669
+ *
670
+ * For posts and redirects, we need server support (flash modals, streams).
671
+ */
672
+ class Ruleset {
673
+ constructor(rules = []) {
674
+ this.rules = [];
636
675
 
637
- escape(event) {
638
- if (
639
- event.key === "Escape" &&
640
- !this.captiveValue &&
641
- !event.defaultPrevented
642
- ) {
643
- this.dispatch("dismiss", { bubbles: true });
644
- }
676
+ rules.forEach((rule) => {
677
+ this.#compileRule(rule);
678
+ });
645
679
  }
646
680
 
647
681
  /**
648
- * Clips body to viewport size and sets the z-index
682
+ * Returns properties for the given URL
683
+ *
684
+ * @param {URL} location
685
+ * @returns {} properties
649
686
  */
650
- #show(captive, zIndex, top) {
651
- this.captiveValue = captive;
652
- this.zIndexValue = zIndex;
653
- this.scrollY = top;
654
-
655
- this.element.style.zIndex = this.zIndexValue;
656
- document.body.style.top = `-${top}px`;
657
- document.body.style.position = "fixed";
658
- document.body.style.paddingRight = `-${this.scrollPadding}px`;
659
-
660
- if (document.body.scrollHeight > window.innerHeight) {
661
- document.body.style.overflowY = "scroll";
662
- }
687
+ properties(location) {
688
+ return this.rules.reduce((c, f) => f(location, c), {});
663
689
  }
664
690
 
691
+ #compileRule({ patterns, properties }) {
692
+ patterns.forEach((pattern) => {
693
+ this.rules.push(locationMatcher(new RegExp(pattern), properties));
694
+ });
695
+ }
696
+ }
697
+
698
+ function locationMatcher(re, properties) {
699
+ return (location, accumulator) =>
700
+ re.test(location.pathname)
701
+ ? { ...accumulator, ...properties }
702
+ : accumulator;
703
+ }
704
+
705
+ class StreamModal extends Modal {
665
706
  /**
666
- * Unclips body from viewport size and unsets the z-index
707
+ * When a turbo-stream[action=kpop_open] element is rendered, it runs this
708
+ * method to load the modal template as a StreamModal.
709
+ *
710
+ * @param {Kpop__FrameController} frame
711
+ * @param {Turbo.StreamElement} action
667
712
  */
668
- #hide() {
669
- this.captiveValue = this.defaultCaptiveValue;
670
- this.zIndexValue = this.defaultZIndexValue;
713
+ static async open(frame, action) {
714
+ const animate = !frame.isOpen;
715
+
716
+ await frame.dismiss({ animate, reason: "turbo-stream.kpop_open" });
671
717
 
672
- this.element.style.removeProperty("z-index");
673
- document.body.style.removeProperty("position");
674
- document.body.style.removeProperty("top");
675
- document.body.style.removeProperty("overflow-y");
718
+ frame.element.append(action.templateContent);
676
719
 
677
- window.scrollTo({ left: 0, top: this.scrollY, behavior: "instant" });
720
+ const dialog = frame.element.querySelector("dialog");
721
+ const src = dialog.dataset.src;
678
722
 
679
- delete this.scrollY;
723
+ await frame.open(new StreamModal(frame, dialog, src), { animate });
680
724
  }
681
725
  }
682
726
 
683
- class StreamModal extends Modal {
684
- constructor(id, action) {
685
- super(id);
727
+ class StreamActions {
728
+ start() {
729
+ Turbo$1.StreamActions.kpop_open = openStreamModal;
730
+ }
686
731
 
687
- this.action = action;
732
+ stop() {
733
+ delete Turbo$1.StreamActions.kpop_open;
688
734
  }
735
+ }
689
736
 
690
- /**
691
- * When the modal opens, push a state event for the current location so that
692
- * the user can dismiss the modal by navigating back.
693
- *
694
- * @returns {Promise<void>}
695
- */
696
- async open() {
697
- await super.open();
737
+ function openStreamModal() {
738
+ const frame = this.targetElements[0]?.kpop;
698
739
 
699
- window.history.pushState({ kpop: true, id: this.id }, "", window.location);
740
+ if (frame) {
741
+ StreamModal.open(frame, this).then(() => {});
700
742
  }
743
+ }
701
744
 
702
- /**
703
- * On dismiss, pop the state event that was pushed when the modal opened,
704
- * then clear any modals from the turbo frame element.
705
- *
706
- * @returns {Promise<void>}
707
- */
708
- async dismiss() {
709
- await super.dismiss();
745
+ const controllers = [
746
+ { identifier: "kpop--frame", controllerConstructor: Kpop__FrameController },
747
+ ];
710
748
 
711
- if (this.isCurrentLocation) {
712
- await this.pop("popstate", () => window.history.back());
713
- }
749
+ class Application {
750
+ static configure(config = {}) {
751
+ this.instance ||= new this(config);
752
+ debug.enabled = this.instance.debug;
753
+ return this.instance;
754
+ }
714
755
 
715
- this.frameElement.innerHTML = "";
756
+ constructor({ rules = [], debug = false } = {}) {
757
+ this.config = { rules, debug };
758
+ this.ruleset = new Ruleset(rules);
759
+ this.linkObserver = new LinkObserver(this, document);
760
+ this.streamActions = new StreamActions();
716
761
  }
717
762
 
718
- /**
719
- * On navigation from inside the modal, dismiss the modal first so that the
720
- * modal does not appear in the history stack.
721
- *
722
- * @param frame TurboFrame element
723
- * @param e Turbo navigation event
724
- */
725
- beforeVisit(frame, e) {
726
- super.beforeVisit(frame, e);
763
+ start() {
764
+ this.streamActions.start();
765
+ this.linkObserver.start();
727
766
 
728
- e.preventDefault();
767
+ window.addEventListener(
768
+ "turbo:before-fetch-request",
769
+ addKpopToRequestHeaders,
770
+ );
729
771
 
730
- frame.dismiss({ animate: false }).then(() => {
731
- Turbo$1.visit(e.detail.url);
772
+ if (this.debug) {
773
+ document.addEventListener("focusin", debugFocusIn);
774
+ document.addEventListener("focusout", debugFocusOut);
775
+ }
732
776
 
733
- this.debug("before-visit-end");
734
- });
777
+ return this;
735
778
  }
736
779
 
737
- /**
738
- * If the user pops state, dismiss the modal.
739
- *
740
- * @param frame FrameController
741
- * @param e history event
742
- */
743
- popstate(frame, e) {
744
- super.popstate(frame, e);
780
+ stop() {
781
+ window.removeEventListener(
782
+ "turbo:before-fetch-request",
783
+ addKpopToRequestHeaders,
784
+ );
785
+ document.removeEventListener("focusin", debugFocusIn);
786
+ document.removeEventListener("focusout", debugFocusOut);
745
787
 
746
- frame.dismiss({ animate: true, reason: "popstate" });
788
+ this.streamActions.stop();
789
+ this.linkObserver.stop();
747
790
  }
748
791
 
749
- get isCurrentLocation() {
750
- return window.history.state?.kpop && window.history.state?.id === this.id;
751
- }
752
- }
753
-
754
- class StreamRenderer {
755
- constructor(frame, action) {
756
- this.frame = frame;
757
- this.action = action;
792
+ isModalLink(link, location) {
793
+ const properties = this.ruleset.properties(location);
794
+ return properties.context === "modal";
758
795
  }
759
796
 
760
- render() {
761
- this.frame.src = "";
762
- this.frame.innerHTML = "";
763
- this.frame.append(this.action.templateContent);
797
+ get debug() {
798
+ return Boolean(this.config.debug);
764
799
  }
765
800
  }
766
801
 
767
- function kpop(action) {
768
- return action.targetElements[0]?.kpop;
769
- }
770
-
771
- Turbo$1.StreamActions.kpop_open = function () {
772
- const animate = !kpop(this).openValue;
802
+ const debugFocusIn = (e) => debug("Application")("focus", e.target);
803
+ const debugFocusOut = (e) => debug("Application")("blur", e.target);
773
804
 
774
- kpop(this)
775
- ?.dismiss({ animate, reason: "before-turbo-stream" })
776
- .then(() => {
777
- new StreamRenderer(this.targetElements[0], this).render();
778
- kpop(this)?.open(new StreamModal(this.target, this), { animate });
779
- });
780
- };
805
+ const addKpopToRequestHeaders = (e) => {
806
+ const headers = e.detail.fetchOptions.headers;
781
807
 
782
- Turbo$1.StreamActions.kpop_dismiss = function () {
783
- kpop(this)?.dismiss({ reason: "turbo_stream.kpop.dismiss" });
784
- };
785
-
786
- Turbo$1.StreamActions.kpop_redirect_to = function () {
787
- if (this.dataset.turboFrame === this.target) {
788
- const a = document.createElement("A");
789
- a.setAttribute("data-turbo-action", "replace");
790
- this.targetElements[0].delegate.linkClickIntercepted(
791
- a,
792
- this.getAttribute("href"),
793
- );
794
- } else {
795
- Turbo$1.visit(this.getAttribute("href"), {
796
- action: this.dataset.turboAction,
797
- });
808
+ if (headers["Accept"]?.includes("text/vnd.turbo-stream.html")) {
809
+ headers["Kpop-Available"] = "true";
798
810
  }
799
811
  };
800
812
 
801
- const Definitions = [
802
- { identifier: "kpop--frame", controllerConstructor: Kpop__FrameController },
803
- { identifier: "kpop--modal", controllerConstructor: Kpop__ModalController },
804
- { identifier: "scrim", controllerConstructor: ScrimController },
805
- ];
806
-
807
- export { Definitions as default };
813
+ export { controllers, Application as default };