phoenix_live_view 1.2.0-rc.2 → 1.2.0

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 (27) hide show
  1. package/README.md +5 -5
  2. package/assets/js/phoenix_live_view/README.md +3 -0
  3. package/assets/js/phoenix_live_view/{aria.js → aria.ts} +18 -10
  4. package/assets/js/phoenix_live_view/{browser.js → browser.ts} +12 -8
  5. package/assets/js/phoenix_live_view/{dom.js → dom.ts} +107 -34
  6. package/assets/js/phoenix_live_view/{dom_patch.js → dom_patch.ts} +187 -124
  7. package/assets/js/phoenix_live_view/{dom_post_morph_restorer.js → dom_post_morph_restorer.ts} +17 -2
  8. package/assets/js/phoenix_live_view/{element_ref.js → element_ref.ts} +17 -11
  9. package/assets/js/phoenix_live_view/entry_uploader.js +4 -4
  10. package/assets/js/phoenix_live_view/{hooks.js → hooks.ts} +108 -91
  11. package/assets/js/phoenix_live_view/index.ts +14 -301
  12. package/assets/js/phoenix_live_view/js.js +2 -1
  13. package/assets/js/phoenix_live_view/js_commands.ts +12 -9
  14. package/assets/js/phoenix_live_view/{live_socket.js → live_socket.ts} +582 -114
  15. package/assets/js/phoenix_live_view/live_uploader.js +1 -1
  16. package/assets/js/phoenix_live_view/rendered.js +3 -0
  17. package/assets/js/phoenix_live_view/{utils.js → utils.ts} +35 -6
  18. package/assets/js/phoenix_live_view/{view.js → view.ts} +221 -110
  19. package/assets/js/phoenix_live_view/view_hook.ts +92 -32
  20. package/package.json +5 -2
  21. package/priv/static/phoenix_live_view.cjs.js +577 -314
  22. package/priv/static/phoenix_live_view.cjs.js.map +4 -4
  23. package/priv/static/phoenix_live_view.esm.js +577 -314
  24. package/priv/static/phoenix_live_view.esm.js.map +4 -4
  25. package/priv/static/phoenix_live_view.js +584 -314
  26. package/priv/static/phoenix_live_view.min.js +7 -7
  27. /package/assets/js/phoenix_live_view/{constants.js → constants.ts} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Phoenix LiveView
2
2
 
