katalyst-kpop 3.0.0.beta.7 → 3.0.0.beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 703872786c0b5e4229301d5b3af0bbf25c114d71cded5d82a000ceaa5b1ce171
4
- data.tar.gz: a05c973a7e0485e5d01b7e1f9043a4ea18fe72863997310dfe2ab3db1bf17e18
3
+ metadata.gz: c29776e86c6d5f591b5f5398987da2487a6d8eba09f44da2988852c4b594502b
4
+ data.tar.gz: '004809bbf80602c942b5d4074062d5793c3f210f07f1d1a97a844e544ef60038'
5
5
  SHA512:
6
- metadata.gz: 4afa23866748222ae7d1dd092700dfee01a8885e1afb8c847043f1c117e7f2e72ea1949c325e37c33e4d90b959a019d3ab81a0b282b1ca91da78f58e5f2d4dce
7
- data.tar.gz: 2f85201edeaf47ac7890a1e3824285ab34e1418d41134d5bd623046c5aabc8ad4ec082c3f27435eb034c8779f8fdc3df5369dc5931b4ef4f21bb2c888f7a3fee
6
+ metadata.gz: c4d2466c600eb9c88e68556d08aac862b102052675fa8cdad68aac0033356a52669b0e02f2238b6affff871d98d3410efe22c39bc0bcaeb09a9ae619d728026d
7
+ data.tar.gz: 613392880bd2d59e6610213cec65b4746766c5c8fa6409ec2dca8e9add724a0423f0abdc5c607fe492465409ab79451558d50a44534ce131345e28d39c5811c5
@@ -44,6 +44,10 @@ class Modal {
44
44
  return document.getElementById(this.id);
45
45
  }
46
46
 
47
+ get controller() {
48
+ return this.frameElement?.kpop;
49
+ }
50
+
47
51
  get modalElement() {
48
52
  return this.frameElement?.querySelector("[data-controller*='kpop--modal']");
49
53
  }
@@ -53,7 +57,7 @@ class Modal {
53
57
  }
54
58
 
55
59
  get fallbackLocationValue() {
56
- return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"] || "/";
60
+ return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"];
57
61
  }
58
62
 
59
63
  get isCurrentLocation() {
@@ -62,18 +66,39 @@ class Modal {
62
66
  );
63
67
  }
64
68
 
69
+ static debug(event, ...args) {
70
+ }
71
+
65
72
  debug(event, ...args) {
66
73
  }
67
74
  }
68
75
 
