phoenix_live_view 0.20.14 → 0.20.15

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/README.md CHANGED
@@ -166,7 +166,12 @@ $ mix deps.get
166
166
  $ mix test
167
167
  ```
168
168
 
169
- Running the Javascript tests:
169
+ Running all JavaScript tests:
170
+ ```bash
171
+ $ npm run test
172
+ ```
173
+
174
+ Running the JavaScript unit tests:
170
175
 
171
176
  ```bash
172
177
  $ cd assets
@@ -176,4 +181,23 @@ $ npm run test
176
181
  $ npm run test.watch
177
182
  ```
178
183
 
184
+ or simply:
185
+
186
+ ```bash
187
+ $ npm run js:test
188
+ ```
189
+
190
+ Running the JavaScript end-to-end tests:
191
+
192
+ ```bash
193
+ $ npm run e2e:test
194
+ ```
195
+
196
+ Checking test coverage:
197
+
198
+ ```bash
199
+ $ npm run cover
200
+ $ npm run cover:report
201
+ ```
202
+
179
203
  JS contributions are very welcome, but please do not include an updated `priv/static/phoenix_live_view.js` in pull requests. The maintainers will update it as part of the release process.
@@ -70,7 +70,8 @@ let DOM = {
70
70
  let wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1)
71
71
  let isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download"))
72
72
  let isTargetBlank = e.target.hasAttribute("target") && e.target.getAttribute("target").toLowerCase() === "_blank"
73
- return wantsNewTab || isTargetBlank || isDownload
73
+ let isTargetNamedTab = e.target.hasAttribute("target") && !e.target.getAttribute("target").startsWith("_")
74
+ return wantsNewTab || isTargetBlank || isDownload || isTargetNamedTab
74
75
  },
75
76
 
76
77
  isUnloadableFormSubmit(e){
@@ -222,7 +223,9 @@ let DOM = {
222
223
 
223
224
  case "blur":
224
225
  if(this.once(el, "debounce-blur")){
225
- el.addEventListener("blur", () => callback())
226
+ el.addEventListener("blur", () => {
227
+ if(asyncFilter()){ callback() }
228
+ })
226
229
  }
227
230
  return
228
231
 
@@ -479,7 +482,6 @@ let DOM = {
479
482
  if(!DOM.isTextualInput(focused)){ return }
480
483
 
481
484
  let wasFocused = focused.matches(":focus")
482
- if(focused.readOnly){ focused.blur() }
483
485
  if(!wasFocused){ focused.focus() }
484
486
  if(this.hasSelectionRange(focused)){
485
487
  focused.setSelectionRange(selectionStart, selectionEnd)
@@ -505,18 +507,9 @@ let DOM = {
505
507
  if(ref === null){ return true }
506
508
  let refSrc = fromEl.getAttribute(PHX_REF_SRC)
507
509
 
508
- if(DOM.isFormInput(fromEl) || fromEl.getAttribute(disableWith) !== null){
509
- if(DOM.isUploadInput(fromEl)){ DOM.mergeAttrs(fromEl, toEl, {isIgnored: true}) }
510
- DOM.putPrivate(fromEl, PHX_REF, toEl)
511
- return false
512
- } else {
513
- PHX_EVENT_CLASSES.forEach(className => {
514
- fromEl.classList.contains(className) && toEl.classList.add(className)
515
- })
516
- toEl.setAttribute(PHX_REF, ref)
517
- toEl.setAttribute(PHX_REF_SRC, refSrc)
518
- return true
519
- }
510
+ if(DOM.isUploadInput(fromEl)){ DOM.mergeAttrs(fromEl, toEl, {isIgnored: true}) }
511
+ DOM.putPrivate(fromEl, PHX_REF, toEl)
512
+ return false
520
513
  },
521
514
 
522
515
  cleanChildNodes(container, phxUpdate){
@@ -526,7 +519,7 @@ let DOM = {
526
519
  if(!childNode.id){
527
520
  // Skip warning if it's an empty text node (e.g. a new-line)
528
521
  let isEmptyTextNode = childNode.nodeType === Node.TEXT_NODE && childNode.nodeValue.trim() === ""
529
- if(!isEmptyTextNode){
522
+ if(!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE){
530
523
  logError("only HTML element tags with an id are allowed inside containers with phx-update.\n\n" +
531
524
  `removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"\n\n`)
532
525
  }
