phoenix_live_view 1.2.0-rc.1 → 1.2.0-rc.3

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 (26) hide show
  1. package/assets/js/phoenix_live_view/README.md +3 -0
  2. package/assets/js/phoenix_live_view/{aria.js → aria.ts} +18 -10
  3. package/assets/js/phoenix_live_view/{browser.js → browser.ts} +12 -8
  4. package/assets/js/phoenix_live_view/{dom.js → dom.ts} +107 -34
  5. package/assets/js/phoenix_live_view/{dom_patch.js → dom_patch.ts} +187 -124
  6. package/assets/js/phoenix_live_view/{dom_post_morph_restorer.js → dom_post_morph_restorer.ts} +17 -2
  7. package/assets/js/phoenix_live_view/{element_ref.js → element_ref.ts} +17 -11
  8. package/assets/js/phoenix_live_view/entry_uploader.js +4 -4
  9. package/assets/js/phoenix_live_view/{hooks.js → hooks.ts} +109 -92
  10. package/assets/js/phoenix_live_view/index.ts +14 -301
  11. package/assets/js/phoenix_live_view/js.js +2 -1
  12. package/assets/js/phoenix_live_view/js_commands.ts +12 -9
  13. package/assets/js/phoenix_live_view/{live_socket.js → live_socket.ts} +582 -114
  14. package/assets/js/phoenix_live_view/live_uploader.js +1 -1
  15. package/assets/js/phoenix_live_view/rendered.js +3 -0
  16. package/assets/js/phoenix_live_view/{utils.js → utils.ts} +35 -6
  17. package/assets/js/phoenix_live_view/{view.js → view.ts} +217 -110
  18. package/assets/js/phoenix_live_view/view_hook.ts +75 -23
  19. package/package.json +5 -2
  20. package/priv/static/phoenix_live_view.cjs.js +575 -315
  21. package/priv/static/phoenix_live_view.cjs.js.map +4 -4
  22. package/priv/static/phoenix_live_view.esm.js +575 -315
  23. package/priv/static/phoenix_live_view.esm.js.map +4 -4
  24. package/priv/static/phoenix_live_view.js +582 -315
  25. package/priv/static/phoenix_live_view.min.js +7 -7
  26. /package/assets/js/phoenix_live_view/{constants.js → constants.ts} +0 -0
@@ -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://hexdocs.pm/phoenix_live_view/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;