Packages
phoenix_live_view
1.1.24
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.2.0-rc.3
1.2.0-rc.2
1.2.0-rc.1
1.2.0-rc.0
1.1.32
1.1.31
1.1.30
1.1.29
1.1.28
1.1.27
1.1.26
1.1.25
1.1.24
1.1.23
1.1.22
1.1.21
1.1.20
1.1.19
1.1.18
1.1.17
1.1.16
1.1.15
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.1.0-rc.4
1.1.0-rc.3
1.1.0-rc.2
1.1.0-rc.1
1.1.0-rc.0
1.0.18
1.0.17
1.0.16
1.0.15
1.0.14
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.8
retired
1.0.7
1.0.6
retired
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-rc.9
1.0.0-rc.8
1.0.0-rc.7
1.0.0-rc.6
1.0.0-rc.5
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.4
0.20.3
0.20.2
0.20.1
0.20.0
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.18
0.18.17
0.18.16
0.18.15
0.18.14
0.18.13
0.18.12
0.18.11
0.18.10
0.18.9
0.18.8
0.18.7
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.14
0.17.13
0.17.12
0.17.11
0.17.10
0.17.9
0.17.8
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.8
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.1
0.11.0
0.10.0
0.9.0
0.8.1
0.8.0
0.7.1
0.7.0
0.6.0
0.6.0-dev
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.1
0.3.0
0.2.1
0.2.0
0.1.1
0.1.0
Rich, real-time user experiences with server-rendered HTML
Current section
Files
Jump to
Current section
Files
assets/js/phoenix_live_view/hooks.js
import {
PHX_ACTIVE_ENTRY_REFS,
PHX_LIVE_FILE_UPDATED,
PHX_PREFLIGHTED_REFS,
PHX_UPLOAD_REF,
PHX_VIEWPORT_OVERRUN_TARGET,
} from "./constants";
import LiveUploader from "./live_uploader";
import ARIA from "./aria";
const Hooks = {
LiveFileUpload: {
activeRefs() {
return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS);
},
preflightedRefs() {
return this.el.getAttribute(PHX_PREFLIGHTED_REFS);
},
mounted() {
this.js().ignoreAttributes(this.el, ["value"]);
this.preflightedWas = this.preflightedRefs();
},
updated() {
const newPreflights = this.preflightedRefs();
if (this.preflightedWas !== newPreflights) {
this.preflightedWas = newPreflights;
if (newPreflights === "") {
this.__view().cancelSubmit(this.el.form);
}
}
if (this.activeRefs() === "") {
this.el.value = null;
}
this.el.dispatchEvent(new CustomEvent(PHX_LIVE_FILE_UPDATED));
},
},
LiveImgPreview: {
mounted() {
this.ref = this.el.getAttribute("data-phx-entry-ref");
this.inputEl = document.getElementById(
this.el.getAttribute(PHX_UPLOAD_REF),
);
LiveUploader.getEntryDataURL(this.inputEl, this.ref, (url) => {
this.url = url;
this.el.src = url;
});
},
destroyed() {
URL.revokeObjectURL(this.url);
},
},
FocusWrap: {
mounted() {
this.focusStart = this.el.firstElementChild;
this.focusEnd = this.el.lastElementChild;
this.focusStart.addEventListener("focus", (e) => {
if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) {
// Handle focus entering from outside (e.g. Tab when body is focused)
// https://github.com/phoenixframework/phoenix_live_view/issues/3636
const nextFocus = e.target.nextElementSibling;
ARIA.attemptFocus(nextFocus) || ARIA.focusFirst(nextFocus);
} else {
ARIA.focusLast(this.el);
}
});
this.focusEnd.addEventListener("focus", (e) => {
if (!e.relatedTarget || !this.el.contains(e.relatedTarget)) {
// Handle focus entering from outside (e.g. Shift+Tab when body is focused)
// https://github.com/phoenixframework/phoenix_live_view/issues/3636
const nextFocus = e.target.previousElementSibling;
ARIA.attemptFocus(nextFocus) || ARIA.focusLast(nextFocus);
} else {
ARIA.focusFirst(this.el);
}
});
// only try to change the focus if it is not already inside
if (!this.el.contains(document.activeElement)) {
this.el.addEventListener("phx:show-end", () => this.el.focus());
if (window.getComputedStyle(this.el).display !== "none") {
ARIA.focusFirst(this.el);
}
}
},
},
};
const findScrollContainer = (el) => {
// the scroll event won't be fired on the html/body element even if overflow is set
// therefore we return null to instead listen for scroll events on document
if (["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) return null;
if (["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0)
return el;
return findScrollContainer(el.parentElement);
};
const scrollTop = (scrollContainer) => {
if (scrollContainer) {
return scrollContainer.scrollTop;
} else {
return document.documentElement.scrollTop || document.body.scrollTop;
}
};
const bottom = (scrollContainer) => {
if (scrollContainer) {
return scrollContainer.getBoundingClientRect().bottom;
} else {
// when we have no container, the whole page scrolls,
// therefore the bottom coordinate is the viewport height
return window.innerHeight || document.documentElement.clientHeight;
}
};
const top = (scrollContainer) => {
if (scrollContainer) {
return scrollContainer.getBoundingClientRect().top;
} else {
// when we have no container the whole page scrolls,
// therefore the top coordinate is 0
return 0;
}
};
const isAtViewportTop = (el, scrollContainer) => {
const rect = el.getBoundingClientRect();
return (
Math.ceil(rect.top) >= top(scrollContainer) &&
Math.ceil(rect.left) >= 0 &&
Math.floor(rect.top) <= bottom(scrollContainer)
);
};
const isAtViewportBottom = (el, scrollContainer) => {
const rect = el.getBoundingClientRect();
return (
Math.ceil(rect.bottom) >= top(scrollContainer) &&
Math.ceil(rect.left) >= 0 &&
Math.floor(rect.bottom) <= bottom(scrollContainer)
);
};
const isWithinViewport = (el, scrollContainer) => {
const rect = el.getBoundingClientRect();
return (
Math.ceil(rect.top) >= top(scrollContainer) &&
Math.ceil(rect.left) >= 0 &&
Math.floor(rect.top) <= bottom(scrollContainer)
);
};
Hooks.InfiniteScroll = {
mounted() {
this.scrollContainer = findScrollContainer(this.el);
let scrollBefore = scrollTop(this.scrollContainer);
let topOverran = false;
const throttleInterval = 500;
let pendingOp = null;
const onTopOverrun = this.throttle(
throttleInterval,
(topEvent, firstChild) => {
pendingOp = () => true;
this.liveSocket.js().push(this.el, topEvent, {
value: { id: firstChild.id, _overran: true },
callback: () => {
pendingOp = null;
},
});
},
);
const onFirstChildAtTop = this.throttle(
throttleInterval,
(topEvent, firstChild) => {
pendingOp = () => firstChild.scrollIntoView({ block: "start" });
this.liveSocket.js().push(this.el, topEvent, {
value: { id: firstChild.id },
callback: () => {
pendingOp = null;
// make sure that the DOM is patched by waiting for the next tick
window.requestAnimationFrame(() => {
if (!isWithinViewport(firstChild, this.scrollContainer)) {
firstChild.scrollIntoView({ block: "start" });
}
});
},
});
},
);
const onLastChildAtBottom = this.throttle(
throttleInterval,
(bottomEvent, lastChild) => {
pendingOp = () => lastChild.scrollIntoView({ block: "end" });
this.liveSocket.js().push(this.el, bottomEvent, {
value: { id: lastChild.id },
callback: () => {
pendingOp = null;
// make sure that the DOM is patched by waiting for the next tick
window.requestAnimationFrame(() => {
if (!isWithinViewport(lastChild, this.scrollContainer)) {
lastChild.scrollIntoView({ block: "end" });
}
});
},
});
},
);
this.onScroll = (_e) => {
const scrollNow = scrollTop(this.scrollContainer);
if (pendingOp) {
scrollBefore = scrollNow;
return pendingOp();
}
const rect = this.findOverrunTarget();
const topEvent = this.el.getAttribute(
this.liveSocket.binding("viewport-top"),
);
const bottomEvent = this.el.getAttribute(
this.liveSocket.binding("viewport-bottom"),
);
const lastChild = this.el.lastElementChild;
const firstChild = this.el.firstElementChild;
const isScrollingUp = scrollNow < scrollBefore;
const isScrollingDown = scrollNow > scrollBefore;
// el overran while scrolling up
if (isScrollingUp && topEvent && !topOverran && rect.top >= 0) {
topOverran = true;
onTopOverrun(topEvent, firstChild);
} else if (isScrollingDown && topOverran && rect.top <= 0) {
topOverran = false;
}
if (
topEvent &&
isScrollingUp &&
isAtViewportTop(firstChild, this.scrollContainer)
) {
onFirstChildAtTop(topEvent, firstChild);
} else if (
bottomEvent &&
isScrollingDown &&
isAtViewportBottom(lastChild, this.scrollContainer)
) {
onLastChildAtBottom(bottomEvent, lastChild);
}
scrollBefore = scrollNow;
};
if (this.scrollContainer) {
this.scrollContainer.addEventListener("scroll", this.onScroll);
} else {
window.addEventListener("scroll", this.onScroll);
}
},
destroyed() {
if (this.scrollContainer) {
this.scrollContainer.removeEventListener("scroll", this.onScroll);
} else {
window.removeEventListener("scroll", this.onScroll);
}
},
throttle(interval, callback) {
let lastCallAt = 0;
let timer;
return (...args) => {
const now = Date.now();
const remainingTime = interval - (now - lastCallAt);
if (remainingTime <= 0 || remainingTime > interval) {
if (timer) {
clearTimeout(timer);
timer = null;
}
lastCallAt = now;
callback(...args);
} else if (!timer) {
timer = setTimeout(() => {
lastCallAt = Date.now();
timer = null;
callback(...args);
}, remainingTime);
}
};
},
findOverrunTarget() {
let rect;
const overrunTarget = this.el.getAttribute(
this.liveSocket.binding(PHX_VIEWPORT_OVERRUN_TARGET),
);
if (overrunTarget) {
const overrunEl = document.getElementById(overrunTarget);
if (overrunEl) {
rect = overrunEl.getBoundingClientRect();
} else {
throw new Error("did not find element with id " + overrunTarget);
}
} else {
rect = this.el.getBoundingClientRect();
}
return rect;
},
};
export default Hooks;