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.
- package/assets/js/phoenix_live_view/README.md +3 -0
- package/assets/js/phoenix_live_view/{aria.js → aria.ts} +18 -10
- package/assets/js/phoenix_live_view/{browser.js → browser.ts} +12 -8
- package/assets/js/phoenix_live_view/{dom.js → dom.ts} +107 -34
- package/assets/js/phoenix_live_view/{dom_patch.js → dom_patch.ts} +187 -124
- package/assets/js/phoenix_live_view/{dom_post_morph_restorer.js → dom_post_morph_restorer.ts} +17 -2
- package/assets/js/phoenix_live_view/{element_ref.js → element_ref.ts} +17 -11
- package/assets/js/phoenix_live_view/entry_uploader.js +4 -4
- package/assets/js/phoenix_live_view/{hooks.js → hooks.ts} +109 -92
- package/assets/js/phoenix_live_view/index.ts +14 -301
- package/assets/js/phoenix_live_view/js.js +2 -1
- package/assets/js/phoenix_live_view/js_commands.ts +12 -9
- package/assets/js/phoenix_live_view/{live_socket.js → live_socket.ts} +582 -114
- package/assets/js/phoenix_live_view/live_uploader.js +1 -1
- package/assets/js/phoenix_live_view/rendered.js +3 -0
- package/assets/js/phoenix_live_view/{utils.js → utils.ts} +35 -6
- package/assets/js/phoenix_live_view/{view.js → view.ts} +217 -110
- package/assets/js/phoenix_live_view/view_hook.ts +75 -23
- package/package.json +5 -2
- package/priv/static/phoenix_live_view.cjs.js +575 -315
- package/priv/static/phoenix_live_view.cjs.js.map +4 -4
- package/priv/static/phoenix_live_view.esm.js +575 -315
- package/priv/static/phoenix_live_view.esm.js.map +4 -4
- package/priv/static/phoenix_live_view.js +582 -315
- package/priv/static/phoenix_live_view.min.js +7 -7
- /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(
|
|
3
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|