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,12 +1,11 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- import DEBUG from "../debug";
4
3
  import { ContentModal } from "../modals/content_modal";
5
4
  import { FrameModal } from "../modals/frame_modal";
6
5
 
6
+ import debug from "../utils/debug";
7
+
7
8
  export default class Kpop__FrameController extends Controller {
8
- static outlets = ["scrim"];
9
- static targets = ["modal"];
10
9
  static values = {
11
10
  open: Boolean,
12
11
  };
@@ -19,12 +18,14 @@ export default class Kpop__FrameController extends Controller {
19
18
  // allow our code to intercept frame navigation requests before dom changes
20
19
  installNavigationInterception(this);
21
20
 
22
- if (this.element.src && this.element.complete) {
21
+ const dialog = this.element.querySelector("dialog");
22
+
23
+ if (this.element.src && dialog) {
23
24
  this.debug("new frame modal", this.element.src);
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);
25
+ FrameModal.connect(this, dialog, this.element.src).then(() => {});
26
+ } else if (dialog) {
27
+ this.debug("new content modal", dialog);
28
+ ContentModal.connect(this, dialog);
28
29
  } else {
29
30
  this.debug("no modal");
30
31
  this.clear();
@@ -38,24 +39,18 @@ export default class Kpop__FrameController extends Controller {
38
39
  delete this.modal;
39
40
  }
40
41
 
41
- scrimOutletConnected(scrim) {
42
- this.debug("scrim-connected");
43
-
44
- this.scrimConnected = true;
45
-
46
- if (this.openValue) {
47
- scrim.show({ animate: false });
48
- } else {
49
- scrim.hide({ animate: false });
50
- }
51
- }
52
-
53
42
  openValueChanged(open) {
54
43
  this.debug("open-changed", open);
55
-
56
- this.element.parentElement.style.display = open ? "flex" : "none";
57
44
  }
58
45
 
46
+ /**
47
+ * Animate an attached modal into the foreground. Returns a promise that
48
+ * resolves when the animation is complete.
49
+ *
50
+ * @param modal
51
+ * @param animate
52
+ * @returns {Promise<Boolean>}
53
+ */
59
54
  async open(modal, { animate = true } = {}) {
60
55
  if (this.isOpen) {
61
56
  this.debug("skip open as already open");
@@ -65,12 +60,25 @@ export default class Kpop__FrameController extends Controller {
65
60
 
66
61
  await this.dismissing;
67
62
 
68
- return (this.opening ||= this.#nextFrame(() =>
69
- this.#open(modal, { animate }),
70
- ));
63
+ return (this.opening ||= Promise.resolve().then(() => {
64
+ modal.connect();
65
+ return this.#open(modal, { animate });
66
+ }));
71
67
  }
72
68
 
69
+ /**
70
+ * Cause a modal to hide. Returns a promise that will resolve when the
71
+ * animation (if requested) is finished.
72
+ *
73
+ * If the modal is already animating out, returns the existing promise instead.
74
+ *
75
+ * @param {Boolean} animate
76
+ * @param {String} reason
77
+ * @returns {Promise}
78
+ */
73
79
  async dismiss({ animate = true, reason = "" } = {}) {
80
+ this.debug("event:dismiss", reason);
81
+
74
82
  if (!this.isOpen) {
75
83
  this.debug("skip dismiss as already closed");
76
84
  return false;
@@ -78,36 +86,30 @@ export default class Kpop__FrameController extends Controller {
78
86
 
79
87
  await this.opening;
80
88
 
81
- return (this.dismissing ||= this.#nextFrame(() =>
82
- this.#dismiss({ animate, reason }),
83
- ));
89
+ return (this.dismissing ||= this.#dismiss({ animate, reason }));
84
90
  }
85
91
 
86
- async clear() {
92
+ /**
93
+ * Clean up after a modal is finished dismissing.
94
+ */
95
+ clear({ reason = "" } = {}) {
96
+ this.debug("event:clear", reason);
97
+
87
98
  // clear the src from the frame (if any)
88
99
  this.element.src = "";
100
+ this.element.innerHTML = "";
89
101
 
90
- // remove any open modal(s)
91
- this.modalElements.forEach((element) => element.remove());
92
-
93
- // mark the modal as hidden (will hide scrim on connect)
102
+ // mark the modal as hidden
94
103
  this.openValue = false;
95
104
 
96
- // close the scrim, if connected
97
- if (this.scrimConnected) {
98
- return this.scrimOutlet.hide({ animate: false });
99
- }
100
-
101
105
  // unset modal
102
- this.modal = null;
106
+ if (this.modal) this.modal.disconnect();
107
+ delete this.modal;
108
+ delete this.dismissing;
103
109
  }
104
110
 
105
111
  // EVENTS
106
112
 
107
- popstate(event) {
108
- this.modal?.popstate(this, event);
109
- }
110
-
111
113
  /**
112
114
  * Incoming frame render, dismiss the current modal (if any) first.
113
115
  *
@@ -151,53 +153,72 @@ export default class Kpop__FrameController extends Controller {
151
153
  // ignore visits to the current frame, these fire when the frame navigates
152
154
  if (e.detail.url === this.element.src) return;
153
155
 
156
+ const url = new URL(e.detail.url.toString(), document.baseURI);
157
+ if (url.pathname === "/resume_historical_location") {
158
+ e.preventDefault();
159
+ return this.dismiss();
160
+ }
161
+
154
162
  // ignore unless we're open
155
163
  if (!this.isOpen) return;
156
164
 
157
165
  this.modal.beforeVisit(this, e);
158
166
  }
159
167
 
160
- frameLoad(event) {
168
+ frameLoad(e) {
161
169
  this.debug("frame-load");
162
170
 
163
- const modal = new FrameModal(this.element.id, this.element.src);
164
-
165
- window.addEventListener(
166
- "turbo:visit",
167
- (e) => {
168
- this.open(modal, { animate: true });
169
- },
170
- { once: true },
171
+ FrameModal.load(this, e.target.firstElementChild, e.target.src).then(
172
+ () => {},
171
173
  );
172
174
  }
173
175
 
174
- get isOpen() {
175
- return this.openValue && !this.dismissing;
176
+ /**
177
+ * Outgoing fetch request. Capture the initiator so we can return focus if it causes a modal to show.
178
+ */
179
+ beforeFetchRequest() {
180
+ const focusElement = document.activeElement;
181
+
182
+ if (focusElement === document.body) {
183
+ delete this.lastFetchFocusRef;
184
+ } else {
185
+ this.lastFetchFocusRef = new WeakRef(focusElement);
186
+ }
176
187
  }
177
188
 
178
- get modalElements() {
179
- return this.element.querySelectorAll("[data-controller*='kpop--modal']");
189
+ get isOpen() {
190
+ return this.openValue && !this.dismissing;
180
191
  }
181
192
 
182
193
  async #open(modal, { animate = true } = {}) {
183
194
  this.debug("open-start", { animate });
184
195
 
185
- const scrim = this.scrimConnected && this.scrimOutlet;
196
+ this.previousFocusRef =
197
+ document.activeElement === document.body
198
+ ? this.lastFetchFocusRef
199
+ : new WeakRef(document.activeElement);
200
+ this.debug("capture focus", this.previousFocusRef?.deref());
186
201
 
187
202
  this.modal = modal;
188
203
  this.openValue = true;
189
204
 
205
+ // Set turbo-frame[src] without causing a load event
206
+ this.element.delegate.sourceURL = this.modal.src;
207
+
190
208
  await modal.open({ animate });
191
- await scrim?.show({ animate });
192
209
 
193
210
  delete this.opening;
194
211
 
195
212
  this.debug("open-end");
196
213
 
214
+ autofocus(this.modal?.element)?.focus();
215
+
197
216
  // Detect https://github.com/hotwired/turbo-rails/issues/580
198
217
  if (Turbo.session.view.forceReloaded) {
199
218
  console.error("Turbo-Frame response is incompatible with current page");
200
219
  }
220
+
221
+ return true;
201
222
  }
202
223
 
203
224
  async #dismiss({ animate = true, reason = "" } = {}) {
@@ -211,15 +232,15 @@ export default class Kpop__FrameController extends Controller {
211
232
 
212
233
  if (!this.modal) {
213
234
  console.warn("modal missing on dismiss");
214
- if (DEBUG) debugger;
215
235
  }
216
236
 
217
- await this.scrimOutlet.hide({ animate });
218
- await this.modal?.dismiss();
237
+ await this.modal?.dismiss({ animate });
219
238
 
220
- this.openValue = false;
221
- this.modal = null;
222
- delete this.dismissing;
239
+ this.clear();
240
+
241
+ this.previousFocusRef?.deref()?.focus();
242
+ this.debug("restore focus", this.previousFocusRef?.deref());
243
+ delete this.previousFocusRef;
223
244
 
224
245
  this.debug("dismiss-end");
225
246
  }
@@ -228,8 +249,8 @@ export default class Kpop__FrameController extends Controller {
228
249
  return new Promise(window.requestAnimationFrame).then(callback);
229
250
  }
230
251
 
231
- debug(event, ...args) {
232
- if (DEBUG) console.debug(`FrameController:${event}`, ...args);
252
+ get debug() {
253
+ return debug("FrameController");
233
254
  }
234
255
  }
235
256
 
@@ -276,3 +297,12 @@ function installNavigationInterception(controller) {
276
297
  }
277
298
  };
278
299
  }
300
+
301
+ function autofocus(container) {
302
+ if (!container) return null;
303
+
304
+ return (
305
+ container.querySelector("[autofocus]") ??
306
+ container.querySelector("button:not([disabled])")
307
+ );
308
+ }
@@ -1,63 +1,7 @@
1
- import { Turbo } from "@hotwired/turbo-rails";
2
-
3
1
  import { Modal } from "./modal";
4
2
 
5
3
  export class ContentModal extends Modal {
6
- static connect(frame, element) {
7
- frame.open(new ContentModal(element.id), { animate: false });
8
- }
9
-
10
- constructor(id, src = null) {
11
- super(id);
12
-
13
- if (src) this.src = src;
14
- }
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
- */
28
- async dismiss() {
29
- const fallbackLocation = this.fallbackLocationValue;
30
-
31
- await super.dismiss();
32
-
33
- if (this.visitStarted) {
34
- this.debug("skipping dismiss, visit started");
35
- return;
36
- }
37
- if (!this.isCurrentLocation) {
38
- this.debug("skipping dismiss, not current location");
39
- return;
40
- }
41
-
42
- this.frameElement.innerHTML = "";
43
-
44
- if (fallbackLocation) {
45
- window.history.replaceState(window.history.state, "", fallbackLocation);
46
- }
47
- }
48
-
49
- beforeVisit(frame, e) {
50
- super.beforeVisit(frame, e);
51
-
52
- this.visitStarted = true;
53
-
54
- frame.scrimOutlet.hide({ animate: false });
55
- }
56
-
57
- get src() {
58
- return new URL(
59
- this.currentLocationValue.toString(),
60
- document.baseURI,
61
- ).toString();
4
+ static connect(frame, dialog) {
5
+ frame.open(new ContentModal(frame, dialog), { animate: false });
62
6
  }
63
7
  }
@@ -1,5 +1,3 @@
1
- import { Turbo } from "@hotwired/turbo-rails";
2
-
3
1
  import { Modal } from "./modal";
4
2
 
5
3
  export class FrameModal extends Modal {
@@ -7,25 +5,27 @@ export class FrameModal extends Modal {
7
5
  * When the FrameController detects a frame element on connect, it runs this
8
6
  * method to sanity check the frame src and restore the modal state.
9
7
  *
10
- * @param frame FrameController
11
- * @param element TurboFrame element
8
+ * @param {Kpop__FrameController} frame
9
+ * @param {HTMLDialogElement} dialog
10
+ * @param {String} src
12
11
  */
13
- static connect(frame, element) {
14
- const modal = new FrameModal(element.id, element.src);
12
+ static connect(frame, dialog, src) {
13
+ // restoration visit
14
+ this.debug("restore", src);
15
+ return frame.open(new FrameModal(frame, dialog, src), { animate: false });
16
+ }
15
17
 
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
- }
18
+ /**
19
+ * When the FrameController detects a frame load event, it runs this
20
+ * method to open the modal.
21
+ *
22
+ * @param {Kpop__FrameController} frame
23
+ * @param {HTMLDialogElement} dialog
24
+ * @param {String} src
25
+ */
26
+ static load(frame, dialog, src) {
27
+ this.debug("load", src);
28
+ return frame.open(new FrameModal(frame, dialog, src), { animate: true });
29
29
  }
30
30
 
31
31
  /**
@@ -48,64 +48,7 @@ export class FrameModal extends Modal {
48
48
  element.src = "";
49
49
  }
50
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
51
  this.debug("navigate to", location);
67
52
  resolve();
68
53
  }
69
-
70
- constructor(id, src) {
71
- super(id);
72
- this.src = src;
73
- }
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
- */
81
- async dismiss() {
82
- await super.dismiss();
83
-
84
- if (!this.isCurrentLocation) {
85
- this.debug("skipping dismiss, not current location");
86
- } else {
87
- await this.pop("turbo:load", () => window.history.back());
88
- }
89
-
90
- // no specific close action required, this is turbo's responsibility
91
- }
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
- */
100
- beforeVisit(frame, e) {
101
- super.beforeVisit(frame, e);
102
-
103
- e.preventDefault();
104
-
105
- frame.dismiss({ animate: false }).then(() => {
106
- Turbo.visit(e.detail.url);
107
-
108
- this.debug("before-visit-end");
109
- });
110
- }
111
54
  }
@@ -1,77 +1,124 @@
1
- import { Turbo } from "@hotwired/turbo-rails";
2
-
3
- import DEBUG from "../debug";
1
+ import debug from "../utils/debug";
4
2
 
5
3
  export class Modal {
6
- constructor(id) {
7
- this.id = id;
4
+ constructor(frame, dialog, src = null) {
5
+ this.frame = frame;
6
+ this.element = dialog;
7
+ this.uri = new URL(src || dialog.dataset.src, window.location.origin);
8
8
  }
9
9
 
10
- async open() {
11
- this.debug("open");
10
+ connect() {
11
+ this.element.addEventListener("cancel", this.cancel);
12
+ this.element.addEventListener("close", this.close);
13
+ this.element.addEventListener("mousedown", this.scrim);
12
14
  }
13
15
 
14
- async dismiss() {
15
- this.debug(`dismiss`);
16
+ disconnect() {
17
+ this.element.removeEventListener("cancel", this.cancel);
18
+ this.element.removeEventListener("close", this.close);
19
+ this.element.removeEventListener("mousedown", this.scrim);
16
20
  }
17
21
 
18
- beforeVisit(frame, e) {
19
- this.debug(`before-visit`, e.detail.url);
22
+ get src() {
23
+ return this.uri.pathname + this.uri.search + this.uri.hash;
20
24
  }
21
25
 
22
- popstate(frame, e) {
23
- this.debug(`popstate`, e.state);
24
- }
26
+ cancel = (e) => {
27
+ this.debug("event:cancel", e);
25
28
 
26
- async pop(event, callback) {
27
- this.debug(`pop`);
29
+ e.preventDefault();
28
30
 
29
- const promise = new Promise((resolve) => {
30
- window.addEventListener(
31
- event,
32
- () => {
33
- resolve();
34
- },
35
- { once: true },
36
- );
37
- });
31
+ this.frame.dismiss({ animate: true, reason: "dialog:cancel" });
32
+ };
38
33
 
39
- callback();
34
+ close = (e) => {
35
+ this.debug("event:close", e);
40
36
 
41
- return promise;
42
- }
37
+ this.frame.clear({ reason: "dialog:close" });
38
+ };
43
39
 
44
- get frameElement() {
45
- return document.getElementById(this.id);
46
- }
40
+ scrim = (e) => {
41
+ if (e.target.tagName === "DIALOG") {
42
+ this.debug("event:scrim", e);
47
43
 
48
- get controller() {
49
- return this.frameElement?.kpop;
50
- }
44
+ this.frame.dismiss({ animate: true, reason: "dialog:scrim" });
45
+ }
46
+ };
51
47
 
52
- get modalElement() {
53
- return this.frameElement?.querySelector("[data-controller*='kpop--modal']");
54
- }
48
+ async open({ animate = true } = {}) {
49
+ this.debug("open-start", animate);
55
50
 
56
- get currentLocationValue() {
57
- return this.modalElement?.dataset["kpop-ModalCurrentLocationValue"] || "/";
58
- }
51
+ await animation(this.element, animate, () => this.element.showModal());
59
52
 
60
- get fallbackLocationValue() {
61
- return this.modalElement?.dataset["kpop-ModalFallbackLocationValue"];
53
+ this.debug("open-end");
62
54
  }
63
55
 
64
- get isCurrentLocation() {
65
- return (
66
- window.history.state?.turbo && Turbo.session.location.href === this.src
56
+ /**
57
+ * Modals are closed by animating out the modal then removing the modal
58
+ * element from the wrapping frame.
59
+ *
60
+ * @returns {Promise<void>}
61
+ */
62
+ async dismiss({ animate = true } = {}) {
63
+ this.debug("dismiss-start", animate);
64
+
65
+ await animation(this.element, animate, () =>
66
+ this.element.removeAttribute("open"),
67
67
  );
68
+
69
+ this.debug("dismiss-end");
70
+
71
+ this.element.close();
72
+ }
73
+
74
+ /**
75
+ * When user navigates from inside a modal, dismiss the modal first so
76
+ * that the modal does not appear in the history stack.
77
+ *
78
+ * @param frame FrameController
79
+ * @param e Turbo navigation event
80
+ */
81
+ beforeVisit(frame, e) {
82
+ this.debug(`before-visit`, e.detail.url);
83
+
84
+ this.frame.clear();
68
85
  }
69
86
 
70
- static debug(event, ...args) {
71
- if (DEBUG) console.debug(`${this.name}:${event}`, ...args);
87
+ static get debug() {
88
+ return debug(this.name);
72
89
  }
73
90
 
74
- debug(event, ...args) {
75
- if (DEBUG) console.debug(`${this.constructor.name}:${event}`, ...args);
91
+ get debug() {
92
+ return debug(this.constructor.name);
76
93
  }
77
94
  }
95
+
96
+ function animation(el, animate, trigger) {
97
+ if (!animate) return trigger();
98
+
99
+ const duration = animationDuration(el);
100
+
101
+ return new Promise((resolve) => {
102
+ const resolver = () => {
103
+ el.removeEventListener("animationend", resolver, { once: true });
104
+ clearTimeout(timeout);
105
+ el.toggleAttribute("animate", false);
106
+ resolve();
107
+ };
108
+
109
+ el.addEventListener("animationend", resolver, { once: true });
110
+ const timeout = setTimeout(resolver, duration);
111
+
112
+ el.toggleAttribute("animate", animate);
113
+ trigger();
114
+ });
115
+ }
116
+
117
+ function animationDuration(el, defaultValue = "0.2s") {
118
+ const value =
119
+ getComputedStyle(el).getPropertyValue("--animation-duration") ||
120
+ defaultValue;
121
+ const num = parseFloat(value);
122
+ if (value.endsWith("ms")) return num;
123
+ return num * 1000;
124
+ }