Packages
phoenix_live_view
0.18.11
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/live_socket.js
/** Initializes the LiveSocket
*
*
* @param {string} endPoint - The string WebSocket endpoint, ie, `"wss://example.com/live"`,
* `"/live"` (inherited host & protocol)
* @param {Phoenix.Socket} socket - the required Phoenix Socket class imported from "phoenix". For example:
*
* import {Socket} from "phoenix"
* import {LiveSocket} from "phoenix_live_view"
* let liveSocket = new LiveSocket("/live", Socket, {...})
*
* @param {Object} [opts] - Optional configuration. Outside of keys listed below, all
* configuration is passed directly to the Phoenix Socket constructor.
* @param {Object} [opts.defaults] - The optional defaults to use for various bindings,
* such as `phx-debounce`. Supports the following keys:
*
* - debounce - the millisecond phx-debounce time. Defaults 300
* - throttle - the millisecond phx-throttle time. Defaults 300
*
* @param {Function} [opts.params] - The optional function for passing connect params.
* The function receives the element associated with a given LiveView. For example:
*
* (el) => {view: el.getAttribute("data-my-view-name", token: window.myToken}
*
* @param {string} [opts.bindingPrefix] - The optional prefix to use for all phx DOM annotations.
* Defaults to "phx-".
* @param {Object} [opts.hooks] - The optional object for referencing LiveView hook callbacks.
* @param {Object} [opts.uploaders] - The optional object for referencing LiveView uploader callbacks.
* @param {integer} [opts.loaderTimeout] - The optional delay in milliseconds to wait before apply
* loading states.
* @param {integer} [opts.maxReloads] - The maximum reloads before entering failsafe mode.
* @param {integer} [opts.reloadJitterMin] - The minimum time between normal reload attempts.
* @param {integer} [opts.reloadJitterMax] - The maximum time between normal reload attempts.
* @param {integer} [opts.failsafeJitter] - The time between reload attempts in failsafe mode.
* @param {Function} [opts.viewLogger] - The optional function to log debug information. For example:
*
* (view, kind, msg, obj) => console.log(`${view.id} ${kind}: ${msg} - `, obj)
*
* @param {Object} [opts.metadata] - The optional object mapping event names to functions for
* populating event metadata. For example:
*
* metadata: {
* click: (e, el) => {
* return {
* ctrlKey: e.ctrlKey,
* metaKey: e.metaKey,
* detail: e.detail || 1,
* }
* },
* keydown: (e, el) => {
* return {
* key: e.key,
* ctrlKey: e.ctrlKey,
* metaKey: e.metaKey,
* shiftKey: e.shiftKey
* }
* }
* }
* @param {Object} [opts.sessionStorage] - An optional Storage compatible object
* Useful when LiveView won't have access to `sessionStorage`. For example, This could
* happen if a site loads a cross-domain LiveView in an iframe. Example usage:
*
* class InMemoryStorage {
* constructor() { this.storage = {} }
* getItem(keyName) { return this.storage[keyName] || null }
* removeItem(keyName) { delete this.storage[keyName] }
* setItem(keyName, keyValue) { this.storage[keyName] = keyValue }
* }
*
* @param {Object} [opts.localStorage] - An optional Storage compatible object
* Useful for when LiveView won't have access to `localStorage`.
* See `opts.sessionStorage` for examples.
*/
import {
BINDING_PREFIX,
CONSECUTIVE_RELOADS,
DEFAULTS,
FAILSAFE_JITTER,
LOADER_TIMEOUT,
MAX_RELOADS,
PHX_DEBOUNCE,
PHX_DROP_TARGET,
PHX_HAS_FOCUSED,
PHX_KEY,
PHX_LINK_STATE,
PHX_LIVE_LINK,
PHX_LV_DEBUG,
PHX_LV_LATENCY_SIM,
PHX_LV_PROFILE,
PHX_MAIN,
PHX_PARENT_ID,
PHX_VIEW_SELECTOR,
PHX_ROOT_ID,
PHX_THROTTLE,
PHX_TRACK_UPLOADS,
PHX_SESSION,
RELOAD_JITTER_MIN,
RELOAD_JITTER_MAX,
} from "./constants"
import {
clone,
closestPhxBinding,
closure,
debug,
isObject,
maybe
} from "./utils"
import Browser from "./browser"
import DOM from "./dom"
import Hooks from "./hooks"
import LiveUploader from "./live_uploader"
import View from "./view"
import JS from "./js"
export default class LiveSocket {
constructor(url, phxSocket, opts = {}){
this.unloaded = false
if(!phxSocket || phxSocket.constructor.name === "Object"){
throw new Error(`
a phoenix Socket must be provided as the second argument to the LiveSocket constructor. For example:
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
let liveSocket = new LiveSocket("/live", Socket, {...})
`)
}
this.socket = new phxSocket(url, opts)
this.bindingPrefix = opts.bindingPrefix || BINDING_PREFIX
this.opts = opts
this.params = closure(opts.params || {})
this.viewLogger = opts.viewLogger
this.metadataCallbacks = opts.metadata || {}
this.defaults = Object.assign(clone(DEFAULTS), opts.defaults || {})
this.activeElement = null
this.prevActive = null
this.silenced = false
this.main = null
this.outgoingMainEl = null
this.clickStartedAtTarget = null
this.linkRef = 1
this.roots = {}
this.href = window.location.href
this.pendingLink = null
this.currentLocation = clone(window.location)
this.hooks = opts.hooks || {}
this.uploaders = opts.uploaders || {}
this.loaderTimeout = opts.loaderTimeout || LOADER_TIMEOUT
this.reloadWithJitterTimer = null
this.maxReloads = opts.maxReloads || MAX_RELOADS
this.reloadJitterMin = opts.reloadJitterMin || RELOAD_JITTER_MIN
this.reloadJitterMax = opts.reloadJitterMax || RELOAD_JITTER_MAX
this.failsafeJitter = opts.failsafeJitter || FAILSAFE_JITTER
this.localStorage = opts.localStorage || window.localStorage
this.sessionStorage = opts.sessionStorage || window.sessionStorage
this.boundTopLevelEvents = false
this.domCallbacks = Object.assign({onNodeAdded: closure(), onBeforeElUpdated: closure()}, opts.dom || {})
this.transitions = new TransitionSet()
window.addEventListener("pagehide", _e => {
this.unloaded = true
})
this.socket.onOpen(() => {
if(this.isUnloaded()){
// reload page if being restored from back/forward cache and browser does not emit "pageshow"
window.location.reload()
}
})
}
// public
isProfileEnabled(){ return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true" }
isDebugEnabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true" }
isDebugDisabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "false" }
enableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "true") }
enableProfiling(){ this.sessionStorage.setItem(PHX_LV_PROFILE, "true") }
disableDebug(){ this.sessionStorage.setItem(PHX_LV_DEBUG, "false") }
disableProfiling(){ this.sessionStorage.removeItem(PHX_LV_PROFILE) }
enableLatencySim(upperBoundMs){
this.enableDebug()
console.log("latency simulator enabled for the duration of this browser session. Call disableLatencySim() to disable")
this.sessionStorage.setItem(PHX_LV_LATENCY_SIM, upperBoundMs)
}
disableLatencySim(){ this.sessionStorage.removeItem(PHX_LV_LATENCY_SIM) }
getLatencySim(){
let str = this.sessionStorage.getItem(PHX_LV_LATENCY_SIM)
return str ? parseInt(str) : null
}
getSocket(){ return this.socket }
connect(){
// enable debug by default if on localhost and not explicitly disabled
if(window.location.hostname === "localhost" && !this.isDebugDisabled()){ this.enableDebug() }
let doConnect = () => {
if(this.joinRootViews()){
this.bindTopLevelEvents()
this.socket.connect()
} else if(this.main){
this.socket.connect()
} else {
this.bindTopLevelEvents({dead: true})
}
this.joinDeadView()
}
if(["complete", "loaded", "interactive"].indexOf(document.readyState) >= 0){
doConnect()
} else {
document.addEventListener("DOMContentLoaded", () => doConnect())
}
}
disconnect(callback){
clearTimeout(this.reloadWithJitterTimer)
this.socket.disconnect(callback)
}
replaceTransport(transport){
clearTimeout(this.reloadWithJitterTimer)
this.socket.replaceTransport(transport)
this.connect()
}
execJS(el, encodedJS, eventType = null){
this.owner(el, view => JS.exec(eventType, encodedJS, view, el))
}
// private
unload(){
if(this.unloaded){ return }
if(this.main && this.isConnected()){ this.log(this.main, "socket", () => ["disconnect for page nav"]) }
this.unloaded = true
this.destroyAllViews()
this.disconnect()
}
triggerDOM(kind, args){ this.domCallbacks[kind](...args) }
time(name, func){
if(!this.isProfileEnabled() || !console.time){ return func() }
console.time(name)
let result = func()
console.timeEnd(name)
return result
}
log(view, kind, msgCallback){
if(this.viewLogger){
let [msg, obj] = msgCallback()
this.viewLogger(view, kind, msg, obj)
} else if(this.isDebugEnabled()){
let [msg, obj] = msgCallback()
debug(view, kind, msg, obj)
}
}
requestDOMUpdate(callback){
this.transitions.after(callback)
}
transition(time, onStart, onDone = function(){}){
this.transitions.addTransition(time, onStart, onDone)
}
onChannel(channel, event, cb){
channel.on(event, data => {
let latency = this.getLatencySim()
if(!latency){
cb(data)
} else {
setTimeout(() => cb(data), latency)
}
})
}
wrapPush(view, opts, push){
let latency = this.getLatencySim()
let oldJoinCount = view.joinCount
if(!latency){
if(this.isConnected() && opts.timeout){
return push().receive("timeout", () => {
if(view.joinCount === oldJoinCount && !view.isDestroyed()){
this.reloadWithJitter(view, () => {
this.log(view, "timeout", () => ["received timeout while communicating with server. Falling back to hard refresh for recovery"])
})
}
})
} else {
return push()
}
}
let fakePush = {
receives: [],
receive(kind, cb){ this.receives.push([kind, cb]) }
}
setTimeout(() => {
if(view.isDestroyed()){ return }
fakePush.receives.reduce((acc, [kind, cb]) => acc.receive(kind, cb), push())
}, latency)
return fakePush
}
reloadWithJitter(view, log){
clearTimeout(this.reloadWithJitterTimer)
this.disconnect()
let minMs = this.reloadJitterMin
let maxMs = this.reloadJitterMax
let afterMs = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs
let tries = Browser.updateLocal(this.localStorage, window.location.pathname, CONSECUTIVE_RELOADS, 0, count => count + 1)
if(tries > this.maxReloads){
afterMs = this.failsafeJitter
}
this.reloadWithJitterTimer = setTimeout(() => {
// if view has recovered, such as transport replaced, then cancel
if(view.isDestroyed() || view.isConnected()){ return }
view.destroy()
log ? log() : this.log(view, "join", () => [`encountered ${tries} consecutive reloads`])
if(tries > this.maxReloads){
this.log(view, "join", () => [`exceeded ${this.maxReloads} consecutive reloads. Entering failsafe mode`])
}
if(this.hasPendingLink()){
window.location = this.pendingLink
} else {
window.location.reload()
}
}, afterMs)
}
getHookCallbacks(name){
return name && name.startsWith("Phoenix.") ? Hooks[name.split(".")[1]] : this.hooks[name]
}
isUnloaded(){ return this.unloaded }
isConnected(){ return this.socket.isConnected() }
getBindingPrefix(){ return this.bindingPrefix }
binding(kind){ return `${this.getBindingPrefix()}${kind}` }
channel(topic, params){ return this.socket.channel(topic, params) }
joinDeadView(){
let body = document.body
if(body && !this.isPhxView(body) && !this.isPhxView(document.firstElementChild)){
let view = this.newRootView(body)
view.setHref(this.getHref())
view.joinDead()
if(!this.main){ this.main = view }
window.requestAnimationFrame(() => view.execNewMounted())
}
}
joinRootViews(){
let rootsFound = false
DOM.all(document, `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, rootEl => {
if(!this.getRootById(rootEl.id)){
let view = this.newRootView(rootEl)
view.setHref(this.getHref())
view.join()
if(rootEl.hasAttribute(PHX_MAIN)){ this.main = view }
}
rootsFound = true
})
return rootsFound
}
redirect(to, flash){
this.disconnect()
Browser.redirect(to, flash)
}
replaceMain(href, flash, callback = null, linkRef = this.setPendingLink(href)){
let liveReferer = this.currentLocation.href
this.outgoingMainEl = this.outgoingMainEl || this.main.el
let newMainEl = DOM.cloneNode(this.outgoingMainEl, "")
this.main.showLoader(this.loaderTimeout)
this.main.destroy()
this.main = this.newRootView(newMainEl, flash, liveReferer)
this.main.setRedirect(href)
this.transitionRemoves()
this.main.join((joinCount, onDone) => {
if(joinCount === 1 && this.commitPendingLink(linkRef)){
this.requestDOMUpdate(() => {
DOM.findPhxSticky(document).forEach(el => newMainEl.appendChild(el))
this.outgoingMainEl.replaceWith(newMainEl)
this.outgoingMainEl = null
callback && requestAnimationFrame(callback)
onDone()
})
}
})
}
transitionRemoves(elements){
let removeAttr = this.binding("remove")
elements = elements || DOM.all(document, `[${removeAttr}]`)
elements.forEach(el => {
if(document.body.contains(el)){ // skip children already removed
this.execJS(el, el.getAttribute(removeAttr), "remove")
}
})
}
isPhxView(el){ return el.getAttribute && el.getAttribute(PHX_SESSION) !== null }
newRootView(el, flash, liveReferer){
let view = new View(el, this, null, flash, liveReferer)
this.roots[view.id] = view
return view
}
owner(childEl, callback){
let view = maybe(childEl.closest(PHX_VIEW_SELECTOR), el => this.getViewByEl(el)) || this.main
if(view){ callback(view) }
}
withinOwners(childEl, callback){
this.owner(childEl, view => callback(view, childEl))
}
getViewByEl(el){
let rootId = el.getAttribute(PHX_ROOT_ID)
return maybe(this.getRootById(rootId), root => root.getDescendentByEl(el))
}
getRootById(id){ return this.roots[id] }
destroyAllViews(){
for(let id in this.roots){
this.roots[id].destroy()
delete this.roots[id]
}
this.main = null
}
destroyViewByEl(el){
let root = this.getRootById(el.getAttribute(PHX_ROOT_ID))
if(root && root.id === el.id){
root.destroy()
delete this.roots[root.id]
} else if(root){
root.destroyDescendent(el.id)
}
}
setActiveElement(target){
if(this.activeElement === target){ return }
this.activeElement = target
let cancel = () => {
if(target === this.activeElement){ this.activeElement = null }
target.removeEventListener("mouseup", this)
target.removeEventListener("touchend", this)
}
target.addEventListener("mouseup", cancel)
target.addEventListener("touchend", cancel)
}
getActiveElement(){
if(document.activeElement === document.body){
return this.activeElement || document.activeElement
} else {
// document.activeElement can be null in Internet Explorer 11
return document.activeElement || document.body
}
}
dropActiveElement(view){
if(this.prevActive && view.ownsElement(this.prevActive)){
this.prevActive = null
}
}
restorePreviouslyActiveFocus(){
if(this.prevActive && this.prevActive !== document.body){
this.prevActive.focus()
}
}
blurActiveElement(){
this.prevActive = this.getActiveElement()
if(this.prevActive !== document.body){ this.prevActive.blur() }
}
bindTopLevelEvents({dead} = {}){
if(this.boundTopLevelEvents){ return }
this.boundTopLevelEvents = true
// enter failsafe reload if server has gone away intentionally, such as "disconnect" broadcast
this.socket.onClose(event => {
// unload when navigating href or form submit (such as for firefox)
if(event && event.code === 1001){ return this.unload() }
// failsafe reload if normal closure and we still have a main LV
if(event && event.code === 1000 && this.main){ return this.reloadWithJitter(this.main) }
})
document.body.addEventListener("click", function (){ }) // ensure all click events bubble for mobile Safari
window.addEventListener("pageshow", e => {
if(e.persisted){ // reload page if being restored from back/forward cache
this.getSocket().disconnect()
this.withPageLoading({to: window.location.href, kind: "redirect"})
window.location.reload()
}
}, true)
if(!dead){ this.bindNav() }
this.bindClicks()
if(!dead){ this.bindForms() }
this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, eventTarget) => {
let matchKey = targetEl.getAttribute(this.binding(PHX_KEY))
let pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key
if(matchKey && matchKey.toLowerCase() !== pressedKey){ return }
let data = {key: e.key, ...this.eventMeta(type, e, targetEl)}
JS.exec(type, phxEvent, view, targetEl, ["push", {data}])
})
this.bind({blur: "focusout", focus: "focusin"}, (e, type, view, targetEl, phxEvent, eventTarget) => {
if(!eventTarget){
let data = {key: e.key, ...this.eventMeta(type, e, targetEl)}
JS.exec(type, phxEvent, view, targetEl, ["push", {data}])
}
})
this.bind({blur: "blur", focus: "focus"}, (e, type, view, targetEl, targetCtx, phxEvent, phxTarget) => {
// blur and focus are triggered on document and window. Discard one to avoid dups
if(phxTarget === "window"){
let data = this.eventMeta(type, e, targetEl)
JS.exec(type, phxEvent, view, targetEl, ["push", {data}])
}
})
window.addEventListener("dragover", e => e.preventDefault())
window.addEventListener("drop", e => {
e.preventDefault()
let dropTargetId = maybe(closestPhxBinding(e.target, this.binding(PHX_DROP_TARGET)), trueTarget => {
return trueTarget.getAttribute(this.binding(PHX_DROP_TARGET))
})
let dropTarget = dropTargetId && document.getElementById(dropTargetId)
let files = Array.from(e.dataTransfer.files || [])
if(!dropTarget || dropTarget.disabled || files.length === 0 || !(dropTarget.files instanceof FileList)){ return }
LiveUploader.trackFiles(dropTarget, files)
dropTarget.dispatchEvent(new Event("input", {bubbles: true}))
})
this.on(PHX_TRACK_UPLOADS, e => {
let uploadTarget = e.target
if(!DOM.isUploadInput(uploadTarget)){ return }
let files = Array.from(e.detail.files || []).filter(f => f instanceof File || f instanceof Blob)
LiveUploader.trackFiles(uploadTarget, files)
uploadTarget.dispatchEvent(new Event("input", {bubbles: true}))
})
}
eventMeta(eventName, e, targetEl){
let callback = this.metadataCallbacks[eventName]
return callback ? callback(e, targetEl) : {}
}
setPendingLink(href){
this.linkRef++
this.pendingLink = href
return this.linkRef
}
commitPendingLink(linkRef){
if(this.linkRef !== linkRef){
return false
} else {
this.href = this.pendingLink
this.pendingLink = null
return true
}
}
getHref(){ return this.href }
hasPendingLink(){ return !!this.pendingLink }
bind(events, callback){
for(let event in events){
let browserEventName = events[event]
this.on(browserEventName, e => {
let binding = this.binding(event)
let windowBinding = this.binding(`window-${event}`)
let targetPhxEvent = e.target.getAttribute && e.target.getAttribute(binding)
if(targetPhxEvent){
this.debounce(e.target, e, browserEventName, () => {
this.withinOwners(e.target, view => {
callback(e, event, view, e.target, targetPhxEvent, null)
})
})
} else {
DOM.all(document, `[${windowBinding}]`, el => {
let phxEvent = el.getAttribute(windowBinding)
this.debounce(el, e, browserEventName, () => {
this.withinOwners(el, view => {
callback(e, event, view, el, phxEvent, "window")
})
})
})
}
})
}
}
bindClicks(){
window.addEventListener("click", e => this.clickStartedAtTarget = e.target)
this.bindClick("click", "click", false)
this.bindClick("mousedown", "capture-click", true)
}
bindClick(eventName, bindingName, capture){
let click = this.binding(bindingName)
window.addEventListener(eventName, e => {
let target = null
if(capture){
target = e.target.matches(`[${click}]`) ? e.target : e.target.querySelector(`[${click}]`)
} else {
let clickStartedAtTarget = this.clickStartedAtTarget || e.target
target = closestPhxBinding(clickStartedAtTarget, click)
this.dispatchClickAway(e, clickStartedAtTarget)
this.clickStartedAtTarget = null
}
let phxEvent = target && target.getAttribute(click)
if(!phxEvent){
let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null
if(!capture && href !== null && !DOM.wantsNewTab(e) && DOM.isNewPageHref(href, window.location)){
this.unload()
}
return
}
if(target.getAttribute("href") === "#"){ e.preventDefault() }
this.debounce(target, e, "click", () => {
this.withinOwners(target, view => {
JS.exec("click", phxEvent, view, target, ["push", {data: this.eventMeta("click", e, target)}])
})
})
}, capture)
}
dispatchClickAway(e, clickStartedAt){
let phxClickAway = this.binding("click-away")
DOM.all(document, `[${phxClickAway}]`, el => {
if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){
this.withinOwners(e.target, view => {
let phxEvent = el.getAttribute(phxClickAway)
if(JS.isVisible(el)){
JS.exec("click", phxEvent, view, el, ["push", {data: this.eventMeta("click", e, e.target)}])
}
})
}
})
}
bindNav(){
if(!Browser.canPushState()){ return }
if(history.scrollRestoration){ history.scrollRestoration = "manual" }
let scrollTimer = null
window.addEventListener("scroll", _e => {
clearTimeout(scrollTimer)
scrollTimer = setTimeout(() => {
Browser.updateCurrentState(state => Object.assign(state, {scroll: window.scrollY}))
}, 100)
})
window.addEventListener("popstate", event => {
if(!this.registerNewLocation(window.location)){ return }
let {type, id, root, scroll} = event.state || {}
let href = window.location.href
this.requestDOMUpdate(() => {
if(this.main.isConnected() && (type === "patch" && id === this.main.id)){
this.main.pushLinkPatch(href, null, () => {
this.maybeScroll(scroll)
})
} else {
this.replaceMain(href, null, () => {
if(root){ this.replaceRootHistory() }
this.maybeScroll(scroll)
})
}
})
}, false)
window.addEventListener("click", e => {
let target = closestPhxBinding(e.target, PHX_LIVE_LINK)
let type = target && target.getAttribute(PHX_LIVE_LINK)
if(!type || !this.isConnected() || !this.main || DOM.wantsNewTab(e)){ return }
let href = target.href
let linkState = target.getAttribute(PHX_LINK_STATE)
e.preventDefault()
e.stopImmediatePropagation() // do not bubble click to regular phx-click bindings
if(this.pendingLink === href){ return }
this.requestDOMUpdate(() => {
if(type === "patch"){
this.pushHistoryPatch(href, linkState, target)
} else if(type === "redirect"){
this.historyRedirect(href, linkState)
} else {
throw new Error(`expected ${PHX_LIVE_LINK} to be "patch" or "redirect", got: ${type}`)
}
let phxClick = target.getAttribute(this.binding("click"))
if(phxClick){
this.requestDOMUpdate(() => this.execJS(target, phxClick, "click"))
}
})
}, false)
}
maybeScroll(scroll) {
if(typeof(scroll) === "number"){
requestAnimationFrame(() => {
window.scrollTo(0, scroll)
}) // the body needs to render before we scroll.
}
}
dispatchEvent(event, payload = {}){
DOM.dispatchEvent(window, `phx:${event}`, {detail: payload})
}
dispatchEvents(events){
events.forEach(([event, payload]) => this.dispatchEvent(event, payload))
}
withPageLoading(info, callback){
DOM.dispatchEvent(window, "phx:page-loading-start", {detail: info})
let done = () => DOM.dispatchEvent(window, "phx:page-loading-stop", {detail: info})
return callback ? callback(done) : done
}
pushHistoryPatch(href, linkState, targetEl){
if(!this.isConnected()){ return Browser.redirect(href) }
this.withPageLoading({to: href, kind: "patch"}, done => {
this.main.pushLinkPatch(href, targetEl, linkRef => {
this.historyPatch(href, linkState, linkRef)
done()
})
})
}
historyPatch(href, linkState, linkRef = this.setPendingLink(href)){
if(!this.commitPendingLink(linkRef)){ return }
Browser.pushState(linkState, {type: "patch", id: this.main.id}, href)
this.registerNewLocation(window.location)
}
historyRedirect(href, linkState, flash){
// convert to full href if only path prefix
if(!this.isConnected()){ return Browser.redirect(href, flash) }
if(/^\/$|^\/[^\/]+.*$/.test(href)){
let {protocol, host} = window.location
href = `${protocol}//${host}${href}`
}
let scroll = window.scrollY
this.withPageLoading({to: href, kind: "redirect"}, done => {
this.replaceMain(href, flash, () => {
Browser.pushState(linkState, {type: "redirect", id: this.main.id, scroll: scroll}, href)
this.registerNewLocation(window.location)
done()
})
})
}
replaceRootHistory(){
Browser.pushState("replace", {root: true, type: "patch", id: this.main.id})
}
registerNewLocation(newLocation){
let {pathname, search} = this.currentLocation
if(pathname + search === newLocation.pathname + newLocation.search){
return false
} else {
this.currentLocation = clone(newLocation)
return true
}
}
bindForms(){
let iterations = 0
let externalFormSubmitted = false
// disable forms on submit that track phx-change but perform external submit
this.on("submit", e => {
let phxSubmit = e.target.getAttribute(this.binding("submit"))
let phxChange = e.target.getAttribute(this.binding("change"))
if(!externalFormSubmitted && phxChange && !phxSubmit){
externalFormSubmitted = true
e.preventDefault()
this.withinOwners(e.target, view => {
view.disableForm(e.target)
// safari needs next tick
window.requestAnimationFrame(() => {
if(DOM.isUnloadableFormSubmit(e)){ this.unload() }
e.target.submit()
})
})
}
}, true)
this.on("submit", e => {
let phxEvent = e.target.getAttribute(this.binding("submit"))
if(!phxEvent){
if(DOM.isUnloadableFormSubmit(e)){ this.unload() }
return
}
e.preventDefault()
e.target.disabled = true
this.withinOwners(e.target, view => {
JS.exec("submit", phxEvent, view, e.target, ["push", {}])
})
}, false)
for(let type of ["change", "input"]){
this.on(type, e => {
let phxChange = this.binding("change")
let input = e.target
let inputEvent = input.getAttribute(phxChange)
let formEvent = input.form && input.form.getAttribute(phxChange)
let phxEvent = inputEvent || formEvent
if(!phxEvent){ return }
if(input.type === "number" && input.validity && input.validity.badInput){ return }
let dispatcher = inputEvent ? input : input.form
let currentIterations = iterations
iterations++
let {at: at, type: lastType} = DOM.private(input, "prev-iteration") || {}
// detect dup because some browsers dispatch both "input" and "change"
if(at === currentIterations - 1 && type !== lastType){ return }
DOM.putPrivate(input, "prev-iteration", {at: currentIterations, type: type})
this.debounce(input, e, type, () => {
this.withinOwners(dispatcher, view => {
DOM.putPrivate(input, PHX_HAS_FOCUSED, true)
if(!DOM.isTextualInput(input)){
this.setActiveElement(input)
}
JS.exec("change", phxEvent, view, input, ["push", {_target: e.target.name, dispatcher: dispatcher}])
})
})
}, false)
}
}
debounce(el, event, eventType, callback){
if(eventType === "blur" || eventType === "focusout"){ return callback() }
let phxDebounce = this.binding(PHX_DEBOUNCE)
let phxThrottle = this.binding(PHX_THROTTLE)
let defaultDebounce = this.defaults.debounce.toString()
let defaultThrottle = this.defaults.throttle.toString()
this.withinOwners(el, view => {
let asyncFilter = () => !view.isDestroyed() && document.body.contains(el)
DOM.debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, () => {
callback()
})
})
}
silenceEvents(callback){
this.silenced = true
callback()
this.silenced = false
}
on(event, callback){
window.addEventListener(event, e => {
if(!this.silenced){ callback(e) }
})
}
}
class TransitionSet {
constructor(){
this.transitions = new Set()
this.pendingOps = []
this.reset()
}
reset(){
this.transitions.forEach(timer => {
clearTimeout(timer)
this.transitions.delete(timer)
})
this.flushPendingOps()
}
after(callback){
if(this.size() === 0){
callback()
} else {
this.pushPendingOp(callback)
}
}
addTransition(time, onStart, onDone){
onStart()
let timer = setTimeout(() => {
this.transitions.delete(timer)
onDone()
if(this.size() === 0){ this.flushPendingOps() }
}, time)
this.transitions.add(timer)
}
pushPendingOp(op){ this.pendingOps.push(op) }
size(){ return this.transitions.size }
flushPendingOps(){
this.pendingOps.forEach(op => op())
this.pendingOps = []
}
}