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

Sign up to get free protection for your applications and to get access to all the features.
@@ -15,22 +15,19 @@ export default class Kpop__FrameController extends Controller {
15
15
  this.debug("connect", this.element.src);
16
16
 
17
17
  this.element.kpop = this;
18
- installNavigationInterception(this.element, this.element.delegate);
19
18
 
20
- // restoration visit
19
+ // allow our code to intercept frame navigation requests before dom changes
20
+ installNavigationInterception(this);
21
+
21
22
  if (this.element.src && this.element.complete) {
22
23
  this.debug("new frame modal", this.element.src);
23
- this.open(new FrameModal(this.element.id, this.element.src), {
24
- animate: false,
25
- });
24
+ FrameModal.connect(this, this.element);
25
+ } else if (this.modalElements.length > 0) {
26
+ this.debug("new content modal", window.location.pathname);
27
+ ContentModal.connect(this, this.element);
26
28
  } else {
27
- const element = this.element.querySelector(
28
- "[data-controller*='kpop--modal']"
29
- );
30
- if (element) {
31
- this.debug("new content modal", window.location.pathname);
32
- this.open(new ContentModal(this.element.id), { animate: false });
33
- }
29
+ this.debug("no modal");
30
+ this.clear();
34
31
  }
35
32
  }
36
33
 
@@ -66,6 +63,8 @@ export default class Kpop__FrameController extends Controller {
66
63
  return false;
67
64
  }
68
65
 
66
+ await this.dismissing;
67
+
69
68
  return (this.opening ||= this.#nextFrame(() =>
70
69
  this.#open(modal, { animate })
71
70
  ));
@@ -77,46 +76,45 @@ export default class Kpop__FrameController extends Controller {
77
76
  return false;
78
77
  }
79
78
 
79
+ await this.opening;
80
+
80
81
  return (this.dismissing ||= this.#nextFrame(() =>
81
82
  this.#dismiss({ animate, reason })
82
83
  ));
83
84
  }
84
85
 
