phoenix_live_view 0.20.2 → 0.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/js/phoenix_live_view/constants.js +3 -1
- package/assets/js/phoenix_live_view/dom.js +78 -12
- package/assets/js/phoenix_live_view/dom_patch.js +96 -58
- package/assets/js/phoenix_live_view/hooks.js +71 -17
- package/assets/js/phoenix_live_view/js.js +39 -8
- package/assets/js/phoenix_live_view/live_socket.js +22 -12
- package/assets/js/phoenix_live_view/live_uploader.js +8 -1
- package/assets/js/phoenix_live_view/rendered.js +52 -49
- package/assets/js/phoenix_live_view/upload_entry.js +10 -1
- package/assets/js/phoenix_live_view/view.js +52 -21
- package/assets/package.json +11 -9
- package/package.json +9 -2
- package/priv/static/phoenix_live_view.cjs.js +333 -167
- package/priv/static/phoenix_live_view.cjs.js.map +2 -2
- package/priv/static/phoenix_live_view.esm.js +333 -167
- package/priv/static/phoenix_live_view.esm.js.map +2 -2
- package/priv/static/phoenix_live_view.js +333 -167
- package/priv/static/phoenix_live_view.min.js +4 -5
|
@@ -5,7 +5,8 @@ export const RELOAD_JITTER_MAX = 10000
|
|
|
5
5
|
export const FAILSAFE_JITTER = 30000
|
|
6
6
|
export const PHX_EVENT_CLASSES = [
|
|
7
7
|
"phx-click-loading", "phx-change-loading", "phx-submit-loading",
|
|
8
|
-
"phx-keydown-loading", "phx-keyup-loading", "phx-blur-loading", "phx-focus-loading"
|
|
8
|
+
"phx-keydown-loading", "phx-keyup-loading", "phx-blur-loading", "phx-focus-loading",
|
|
9
|
+
"phx-hook-loading"
|
|
9
10
|
]
|
|
10
11
|
export const PHX_COMPONENT = "data-phx-component"
|
|
11
12
|
export const PHX_LIVE_LINK = "data-phx-link"
|
|
@@ -37,6 +38,7 @@ export const PHX_VIEWPORT_TOP = "viewport-top"
|
|
|
37
38
|
export const PHX_VIEWPORT_BOTTOM = "viewport-bottom"
|
|
38
39
|
export const PHX_TRIGGER_ACTION = "trigger-action"
|
|
39
40
|
export const PHX_FEEDBACK_FOR = "feedback-for"
|
|
41
|
+
export const PHX_FEEDBACK_GROUP = "feedback-group"
|
|
40
42
|
export const PHX_HAS_FOCUSED = "phx-has-focused"
|
|
41
43
|
export const FOCUSABLE_INPUTS = ["text", "textarea", "number", "email", "password", "search", "tel", "url", "date", "time", "datetime-local", "color", "range"]
|
|
42
44
|
export const CHECKABLE_INPUTS = ["checkbox", "radio"]
|
|
@@ -50,7 +50,11 @@ let DOM = {
|
|
|
50
50
|
|
|
51
51
|
isAutoUpload(inputEl){ return inputEl.hasAttribute("data-phx-auto-upload") },
|
|
52
52
|
|
|
53
|
-
findUploadInputs(node){
|
|
53
|
+
findUploadInputs(node){
|
|
54
|
+
const formId = node.id
|
|
55
|
+
const inputsOutsideForm = this.all(document, `input[type="file"][${PHX_UPLOAD_REF}][form="${formId}"]`)
|
|
56
|
+
return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat(inputsOutsideForm)
|
|
57
|
+
},
|
|
54
58
|
|
|
55
59
|
findComponentNodeList(node, cid){
|
|
56
60
|
return this.filterWithinSameLiveView(this.all(node, `[${PHX_COMPONENT}="${cid}"]`), node)
|
|
@@ -252,7 +256,10 @@ let DOM = {
|
|
|
252
256
|
})
|
|
253
257
|
}
|
|
254
258
|
if(this.once(el, "bind-debounce")){
|
|
255
|
-
el.addEventListener("blur", () =>
|
|
259
|
+
el.addEventListener("blur", () => {
|
|
260
|
+
// always trigger callback on blur
|
|
261
|
+
callback()
|
|
262
|
+
})
|
|
256
263
|
}
|
|
257
264
|
}
|
|
258
265
|
},
|
|
@@ -285,14 +292,41 @@ let DOM = {
|
|
|
285
292
|
}
|
|
286
293
|
},
|
|
287
294
|
|
|
288
|
-
maybeHideFeedback(container,
|
|
295
|
+
maybeHideFeedback(container, forms, phxFeedbackFor, phxFeedbackGroup){
|
|
289
296
|
let feedbacks = []
|
|
290
|
-
inputs
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
297
|
+
// if there are multiple inputs with the same name
|
|
298
|
+
// (for example the default checkbox renders a hidden input as well)
|
|
299
|
+
// we must only add the no feedback class if none of them have been focused yet
|
|
300
|
+
let inputNamesFocused = {}
|
|
301
|
+
// an entry in this object will be true if NO input in the group has been focused yet
|
|
302
|
+
let feedbackGroups = {}
|
|
303
|
+
|
|
304
|
+
forms.forEach(form => {
|
|
305
|
+
Array.from(form.elements).forEach(input => {
|
|
306
|
+
const group = input.getAttribute(phxFeedbackGroup)
|
|
307
|
+
// initialize the group to true if it doesn't exist
|
|
308
|
+
if(group && !(group in feedbackGroups)){ feedbackGroups[group] = true }
|
|
309
|
+
// initialize the focused state to false if it doesn't exist
|
|
310
|
+
if(!(input.name in inputNamesFocused)){ inputNamesFocused[input.name] = false }
|
|
311
|
+
if(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED)){
|
|
312
|
+
inputNamesFocused[input.name] = true
|
|
313
|
+
// the input was focused, therefore the group will NOT get phx-no-feedback
|
|
314
|
+
if(group){ feedbackGroups[group] = false }
|
|
315
|
+
}
|
|
316
|
+
})
|
|
295
317
|
})
|
|
318
|
+
|
|
319
|
+
for(const [name, focused] of Object.entries(inputNamesFocused)){
|
|
320
|
+
if(!focused){
|
|
321
|
+
feedbacks.push(name)
|
|
322
|
+
if(name.endsWith("[]")){ feedbacks.push(name.slice(0, -2)) }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for(const [group, noFeedback] of Object.entries(feedbackGroups)){
|
|
327
|
+
if(noFeedback) feedbacks.push(group)
|
|
328
|
+
}
|
|
329
|
+
|
|
296
330
|
if(feedbacks.length > 0){
|
|
297
331
|
let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ")
|
|
298
332
|
DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))
|
|
@@ -329,12 +363,21 @@ let DOM = {
|
|
|
329
363
|
return node.getAttribute && node.getAttribute(PHX_STICKY) !== null
|
|
330
364
|
},
|
|
331
365
|
|
|
366
|
+
isChildOfAny(el, parents){
|
|
367
|
+
return !!parents.find(parent => parent.contains(el))
|
|
368
|
+
},
|
|
369
|
+
|
|
332
370
|
firstPhxChild(el){
|
|
333
371
|
return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0]
|
|
334
372
|
},
|
|
335
373
|
|
|
336
374
|
dispatchEvent(target, name, opts = {}){
|
|
337
|
-
let
|
|
375
|
+
let defaultBubble = true
|
|
376
|
+
let isUploadTarget = target.nodeName === "INPUT" && target.type === "file"
|
|
377
|
+
if(isUploadTarget && name === "click"){
|
|
378
|
+
defaultBubble = false
|
|
379
|
+
}
|
|
380
|
+
let bubbles = opts.bubbles === undefined ? defaultBubble : !!opts.bubbles
|
|
338
381
|
let eventOpts = {bubbles: bubbles, cancelable: true, detail: opts.detail || {}}
|
|
339
382
|
let event = name === "click" ? new MouseEvent("click", eventOpts) : new CustomEvent(name, eventOpts)
|
|
340
383
|
target.dispatchEvent(event)
|
|
@@ -350,20 +393,40 @@ let DOM = {
|
|
|
350
393
|
}
|
|
351
394
|
},
|
|
352
395
|
|
|
396
|
+
// merge attributes from source to target
|
|
397
|
+
// if an element is ignored, we only merge data attributes
|
|
398
|
+
// including removing data attributes that are no longer in the source
|
|
353
399
|
mergeAttrs(target, source, opts = {}){
|
|
354
|
-
let exclude = opts.exclude || []
|
|
400
|
+
let exclude = new Set(opts.exclude || [])
|
|
355
401
|
let isIgnored = opts.isIgnored
|
|
356
402
|
let sourceAttrs = source.attributes
|
|
357
403
|
for(let i = sourceAttrs.length - 1; i >= 0; i--){
|
|
358
404
|
let name = sourceAttrs[i].name
|
|
359
|
-
if(exclude.
|
|
405
|
+
if(!exclude.has(name)){
|
|
406
|
+
const sourceValue = source.getAttribute(name)
|
|
407
|
+
if(target.getAttribute(name) !== sourceValue && (!isIgnored || (isIgnored && name.startsWith("data-")))){
|
|
408
|
+
target.setAttribute(name, sourceValue)
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// We exclude the value from being merged on focused inputs, because the
|
|
412
|
+
// user's input should always win.
|
|
413
|
+
// We can still assign it as long as the value property is the same, though.
|
|
414
|
+
// This prevents a situation where the updated hook is not being triggered
|
|
415
|
+
// when an input is back in its "original state", because the attribute
|
|
416
|
+
// was never changed, see:
|
|
417
|
+
// https://github.com/phoenixframework/phoenix_live_view/issues/2163
|
|
418
|
+
if(name === "value" && target.value === source.value){
|
|
419
|
+
// actually set the value attribute to sync it with the value property
|
|
420
|
+
target.setAttribute("value", source.getAttribute(name))
|
|
421
|
+
}
|
|
422
|
+
}
|
|
360
423
|
}
|
|
361
424
|
|
|
362
425
|
let targetAttrs = target.attributes
|
|
363
426
|
for(let i = targetAttrs.length - 1; i >= 0; i--){
|
|
364
427
|
let name = targetAttrs[i].name
|
|
365
428
|
if(isIgnored){
|
|
366
|
-
if(name.startsWith("data-") && !source.hasAttribute(name)){ target.removeAttribute(name) }
|
|
429
|
+
if(name.startsWith("data-") && !source.hasAttribute(name) && ![PHX_REF, PHX_REF_SRC].includes(name)){ target.removeAttribute(name) }
|
|
367
430
|
} else {
|
|
368
431
|
if(!source.hasAttribute(name)){ target.removeAttribute(name) }
|
|
369
432
|
}
|
|
@@ -373,6 +436,7 @@ let DOM = {
|
|
|
373
436
|
mergeFocusedInput(target, source){
|
|
374
437
|
// skip selects because FF will reset highlighted index for any setAttribute
|
|
375
438
|
if(!(target instanceof HTMLSelectElement)){ DOM.mergeAttrs(target, source, {exclude: ["value"]}) }
|
|
439
|
+
|
|
376
440
|
if(source.readOnly){
|
|
377
441
|
target.setAttribute("readonly", true)
|
|
378
442
|
} else {
|
|
@@ -385,7 +449,9 @@ let DOM = {
|
|
|
385
449
|
},
|
|
386
450
|
|
|
387
451
|
restoreFocus(focused, selectionStart, selectionEnd){
|
|
452
|
+
if(focused instanceof HTMLSelectElement){ focused.focus() }
|
|
388
453
|
if(!DOM.isTextualInput(focused)){ return }
|
|
454
|
+
|
|
389
455
|
let wasFocused = focused.matches(":focus")
|
|
390
456
|
if(focused.readOnly){ focused.blur() }
|
|
391
457
|
if(!wasFocused){ focused.focus() }
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
PHX_COMPONENT,
|
|
3
3
|
PHX_DISABLE_WITH,
|
|
4
4
|
PHX_FEEDBACK_FOR,
|
|
5
|
+
PHX_FEEDBACK_GROUP,
|
|
5
6
|
PHX_PRUNE,
|
|
6
7
|
PHX_ROOT_ID,
|
|
7
8
|
PHX_SESSION,
|
|
@@ -47,6 +48,7 @@ export default class DOMPatch {
|
|
|
47
48
|
this.html = html
|
|
48
49
|
this.streams = streams
|
|
49
50
|
this.streamInserts = {}
|
|
51
|
+
this.streamComponentRestore = {}
|
|
50
52
|
this.targetCID = targetCID
|
|
51
53
|
this.cidPatch = isCid(this.targetCID)
|
|
52
54
|
this.pendingRemoves = []
|
|
@@ -71,7 +73,6 @@ export default class DOMPatch {
|
|
|
71
73
|
|
|
72
74
|
markPrunableContentForRemoval(){
|
|
73
75
|
let phxUpdate = this.liveSocket.binding(PHX_UPDATE)
|
|
74
|
-
DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => el.innerHTML = "")
|
|
75
76
|
DOM.all(this.container, `[${phxUpdate}=append] > *, [${phxUpdate}=prepend] > *`, el => {
|
|
76
77
|
el.setAttribute(PHX_PRUNE, "")
|
|
77
78
|
})
|
|
@@ -86,12 +87,13 @@ export default class DOMPatch {
|
|
|
86
87
|
let {selectionStart, selectionEnd} = focused && DOM.hasSelectionRange(focused) ? focused : {}
|
|
87
88
|
let phxUpdate = liveSocket.binding(PHX_UPDATE)
|
|
88
89
|
let phxFeedbackFor = liveSocket.binding(PHX_FEEDBACK_FOR)
|
|
90
|
+
let phxFeedbackGroup = liveSocket.binding(PHX_FEEDBACK_GROUP)
|
|
89
91
|
let disableWith = liveSocket.binding(PHX_DISABLE_WITH)
|
|
90
92
|
let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP)
|
|
91
93
|
let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM)
|
|
92
94
|
let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION)
|
|
93
95
|
let added = []
|
|
94
|
-
let
|
|
96
|
+
let trackedForms = new Set()
|
|
95
97
|
let updates = []
|
|
96
98
|
let appendPrependUpdates = []
|
|
97
99
|
|
|
@@ -102,16 +104,12 @@ export default class DOMPatch {
|
|
|
102
104
|
|
|
103
105
|
liveSocket.time("morphdom", () => {
|
|
104
106
|
this.streams.forEach(([ref, inserts, deleteIds, reset]) => {
|
|
105
|
-
|
|
106
|
-
this.streamInserts[key] = {ref, streamAt, limit,
|
|
107
|
+
inserts.forEach(([key, streamAt, limit]) => {
|
|
108
|
+
this.streamInserts[key] = {ref, streamAt, limit, reset}
|
|
107
109
|
})
|
|
108
110
|
if(reset !== undefined){
|
|
109
111
|
DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => {
|
|
110
|
-
|
|
111
|
-
this.streamInserts[child.id].resetKept = true
|
|
112
|
-
} else {
|
|
113
|
-
this.removeStreamChildElement(child)
|
|
114
|
-
}
|
|
112
|
+
this.removeStreamChildElement(child)
|
|
115
113
|
})
|
|
116
114
|
}
|
|
117
115
|
deleteIds.forEach(id => {
|
|
@@ -120,6 +118,21 @@ export default class DOMPatch {
|
|
|
120
118
|
})
|
|
121
119
|
})
|
|
122
120
|
|
|
121
|
+
// clear stream items from the dead render if they are not inserted again
|
|
122
|
+
if(isJoinPatch){
|
|
123
|
+
DOM.all(this.container, `[${phxUpdate}=${PHX_STREAM}]`, el => {
|
|
124
|
+
// make sure to only remove elements owned by the current view
|
|
125
|
+
// see https://github.com/phoenixframework/phoenix_live_view/issues/3047
|
|
126
|
+
this.liveSocket.owner(el, (view) => {
|
|
127
|
+
if(view === this.view){
|
|
128
|
+
Array.from(el.children).forEach(child => {
|
|
129
|
+
this.removeStreamChildElement(child)
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
morphdom(targetContainer, html, {
|
|
124
137
|
childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null,
|
|
125
138
|
getNodeKey: (node) => {
|
|
@@ -133,10 +146,18 @@ export default class DOMPatch {
|
|
|
133
146
|
skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM },
|
|
134
147
|
// tell morphdom how to add a child
|
|
135
148
|
addChild: (parent, child) => {
|
|
136
|
-
let {ref, streamAt
|
|
137
|
-
if(ref === undefined)
|
|
149
|
+
let {ref, streamAt} = this.getStreamInsert(child)
|
|
150
|
+
if(ref === undefined){ return parent.appendChild(child) }
|
|
151
|
+
|
|
152
|
+
this.setStreamRef(child, ref)
|
|
138
153
|
|
|
139
|
-
|
|
154
|
+
// we may need to restore skipped components, see removeStreamChildElement
|
|
155
|
+
child.querySelectorAll(`[${PHX_MAGIC_ID}][${PHX_SKIP}]`).forEach(el => {
|
|
156
|
+
const component = this.streamComponentRestore[el.getAttribute(PHX_MAGIC_ID)]
|
|
157
|
+
if(component){
|
|
158
|
+
el.replaceWith(component)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
140
161
|
|
|
141
162
|
// streaming
|
|
142
163
|
if(streamAt === 0){
|
|
@@ -147,19 +168,6 @@ export default class DOMPatch {
|
|
|
147
168
|
let sibling = Array.from(parent.children)[streamAt]
|
|
148
169
|
parent.insertBefore(child, sibling)
|
|
149
170
|
}
|
|
150
|
-
let children = limit !== null && Array.from(parent.children)
|
|
151
|
-
let childrenToRemove = []
|
|
152
|
-
if(limit && limit < 0 && children.length > limit * -1){
|
|
153
|
-
childrenToRemove = children.slice(0, children.length + limit)
|
|
154
|
-
} else if(limit && limit >= 0 && children.length > limit){
|
|
155
|
-
childrenToRemove = children.slice(limit)
|
|
156
|
-
}
|
|
157
|
-
childrenToRemove.forEach(removeChild => {
|
|
158
|
-
// do not remove child as part of limit if we are re-adding it
|
|
159
|
-
if(!this.streamInserts[removeChild.id]){
|
|
160
|
-
this.removeStreamChildElement(removeChild)
|
|
161
|
-
}
|
|
162
|
-
})
|
|
163
171
|
},
|
|
164
172
|
onBeforeNodeAdded: (el) => {
|
|
165
173
|
DOM.maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom)
|
|
@@ -167,7 +175,7 @@ export default class DOMPatch {
|
|
|
167
175
|
return el
|
|
168
176
|
},
|
|
169
177
|
onNodeAdded: (el) => {
|
|
170
|
-
if(el.getAttribute){ this.maybeReOrderStream(el) }
|
|
178
|
+
if(el.getAttribute){ this.maybeReOrderStream(el, true) }
|
|
171
179
|
|
|
172
180
|
// hack to fix Safari handling of img srcset and video tags
|
|
173
181
|
if(el instanceof HTMLImageElement && el.srcset){
|
|
@@ -180,7 +188,7 @@ export default class DOMPatch {
|
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
if(el.getAttribute && el.getAttribute("name") && DOM.isFormInput(el)){
|
|
183
|
-
|
|
191
|
+
trackedForms.add(el.form)
|
|
184
192
|
}
|
|
185
193
|
// nested view handling
|
|
186
194
|
if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){
|
|
@@ -188,27 +196,6 @@ export default class DOMPatch {
|
|
|
188
196
|
}
|
|
189
197
|
added.push(el)
|
|
190
198
|
},
|
|
191
|
-
onBeforeElChildrenUpdated: (fromEl, toEl) => {
|
|
192
|
-
// before we update the children, we need to set existing stream children
|
|
193
|
-
// into the new order from the server if they were kept during a stream reset
|
|
194
|
-
if(fromEl.getAttribute(phxUpdate) === PHX_STREAM){
|
|
195
|
-
let toIds = Array.from(toEl.children).map(child => child.id)
|
|
196
|
-
Array.from(fromEl.children).filter(child => {
|
|
197
|
-
let {resetKept} = this.getStreamInsert(child)
|
|
198
|
-
return resetKept
|
|
199
|
-
}).sort((a, b) => {
|
|
200
|
-
let aIdx = toIds.indexOf(a.id)
|
|
201
|
-
let bIdx = toIds.indexOf(b.id)
|
|
202
|
-
if(aIdx === bIdx){
|
|
203
|
-
return 0
|
|
204
|
-
} else if(aIdx < bIdx){
|
|
205
|
-
return -1
|
|
206
|
-
} else {
|
|
207
|
-
return 1
|
|
208
|
-
}
|
|
209
|
-
}).forEach(child => fromEl.appendChild(child))
|
|
210
|
-
}
|
|
211
|
-
},
|
|
212
199
|
onNodeDiscarded: (el) => this.onNodeDiscarded(el),
|
|
213
200
|
onBeforeNodeDiscarded: (el) => {
|
|
214
201
|
if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
|
|
@@ -226,12 +213,16 @@ export default class DOMPatch {
|
|
|
226
213
|
externalFormTriggered = el
|
|
227
214
|
}
|
|
228
215
|
updates.push(el)
|
|
229
|
-
this.maybeReOrderStream(el)
|
|
216
|
+
this.maybeReOrderStream(el, false)
|
|
230
217
|
},
|
|
231
218
|
onBeforeElUpdated: (fromEl, toEl) => {
|
|
232
219
|
DOM.maybeAddPrivateHooks(toEl, phxViewportTop, phxViewportBottom)
|
|
233
220
|
DOM.cleanChildNodes(toEl, phxUpdate)
|
|
234
|
-
if(this.skipCIDSibling(toEl)){
|
|
221
|
+
if(this.skipCIDSibling(toEl)){
|
|
222
|
+
// if this is a live component used in a stream, we may need to reorder it
|
|
223
|
+
this.maybeReOrderStream(fromEl)
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
235
226
|
if(DOM.isPhxSticky(fromEl)){ return false }
|
|
236
227
|
if(DOM.isIgnored(fromEl, phxUpdate) || (fromEl.form && fromEl.form.isSameNode(externalFormTriggered))){
|
|
237
228
|
this.trackBefore("updated", fromEl, toEl)
|
|
@@ -264,15 +255,19 @@ export default class DOMPatch {
|
|
|
264
255
|
DOM.copyPrivates(toEl, fromEl)
|
|
265
256
|
|
|
266
257
|
let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
|
|
267
|
-
|
|
258
|
+
// skip patching focused inputs unless focus is a select that has changed options
|
|
259
|
+
let focusedSelectChanged = isFocusedFormEl && this.isChangedSelect(fromEl, toEl)
|
|
260
|
+
if(isFocusedFormEl && fromEl.type !== "hidden" && !focusedSelectChanged){
|
|
268
261
|
this.trackBefore("updated", fromEl, toEl)
|
|
269
262
|
DOM.mergeFocusedInput(fromEl, toEl)
|
|
270
263
|
DOM.syncAttrsToProps(fromEl)
|
|
271
264
|
updates.push(fromEl)
|
|
272
265
|
DOM.applyStickyOperations(fromEl)
|
|
273
|
-
|
|
266
|
+
trackedForms.add(fromEl.form)
|
|
274
267
|
return false
|
|
275
268
|
} else {
|
|
269
|
+
// blur focused select if it changed so native UI is updated (ie safari won't update visible options)
|
|
270
|
+
if(focusedSelectChanged){ fromEl.blur() }
|
|
276
271
|
if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){
|
|
277
272
|
appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate)))
|
|
278
273
|
}
|
|
@@ -280,7 +275,7 @@ export default class DOMPatch {
|
|
|
280
275
|
DOM.syncAttrsToProps(toEl)
|
|
281
276
|
DOM.applyStickyOperations(toEl)
|
|
282
277
|
if(toEl.getAttribute("name") && DOM.isFormInput(toEl)){
|
|
283
|
-
|
|
278
|
+
trackedForms.add(toEl.form)
|
|
284
279
|
}
|
|
285
280
|
this.trackBefore("updated", fromEl, toEl)
|
|
286
281
|
return true
|
|
@@ -297,7 +292,7 @@ export default class DOMPatch {
|
|
|
297
292
|
})
|
|
298
293
|
}
|
|
299
294
|
|
|
300
|
-
DOM.maybeHideFeedback(targetContainer,
|
|
295
|
+
DOM.maybeHideFeedback(targetContainer, trackedForms, phxFeedbackFor, phxFeedbackGroup)
|
|
301
296
|
|
|
302
297
|
liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
|
|
303
298
|
DOM.dispatchEvent(document, "phx:update")
|
|
@@ -332,6 +327,13 @@ export default class DOMPatch {
|
|
|
332
327
|
|
|
333
328
|
removeStreamChildElement(child){
|
|
334
329
|
if(!this.maybePendingRemove(child)){
|
|
330
|
+
if(this.streamInserts[child.id]){
|
|
331
|
+
// we need to store children so we can restore them later
|
|
332
|
+
// in case they are skipped
|
|
333
|
+
child.querySelectorAll(`[${PHX_MAGIC_ID}]`).forEach(el => {
|
|
334
|
+
this.streamComponentRestore[el.getAttribute(PHX_MAGIC_ID)] = el
|
|
335
|
+
})
|
|
336
|
+
}
|
|
335
337
|
child.remove()
|
|
336
338
|
this.onNodeDiscarded(child)
|
|
337
339
|
}
|
|
@@ -342,12 +344,21 @@ export default class DOMPatch {
|
|
|
342
344
|
return insert || {}
|
|
343
345
|
}
|
|
344
346
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
+
setStreamRef(el, ref){
|
|
348
|
+
DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
maybeReOrderStream(el, isNew){
|
|
352
|
+
let {ref, streamAt, reset} = this.getStreamInsert(el)
|
|
347
353
|
if(streamAt === undefined){ return }
|
|
348
354
|
|
|
349
|
-
// we need to the PHX_STREAM_REF here as well as addChild is invoked only for parents
|
|
350
|
-
|
|
355
|
+
// we need to set the PHX_STREAM_REF here as well as addChild is invoked only for parents
|
|
356
|
+
this.setStreamRef(el, ref)
|
|
357
|
+
|
|
358
|
+
if(!reset && !isNew){
|
|
359
|
+
// we only reorder if the element is new or it's a stream reset
|
|
360
|
+
return
|
|
361
|
+
}
|
|
351
362
|
|
|
352
363
|
if(streamAt === 0){
|
|
353
364
|
el.parentElement.insertBefore(el, el.parentElement.firstElementChild)
|
|
@@ -365,6 +376,18 @@ export default class DOMPatch {
|
|
|
365
376
|
}
|
|
366
377
|
}
|
|
367
378
|
}
|
|
379
|
+
|
|
380
|
+
this.maybeLimitStream(el)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
maybeLimitStream(el){
|
|
384
|
+
let {limit} = this.getStreamInsert(el)
|
|
385
|
+
let children = limit !== null && Array.from(el.parentElement.children)
|
|
386
|
+
if(limit && limit < 0 && children.length > limit * -1){
|
|
387
|
+
children.slice(0, children.length + limit).forEach(child => this.removeStreamChildElement(child))
|
|
388
|
+
} else if(limit && limit >= 0 && children.length > limit){
|
|
389
|
+
children.slice(limit).forEach(child => this.removeStreamChildElement(child))
|
|
390
|
+
}
|
|
368
391
|
}
|
|
369
392
|
|
|
370
393
|
transitionPendingRemoves(){
|
|
@@ -382,6 +405,21 @@ export default class DOMPatch {
|
|
|
382
405
|
}
|
|
383
406
|
}
|
|
384
407
|
|
|
408
|
+
isChangedSelect(fromEl, toEl){
|
|
409
|
+
if(!(fromEl instanceof HTMLSelectElement) || fromEl.multiple){ return false }
|
|
410
|
+
if(fromEl.options.length !== toEl.options.length){ return true }
|
|
411
|
+
|
|
412
|
+
let fromSelected = fromEl.selectedOptions[0]
|
|
413
|
+
let toSelected = toEl.selectedOptions[0]
|
|
414
|
+
if(fromSelected && fromSelected.hasAttribute("selected")){
|
|
415
|
+
toSelected.setAttribute("selected", fromSelected.getAttribute("selected"))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// in general we have to be very careful with using isEqualNode as it does not a reliable
|
|
419
|
+
// DOM tree equality check, but for selection attributes and options it works fine
|
|
420
|
+
return !fromEl.isEqualNode(toEl)
|
|
421
|
+
}
|
|
422
|
+
|
|
385
423
|
isCIDPatch(){ return this.cidPatch }
|
|
386
424
|
|
|
387
425
|
skipCIDSibling(el){
|
|
@@ -57,27 +57,59 @@ let Hooks = {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
let
|
|
61
|
-
|
|
60
|
+
let findScrollContainer = (el) => {
|
|
61
|
+
if(["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) return el
|
|
62
|
+
if(document.documentElement === el) return null
|
|
63
|
+
return findScrollContainer(el.parentElement)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let scrollTop = (scrollContainer) => {
|
|
67
|
+
if(scrollContainer){
|
|
68
|
+
return scrollContainer.scrollTop
|
|
69
|
+
} else {
|
|
70
|
+
return document.documentElement.scrollTop || document.body.scrollTop
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let bottom = (scrollContainer) => {
|
|
75
|
+
if(scrollContainer){
|
|
76
|
+
return scrollContainer.getBoundingClientRect().bottom
|
|
77
|
+
} else {
|
|
78
|
+
// when we have no container, the whole page scrolls,
|
|
79
|
+
// therefore the bottom coordinate is the viewport height
|
|
80
|
+
return window.innerHeight || document.documentElement.clientHeight
|
|
81
|
+
}
|
|
82
|
+
}
|
|
62
83
|
|
|
63
|
-
let
|
|
84
|
+
let top = (scrollContainer) => {
|
|
85
|
+
if(scrollContainer){
|
|
86
|
+
return scrollContainer.getBoundingClientRect().top
|
|
87
|
+
} else {
|
|
88
|
+
// when we have no container the whole page scrolls,
|
|
89
|
+
// therefore the top coordinate is 0
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let isAtViewportTop = (el, scrollContainer) => {
|
|
64
95
|
let rect = el.getBoundingClientRect()
|
|
65
|
-
return rect.top >=
|
|
96
|
+
return rect.top >= top(scrollContainer) && rect.left >= 0 && rect.top <= bottom(scrollContainer)
|
|
66
97
|
}
|
|
67
98
|
|
|
68
|
-
let isAtViewportBottom = (el) => {
|
|
99
|
+
let isAtViewportBottom = (el, scrollContainer) => {
|
|
69
100
|
let rect = el.getBoundingClientRect()
|
|
70
|
-
return rect.right >=
|
|
101
|
+
return rect.right >= top(scrollContainer) && rect.left >= 0 && rect.bottom <= bottom(scrollContainer)
|
|
71
102
|
}
|
|
72
103
|
|
|
73
|
-
let isWithinViewport = (el) => {
|
|
104
|
+
let isWithinViewport = (el, scrollContainer) => {
|
|
74
105
|
let rect = el.getBoundingClientRect()
|
|
75
|
-
return rect.top >=
|
|
106
|
+
return rect.top >= top(scrollContainer) && rect.left >= 0 && rect.top <= bottom(scrollContainer)
|
|
76
107
|
}
|
|
77
108
|
|
|
78
109
|
Hooks.InfiniteScroll = {
|
|
79
110
|
mounted(){
|
|
80
|
-
|
|
111
|
+
this.scrollContainer = findScrollContainer(this.el)
|
|
112
|
+
let scrollBefore = scrollTop(this.scrollContainer)
|
|
81
113
|
let topOverran = false
|
|
82
114
|
let throttleInterval = 500
|
|
83
115
|
let pendingOp = null
|
|
@@ -93,7 +125,12 @@ Hooks.InfiniteScroll = {
|
|
|
93
125
|
pendingOp = () => firstChild.scrollIntoView({block: "start"})
|
|
94
126
|
this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id}, () => {
|
|
95
127
|
pendingOp = null
|
|
96
|
-
|
|
128
|
+
// make sure that the DOM is patched by waiting for the next tick
|
|
129
|
+
window.requestAnimationFrame(() => {
|
|
130
|
+
if(!isWithinViewport(firstChild, this.scrollContainer)){
|
|
131
|
+
firstChild.scrollIntoView({block: "start"})
|
|
132
|
+
}
|
|
133
|
+
})
|
|
97
134
|
})
|
|
98
135
|
})
|
|
99
136
|
|
|
@@ -101,12 +138,17 @@ Hooks.InfiniteScroll = {
|
|
|
101
138
|
pendingOp = () => lastChild.scrollIntoView({block: "end"})
|
|
102
139
|
this.liveSocket.execJSHookPush(this.el, bottomEvent, {id: lastChild.id}, () => {
|
|
103
140
|
pendingOp = null
|
|
104
|
-
|
|
141
|
+
// make sure that the DOM is patched by waiting for the next tick
|
|
142
|
+
window.requestAnimationFrame(() => {
|
|
143
|
+
if(!isWithinViewport(lastChild, this.scrollContainer)){
|
|
144
|
+
lastChild.scrollIntoView({block: "end"})
|
|
145
|
+
}
|
|
146
|
+
})
|
|
105
147
|
})
|
|
106
148
|
})
|
|
107
149
|
|
|
108
|
-
this.onScroll = (
|
|
109
|
-
let scrollNow = scrollTop()
|
|
150
|
+
this.onScroll = (_e) => {
|
|
151
|
+
let scrollNow = scrollTop(this.scrollContainer)
|
|
110
152
|
|
|
111
153
|
if(pendingOp){
|
|
112
154
|
scrollBefore = scrollNow
|
|
@@ -128,16 +170,28 @@ Hooks.InfiniteScroll = {
|
|
|
128
170
|
topOverran = false
|
|
129
171
|
}
|
|
130
172
|
|
|
131
|
-
if(topEvent && isScrollingUp && isAtViewportTop(firstChild)){
|
|
173
|
+
if(topEvent && isScrollingUp && isAtViewportTop(firstChild, this.scrollContainer)){
|
|
132
174
|
onFirstChildAtTop(topEvent, firstChild)
|
|
133
|
-
} else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild)){
|
|
175
|
+
} else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild, this.scrollContainer)){
|
|
134
176
|
onLastChildAtBottom(bottomEvent, lastChild)
|
|
135
177
|
}
|
|
136
178
|
scrollBefore = scrollNow
|
|
137
179
|
}
|
|
138
|
-
|
|
180
|
+
|
|
181
|
+
if(this.scrollContainer){
|
|
182
|
+
this.scrollContainer.addEventListener("scroll", this.onScroll)
|
|
183
|
+
} else {
|
|
184
|
+
window.addEventListener("scroll", this.onScroll)
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
destroyed(){
|
|
189
|
+
if(this.scrollContainer){
|
|
190
|
+
this.scrollContainer.removeEventListener("scroll", this.onScroll)
|
|
191
|
+
} else {
|
|
192
|
+
window.removeEventListener("scroll", this.onScroll)
|
|
193
|
+
}
|
|
139
194
|
},
|
|
140
|
-
destroyed(){ window.removeEventListener("scroll", this.onScroll) },
|
|
141
195
|
|
|
142
196
|
throttle(interval, callback){
|
|
143
197
|
let lastCallAt = 0
|