Packages
phoenix_live_view
1.0.13
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
} from "./constants"
import LiveUploader from "./live_uploader"
import ARIA from "./aria"
let Hooks = {
LiveFileUpload: {
activeRefs(){ return this.el.getAttribute(PHX_ACTIVE_ENTRY_REFS) },
preflightedRefs(){ return this.el.getAttribute(PHX_PREFLIGHTED_REFS) },
mounted(){ this.preflightedWas = this.preflightedRefs() },
updated(){
let 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)
}
})
this.el.addEventListener("phx:show-end", () => this.el.focus())
if(window.getComputedStyle(this.el).display !== "none"){
ARIA.focusFirst(this.el)
}
}
}
}
let 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)
}
let scrollTop = (scrollContainer) => {
if(scrollContainer){
return scrollContainer.scrollTop
} else {
return document.documentElement.scrollTop || document.body.scrollTop
}
}
let 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
}
}
let 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
}
}
let isAtViewportTop = (el, scrollContainer) => {
let rect = el.getBoundingClientRect()
return Math.ceil(rect.top) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.top) <= bottom(scrollContainer)
}
let isAtViewportBottom = (el, scrollContainer) => {
let rect = el.getBoundingClientRect()
return Math.ceil(rect.bottom) >= top(scrollContainer) && Math.ceil(rect.left) >= 0 && Math.floor(rect.bottom) <= bottom(scrollContainer)
}
let isWithinViewport = (el, scrollContainer) => {
let 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
let throttleInterval = 500
let pendingOp = null
let onTopOverrun = this.throttle(throttleInterval, (topEvent, firstChild) => {
pendingOp = () => true
this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id, _overran: true}, () => {
pendingOp = null
})
})
let onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => {
pendingOp = () => firstChild.scrollIntoView({block: "start"})
this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id}, () => {
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"})
}
})
})
})
let onLastChildAtBottom = this.throttle(throttleInterval, (bottomEvent, lastChild) => {
pendingOp = () => lastChild.scrollIntoView({block: "end"})
this.liveSocket.execJSHookPush(this.el, bottomEvent, {id: lastChild.id}, () => {
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) => {
let scrollNow = scrollTop(this.scrollContainer)
if(pendingOp){
scrollBefore = scrollNow
return pendingOp()
}
let rect = this.el.getBoundingClientRect()
let topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top"))
let bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom"))
let lastChild = this.el.lastElementChild
let firstChild = this.el.firstElementChild
let isScrollingUp = scrollNow < scrollBefore
let 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) => {
let now = Date.now()
let 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)
}
}
}
}
export default Hooks