85
- // EVENTS
86
+ async clear() {
87
+ // clear the src from the frame (if any)
88
+ this.element.src = "";
86
89
 
87
- popstate(event) {
88
- this.modal?.popstate(this, event);
89
- }
90
+ // remove any open modal(s)
91
+ this.modalElements.forEach((element) => element.remove());
90
92
 
91
- navigateFrame(element, location) {
92
- this.debug("navigate-frame", this.element.src, location);
93
+ // mark the modal as hidden (will hide scrim on connect)
94
+ this.openValue = false;
93
95
 
94
- // Ensure that turbo doesn't cache the frame in a loading state by cancelling
95
- // the current request (if any) by clearing the src.
96
- // Known issue: this won't work if the frame was previously rendering a useful src.
97
- if (this.element.hasAttribute("busy")) {
98
- this.debug("clearing src to cancel turbo request");
99
- this.element.src = "";
96
+ // close the scrim, if connected
97
+ if (this.scrimConnected) {
98
+ return this.scrimOutlet.hide({ animate: false });
100
99
  }
101
100
 
102
- if (this.element.src === location) {
103
- this.debug("skipping navigate as already on location");
104
- return false;
105
- }
101
+ // unset modal
102
+ this.modal = null;
103
+ }
106
104
 
107
- if (this.element.src !== window.location.href) {
108
- console.warn("kpop: frame src doesn't match window", this.element.src, window.location.href, location);
109
- // clear src so that turbo doesn't cache the frame in a loading state
110
- this.element.delegate.ignoringChangesToAttribute("src", (() => {
111
- this.element.src = "";
112
- this.element.delegate.complete = false;
113
- }));
114
- }
105
+ // EVENTS
115
106
 
116
- // Delay turbo's navigateFrame until next tick to let the src change settle.
117
- return Promise.resolve(true);
107
+ popstate(event) {
108
+ this.modal?.popstate(this, event);
118
109
  }
119
110
 
111
+ /**
112
+ * Incoming frame render, dismiss the current modal (if any) first.
113
+ *
114
+ * We're starting the actual visit
115
+ *
116
+ * @param event turbo:before-render
117
+ */
120
118
  beforeFrameRender(event) {
121
119
  this.debug("before-frame-render", event.detail.newFrame.baseURI);
122
120
 
@@ -159,15 +157,25 @@ export default class Kpop__FrameController extends Controller {
159
157
  frameLoad(event) {
160
158
  this.debug("frame-load");
161
159
 
162
- return this.open(new FrameModal(this.element.id, this.element.src), {
163
- animate: true,
164
- });
160
+ const modal = new FrameModal(this.element.id, this.element.src);
161
+
162
+ window.addEventListener(
163
+ "turbo:visit",
164
+ (e) => {
165
+ this.open(modal, { animate: true });
166
+ },
167
+ { once: true }
168
+ );
165
169
  }
166
170
 
167
171
  get isOpen() {
168
172
  return this.openValue && !this.dismissing;
169
173
  }
170
174
 
175
+ get modalElements() {
176
+ return this.element.querySelectorAll("[data-controller*='kpop--modal']");
177
+ }
178
+
171
179
  async #open(modal, { animate = true } = {}) {
172
180
  this.debug("open-start", { animate });
173
181
 
@@ -226,14 +234,24 @@ export default class Kpop__FrameController extends Controller {
226
234
  *
227
235
  * See Turbo issue: https://github.com/hotwired/turbo/issues/1055
228
236
  *
229
- * @param frameElement turbo-frame element
237
+ * @param controller FrameController
230
238
  */
231
- function installNavigationInterception(frameElement, controller) {
232
- if (controller._navigateFrame === undefined) {
233
- controller._navigateFrame = controller.navigateFrame;
234
- controller.navigateFrame = async (element, location) => {
235
- const navigate = await frameElement.kpop?.navigateFrame(element, location);
236
- return navigate && controller._navigateFrame(element, location);
237
- };
238
- }
239
+ function installNavigationInterception(controller) {
240
+ const TurboFrameController =
241
+ controller.element.delegate.constructor.prototype;
242
+
243
+ if (TurboFrameController._navigateFrame) return;
244
+
245
+ TurboFrameController._navigateFrame = TurboFrameController.navigateFrame;
246
+ TurboFrameController.navigateFrame = function (element, url, submitter) {
247
+ const frame = this.findFrameElement(element, submitter);
248
+
249
+ if (frame.kpop) {
250
+ FrameModal.visit(url, frame.kpop, frame, () => {
251
+ TurboFrameController._navigateFrame.call(this, element, url, submitter);
252
+ });
253
+ } else {
254
+ TurboFrameController._navigateFrame.call(this, element, url, submitter);
255
+ }
256
+ };
239
257
  }
@@ -3,13 +3,31 @@ import { Turbo } from "@hotwired/turbo-rails";
3
3
  import { Modal } from "./modal";
4
4
 
5
5
  export class ContentModal extends Modal {
6
+ static connect(frame, element) {
7
+ frame.open(new ContentModal(element.id), { animate: false });
8
+ }
9
+
6
10
  constructor(id, src = null) {
7
11
  super(id);
8
12
 
9
13
  if (src) this.src = src;
10
14
  }
11
15
 
16
+ /**
17
+ * When the modal is dismissed we can't rely on a back navigation to close the
18
+ * modal as the user may have navigated to a different location. Instead we
19
+ * remove the content from the dom and replace the current history state with
20
+ * the fallback location, if set.
21
+ *
22
+ * If there is no fallback location, we may be showing a stream modal that was
23
+ * injected and cached by turbo. In this case, we clear the frame element and
24
+ * do not change history.
25
+ *
26
+ * @returns {Promise<void>}
27
+ */
12
28
  async dismiss() {
29
+ const fallbackLocation = this.fallbackLocationValue;
30
+
13
31
  await super.dismiss();
14
32
 
15
33
  if (this.visitStarted) {
@@ -21,12 +39,11 @@ export class ContentModal extends Modal {
21
39
  return;
22
40
  }
23
41
 
24
- return this.pop("turbo:load", () => {
25
- this.debug("turbo-visit", this.fallbackLocationValue);
26
- Turbo.visit(this.fallbackLocationValue);
27
- });
42
+ this.frameElement.innerHTML = "";
28
43
 
29
- // no specific close action required, this is turbo's responsibility
44
+ if (fallbackLocation) {
45
+ window.history.replaceState(window.history.state, "", fallbackLocation);
46
+ }
30
47
  }
31
48
 
32
49
  beforeVisit(frame, e) {
@@ -3,11 +3,81 @@ import { Turbo } from "@hotwired/turbo-rails";
3
3
  import { Modal } from "./modal";
4
4
 
5
5
  export class FrameModal extends Modal {
6
+ /**
7
+ * When the FrameController detects a frame element on connect, it runs this
8
+ * method to santity check the frame src and restore the modal state.
9
+ *
10
+ * @param frame FrameController
11
+ * @param element TurboFrame element
12
+ */
13
+ static connect(frame, element) {
14
+ const modal = new FrameModal(element.id, element.src);
15
+
16
+ // state reconciliation for turbo restore of invalid frames
17
+ if (modal.isCurrentLocation) {
18
+ // restoration visit
19
+ this.debug("restore", element.src);
20
+ return frame.open(modal, { animate: false });
21
+ } else {
22
+ console.warn(
23
+ "kpop: restored frame src doesn't match window href",
24
+ modal.src,
25
+ window.location.href
26
+ );
27
+ return frame.clear();
28
+ }
29
+ }
30
+
31
+ /**
32
+ * When a user clicks a kpop link, turbo intercepts the click and calls
33
+ * navigateFrame on the turbo frame controller before setting the TurboFrame
34
+ * element's src attribute. KPOP intercepts this call and calls this method
35
+ * first so we cancel problematic navigations that might cache invalid states.
36
+ *
37
+ * @param location URL requested by turbo
38
+ * @param frame FrameController
39
+ * @param element TurboFrame element
40
+ * @param resolve continuation chain
41
+ */
42
+ static visit(location, frame, element, resolve) {
43
+ // Ensure that turbo doesn't cache the frame in a loading state by cancelling
44
+ // the current request (if any) by clearing the src.
45
+ // Known issue: this won't work if the frame was previously rendering a useful src.
46
+ if (element.hasAttribute("busy")) {
47
+ this.debug("clearing src to cancel turbo request");
48
+ element.src = "";
49
+ }
50
+
51
+ if (element.src === location) {
52
+ this.debug("skipping navigate as already on location");
53
+ return;
54
+ }
55
+
56
+ if (element.src && element.src !== window.location.href) {
57
+ console.warn(
58
+ "kpop: frame src doesn't match window",
59
+ element.src,
60
+ window.location.href,
61
+ location
62
+ );
63
+ frame.clear();
64
+ }
65
+
66
+ this.debug("navigate to", location);
67
+ resolve();
68
+ }
69
+
6
70
  constructor(id, src) {
7
71
  super(id);
8
72
  this.src = src;
9
73
  }
10
74
 
75
+ /**
76
+ * FrameModals are closed by running pop state and awaiting the turbo:load
77
+ * event that follows on history restoration.
78
+ *
79
+ * @returns {Promise<void>}
80
+ */
11
81
  async dismiss() {
12
82
  await super.dismiss();
13
83
 
@@ -20,6 +90,13 @@ export class FrameModal extends Modal {
20
90
  // no specific close action required, this is turbo's responsibility
21
91
  }
22
92
 
93
+ /**
94
+ * When user navigates from inside a Frame modal, dismiss the modal first so
95
+ * that the modal does not appear in the history stack.
96
+ *
97
+ * @param frame FrameController
98
+ * @param e Turbo navigation event
99
+ */
23
100
  beforeVisit(frame, e) {
24
101
  super.beforeVisit(frame, e);
25
102
 
@@ -31,11 +108,4 @@ export class FrameModal extends Modal {
31
108
  this.debug("before-visit-end");
32
109
  });
33
110
  }
34
-
35
- popstate(frame, e) {
36
- super.popstate(frame, e);
37
-
38
- // Turbo will restore modal state, but we need to reset the scrim
39
- frame.scrimOutlet.hide({ animate: false });
40
- }
41
111
  }
@@ -45,6 +45,10 @@ export class Modal {
45
45
  return document.getElementById(this.id);
46
46
  }
47
47
 
48
+ get controller() {
49
+ return this.frameElement?.kpop;
50
+ }
51
+
48
52
  get modalElement() {
49
53
  return this.frameElement?.querySelector("[data-controller*='kpop--modal']");
50
54
  }
@@ -54,7 +58,7 @@ export class Modal {
54
58
  }
55
59
 
56
60
  get fallbackLocationValue() {
57
- return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"] || "/";
61
+ return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"];
58
62
  }
59
63
 
60
64
  get isCurrentLocation() {
@@ -63,6 +67,10 @@ export class Modal {
63
67
  );
64
68
  }
65
69
 
70
+ static debug(event, ...args) {
71
+ if (DEBUG) console.debug(`${this.name}:${event}`, ...args);
72
+ }
73
+
66
74
  debug(event, ...args) {
67
75
  if (DEBUG) console.debug(`${this.constructor.name}:${event}`, ...args);
68
76
  }
@@ -9,12 +9,24 @@ export class StreamModal extends Modal {
9
9
  this.action = action;
10
10
  }
11
11
 
12
+ /**
13
+ * When the modal opens, push a state event for the current location so that
14
+ * the user can dismiss the modal by navigating back.
15
+ *
16
+ * @returns {Promise<void>}
17
+ */
12
18
  async open() {
13
19
  await super.open();
14
20
 
15
21
  window.history.pushState({ kpop: true, id: this.id }, "", window.location);
16
22
  }
17
23
 
24
+ /**
25
+ * On dismiss, pop the state event that was pushed when the modal opened,
26
+ * then clear any modals from the turbo frame element.
27
+ *
28
+ * @returns {Promise<void>}
29
+ */
18
30
  async dismiss() {
19
31
  await super.dismiss();
20
32
 
@@ -25,6 +37,13 @@ export class StreamModal extends Modal {
25
37
  this.frameElement.innerHTML = "";
26
38
  }
27
39
 
40
+ /**
41
+ * On navigation from inside the modal, dismiss the modal first so that the
42
+ * modal does not appear in the history stack.
43
+ *
44
+ * @param frame TurboFrame element
45
+ * @param e Turbo navigation event
46
+ */
28
47
  beforeVisit(frame, e) {
29
48
  super.beforeVisit(frame, e);
30
49
 
@@ -37,6 +56,12 @@ export class StreamModal extends Modal {
37
56
  });
38
57
  }
39
58
 
59
+ /**
60
+ * If the user pops state, dismiss the modal.
61
+ *
62
+ * @param frame FrameController
63
+ * @param e history event
64
+ */
40
65
  popstate(frame, e) {
41
66
  super.popstate(frame, e);
42
67
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Katalyst
4
4
  module Kpop
5
- VERSION = "3.0.0.beta.7"
5
+ VERSION = "3.0.0.beta.8"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: katalyst-kpop
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.beta.7
4
+ version: 3.0.0.beta.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Katalyst Interactive