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.
@@ -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){ return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`) },
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", () => this.triggerCycle(el, DEBOUNCE_TRIGGER))
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, inputs, phxFeedbackFor){
295
+ maybeHideFeedback(container, forms, phxFeedbackFor, phxFeedbackGroup){
289
296
  let feedbacks = []
290
- inputs.forEach(input => {
291
- if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
292
- feedbacks.push(input.name)
293
- if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
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 bubbles = opts.bubbles === undefined ? true : !!opts.bubbles
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.indexOf(name) < 0){ target.setAttribute(name, source.getAttribute(name)) }
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 trackedInputs = []
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
- Object.entries(inserts).forEach(([key, [streamAt, limit]]) => {
106
- this.streamInserts[key] = {ref, streamAt, limit, resetKept: false}
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
- if(inserts[child.id]){
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, limit} = this.getStreamInsert(child)
137
- if(ref === undefined) { return parent.appendChild(child) }
149
+ let {ref, streamAt} = this.getStreamInsert(child)
150
+ if(ref === undefined){ return parent.appendChild(child) }
151
+
152
+ this.setStreamRef(child, ref)
138
153
 
139
- DOM.putSticky(child, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
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
- trackedInputs.push(el)
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)){ return false }
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
- if(isFocusedFormEl && fromEl.type !== "hidden"){
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
- trackedInputs.push(fromEl)
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
- trackedInputs.push(toEl)
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, trackedInputs, phxFeedbackFor)
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
- maybeReOrderStream(el){
346
- let {ref, streamAt, limit} = this.getStreamInsert(el)
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
- DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
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 scrollTop = () => document.documentElement.scrollTop || document.body.scrollTop
61
- let winHeight = () => window.innerHeight || document.documentElement.clientHeight
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 isAtViewportTop = (el) => {
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 >= 0 && rect.left >= 0 && rect.top <= winHeight()
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 >= 0 && rect.left >= 0 && rect.bottom <= winHeight()
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 >= 0 && rect.left >= 0 && rect.top <= winHeight()
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
- let scrollBefore = scrollTop()
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
- if(!isWithinViewport(firstChild)){ firstChild.scrollIntoView({block: "start"}) }
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
- if(!isWithinViewport(lastChild)){ lastChild.scrollIntoView({block: "end"}) }
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 = (e) => {
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
- window.addEventListener("scroll", this.onScroll)
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