phoenix_live_view
Advanced tools
Comparing version 0.20.3 to 0.20.4
@@ -8,3 +8,4 @@ export const CONSECUTIVE_RELOADS = "consecutive-reloads" | ||
"phx-click-loading", "phx-change-loading", "phx-submit-loading", | ||
"phx-keydown-loading", "phx-keyup-loading", "phx-blur-loading", "phx-focus-loading" | ||
"phx-keydown-loading", "phx-keyup-loading", "phx-blur-loading", "phx-focus-loading", | ||
"phx-hook-loading" | ||
] | ||
@@ -41,2 +42,3 @@ export const PHX_COMPONENT = "data-phx-component" | ||
export const PHX_FEEDBACK_FOR = "feedback-for" | ||
export const PHX_FEEDBACK_GROUP = "feedback-group" | ||
export const PHX_HAS_FOCUSED = "phx-has-focused" | ||
@@ -43,0 +45,0 @@ export const FOCUSABLE_INPUTS = ["text", "textarea", "number", "email", "password", "search", "tel", "url", "date", "time", "datetime-local", "color", "range"] |
@@ -5,2 +5,3 @@ import { | ||
PHX_FEEDBACK_FOR, | ||
PHX_FEEDBACK_GROUP, | ||
PHX_PRUNE, | ||
@@ -51,2 +52,3 @@ PHX_ROOT_ID, | ||
this.streamInserts = {} | ||
this.streamComponentRestore = {} | ||
this.targetCID = targetCID | ||
@@ -76,3 +78,2 @@ this.cidPatch = isCid(this.targetCID) | ||
let phxUpdate = this.liveSocket.binding(PHX_UPDATE) | ||
DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => el.innerHTML = "") | ||
DOM.all(this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, el => { | ||
@@ -92,2 +93,3 @@ el.setAttribute(PHX_PRUNE, "") | ||
let phxFeedbackFor = liveSocket.binding(PHX_FEEDBACK_FOR) | ||
let phxFeedbackGroup = liveSocket.binding(PHX_FEEDBACK_GROUP) | ||
let disableWith = liveSocket.binding(PHX_DISABLE_WITH) | ||
@@ -98,3 +100,3 @@ let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP) | ||
let added = [] | ||
let trackedInputs = [] | ||
let trackedForms = new Set() | ||
let updates = [] | ||
@@ -110,12 +112,8 @@ let appendPrependUpdates = [] | ||
this.streams.forEach(([ref, inserts, deleteIds, reset]) => { | ||
Object.entries(inserts).forEach(([key, [streamAt, limit]]) => { | ||
this.streamInserts[key] = {ref, streamAt, limit, resetKept: false} | ||
inserts.forEach(([key, streamAt, limit]) => { | ||
this.streamInserts[key] = {ref, streamAt, limit, reset} | ||
}) | ||
if(reset !== undefined){ | ||
DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => { | ||
if(inserts[child.id]){ | ||
this.streamInserts[child.id].resetKept = true | ||
} else { | ||
this.removeStreamChildElement(child) | ||
} | ||
this.removeStreamChildElement(child) | ||
}) | ||
@@ -129,2 +127,17 @@ } | ||
// clear stream items from the dead render if they are not inserted again | ||
if(isJoinPatch){ | ||
DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => { | ||
// make sure to only remove elements owned by the current view | ||
// see https://github.com/phoenixframework/phoenix_live_view/issues/3047 | ||
this.liveSocket.owner(el, (view) => { | ||
if(view === this.view){ | ||
Array.from(el.children).forEach(child => { | ||
this.removeStreamChildElement(child) | ||
}) | ||
} | ||
}) | ||
}) | ||
} | ||
morphdom(targetContainer, html, { | ||
@@ -143,7 +156,15 @@ childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null, | ||
addChild: (parent, child) => { | ||
let {ref, streamAt, limit} = this.getStreamInsert(child) | ||
if(ref === undefined) { return parent.appendChild(child) } | ||
let {ref, streamAt} = this.getStreamInsert(child) | ||
if(ref === undefined){ return parent.appendChild(child) } | ||
DOM.putSticky(child, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref)) | ||
this.setStreamRef(child, ref) | ||
// we may need to restore skipped components, see removeStreamChildElement | ||
child.querySelectorAll(`[${PHX_MAGIC_ID}][${PHX_SKIP}]`).forEach(el => { | ||
const component = this.streamComponentRestore[el.getAttribute(PHX_MAGIC_ID)] | ||
if(component){ | ||
el.replaceWith(component) | ||
} | ||
}) | ||
// streaming | ||
@@ -158,15 +179,2 @@ if(streamAt === 0){ | ||
} | ||
let children = limit !== null && Array.from(parent.children) | ||
let childrenToRemove = [] | ||
if(limit && limit < 0 && children.length > limit * -1){ | ||
childrenToRemove = children.slice(0, children.length + limit) | ||
} else if(limit && limit >= 0 && children.length > limit){ | ||
childrenToRemove = children.slice(limit) | ||
} | ||
childrenToRemove.forEach(removeChild => { | ||
// do not remove child as part of limit if we are re-adding it | ||
if(!this.streamInserts[removeChild.id]){ | ||
this.removeStreamChildElement(removeChild) | ||
} | ||
}) | ||
}, | ||
@@ -192,3 +200,3 @@ onBeforeNodeAdded: (el) => { | ||
if(el.getAttribute && el.getAttribute("name") && DOM.isFormInput(el)){ | ||
trackedInputs.push(el) | ||
trackedForms.add(el.form) | ||
} | ||
@@ -201,15 +209,2 @@ // nested view handling | ||
}, | ||
onBeforeElChildrenUpdated: (fromEl, toEl) => { | ||
// before we update the children, we need to set existing stream children | ||
// into the new order from the server if they were kept during a stream reset | ||
if(fromEl.getAttribute(phxUpdate) === PHX_STREAM){ | ||
let toIds = Array.from(toEl.children).map(child => child.id) | ||
Array.from(fromEl.children).filter(child => { | ||
let {resetKept} = this.getStreamInsert(child) | ||
return resetKept | ||
}).forEach((child) => { | ||
this.streamInserts[child.id].streamAt = toIds.indexOf(child.id) | ||
}) | ||
} | ||
}, | ||
onNodeDiscarded: (el) => this.onNodeDiscarded(el), | ||
@@ -237,3 +232,7 @@ onBeforeNodeDiscarded: (el) => { | ||
DOM.cleanChildNodes(toEl, phxUpdate) | ||
if(this.skipCIDSibling(toEl)){ return false } | ||
if(this.skipCIDSibling(toEl)){ | ||
// if this is a live component used in a stream, we may need to reorder it | ||
this.maybeReOrderStream(fromEl) | ||
return false | ||
} | ||
if(DOM.isPhxSticky(fromEl)){ return false } | ||
@@ -271,3 +270,5 @@ if(DOM.isIgnored(fromEl, phxUpdate) || (fromEl.form && fromEl.form.isSameNode(externalFormTriggered))){ | ||
let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl) | ||
if(isFocusedFormEl && fromEl.type !== "hidden"){ | ||
// skip patching focused inputs unless focus is a select that has changed options | ||
let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl) | ||
if(isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged){ | ||
this.trackBefore("updated", fromEl, toEl) | ||
@@ -278,5 +279,7 @@ DOM.mergeFocusedInput(fromEl, toEl) | ||
DOM.applyStickyOperations(fromEl) | ||
trackedInputs.push(fromEl) | ||
trackedForms.add(fromEl.form) | ||
return false | ||
} else { | ||
// blur focused select if it changed so native UI is updated (ie safari won't update visible options) | ||
if(focusedSelectChanged){ fromEl.blur() } | ||
if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){ | ||
@@ -289,3 +292,3 @@ appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate))) | ||
if(toEl.getAttribute("name") && DOM.isFormInput(toEl)){ | ||
trackedInputs.push(toEl) | ||
trackedForms.add(toEl.form) | ||
} | ||
@@ -307,3 +310,3 @@ this.trackBefore("updated", fromEl, toEl) | ||
DOM.maybeHideFeedback(targetContainer, trackedInputs, phxFeedbackFor) | ||
DOM.maybeHideFeedback(targetContainer, trackedForms, phxFeedbackFor, phxFeedbackGroup) | ||
@@ -343,2 +346,9 @@ liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd)) | ||
if(!this.maybePendingRemove(child)){ | ||
if(this.streamInserts[child.id]){ | ||
// we need to store children so we can restore them later | ||
// in case they are skipped | ||
child.querySelectorAll(`[${PHX_MAGIC_ID}]`).forEach(el => { | ||
this.streamComponentRestore[el.getAttribute(PHX_MAGIC_ID)] = el | ||
}) | ||
} | ||
child.remove() | ||
@@ -354,9 +364,18 @@ this.onNodeDiscarded(child) | ||
setStreamRef(el, ref){ | ||
DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref)) | ||
} | ||
maybeReOrderStream(el, isNew){ | ||
let {ref, streamAt, limit} = this.getStreamInsert(el) | ||
if(streamAt === undefined || (streamAt === 0 && !isNew)){ return } | ||
let {ref, streamAt, reset} = this.getStreamInsert(el) | ||
if(streamAt === undefined){ return } | ||
// we need to the PHX_STREAM_REF here as well as addChild is invoked only for parents | ||
DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref)) | ||
// we need to set the PHX_STREAM_REF here as well as addChild is invoked only for parents | ||
this.setStreamRef(el, ref) | ||
if(!reset && !isNew){ | ||
// we only reorder if the element is new or it's a stream reset | ||
return | ||
} | ||
if(streamAt === 0){ | ||
@@ -378,4 +397,16 @@ el.parentElement.insertBefore(el, el.parentElement.firstElementChild) | ||
} | ||
this.maybeLimitStream(el) | ||
} | ||
maybeLimitStream(el){ | ||
let {limit} = this.getStreamInsert(el) | ||
let children = limit !== null && Array.from(el.parentElement.children) | ||
if(limit && limit < 0 && children.length > limit * -1){ | ||
children.slice(0, children.length + limit).forEach(child => this.removeStreamChildElement(child)) | ||
} else if(limit && limit >= 0 && children.length > limit){ | ||
children.slice(limit).forEach(child => this.removeStreamChildElement(child)) | ||
} | ||
} | ||
transitionPendingRemoves(){ | ||
@@ -396,2 +427,17 @@ let {pendingRemoves, liveSocket} = this | ||
isChangedSelect(fromEl, toEl){ | ||
if(!(fromEl instanceof HTMLSelectElement) || fromEl.multiple){ return false } | ||
if(fromEl.options.length !== toEl.options.length){ return true } | ||
let fromSelected = fromEl.selectedOptions[0] | ||
let toSelected = toEl.selectedOptions[0] | ||
if(fromSelected && fromSelected.hasAttribute("selected")){ | ||
toSelected.setAttribute("selected", fromSelected.getAttribute("selected")) | ||
} | ||
// in general we have to be very careful with using isEqualNode as it does not a reliable | ||
// DOM tree equality check, but for selection attributes and options it works fine | ||
return !fromEl.isEqualNode(toEl) | ||
} | ||
isCIDPatch(){ return this.cidPatch } | ||
@@ -398,0 +444,0 @@ |
@@ -53,3 +53,7 @@ import { | ||
findUploadInputs(node){ return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`) }, | ||
findUploadInputs(node){ | ||
const formId = node.id | ||
const inputsOutsideForm = this.all(document, `input[type="file"][${PHX_UPLOAD_REF}][form="${formId}"]`) | ||
return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat(inputsOutsideForm) | ||
}, | ||
@@ -256,3 +260,6 @@ findComponentNodeList(node, cid){ | ||
if(this.once(el, "bind-debounce")){ | ||
el.addEventListener("blur", () => this.triggerCycle(el, DEBOUNCE_TRIGGER)) | ||
el.addEventListener("blur", () => { | ||
// always trigger callback on blur | ||
callback() | ||
}) | ||
} | ||
@@ -290,10 +297,37 @@ } | ||
maybeHideFeedback(container, inputs, phxFeedbackFor){ | ||
maybeHideFeedback(container, forms, phxFeedbackFor, phxFeedbackGroup){ | ||
let feedbacks = [] | ||
inputs.forEach(input => { | ||
if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){ | ||
feedbacks.push(input.name) | ||
if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) } | ||
// if there are multiple inputs with the same name | ||
// (for example the default checkbox renders a hidden input as well) | ||
// we must only add the no feedback class if none of them have been focused yet | ||
let inputNamesFocused = {} | ||
// an entry in this object will be true if NO input in the group has been focused yet | ||
let feedbackGroups = {} | ||
forms.forEach(form => { | ||
Array.from(form.elements).forEach(input => { | ||
const group = input.getAttribute(phxFeedbackGroup) | ||
// initialize the group to true if it doesn't exist | ||
if(group && !(group in feedbackGroups)){ feedbackGroups[group] = true } | ||
// initialize the focused state to false if it doesn't exist | ||
if(!(input.name in inputNamesFocused)){ inputNamesFocused[input.name] = false } | ||
if(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED)){ | ||
inputNamesFocused[input.name] = true | ||
// the input was focused, therefore the group will NOT get phx-no-feedback | ||
if(group){ feedbackGroups[group] = false } | ||
} | ||
}) | ||
}) | ||
for(const [name, focused] of Object.entries(inputNamesFocused)){ | ||
if(!focused){ | ||
feedbacks.push(name) | ||
if(name.endsWith("[]")){ feedbacks.push(name.slice(0, -2)) } | ||
} | ||
}) | ||
} | ||
for(const [group, noFeedback] of Object.entries(feedbackGroups)){ | ||
if(noFeedback) feedbacks.push(group) | ||
} | ||
if(feedbacks.length > 0){ | ||
@@ -335,2 +369,6 @@ let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ") | ||
isChildOfAny(el, parents){ | ||
return !!parents.find(parent => parent.contains(el)) | ||
}, | ||
firstPhxChild(el){ | ||
@@ -341,3 +379,8 @@ 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 defaultBubble = true | ||
let isUploadTarget = target.nodeName === "INPUT" && target.type === "file" | ||
if(isUploadTarget && name === "click"){ | ||
defaultBubble = false | ||
} | ||
let bubbles = opts.bubbles === undefined ? defaultBubble : !!opts.bubbles | ||
let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}} | ||
@@ -358,4 +401,7 @@ let event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts) | ||
// merge attributes from source to target | ||
// if an element is ignored, we only merge data attributes | ||
// including removing data attributes that are no longer in the source | ||
mergeAttrs(target, source, opts = {}){ | ||
let exclude = opts.exclude || [] | ||
let exclude = new Set(opts.exclude || []) | ||
let isIgnored = opts.isIgnored | ||
@@ -365,3 +411,20 @@ let sourceAttrs = source.attributes | ||
let name = sourceAttrs[i].name | ||
if(exclude.indexOf(name) < 0){ target.setAttribute(name, source.getAttribute(name)) } | ||
if(!exclude.has(name)){ | ||
const sourceValue = source.getAttribute(name) | ||
if(target.getAttribute(name) !== sourceValue && (!isIgnored || (isIgnored && name.startsWith("data-")))){ | ||
target.setAttribute(name, sourceValue) | ||
} | ||
} else { | ||
// We exclude the value from being merged on focused inputs, because the | ||
// user's input should always win. | ||
// We can still assign it as long as the value property is the same, though. | ||
// This prevents a situation where the updated hook is not being triggered | ||
// when an input is back in its "original state", because the attribute | ||
// was never changed, see: | ||
// https://github.com/phoenixframework/phoenix_live_view/issues/2163 | ||
if(name === "value" && target.value === source.value){ | ||
// actually set the value attribute to sync it with the value property | ||
target.setAttribute("value", source.getAttribute(name)) | ||
} | ||
} | ||
} | ||
@@ -381,3 +444,4 @@ | ||
mergeFocusedInput(target, source){ | ||
DOM.mergeAttrs(target, source, {exclude: ["value"]}) | ||
// skip selects because FF will reset highlighted index for any setAttribute | ||
if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) } | ||
@@ -396,3 +460,5 @@ if(source.readOnly){ | ||
restoreFocus(focused, selectionStart, selectionEnd){ | ||
if(focused instanceof HTMLSelectElement){ focused.focus() } | ||
if(!DOM.isTextualInput(focused)){ return } | ||
let wasFocused = focused.matches(":focus") | ||
@@ -399,0 +465,0 @@ if(focused.readOnly){ focused.blur() } |
@@ -60,18 +60,49 @@ import { | ||
let scrollTop = () => document.documentElement.scrollTop || document.body.scrollTop | ||
let winHeight = () => window.innerHeight || document.documentElement.clientHeight | ||
let findScrollContainer = (el) => { | ||
if(["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) return el | ||
if(document.documentElement === el) return null | ||
return findScrollContainer(el.parentElement) | ||
} | ||
let isAtViewportTop = (el) => { | ||
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 rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight() | ||
return rect.top >= top(scrollContainer) && rect.left >= 0 && rect.top <= bottom(scrollContainer) | ||
} | ||
let isAtViewportBottom = (el) => { | ||
let isAtViewportBottom = (el, scrollContainer) => { | ||
let rect = el.getBoundingClientRect() | ||
return rect.right >= 0 && rect.left >= 0 && rect.bottom <= winHeight() | ||
return rect.right >= top(scrollContainer) && rect.left >= 0 && rect.bottom <= bottom(scrollContainer) | ||
} | ||
let isWithinViewport = (el) => { | ||
let isWithinViewport = (el, scrollContainer) => { | ||
let rect = el.getBoundingClientRect() | ||
return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight() | ||
return rect.top >= top(scrollContainer) && rect.left >= 0 && rect.top <= bottom(scrollContainer) | ||
} | ||
@@ -81,3 +112,4 @@ | ||
mounted(){ | ||
let scrollBefore = scrollTop() | ||
this.scrollContainer = findScrollContainer(this.el) | ||
let scrollBefore = scrollTop(this.scrollContainer) | ||
let topOverran = false | ||
@@ -98,3 +130,8 @@ let throttleInterval = 500 | ||
pendingOp = null | ||
if(!isWithinViewport(firstChild)){ firstChild.scrollIntoView({block: "start"}) } | ||
// make sure that the DOM is patched by waiting for the next tick | ||
window.requestAnimationFrame(() => { | ||
if(!isWithinViewport(firstChild, this.scrollContainer)){ | ||
firstChild.scrollIntoView({block: "start"}) | ||
} | ||
}) | ||
}) | ||
@@ -107,8 +144,13 @@ }) | ||
pendingOp = null | ||
if(!isWithinViewport(lastChild)){ lastChild.scrollIntoView({block: "end"}) } | ||
// 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.onScroll = (_e) => { | ||
let scrollNow = scrollTop(this.scrollContainer) | ||
@@ -135,5 +177,5 @@ if(pendingOp){ | ||
if(topEvent && isScrollingUp && isAtViewportTop(firstChild)){ | ||
if(topEvent && isScrollingUp && isAtViewportTop(firstChild, this.scrollContainer)){ | ||
onFirstChildAtTop(topEvent, firstChild) | ||
} else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild)){ | ||
} else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild, this.scrollContainer)){ | ||
onLastChildAtBottom(bottomEvent, lastChild) | ||
@@ -143,5 +185,17 @@ } | ||
} | ||
window.addEventListener("scroll", this.onScroll) | ||
if(this.scrollContainer){ | ||
this.scrollContainer.addEventListener("scroll", this.onScroll) | ||
} else { | ||
window.addEventListener("scroll", this.onScroll) | ||
} | ||
}, | ||
destroyed(){ window.removeEventListener("scroll", this.onScroll) }, | ||
destroyed(){ | ||
if(this.scrollContainer){ | ||
this.scrollContainer.removeEventListener("scroll", this.onScroll) | ||
} else { | ||
window.removeEventListener("scroll", this.onScroll) | ||
} | ||
}, | ||
@@ -148,0 +202,0 @@ throttle(interval, callback){ |
@@ -5,2 +5,3 @@ import DOM from "./dom" | ||
let focusStack = null | ||
let default_transition_time = 200 | ||
@@ -42,3 +43,3 @@ let JS = { | ||
exec_exec(eventType, phxEvent, view, sourceEl, el, [attr, to]){ | ||
exec_exec(eventType, phxEvent, view, sourceEl, el, {attr, to}){ | ||
let nodes = to ? DOM.all(document, to) : [sourceEl] | ||
@@ -118,2 +119,20 @@ nodes.forEach(node => { | ||
exec_toggle_attr(eventType, phxEvent, view, sourceEl, el, {attr: [attr, val1, val2]}){ | ||
if(el.hasAttribute(attr)){ | ||
if(val2 !== undefined){ | ||
// toggle between val1 and val2 | ||
if(el.getAttribute(attr) === val1){ | ||
this.setOrRemoveAttrs(el, [[attr, val2]], []) | ||
} else { | ||
this.setOrRemoveAttrs(el, [[attr, val1]], []) | ||
} | ||
} else { | ||
// remove attr | ||
this.setOrRemoveAttrs(el, [], [attr]) | ||
} | ||
} else { | ||
this.setOrRemoveAttrs(el, [[attr, val1]], []) | ||
} | ||
}, | ||
exec_transition(eventType, phxEvent, view, sourceEl, el, {time, transition}){ | ||
@@ -158,2 +177,3 @@ this.addOrRemoveClasses(el, [], [], transition, time, view) | ||
toggle(eventType, view, el, display, ins, outs, time){ | ||
time = time || default_transition_time | ||
let [inClasses, inStartClasses, inEndClasses] = ins || [[], [], []] | ||
@@ -221,2 +241,3 @@ let [outClasses, outStartClasses, outEndClasses] = outs || [[], [], []] | ||
addOrRemoveClasses(el, adds, removes, transition, time, view){ | ||
time = time || default_transition_time | ||
let [transitionRun, transitionStart, transitionEnd] = transition || [[], [], []] | ||
@@ -253,5 +274,5 @@ if(transitionRun.length > 0){ | ||
let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes); | ||
let newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets); | ||
let newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes); | ||
let alteredAttrs = sets.map(([attr, _val]) => attr).concat(removes) | ||
let newSets = prevSets.filter(([attr, _val]) => !alteredAttrs.includes(attr)).concat(sets) | ||
let newRemoves = prevRemoves.filter((attr) => !alteredAttrs.includes(attr)).concat(removes) | ||
@@ -258,0 +279,0 @@ DOM.putSticky(el, "attrs", currentEl => { |
@@ -109,3 +109,2 @@ /** Initializes the LiveSocket | ||
debug, | ||
isObject, | ||
maybe | ||
@@ -404,3 +403,3 @@ } from "./utils" | ||
this.main.setRedirect(href) | ||
this.transitionRemoves() | ||
this.transitionRemoves(null, true) | ||
this.main.join((joinCount, onDone) => { | ||
@@ -412,3 +411,3 @@ if(joinCount === 1 && this.commitPendingLink(linkRef)){ | ||
this.outgoingMainEl = null | ||
callback && requestAnimationFrame(() => callback(linkRef)) | ||
callback && callback(linkRef) | ||
onDone() | ||
@@ -420,5 +419,10 @@ }) | ||
transitionRemoves(elements){ | ||
transitionRemoves(elements, skipSticky){ | ||
let removeAttr = this.binding("remove") | ||
elements = elements || DOM.all(document, `[${removeAttr}]`) | ||
if(skipSticky){ | ||
const stickies = DOM.findPhxSticky(document) || [] | ||
elements = elements.filter(el => !DOM.isChildOfAny(el, stickies)) | ||
} | ||
elements.forEach(el => { | ||
@@ -638,2 +642,5 @@ this.execJS(el, el.getAttribute(removeAttr), "remove") | ||
} else { | ||
// a synthetic click event (detail 0) will not have caused a mousedown event, | ||
// therefore the clickStartedAtTarget is stale | ||
if(e.detail === 0) this.clickStartedAtTarget = e.target | ||
let clickStartedAtTarget = this.clickStartedAtTarget || e.target | ||
@@ -667,3 +674,3 @@ target = closestPhxBinding(clickStartedAtTarget, click) | ||
if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){ | ||
this.withinOwners(e.target, view => { | ||
this.withinOwners(el, view => { | ||
let phxEvent = el.getAttribute(phxClickAway) | ||
@@ -736,3 +743,3 @@ if(JS.isVisible(el) && JS.isInViewport(el)){ | ||
maybeScroll(scroll) { | ||
maybeScroll(scroll){ | ||
if(typeof(scroll) === "number"){ | ||
@@ -760,3 +767,3 @@ requestAnimationFrame(() => { | ||
pushHistoryPatch(href, linkState, targetEl){ | ||
if(!this.isConnected()){ return Browser.redirect(href) } | ||
if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href) } | ||
@@ -780,4 +787,5 @@ this.withPageLoading({to: href, kind: "patch"}, done => { | ||
historyRedirect(href, linkState, flash){ | ||
if(!this.isConnected() || !this.main.isMain()){ return Browser.redirect(href, flash) } | ||
// convert to full href if only path prefix | ||
if(!this.isConnected()){ return Browser.redirect(href, flash) } | ||
if(/^\/$|^\/[^\/]+.*$/.test(href)){ | ||
@@ -885,6 +893,8 @@ let {protocol, host} = window.location | ||
let input = Array.from(form.elements).find(el => el.type === "reset") | ||
// wait until next tick to get updated input value | ||
window.requestAnimationFrame(() => { | ||
input.dispatchEvent(new Event("input", {bubbles: true, cancelable: false})) | ||
}) | ||
if(input){ | ||
// wait until next tick to get updated input value | ||
window.requestAnimationFrame(() => { | ||
input.dispatchEvent(new Event("input", {bubbles: true, cancelable: false})) | ||
}) | ||
} | ||
}) | ||
@@ -891,0 +901,0 @@ } |
@@ -97,5 +97,9 @@ import { | ||
static filesAwaitingPreflight(input){ | ||
return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f)) | ||
return this.activeFiles(input).filter(f => !UploadEntry.isPreflighted(input, f) && !UploadEntry.isPreflightInProgress(f)) | ||
} | ||
static markPreflightInProgress(entries){ | ||
entries.forEach(entry => UploadEntry.markPreflightInProgress(entry.file)) | ||
} | ||
constructor(inputEl, view, onComplete){ | ||
@@ -108,2 +112,5 @@ this.view = view | ||
// prevent sending duplicate preflight requests | ||
LiveUploader.markPreflightInProgress(this._entries) | ||
this.numEntriesInProgress = this._entries.length | ||
@@ -110,0 +117,0 @@ } |
@@ -158,2 +158,10 @@ import { | ||
resetRender(cid){ | ||
// we are racing a component destroy, it could not exist, so | ||
// make sure that we don't try to set reset on undefined | ||
if(this.rendered[COMPONENTS][cid]){ | ||
this.rendered[COMPONENTS][cid].reset = true | ||
} | ||
} | ||
mergeDiff(diff){ | ||
@@ -290,3 +298,3 @@ let newc = diff[COMPONENTS] | ||
// It is disabled for comprehensions since we must re-render the entire collection | ||
// and no invidial element is tracked inside the comprehension. | ||
// and no individual element is tracked inside the comprehension. | ||
toOutputBuffer(rendered, templates, output, changeTracking, rootAttrs = {}){ | ||
@@ -300,2 +308,4 @@ if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) } | ||
// this condition is called when first rendering an optimizable function component. | ||
// LC have their magicId previously set | ||
if(changeTracking && isRoot && !rendered.magicId){ | ||
@@ -319,4 +329,8 @@ rendered.newRender = true | ||
let attrs | ||
if(changeTracking || Object.keys(rootAttrs).length > 0){ | ||
skip = !rendered.newRender | ||
// when a LC is added on the page, we need to re-render the entire LC tree, | ||
// therefore changeTracking is false; however, we need to keep all the magicIds | ||
// from any function component so the next time the LC is updated, we can apply | ||
// the skip optimization | ||
if(changeTracking || rendered.magicId){ | ||
skip = changeTracking && !rendered.newRender | ||
attrs = {[PHX_MAGIC_ID]: rendered.magicId, ...rootAttrs} | ||
@@ -326,3 +340,3 @@ } else { | ||
} | ||
if(skip){ attrs[PHX_SKIP] = true} | ||
if(skip){ attrs[PHX_SKIP] = true } | ||
let [newRoot, commentBefore, commentAfter] = modifyRoot(output.buffer, attrs, skip) | ||
@@ -384,3 +398,3 @@ rendered.newRender = false | ||
// works in the same PHX_SKIP attribute fashion as 1, but the newRender tracking is done | ||
// at the general diff merge level. If we merge a diff with new dynamics, we necessariy have | ||
// at the general diff merge level. If we merge a diff with new dynamics, we necessarily have | ||
// experienced a change which must be a newRender, and thus we can't skip the render. | ||
@@ -390,6 +404,14 @@ // | ||
// we track a deterministic magicId based on the cid. | ||
// | ||
// By default changeTracking is enabled, but we special case the flow where the client is pruning | ||
// cids and the server adds the component back. In such cases, we explicitly disable changeTracking | ||
// with resetRender for this cid, then re-enable it after the recursive call to skip the optimization | ||
// for the entire component tree. | ||
component.newRender = !skip | ||
component.magicId = `${this.parentViewId()}-c-${cid}` | ||
let changeTracking = true | ||
// enable change tracking as long as the component hasn't been reset | ||
let changeTracking = !component.reset | ||
let [html, streams] = this.recursiveToString(component, components, onlyCids, changeTracking, attrs) | ||
// disable reset after we've rendered | ||
delete component.reset | ||
@@ -396,0 +418,0 @@ return [html, streams] |
@@ -18,5 +18,6 @@ import { | ||
let isNew = file._phxRef === undefined | ||
let isPreflightInProgress = UploadEntry.isPreflightInProgress(file) | ||
let activeRefs = fileEl.getAttribute(PHX_ACTIVE_ENTRY_REFS).split(",") | ||
let isActive = activeRefs.indexOf(LiveUploader.genFileRef(file)) >= 0 | ||
return file.size > 0 && (isNew || isActive) | ||
return file.size > 0 && (isNew || isActive || !isPreflightInProgress) | ||
} | ||
@@ -30,2 +31,10 @@ | ||
static isPreflightInProgress(file){ | ||
return file._preflightInProgress === true | ||
} | ||
static markPreflightInProgress(file){ | ||
file._preflightInProgress = true | ||
} | ||
constructor(fileEl, file, view){ | ||
@@ -32,0 +41,0 @@ this.ref = LiveUploader.genFileRef(file) |
@@ -59,14 +59,24 @@ import { | ||
let serializeForm = (form, metadata, onlyNames = []) => { | ||
let {submitter, ...meta} = metadata | ||
const {submitter, ...meta} = metadata | ||
// TODO: Replace with `new FormData(form, submitter)` when supported by latest browsers, | ||
// and mention `formdata-submitter-polyfill` in the docs. | ||
let formData = new FormData(form) | ||
// TODO: Remove when FormData constructor supports the submitter argument. | ||
if(submitter && submitter.hasAttribute("name") && submitter.form && submitter.form === form){ | ||
formData.append(submitter.name, submitter.value) | ||
// We must inject the submitter in the order that it exists in the DOM | ||
// releative to other inputs. For example, for checkbox groups, the order must be maintained. | ||
let injectedElement | ||
if(submitter && submitter.name){ | ||
const input = document.createElement("input") | ||
input.type = "hidden" | ||
// set the form attribute if the submitter has one; | ||
// this can happen if the element is outside the actual form element | ||
const formId = submitter.getAttribute("form") | ||
if(formId){ | ||
input.setAttribute("form", form) | ||
} | ||
input.name = submitter.name | ||
input.value = submitter.value | ||
submitter.parentElement.insertBefore(input, submitter) | ||
injectedElement = input | ||
} | ||
let toRemove = [] | ||
const formData = new FormData(form) | ||
const toRemove = [] | ||
@@ -80,3 +90,4 @@ formData.forEach((val, key, _index) => { | ||
let params = new URLSearchParams() | ||
const params = new URLSearchParams() | ||
for(let [key, val] of formData.entries()){ | ||
@@ -87,2 +98,9 @@ if(onlyNames.length === 0 || onlyNames.indexOf(key) >= 0){ | ||
} | ||
// remove the injected element again | ||
// (it would be removed by the next dom patch anyway, but this is cleaner) | ||
if(submitter && injectedElement){ | ||
submitter.parentElement.removeChild(injectedElement) | ||
} | ||
for(let metaKey in meta){ params.append(metaKey, meta[metaKey]) } | ||
@@ -309,2 +327,5 @@ | ||
if(phxStatic){ toEl.setAttribute(PHX_STATIC, phxStatic) } | ||
// set PHX_ROOT_ID to prevent events from being dispatched to the root view | ||
// while the child join is still pending | ||
if(fromEl){ fromEl.setAttribute(PHX_ROOT_ID, this.root.id) } | ||
return this.joinChild(toEl) | ||
@@ -760,2 +781,3 @@ }) | ||
let disabledVal = el.getAttribute(PHX_DISABLED) | ||
let readOnlyVal = el.getAttribute(PHX_READONLY) | ||
// remove refs | ||
@@ -765,4 +787,4 @@ el.removeAttribute(PHX_REF) | ||
// restore inputs | ||
if(el.getAttribute(PHX_READONLY) !== null){ | ||
el.readOnly = false | ||
if(readOnlyVal !== null){ | ||
el.readOnly = readOnlyVal === "true" ? true : false | ||
el.removeAttribute(PHX_READONLY) | ||
@@ -807,2 +829,4 @@ } | ||
if(disableText !== ""){ el.innerText = disableText } | ||
// PHX_DISABLED could have already been set in disableForm | ||
el.setAttribute(PHX_DISABLED, el.getAttribute(PHX_DISABLED) || el.disabled) | ||
el.setAttribute("disabled", "") | ||
@@ -908,2 +932,3 @@ } | ||
let meta = this.extractMeta(inputEl.form) | ||
if(inputEl instanceof HTMLButtonElement){ meta.submitter = inputEl } | ||
if(inputEl.getAttribute(this.binding("change"))){ | ||
@@ -1154,3 +1179,3 @@ formData = serializeForm(inputEl.form, {_target: opts._target, ...meta}, [inputEl.name]) | ||
// which result in an invalid css selector otherwise. | ||
const phxChangeValue = form.getAttribute(phxChange).replaceAll(/([\[\]"])/g, '\\$1') | ||
const phxChangeValue = CSS.escape(form.getAttribute(phxChange)) | ||
let newForm = template.content.querySelector(`form[id="${form.id}"][${phxChange}="${phxChangeValue}"]`) | ||
@@ -1168,13 +1193,14 @@ if(newForm){ | ||
maybePushComponentsDestroyed(destroyedCIDs){ | ||
let willDestroyCIDs = destroyedCIDs.filter(cid => { | ||
let willDestroyCIDs = destroyedCIDs.concat(this.pruningCIDs).filter(cid => { | ||
return DOM.findComponentNodeList(this.el, cid).length === 0 | ||
}) | ||
// make sure this is a copy and not a reference | ||
this.pruningCIDs = willDestroyCIDs.concat([]) | ||
if(willDestroyCIDs.length > 0){ | ||
this.pruningCIDs.push(...willDestroyCIDs) | ||
// we must reset the render change tracking for cids that | ||
// could be added back from the server so we don't skip them | ||
willDestroyCIDs.forEach(cid => this.rendered.resetRender(cid)) | ||
this.pushWithReply(null, "cids_will_destroy", {cids: willDestroyCIDs}, () => { | ||
// The cids are either back on the page or they will be fully removed, | ||
// so we can remove them from the pruningCIDs. | ||
this.pruningCIDs = this.pruningCIDs.filter(cid => willDestroyCIDs.indexOf(cid) !== -1) | ||
// See if any of the cids we wanted to destroy were added back, | ||
@@ -1188,2 +1214,3 @@ // if they were added back, we don't actually destroy them. | ||
this.pushWithReply(null, "cids_destroyed", {cids: completelyDestroyCIDs}, (resp) => { | ||
this.pruningCIDs = this.pruningCIDs.filter(cid => resp.cids.indexOf(cid) === -1) | ||
this.rendered.pruneCIDs(resp.cids) | ||
@@ -1190,0 +1217,0 @@ }) |
{ | ||
"name": "phoenix_live_view", | ||
"version": "0.20.3", | ||
"version": "0.20.4", | ||
"description": "The Phoenix LiveView JavaScript client.", | ||
@@ -13,13 +13,15 @@ "license": "MIT", | ||
"dependencies": { | ||
"morphdom": "2.7.1" | ||
"morphdom": "2.7.2" | ||
}, | ||
"devDependencies": { | ||
"@babel/cli": "7.14.3", | ||
"@babel/core": "7.14.3", | ||
"@babel/preset-env": "7.14.2", | ||
"eslint": "7.27.0", | ||
"eslint-plugin-jest": "24.3.6", | ||
"jest": "^27.0.1", | ||
"phoenix": "1.5.9" | ||
"@babel/cli": "7.23.4", | ||
"@babel/core": "7.23.7", | ||
"@babel/preset-env": "7.23.8", | ||
"css.escape": "^1.5.1", | ||
"eslint": "8.56.0", | ||
"eslint-plugin-jest": "27.6.3", | ||
"jest": "^29.7.0", | ||
"jest-environment-jsdom": "^29.7.0", | ||
"phoenix": "1.7.10" | ||
} | ||
} |
{ | ||
"name": "phoenix_live_view", | ||
"version": "0.20.3", | ||
"version": "0.20.4", | ||
"description": "The Phoenix LiveView JavaScript client.", | ||
@@ -25,3 +25,10 @@ "license": "MIT", | ||
"assets/js/phoenix_live_view/*" | ||
] | ||
], | ||
"devDependencies": { | ||
"@playwright/test": "^1.41.0" | ||
}, | ||
"scripts": { | ||
"e2e:server": "MIX_ENV=e2e mix run test/e2e/test_helper.exs", | ||
"e2e:test": "mix assets.build && cd test/e2e && npx playwright test" | ||
} | ||
} |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
1384140
19057
0
1