69
76
  class ContentModal extends Modal {
77
+ static connect(frame, element) {
78
+ frame.open(new ContentModal(element.id), { animate: false });
79
+ }
80
+
70
81
  constructor(id, src = null) {
71
82
  super(id);
72
83
 
73
84
  if (src) this.src = src;
74
85
  }
75
86
 
87
+ /**
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.
96
+ *
97
+ * @returns {Promise<void>}
98
+ */
76
99
  async dismiss() {
100
+ const fallbackLocation = this.fallbackLocationValue;
101
+
77
102
  await super.dismiss();
78
103
 
79
104
  if (this.visitStarted) {
@@ -85,12 +110,11 @@ class ContentModal extends Modal {
85
110
  return;
86
111
  }
87
112
 
88
- return this.pop("turbo:load", () => {
89
- this.debug("turbo-visit", this.fallbackLocationValue);
90
- Turbo.visit(this.fallbackLocationValue);
91
- });
113
+ this.frameElement.innerHTML = "";
92
114
 
93
- // no specific close action required, this is turbo's responsibility
115
+ if (fallbackLocation) {
116
+ window.history.replaceState(window.history.state, "", fallbackLocation);
117
+ }
94
118
  }
95
119
 
96
120
  beforeVisit(frame, e) {
@@ -110,11 +134,81 @@ class ContentModal extends Modal {
110
134
  }
111
135
 
112
136
  class FrameModal extends Modal {
137
+ /**
138
+ * When the FrameController detects a frame element on connect, it runs this
139
+ * method to santity check the frame src and restore the modal state.
140
+ *
141
+ * @param frame FrameController
142
+ * @param element TurboFrame element
143
+ */
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
+ }
160
+ }
161
+
162
+ /**
163
+ * When a user clicks a kpop link, turbo intercepts the click and calls
164
+ * navigateFrame on the turbo frame controller before setting the TurboFrame
165
+ * element's src attribute. KPOP intercepts this call and calls this method
166
+ * first so we cancel problematic navigations that might cache invalid states.
167
+ *
168
+ * @param location URL requested by turbo
169
+ * @param frame FrameController
170
+ * @param element TurboFrame element
171
+ * @param resolve continuation chain
172
+ */
173
+ static visit(location, frame, element, resolve) {
174
+ // Ensure that turbo doesn't cache the frame in a loading state by cancelling
175
+ // the current request (if any) by clearing the src.
176
+ // Known issue: this won't work if the frame was previously rendering a useful src.
177
+ if (element.hasAttribute("busy")) {
178
+ this.debug("clearing src to cancel turbo request");
179
+ element.src = "";
180
+ }
181
+
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
+ this.debug("navigate to", location);
198
+ resolve();
199
+ }
200
+
113
201
  constructor(id, src) {
114
202
  super(id);
115
203
  this.src = src;
116
204
  }
117
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
+ */
118
212
  async dismiss() {
119
213
  await super.dismiss();
120
214
 
@@ -127,6 +221,13 @@ class FrameModal extends Modal {
127
221
  // no specific close action required, this is turbo's responsibility
128
222
  }
129
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
+ */
130
231
  beforeVisit(frame, e) {
131
232
  super.beforeVisit(frame, e);
132
233
 
@@ -138,13 +239,6 @@ class FrameModal extends Modal {
138
239
  this.debug("before-visit-end");
139
240
  });
140
241
  }
141
-
142
- popstate(frame, e) {
143
- super.popstate(frame, e);
144
-
145
- // Turbo will restore modal state, but we need to reset the scrim
146
- frame.scrimOutlet.hide({ animate: false });
147
- }
148
242
  }
149
243
 
150
244
  class Kpop__FrameController extends Controller {
@@ -158,22 +252,19 @@ class Kpop__FrameController extends Controller {
158
252
  this.debug("connect", this.element.src);
159
253
 
160
254
  this.element.kpop = this;
161
- installNavigationInterception(this.element, this.element.delegate);
162
255
 
163
- // restoration visit
256
+ // allow our code to intercept frame navigation requests before dom changes
257
+ installNavigationInterception(this);
258
+
164
259
  if (this.element.src && this.element.complete) {
165
260
  this.debug("new frame modal", this.element.src);
166
- this.open(new FrameModal(this.element.id, this.element.src), {
167
- animate: false,
168
- });
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);
169
265
  } else {
170
- const element = this.element.querySelector(
171
- "[data-controller*='kpop--modal']"
172
- );
173
- if (element) {
174
- this.debug("new content modal", window.location.pathname);
175
- this.open(new ContentModal(this.element.id), { animate: false });
176
- }
266
+ this.debug("no modal");
267
+ this.clear();
177
268
  }
178
269
  }
179
270
 
@@ -209,6 +300,8 @@ class Kpop__FrameController extends Controller {
209
300
  return false;
210
301
  }
211
302
 
303
+ await this.dismissing;
304
+
212
305
  return (this.opening ||= this.#nextFrame(() =>
213
306
  this.#open(modal, { animate })
214
307
  ));
@@ -220,46 +313,45 @@ class Kpop__FrameController extends Controller {
220
313
  return false;
221
314
  }
222
315
 
316
+ await this.opening;
317
+
223
318
  return (this.dismissing ||= this.#nextFrame(() =>
224
319
  this.#dismiss({ animate, reason })
225
320
  ));
226
321
  }
227
322
 
