Packages
phoenix_live_view
0.18.0
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/dom.js
import {
CHECKABLE_INPUTS,
DEBOUNCE_PREV_KEY,
DEBOUNCE_TRIGGER,
FOCUSABLE_INPUTS,
PHX_COMPONENT,
PHX_EVENT_CLASSES,
PHX_HAS_FOCUSED,
PHX_HAS_SUBMITTED,
PHX_MAIN,
PHX_NO_FEEDBACK_CLASS,
PHX_PARENT_ID,
PHX_PRIVATE,
PHX_REF,
PHX_REF_SRC,
PHX_ROOT_ID,
PHX_SESSION,
PHX_STATIC,
PHX_UPLOAD_REF,
PHX_VIEW_SELECTOR,
PHX_STICKY,
THROTTLED
} from "./constants"
import {
logError
} from "./utils"
let DOM = {
byId(id){ return document.getElementById(id) || logError(`no id found for ${id}`) },
removeClass(el, className){
el.classList.remove(className)
if(el.classList.length === 0){ el.removeAttribute("class") }
},
all(node, query, callback){
if(!node){ return [] }
let array = Array.from(node.querySelectorAll(query))
return callback ? array.forEach(callback) : array
},
childNodeLength(html){
let template = document.createElement("template")
template.innerHTML = html
return template.content.childElementCount
},
isUploadInput(el){ return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null },
findUploadInputs(node){ return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`) },
findComponentNodeList(node, cid){
return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node)
},
isPhxDestroyed(node){
return node.id && DOM.private(node, "destroyed") ? true : false
},
markPhxChildDestroyed(el){
if(this.isPhxChild(el)){ el.setAttribute(PHX_SESSION, "") }
this.putPrivate(el, "destroyed", true)
},
findPhxChildrenInFragment(html, parentId){
let template = document.createElement("template")
template.innerHTML = html
return this.findPhxChildren(template.content, parentId)
},
isIgnored(el, phxUpdate){
return (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) === "ignore"
},
isPhxUpdate(el, phxUpdate, updateTypes){
return el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0
},
findPhxSticky(el){ return this.all(el, `[${PHX_STICKY}]`) },
findPhxChildren(el, parentId){
return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`)
},
findParentCIDs(node, cids){
let initial = new Set(cids)
let parentCids =
cids.reduce((acc, cid) => {
let selector = `[${PHX_COMPONENT}="${cid}"] [${PHX_COMPONENT}]`
this.filterWithinSameLiveView(this.all(node, selector), node)
.map(el => parseInt(el.getAttribute(PHX_COMPONENT)))
.forEach(childCID => acc.delete(childCID))
return acc
}, initial)
return parentCids.size === 0 ? new Set(cids) : parentCids
},
filterWithinSameLiveView(nodes, parent){
if(parent.querySelector(PHX_VIEW_SELECTOR)){
return nodes.filter(el => this.withinSameLiveView(el, parent))
} else {
return nodes
}
},
withinSameLiveView(node, parent){
while(node = node.parentNode){
if(node.isSameNode(parent)){ return true }
if(node.getAttribute(PHX_SESSION) !== null){ return false }
}
},
private(el, key){ return el[PHX_PRIVATE] && el[PHX_PRIVATE][key] },
deletePrivate(el, key){ el[PHX_PRIVATE] && delete (el[PHX_PRIVATE][key]) },
putPrivate(el, key, value){
if(!el[PHX_PRIVATE]){ el[PHX_PRIVATE] = {} }
el[PHX_PRIVATE][key] = value
},
updatePrivate(el, key, defaultVal, updateFunc){
let existing = this.private(el, key)
if(existing === undefined){
this.putPrivate(el, key, updateFunc(defaultVal))
} else {
this.putPrivate(el, key, updateFunc(existing))
}
},
copyPrivates(target, source){
if(source[PHX_PRIVATE]){
target[PHX_PRIVATE] = source[PHX_PRIVATE]
}
},
putTitle(str){
let titleEl = document.querySelector("title")
let {prefix, suffix} = titleEl.dataset
document.title = `${prefix || ""}${str}${suffix || ""}`
},
debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){
let debounce = el.getAttribute(phxDebounce)
let throttle = el.getAttribute(phxThrottle)
if(debounce === ""){ debounce = defaultDebounce }
if(throttle === ""){ throttle = defaultThrottle }
let value = debounce || throttle
switch(value){
case null: return callback()
case "blur":
if(this.once(el, "debounce-blur")){
el.addEventListener("blur", () => callback())
}
return
default:
let timeout = parseInt(value)
let trigger = () => throttle ? this.deletePrivate(el, THROTTLED) : callback()
let currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger)
if(isNaN(timeout)){ return logError(`invalid throttle/debounce value: ${value}`) }
if(throttle){
let newKeyDown = false
if(event.type === "keydown"){
let prevKey = this.private(el, DEBOUNCE_PREV_KEY)
this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key)
newKeyDown = prevKey !== event.key
}
if(!newKeyDown && this.private(el, THROTTLED)){
return false
} else {
callback()
this.putPrivate(el, THROTTLED, true)
setTimeout(() => {
if(asyncFilter()){ this.triggerCycle(el, DEBOUNCE_TRIGGER) }
}, timeout)
}
} else {
setTimeout(() => {
if(asyncFilter()){ this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle) }
}, timeout)
}
let form = el.form
if(form && this.once(form, "bind-debounce")){
form.addEventListener("submit", () => {
Array.from((new FormData(form)).entries(), ([name]) => {
let input = form.querySelector(`[name="${name}"]`)
this.incCycle(input, DEBOUNCE_TRIGGER)
this.deletePrivate(input, THROTTLED)
})
})
}
if(this.once(el, "bind-debounce")){
el.addEventListener("blur", () => this.triggerCycle(el, DEBOUNCE_TRIGGER))
}
}
},
triggerCycle(el, key, currentCycle){
let [cycle, trigger] = this.private(el, key)
if(!currentCycle){ currentCycle = cycle }
if(currentCycle === cycle){
this.incCycle(el, key)
trigger()
}
},
once(el, key){
if(this.private(el, key) === true){ return false }
this.putPrivate(el, key, true)
return true
},
incCycle(el, key, trigger = function (){ }){
let [currentCycle] = this.private(el, key) || [0, trigger]
currentCycle++
this.putPrivate(el, key, [currentCycle, trigger])
return currentCycle
},
discardError(container, el, phxFeedbackFor){
let field = el.getAttribute && el.getAttribute(phxFeedbackFor)
// TODO: Remove id lookup after we update Phoenix to use input_name instead of input_id
let input = field && container.querySelector(`[id="${field}"], [name="${field}"], [name="${field}[]"]`)
if(!input){ return }
if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input.form, PHX_HAS_SUBMITTED))){
el.classList.add(PHX_NO_FEEDBACK_CLASS)
}
},
showError(inputEl, phxFeedbackFor){
if(inputEl.id || inputEl.name){
this.all(inputEl.form, `[${phxFeedbackFor}="${inputEl.id}"], [${phxFeedbackFor}="${inputEl.name}"]`, (el) => {
this.removeClass(el, PHX_NO_FEEDBACK_CLASS)
})
}
},
isPhxChild(node){
return node.getAttribute && node.getAttribute(PHX_PARENT_ID)
},
isPhxSticky(node){
return node.getAttribute && node.getAttribute(PHX_STICKY) !== null
},
firstPhxChild(el){
return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]
},
dispatchEvent(target, name, opts = {}){
let bubbles = opts.bubbles === undefined ? true : !!opts.bubbles
let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}}
let event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts)
target.dispatchEvent(event)
},
cloneNode(node, html){
if(typeof (html) === "undefined"){
return node.cloneNode(true)
} else {
let cloned = node.cloneNode(false)
cloned.innerHTML = html
return cloned
}
},
mergeAttrs(target, source, opts = {}){
let exclude = opts.exclude || []
let isIgnored = opts.isIgnored
let sourceAttrs = source.attributes
for(let i = sourceAttrs.length - 1; i >= 0; i--){
let name = sourceAttrs[i].name
if(exclude.indexOf(name) < 0){ target.setAttribute(name, source.getAttribute(name)) }
}
let targetAttrs = target.attributes
for(let i = targetAttrs.length - 1; i >= 0; i--){
let name = targetAttrs[i].name
if(isIgnored){
if(name.startsWith("data-") && !source.hasAttribute(name)){ target.removeAttribute(name) }
} else {
if(!source.hasAttribute(name)){ target.removeAttribute(name) }
}
}
},
mergeFocusedInput(target, source){
// skip selects because FF will reset highlighted index for any setAttribute
if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) }
if(source.readOnly){
target.setAttribute("readonly", true)
} else {
target.removeAttribute("readonly")
}
},
hasSelectionRange(el){
return el.setSelectionRange && (el.type === "text" || el.type === "textarea")
},
restoreFocus(focused, selectionStart, selectionEnd){
if(!DOM.isTextualInput(focused)){ return }
let wasFocused = focused.matches(":focus")
if(focused.readOnly){ focused.blur() }
if(!wasFocused){ focused.focus() }
if(this.hasSelectionRange(focused)){
focused.setSelectionRange(selectionStart, selectionEnd)
}
},
isFormInput(el){ return /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button" },
syncAttrsToProps(el){
if(el instanceof HTMLInputElement && CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0){
el.checked = el.getAttribute("checked") !== null
}
},
isTextualInput(el){ return FOCUSABLE_INPUTS.indexOf(el.type) >= 0 },
isNowTriggerFormExternal(el, phxTriggerExternal){
return el.getAttribute && el.getAttribute(phxTriggerExternal) !== null
},
syncPendingRef(fromEl, toEl, disableWith){
let ref = fromEl.getAttribute(PHX_REF)
if(ref === null){ return true }
let refSrc = fromEl.getAttribute(PHX_REF_SRC)
if(DOM.isFormInput(fromEl) || fromEl.getAttribute(disableWith) !== null){
if(DOM.isUploadInput(fromEl)){ DOM.mergeAttrs(fromEl, toEl, {isIgnored: true}) }
DOM.putPrivate(fromEl, PHX_REF, toEl)
return false
} else {
PHX_EVENT_CLASSES.forEach(className => {
fromEl.classList.contains(className) && toEl.classList.add(className)
})
toEl.setAttribute(PHX_REF, ref)
toEl.setAttribute(PHX_REF_SRC, refSrc)
return true
}
},
cleanChildNodes(container, phxUpdate){
if(DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend"])){
let toRemove = []
container.childNodes.forEach(childNode => {
if(!childNode.id){
// Skip warning if it's an empty text node (e.g. a new-line)
let isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === ""
if(!isEmptyTextNode){
logError("only HTML element tags with an id are allowed inside containers with phx-update.\n\n" +
`removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"\n\n`)
}
toRemove.push(childNode)
}
})
toRemove.forEach(childNode => childNode.remove())
}
},
replaceRootContainer(container, tagName, attrs){
let retainedAttrs = new Set(["id", PHX_SESSION, PHX_STATIC, PHX_MAIN, PHX_ROOT_ID])
if(container.tagName.toLowerCase() === tagName.toLowerCase()){
Array.from(container.attributes)
.filter(attr => !retainedAttrs.has(attr.name.toLowerCase()))
.forEach(attr => container.removeAttribute(attr.name))
Object.keys(attrs)
.filter(name => !retainedAttrs.has(name.toLowerCase()))
.forEach(attr => container.setAttribute(attr, attrs[attr]))
return container
} else {
let newContainer = document.createElement(tagName)
Object.keys(attrs).forEach(attr => newContainer.setAttribute(attr, attrs[attr]))
retainedAttrs.forEach(attr => newContainer.setAttribute(attr, container.getAttribute(attr)))
newContainer.innerHTML = container.innerHTML
container.replaceWith(newContainer)
return newContainer
}
},
getSticky(el, name, defaultVal){
let op = (DOM.private(el, "sticky") || []).find(([existingName, ]) => name === existingName)
if(op){
let [_name, _op, stashedResult] = op
return stashedResult
} else {
return typeof(defaultVal) === "function" ? defaultVal() : defaultVal
}
},
deleteSticky(el, name){
this.updatePrivate(el, "sticky", [], ops => {
return ops.filter(([existingName, _]) => existingName !== name)
})
},
putSticky(el, name, op){
let stashedResult = op(el)
this.updatePrivate(el, "sticky", [], ops => {
let existingIndex = ops.findIndex(([existingName, ]) => name === existingName)
if(existingIndex >= 0){
ops[existingIndex] = [name, op, stashedResult]
} else {
ops.push([name, op, stashedResult])
}
return ops
})
},
applyStickyOperations(el){
let ops = DOM.private(el, "sticky")
if(!ops){ return }
ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op))
}
}
export default DOM