@@ -99,9 +99,13 @@ export default class DOMPatch {
99
99
 
100
100
  let externalFormTriggered = null
101
101
 
102
- function morph(targetContainer, source){
102
+ function morph(targetContainer, source, withChildren=false){
103
103
  morphdom(targetContainer, source, {
104
- childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null,
104
+ // normally, we are running with childrenOnly, as the patch HTML for a LV
105
+ // does not include the LV attrs (data-phx-session, etc.)
106
+ // when we are patching a live component, we do want to patch the root element as well;
107
+ // another case is the recursive patch of a stream item that was kept on reset (-> onBeforeNodeAdded)
108
+ childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null && !withChildren,
105
109
  getNodeKey: (node) => {
106
110
  if(DOM.isPhxDestroyed(node)){ return null }
107
111
  // If we have a join patch, then by definition there was no PHX_MAGIC_ID.
@@ -137,7 +141,7 @@ export default class DOMPatch {
137
141
  if(!isJoinPatch && this.streamComponentRestore[el.id]){
138
142
  morphedEl = this.streamComponentRestore[el.id]
139
143
  delete this.streamComponentRestore[el.id]
140
- morph.bind(this)(morphedEl, el)
144
+ morph.call(this, morphedEl, el, true)
141
145
  }
142
146
 
143
147
  return morphedEl
@@ -198,7 +202,7 @@ export default class DOMPatch {
198
202
  if(DOM.isPhxSticky(fromEl)){ return false }
199
203
  if(DOM.isIgnored(fromEl, phxUpdate) || (fromEl.form && fromEl.form.isSameNode(externalFormTriggered))){
200
204
  this.trackBefore("updated", fromEl, toEl)
201
- DOM.mergeAttrs(fromEl, toEl, {isIgnored: true})
205
+ DOM.mergeAttrs(fromEl, toEl, {isIgnored: DOM.isIgnored(fromEl, phxUpdate)})
202
206
  updates.push(fromEl)
203
207
  DOM.applyStickyOperations(fromEl)
204
208
  return false
@@ -286,10 +290,18 @@ export default class DOMPatch {
286
290
  })
287
291
  }
288
292
 
289
- morph.bind(this)(targetContainer, html)
293
+ morph.call(this, targetContainer, html)
290
294
  })
291
295
 
292
- if(liveSocket.isDebugEnabled()){ detectDuplicateIds() }
296
+ if(liveSocket.isDebugEnabled()){
297
+ detectDuplicateIds()
298
+ // warn if there are any inputs named "id"
299
+ Array.from(document.querySelectorAll("input[name=id]")).forEach(node => {
300
+ if(node.form){
301
+ console.error("Detected an input with name=\"id\" inside a form! This will cause problems when patching the DOM.\n", node)
302
+ }
303
+ })
304
+ }
293
305
 
294
306
  if(appendPrependUpdates.length > 0){
295
307
  liveSocket.time("post-morph append/prepend restoration", () => {
@@ -58,8 +58,10 @@ let Hooks = {
58
58
  }
59
59
 
60
60
  let findScrollContainer = (el) => {
61
+ // the scroll event won't be fired on the html/body element even if overflow is set
62
+ // therefore we return null to instead listen for scroll events on document
63
+ if (["HTML", "BODY"].indexOf(el.nodeName.toUpperCase()) >= 0) return null
61
64
  if(["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) return el
62
- if(document.documentElement === el) return null
63
65
  return findScrollContainer(el.parentElement)
64
66
  }
65
67
 
@@ -1,7 +1,7 @@
1
1
  import DOM from "./dom"
2
2
  import ARIA from "./aria"
3
3
 
4
- let focusStack = null
4
+ let focusStack = []
5
5
  let default_transition_time = 200
6
6
 
7
7
  let JS = {
@@ -96,13 +96,13 @@ let JS = {
96
96
  },
97
97
 
98
98
  exec_push_focus(eventType, phxEvent, view, sourceEl, el){
99
- window.requestAnimationFrame(() => focusStack = el || sourceEl)
99
+ window.requestAnimationFrame(() => focusStack.push(el || sourceEl))
100
100
  },
101
101
 
102
102
  exec_pop_focus(eventType, phxEvent, view, sourceEl, el){
103
103
  window.requestAnimationFrame(() => {
104
- if(focusStack){ focusStack.focus() }
105
- focusStack = null
104
+ const el = focusStack.pop()
105
+ if(el){ el.focus() }
106
106
  })
107
107
  },
108
108
 
@@ -115,7 +115,7 @@ let JS = {
115
115
  },
116
116
 
117
117
  exec_toggle_class(eventType, phxEvent, view, sourceEl, el, {to, names, transition, time}){
118
- this.toggleClasses(el, names, transition, view)
118
+ this.toggleClasses(el, names, transition, time, view)
119
119
  },
120
120
 
121
121
  exec_toggle_attr(eventType, phxEvent, view, sourceEl, el, {attr: [attr, val1, val2]}){
@@ -158,7 +158,13 @@ export default class LiveSocket {
158
158
  this.localStorage = opts.localStorage || window.localStorage
159
159
  this.sessionStorage = opts.sessionStorage || window.sessionStorage
160
160
  this.boundTopLevelEvents = false
161
- this.domCallbacks = Object.assign({onNodeAdded: closure(), onBeforeElUpdated: closure()}, opts.dom || {})
161
+ this.serverCloseRef = null
162
+ this.domCallbacks = Object.assign({
163
+ onPatchStart: closure(),
164
+ onPatchEnd: closure(),
165
+ onNodeAdded: closure(),
166
+ onBeforeElUpdated: closure()},
167
+ opts.dom || {})
162
168
  this.transitions = new TransitionSet()
163
169
  window.addEventListener("pagehide", _e => {
164
170
  this.unloaded = true
@@ -173,6 +179,8 @@ export default class LiveSocket {
173
179
 
174
180
  // public
175
181
 
182
+ version(){ return LV_VSN }
183
+
176
184
  isProfileEnabled(){ return this.sessionStorage.getItem(PHX_LV_PROFILE) === "true" }
177
185
 
178
186
  isDebugEnabled(){ return this.sessionStorage.getItem(PHX_LV_DEBUG) === "true" }
@@ -225,6 +233,12 @@ export default class LiveSocket {
225
233
 
226
234
  disconnect(callback){
227
235
  clearTimeout(this.reloadWithJitterTimer)
236
+ // remove the socket close listener to avoid trying to handle
237
+ // a server close event when it is actually caused by us disconnecting
238
+ if(this.serverCloseRef){
239
+ this.socket.off(this.serverCloseRef)
240
+ this.serverCloseRef = null
241
+ }
228
242
  this.socket.disconnect(callback)
229
243
  }
230
244
 
@@ -512,7 +526,7 @@ export default class LiveSocket {
512
526
 
513
527
  this.boundTopLevelEvents = true
514
528
  // enter failsafe reload if server has gone away intentionally, such as "disconnect" broadcast
515
- this.socket.onClose(event => {
529
+ this.serverCloseRef = this.socket.onClose(event => {
516
530
  // failsafe reload if normal closure and we still have a main LV
517
531
  if(event && event.code === 1000 && this.main){ return this.reloadWithJitter(this.main) }
518
532
  })
@@ -625,28 +639,23 @@ export default class LiveSocket {
625
639
 
626
640
  bindClicks(){
627
641
  window.addEventListener("mousedown", e => this.clickStartedAtTarget = e.target)
628
- this.bindClick("click", "click", false)
629
- this.bindClick("mousedown", "capture-click", true)
642
+ this.bindClick("click", "click")
630
643
  }
631
644
 
632
- bindClick(eventName, bindingName, capture){
645
+ bindClick(eventName, bindingName){
633
646
  let click = this.binding(bindingName)
634
647
  window.addEventListener(eventName, e => {
635
648
  let target = null
636
- if(capture){
637
- target = e.target.matches(`[${click}]`) ? e.target : e.target.querySelector(`[${click}]`)
638
- } else {
639
- // a synthetic click event (detail 0) will not have caused a mousedown event,
640
- // therefore the clickStartedAtTarget is stale
641
- if(e.detail === 0) this.clickStartedAtTarget = e.target
642
- let clickStartedAtTarget = this.clickStartedAtTarget || e.target
643
- target = closestPhxBinding(clickStartedAtTarget, click)
644
- this.dispatchClickAway(e, clickStartedAtTarget)
645
- this.clickStartedAtTarget = null
646
- }
649
+ // a synthetic click event (detail 0) will not have caused a mousedown event,
650
+ // therefore the clickStartedAtTarget is stale
651
+ if(e.detail === 0) this.clickStartedAtTarget = e.target
652
+ let clickStartedAtTarget = this.clickStartedAtTarget || e.target
653
+ target = closestPhxBinding(clickStartedAtTarget, click)
654
+ this.dispatchClickAway(e, clickStartedAtTarget)
655
+ this.clickStartedAtTarget = null
647
656
  let phxEvent = target && target.getAttribute(click)
648
657
  if(!phxEvent){
649
- if(!capture && DOM.isNewPageClick(e, window.location)){ this.unload() }
658
+ if(DOM.isNewPageClick(e, window.location)){ this.unload() }
650
659
  return
651
660
  }
652
661
 
@@ -660,7 +669,7 @@ export default class LiveSocket {
660
669
  JS.exec("click", phxEvent, view, target, ["push", {data: this.eventMeta("click", e, target)}])
661
670
  })
662
671
  })
663
- }, capture)
672
+ }, false)
664
673
  }
665
674
 
666
675
  dispatchClickAway(e, clickStartedAt){