3
- [![Actions Status](https://github.com/phoenixframework/phoenix_live_view/workflows/CI/badge.svg)](https://github.com/phoenixframework/phoenix_live_view/actions?query=workflow%3ACI) [![Hex.pm](https://img.shields.io/hexpm/v/phoenix_live_view.svg)](https://hex.pm/packages/phoenix_live_view) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://hexdocs.pm/phoenix_live_view)
3
+ [![Actions Status](https://github.com/phoenixframework/phoenix_live_view/workflows/CI/badge.svg)](https://github.com/phoenixframework/phoenix_live_view/actions?query=workflow%3ACI) [![Hex.pm](https://img.shields.io/hexpm/v/phoenix_live_view.svg)](https://hex.pm/packages/phoenix_live_view) [![Documentation](https://img.shields.io/badge/documentation-gray)](https://phoenix-live-view.hexdocs.pm)
4
4
 
5
5
  Phoenix LiveView enables rich, real-time user experiences with server-rendered HTML.
6
6
 
@@ -34,7 +34,7 @@ model while keeping your code closer to your data (and ultimately your source of
34
34
 
35
35
  * **Diffs over the wire:** Instead of sending "HTML over the wire", LiveView knows exactly which parts of your templates change, sending minimal diffs over the wire after the initial render, reducing latency and bandwidth usage. The client leverages this information and optimizes the browser with 5-10x faster updates, compared to solutions that replace whole HTML fragments.
36
36
 
37
- * **Live form validation:** LiveView supports real-time form validation out of the box. Create rich user interfaces with features like uploads, nested inputs, and [specialized recovery](https://hexdocs.pm/phoenix_live_view/form-bindings.html#recovery-following-crashes-or-disconnects).
37
+ * **Live form validation:** LiveView supports real-time form validation out of the box. Create rich user interfaces with features like uploads, nested inputs, and [specialized recovery](https://phoenix-live-view.hexdocs.pm/form-bindings.html#recovery-following-crashes-or-disconnects).
38
38
 
39
39
  * **File uploads:** Real-time file uploads with progress indicators and image previews. Process your uploads on the fly or submit them to your desired cloud service.
40
40
 
@@ -52,9 +52,9 @@ model while keeping your code closer to your data (and ultimately your source of
52
52
 
53
53
  ## Learning
54
54
 
55
- Check our [comprehensive docs](https://hexdocs.pm/phoenix_live_view) to get started.
55
+ Check our [comprehensive docs](https://phoenix-live-view.hexdocs.pm) to get started.
56
56
 
57
- The Phoenix framework documentation also keeps a list of [community resources](https://hexdocs.pm/phoenix/community.html), including books, videos, and other materials, and some include LiveView too.
57
+ The Phoenix framework documentation also keeps a list of [community resources](https://phoenix.hexdocs.pm/community.html), including books, videos, and other materials, and some include LiveView too.
58
58
 
59
59
  Also follow these announcements from the Phoenix team on LiveView for more examples and rationale:
60
60
 
@@ -103,7 +103,7 @@ which provides quick times for "First Meaningful Paint", in addition to
103
103
  helping search and indexing engines.
104
104
 
105
105
  Then LiveView uses a persistent connection between client and server.
106
- This allows LiveView applications to react faster to user events as
106
+ This allows LiveView applications to react faster to user events, as
107
107
  there is less work to be done and less data to be sent compared to
108
108
  stateless requests that have to authenticate, decode, load, and encode
109
109
  data on every request.
@@ -0,0 +1,3 @@
1
+ ## LiveView JavaScript Client
2
+
3
+ This is the documentation for the LiveView JavaScript client. It is a more low-level API documentation for advanced users. For a higher-level overview, [see the page on JavaScript interoperability](https://phoenix-live-view.hexdocs.pm/js-interop.html) instead.
@@ -1,13 +1,16 @@
1
1
  const ARIA = {
2
- anyOf(instance, classes) {
3
- return classes.find((name) => instance instanceof name);
2
+ anyOf(
3
+ instance: unknown,
4
+ classes: (new (...args: any[]) => unknown)[],
5
+ ): boolean {
6
+ return classes.some((name) => instance instanceof name);
4
7
  },
5
8
 
6
- isFocusable(el, interactiveOnly) {
9
+ isFocusable(el: Element, interactiveOnly = false): boolean {
7
10
  return (
8
11
  (el instanceof HTMLAnchorElement && el.rel !== "ignore") ||
9
12
  (el instanceof HTMLAreaElement && el.href !== undefined) ||
10
- (!el.disabled &&
13
+ (!("disabled" in el && el.disabled) &&
11
14
  this.anyOf(el, [
12
15
  HTMLInputElement,
13
16
  HTMLSelectElement,
@@ -15,17 +18,19 @@ const ARIA = {
15
18
  HTMLButtonElement,
16
19
  ])) ||
17
20
  el instanceof HTMLIFrameElement ||
18
- (el.tabIndex >= 0 && el.getAttribute("aria-hidden") !== "true") ||
21
+ (el instanceof HTMLElement &&
22
+ el.tabIndex >= 0 &&
23
+ el.getAttribute("aria-hidden") !== "true") ||
19
24
  (!interactiveOnly &&
20
25
  el.getAttribute("tabindex") !== null &&
21
26
  el.getAttribute("aria-hidden") !== "true")
22
27
  );
23
28
  },
24
29
 
25
- attemptFocus(el, interactiveOnly) {
30
+ attemptFocus(el: Element, interactiveOnly = false): boolean {
26
31
  if (this.isFocusable(el, interactiveOnly)) {
27
32
  try {
28
- el.focus();
33
+ (el as HTMLElement).focus();
29
34
  } catch {
30
35
  // that's fine
31
36
  }
@@ -33,7 +38,7 @@ const ARIA = {
33
38
  return !!document.activeElement && document.activeElement.isSameNode(el);
34
39
  },
35
40
 
36
- focusFirstInteractive(el) {
41
+ focusFirstInteractive(el: Element): boolean {
37
42
  let child = el.firstElementChild;
38
43
  while (child) {
39
44
  if (this.attemptFocus(child, true) || this.focusFirstInteractive(child)) {
@@ -41,9 +46,10 @@ const ARIA = {
41
46
  }
42
47
  child = child.nextElementSibling;
43
48
  }
49
+ return false;
44
50
  },
45
51
 
46
- focusFirst(el) {
52
+ focusFirst(el: Element): boolean {
47
53
  let child = el.firstElementChild;
48
54
  while (child) {
49
55
  if (this.attemptFocus(child) || this.focusFirst(child)) {
@@ -51,9 +57,10 @@ const ARIA = {
51
57
  }
52
58
  child = child.nextElementSibling;
53
59
  }
60
+ return false;
54
61
  },
55
62
 
56
- focusLast(el) {
63
+ focusLast(el: Element): boolean {
57
64
  let child = el.lastElementChild;
58
65
  while (child) {
59
66
  if (this.attemptFocus(child) || this.focusLast(child)) {
@@ -61,6 +68,7 @@ const ARIA = {
61
68
  }
62
69
  child = child.previousElementSibling;
63
70
  }
71
+ return false;
64
72
  },
65
73
  };
66
74
  export default ARIA;
@@ -30,7 +30,11 @@ const Browser = {
30
30
  );
31
31
  },
32
32
 
33
- pushState(kind, meta, to) {
33
+ pushState(
34
+ kind: "replace" | "push",
35
+ meta: { type: string; scroll?: number; id?: string; position?: number },
36
+ to?: string,
37
+ ) {
34
38
  if (this.canPushState()) {
35
39
  if (to !== window.location.href) {
36
40
  if (meta.type == "redirect" && meta.scroll) {
@@ -57,32 +61,32 @@ const Browser = {
57
61
  }
58
62
  });
59
63
  }
60
- } else {
64
+ } else if (to) {
61
65
  this.redirect(to);
62
66
  }
63
67
  },
64
68
 
65
- setCookie(name, value, maxAgeSeconds) {
69
+ setCookie(name: string, value: string | number, maxAgeSeconds?: number) {
66
70
  const expires =
67
71
  typeof maxAgeSeconds === "number" ? ` max-age=${maxAgeSeconds};` : "";
68
72
  document.cookie = `${name}=${value};${expires} path=/`;
69
73
  },
70
74
 
71
- getCookie(name) {
75
+ getCookie(name: string) {
72
76
  return document.cookie.replace(
73
77
  new RegExp(`(?:(?:^|.*;\s*)${name}\s*\=\s*([^;]*).*$)|^.*$`),
74
78
  "$1",
75
79
  );
76
80
  },
77
81
 
78
- deleteCookie(name) {
82
+ deleteCookie(name: string) {
79
83
  document.cookie = `${name}=; max-age=-1; path=/`;
80
84
  },
81
85
 
82
86
  redirect(
83
- toURL,
84
- flash,
85
- navigate = (url) => {
87
+ toURL: string,
88
+ flash: string | null = null,
89
+ navigate: (url: string) => void = (url) => {
86
90
  window.location.href = url;
87
91
  },
88
92
  ) {
@@ -28,11 +28,31 @@ import {
28
28
 
29
29
  import { logError } from "./utils";
30
30
 
31
+ export type FormInputLike = HTMLElement & {
32
+ readonly form?: HTMLFormElement | null;
33
+ readonly type?: string;
34
+ readonly validity?: ValidityState;
35
+ readonly name?: string;
36
+ };
37
+
38
+ export type QueryableNode = Element | Document | DocumentFragment;
39
+
31
40
  const DOM = {
32
41
  byId(id) {
33
42
  return document.getElementById(id) || logError(`no id found for ${id}`);
34
43
  },
35
44
 
45
+ elementFromTarget(target: EventTarget): Element | null {
46
+ if (!(target instanceof Node)) {
47
+ return null;
48
+ }
49
+ if (target.nodeType === Node.ELEMENT_NODE) {
50
+ return target as Element;
51
+ } else {
52
+ return target.parentElement;
53
+ }
54
+ },
55
+
36
56
  removeClass(el, className) {
37
57
  el.classList.remove(className);
38
58
  if (el.classList.length === 0) {
@@ -40,7 +60,11 @@ const DOM = {
40
60
  }
41
61
  },
42
62
 
43
- all(node, query, callback) {
63
+ all(
64
+ node: QueryableNode | null,
65
+ query: string,
66
+ callback?: (el: Element) => void,
67
+ ): Element[] {
44
68
  if (!node) {
45
69
  return [];
46
70
  }
@@ -57,7 +81,7 @@ const DOM = {
57
81
  return template.content.childElementCount;
58
82
  },
59
83
 
60
- isUploadInput(el) {
84
+ isUploadInput(el): el is HTMLInputElement {
61
85
  return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null;
62
86
  },
63
87
 
@@ -65,7 +89,7 @@ const DOM = {
65
89
  return inputEl.hasAttribute("data-phx-auto-upload");
66
90
  },
67
91
 
68
- findUploadInputs(node) {
92
+ findUploadInputs(node): HTMLInputElement[] {
69
93
  const formId = node.id;
70
94
  const inputsOutsideForm = this.all(
71
95
  document,
@@ -73,16 +97,33 @@ const DOM = {
73
97
  );
74
98
  return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat(
75
99
  inputsOutsideForm,
76
- );
100
+ ) as HTMLInputElement[];
77
101
  },
78
102
 
79
- findComponentNodeList(viewId, cid, doc = document) {
80
- return this.all(
81
- doc,
103
+ findComponent(
104
+ viewId: string,
105
+ cid: string | number,
106
+ doc: QueryableNode = document,
107
+ ): Element | null {
108
+ return doc.querySelector(
82
109
  `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]`,
83
110
  );
84
111
  },
85
112
 
113
+ getComponent(
114
+ viewId: string,
115
+ cid: number,
116
+ doc: QueryableNode = document,
117
+ ): Element {
118
+ const el = this.findComponent(viewId, cid, doc);
119
+ if (!el) {
120
+ throw new Error(
121
+ `no component found matching viewId ${viewId} and cid ${cid}`,
122
+ );
123
+ }
124
+ return el;
125
+ },
126
+
86
127
  isPhxDestroyed(node) {
87
128
  return node.id && DOM.private(node, "destroyed") ? true : false;
88
129
  },
@@ -208,7 +249,7 @@ const DOM = {
208
249
  ).forEach((parent) => {
209
250
  parentCids.add(cid);
210
251
  this.all(parent, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}]`)
211
- .map((el) => parseInt(el.getAttribute(PHX_COMPONENT)))
252
+ .map((el) => parseInt(el.getAttribute(PHX_COMPONENT)!))
212
253
  .forEach((childCID) => childrenCids.add(childCID));
213
254
  });
214
255
  });
@@ -376,7 +417,7 @@ const DOM = {
376
417
  }
377
418
  },
378
419
 
379
- triggerCycle(el, key, currentCycle) {
420
+ triggerCycle(el, key, currentCycle?) {
380
421
  const [cycle, trigger] = this.private(el, key);
381
422
  if (!currentCycle) {
382
423
  currentCycle = cycle;
@@ -470,7 +511,7 @@ const DOM = {
470
511
  return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0];
471
512
  },
472
513
 
473
- isPortalTemplate(el) {
514
+ isPortalTemplate(el): el is HTMLTemplateElement {
474
515
  return el.tagName === "TEMPLATE" && el.hasAttribute(PHX_PORTAL);
475
516
  },
476
517
 
@@ -491,7 +532,7 @@ const DOM = {
491
532
  return null;
492
533
  },
493
534
 
494
- dispatchEvent(target, name, opts = {}) {
535
+ dispatchEvent(target, name, opts: { bubbles?: boolean; detail?: any } = {}) {
495
536
  let defaultBubble = true;
496
537
  const isUploadTarget =
497
538
  target.nodeName === "INPUT" && target.type === "file";
@@ -524,7 +565,11 @@ const DOM = {
524
565
  // merge attributes from source to target
525
566
  // if an element is ignored, we only merge data attributes
526
567
  // including removing data attributes that are no longer in the source
527
- mergeAttrs(target, source, opts = {}) {
568
+ mergeAttrs(
569
+ target,
570
+ source,
571
+ opts: { exclude?: string[]; isIgnored?: boolean } = {},
572
+ ) {
528
573
  const exclude = new Set(opts.exclude || []);
529
574
  const isIgnored = opts.isIgnored;
530
575
  const sourceAttrs = source.attributes;
@@ -588,7 +633,7 @@ const DOM = {
588
633
  }
589
634
  },
590
635
 
591
- hasSelectionRange(el) {
636
+ hasSelectionRange(el): el is HTMLInputElement | HTMLTextAreaElement {
592
637
  return (
593
638
  el.setSelectionRange && (el.type === "text" || el.type === "textarea")
594
639
  );
@@ -611,21 +656,41 @@ const DOM = {
611
656
  }
612
657
  },
613
658
 
614
- isFormInput(el) {
615
- if (el.localName && customElements.get(el.localName)) {
616
- // Custom Elements may be form associated. This allows them
617
- // to participate within a form's lifecycle, including form
618
- // validity and form submissions.
619
- // The spec for Form Associated custom elements requires the
620
- // custom element's class to contain a static boolean value of `formAssociated`
621
- // which identifies this class as allowed to associate to a form.
622
- // See https://html.spec.whatwg.org/dev/custom-elements.html#custom-elements-face-example
623
- // for details.
624
- return customElements.get(el.localName)[`formAssociated`];
625
- }
659
+ /**
660
+ * Returns true if the element is an input that can be focused and edited by the user,
661
+ * so we can skip patching it if it has focus.
662
+ */
663
+ isEditableInput(el: Element | EventTarget | null): el is FormInputLike {
664
+ return (
665
+ this.isFormAssociated(el) &&
666
+ !(el instanceof HTMLButtonElement) &&
667
+ !(el instanceof HTMLInputElement && el.type === "button")
668
+ );
669
+ },
626
670
 
671
+ isFormAssociated(el: Element | EventTarget | null): el is FormInputLike {
672
+ if (!(el instanceof HTMLElement)) return false;
673
+ if (el.localName) {
674
+ const customEl = customElements.get(el.localName);
675
+ if (customEl) {
676
+ // Custom Elements may be form associated. This allows them
677
+ // to participate within a form's lifecycle, including form
678
+ // validity and form submissions.
679
+ // The spec for Form Associated custom elements requires the
680
+ // custom element's class to contain a static boolean value of `formAssociated`
681
+ // which identifies this class as allowed to associate to a form.
682
+ // See https://html.spec.whatwg.org/dev/custom-elements.html#custom-elements-face-example
683
+ // for details.
684
+ return (
685
+ (customEl as { formAssociated?: boolean }).formAssociated === true
686
+ );
687
+ }
688
+ }
627
689
  return (
628
- /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button"
690
+ el instanceof HTMLInputElement ||
691
+ el instanceof HTMLSelectElement ||
692
+ el instanceof HTMLTextAreaElement ||
693
+ el instanceof HTMLButtonElement
629
694
  );
630
695
  },
631
696
 
@@ -650,21 +715,22 @@ const DOM = {
650
715
  );
651
716
  },
652
717
 
653
- cleanChildNodes(container, phxUpdate) {
718
+ cleanChildNodes(container: Element, phxUpdate: string) {
654
719
  if (
655
720
  DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend", PHX_STREAM])
656
721
  ) {
657
- const toRemove = [];
722
+ const toRemove: Array<ChildNode> = [];
658
723
  container.childNodes.forEach((childNode) => {
659
- if (!childNode.id) {
724
+ if (!("id" in childNode) || !childNode.id) {
660
725
  // Skip warning if it's an empty text node (e.g. a new-line)
661
726
  const isEmptyTextNode =
662
727
  childNode.nodeType === Node.TEXT_NODE &&
728
+ childNode.nodeValue &&
663
729
  childNode.nodeValue.trim() === "";
664
730
  if (!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE) {
665
731
  logError(
666
732
  "only HTML element tags with an id are allowed inside containers with phx-update.\n\n" +
667
- `removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"\n\n`,
733
+ `removing illegal node: "${(("outerHTML" in childNode && (childNode.outerHTML as string)) || childNode.nodeValue || "").trim()}"\n\n`,
668
734
  );
669
735
  }
670
736
  toRemove.push(childNode);
@@ -674,7 +740,11 @@ const DOM = {
674
740
  }
675
741
  },
676
742
 
677
- replaceRootContainer(container, tagName, attrs) {
743
+ replaceRootContainer(
744
+ container: Element,
745
+ tagName: string,
746
+ attrs: Record<string, string>,
747
+ ) {
678
748
  const retainedAttrs = new Set([
679
749
  "id",
680
750
  PHX_SESSION,
@@ -697,9 +767,12 @@ const DOM = {
697
767
  Object.keys(attrs).forEach((attr) =>
698
768
  newContainer.setAttribute(attr, attrs[attr]),
699
769
  );
700
- retainedAttrs.forEach((attr) =>
701
- newContainer.setAttribute(attr, container.getAttribute(attr)),
702
- );
770
+ retainedAttrs.forEach((attr) => {
771
+ const value = container.getAttribute(attr);
772
+ if (value !== null) {
773
+ newContainer.setAttribute(attr, value);
774
+ }
775
+ });
703
776
  newContainer.innerHTML = container.innerHTML;
704
777
  container.replaceWith(newContainer);
705
778
  return newContainer;