228
- // EVENTS
323
+ async clear() {
324
+ // clear the src from the frame (if any)
325
+ this.element.src = "";
229
326
 
230
- popstate(event) {
231
- this.modal?.popstate(this, event);
232
- }
327
+ // remove any open modal(s)
328
+ this.modalElements.forEach((element) => element.remove());
233
329
 
234
- navigateFrame(element, location) {
235
- this.debug("navigate-frame", this.element.src, location);
330
+ // mark the modal as hidden (will hide scrim on connect)
331
+ this.openValue = false;
236
332
 
237
- // Ensure that turbo doesn't cache the frame in a loading state by cancelling
238
- // the current request (if any) by clearing the src.
239
- // Known issue: this won't work if the frame was previously rendering a useful src.
240
- if (this.element.hasAttribute("busy")) {
241
- this.debug("clearing src to cancel turbo request");
242
- this.element.src = "";
333
+ // close the scrim, if connected
334
+ if (this.scrimConnected) {
335
+ return this.scrimOutlet.hide({ animate: false });
243
336
  }
244
337
 
245
- if (this.element.src === location) {
246
- this.debug("skipping navigate as already on location");
247
- return false;
248
- }
338
+ // unset modal
339
+ this.modal = null;
340
+ }
249
341
 
250
- if (this.element.src !== window.location.href) {
251
- console.warn("kpop: frame src doesn't match window", this.element.src, window.location.href, location);
252
- // clear src so that turbo doesn't cache the frame in a loading state
253
- this.element.delegate.ignoringChangesToAttribute("src", (() => {
254
- this.element.src = "";
255
- this.element.delegate.complete = false;
256
- }));
257
- }
342
+ // EVENTS
258
343
 
259
- // Delay turbo's navigateFrame until next tick to let the src change settle.
260
- return Promise.resolve(true);
344
+ popstate(event) {
345
+ this.modal?.popstate(this, event);
261
346
  }
262
347
 
348
+ /**
349
+ * Incoming frame render, dismiss the current modal (if any) first.
350
+ *
351
+ * We're starting the actual visit
352
+ *
353
+ * @param event turbo:before-render
354
+ */
263
355
  beforeFrameRender(event) {
264
356
  this.debug("before-frame-render", event.detail.newFrame.baseURI);
265
357
 
@@ -302,15 +394,25 @@ class Kpop__FrameController extends Controller {
302
394
  frameLoad(event) {
303
395
  this.debug("frame-load");
304
396
 
305
- return this.open(new FrameModal(this.element.id, this.element.src), {
306
- animate: true,
307
- });
397
+ const modal = new FrameModal(this.element.id, this.element.src);
398
+
399
+ window.addEventListener(
400
+ "turbo:visit",
401
+ (e) => {
402
+ this.open(modal, { animate: true });
403
+ },
404
+ { once: true }
405
+ );
308
406
  }
309
407
 
310
408
  get isOpen() {
311
409
  return this.openValue && !this.dismissing;
312
410
  }
313
411
 
412
+ get modalElements() {
413
+ return this.element.querySelectorAll("[data-controller*='kpop--modal']");
414
+ }
415
+
314
416
  async #open(modal, { animate = true } = {}) {
315
417
  this.debug("open-start", { animate });
316
418
 
@@ -367,16 +469,26 @@ class Kpop__FrameController extends Controller {
367
469
  *
368
470
  * See Turbo issue: https://github.com/hotwired/turbo/issues/1055
369
471
  *
370
- * @param frameElement turbo-frame element
472
+ * @param controller FrameController
371
473
  */
372
- function installNavigationInterception(frameElement, controller) {
373
- if (controller._navigateFrame === undefined) {
374
- controller._navigateFrame = controller.navigateFrame;
375
- controller.navigateFrame = async (element, location) => {
376
- const navigate = await frameElement.kpop?.navigateFrame(element, location);
377
- return navigate && controller._navigateFrame(element, location);
378
- };
379
- }
474
+ function installNavigationInterception(controller) {
475
+ const TurboFrameController =
476
+ controller.element.delegate.constructor.prototype;
477
+
478
+ if (TurboFrameController._navigateFrame) return;
479
+
480
+ TurboFrameController._navigateFrame = TurboFrameController.navigateFrame;
481
+ TurboFrameController.navigateFrame = function (element, url, submitter) {
482
+ const frame = this.findFrameElement(element, submitter);
483
+
484
+ if (frame.kpop) {
485
+ FrameModal.visit(url, frame.kpop, frame, () => {
486
+ TurboFrameController._navigateFrame.call(this, element, url, submitter);
487
+ });
488
+ } else {
489
+ TurboFrameController._navigateFrame.call(this, element, url, submitter);
490
+ }
491
+ };
380
492
  }
381
493
 
382
494
  class Kpop__ModalController extends Controller {
@@ -561,12 +673,24 @@ class StreamModal extends Modal {
561
673
  this.action = action;
562
674
  }
563
675
 
676
+ /**
677
+ * When the modal opens, push a state event for the current location so that
678
+ * the user can dismiss the modal by navigating back.
679
+ *
680
+ * @returns {Promise<void>}
681
+ */
564
682
  async open() {
565
683
  await super.open();
566
684
 
567
685
  window.history.pushState({ kpop: true, id: this.id }, "", window.location);
568
686
  }
569
687
 
688
+ /**
689
+ * On dismiss, pop the state event that was pushed when the modal opened,
690
+ * then clear any modals from the turbo frame element.
691
+ *
692
+ * @returns {Promise<void>}
693
+ */
570
694
  async dismiss() {
571
695
  await super.dismiss();
572
696
 
@@ -577,6 +701,13 @@ class StreamModal extends Modal {
577
701
  this.frameElement.innerHTML = "";
578
702
  }
579
703
 
704
+ /**
705
+ * On navigation from inside the modal, dismiss the modal first so that the
706
+ * modal does not appear in the history stack.
707
+ *
708
+ * @param frame TurboFrame element
709
+ * @param e Turbo navigation event
710
+ */
580
711
  beforeVisit(frame, e) {
581
712
  super.beforeVisit(frame, e);
582
713
 
@@ -589,6 +720,12 @@ class StreamModal extends Modal {
589
720
  });
590
721
  }
591
722
 
723
+ /**
724
+ * If the user pops state, dismiss the modal.
725
+ *
726
+ * @param frame FrameController
727
+ * @param e history event
728
+ */
592
729
  popstate(frame, e) {
593
730
  super.popstate(frame, e);
594
731