phoenix_live_view 0.20.0 → 0.20.2

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
@@ -29,10 +29,10 @@ model while keeping your code closer to your data (and ultimately your source of
29
29
 
30
30
  * **Rich templating language:** Enjoy HEEx: a templating language that supports function components, slots, HTML validation, verified routes, and more.
31
31
 
32
- * **Small payloads:** LiveView is smart enough to track changes so it only sends what the client needs, making LiveView payloads much smaller than server-rendered HTML.
32
+ * **Diffs over the wire:** Instead of sending "HTML over the wire", LiveView knows exactly which parts of your templates change, sending minimal diffs over the wire after the initial render, reducing latency and bandwidth usage. The client leverages this information and optimizes the browser with 5-10x faster updates, compared to solutions that replace whole HTML fragments.
33
33
 
34
34
  * **Live form validation:** LiveView supports real-time form validation out of the box. Create rich user interfaces with features like uploads, nested inputs, and [specialized recovery](https://hexdocs.pm/phoenix_live_view/form-bindings.html#recovery-following-crashes-or-disconnects).
35
-
35
+
36
36
  * **File uploads:** Real-time file uploads with progress indicators and image previews. Process your uploads on the fly or submit them to your desired cloud service.
37
37
 
38
38
  * **Rich integration API:** Use the rich integration API to interact with the client, with `phx-click`, `phx-focus`, `phx-blur`, `phx-submit`, and `phx-hook` included for cases where you have to write JavaScript.
@@ -47,9 +47,13 @@ model while keeping your code closer to your data (and ultimately your source of
47
47
 
48
48
  * **Robust test suite:** Write tests with confidence alongside Phoenix LiveView built-in testing tools. No more running a whole browser alongside your tests.
49
49
 
50
- ## Official announcements
50
+ ## Learning
51
+
52
+ Check our [comprehensive docs](https://hexdocs.pm/phoenix_live_view) to get started.
53
+
54
+ The Phoenix framework documentation also keeps a list of [community resources](https://hexdocs.pm/phoenix/community.html), including books, videos, and other materials, and some include LiveView too.
51
55
 
52
- News from the Phoenix team on LiveView:
56
+ Also follow these announcements from the Phoenix team on LiveView for more examples and rationale:
53
57
 
54
58
  * [LiveBeats: Building a Social Music App With Phoenix LiveView](https://fly.io/blog/livebeats/)
55
59
 
@@ -57,15 +61,11 @@ News from the Phoenix team on LiveView:
57
61
 
58
62
  * [Initial announcement](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript)
59
63
 
60
- ## Learning
61
-
62
- See our existing comprehensive [docs](https://hexdocs.pm/phoenix_live_view) and [guides](https://hexdocs.pm/phoenix_live_view/api-reference.html) for more information.
63
-
64
64
  ## Installation
65
65
 
66
66
  LiveView is included by default in all new Phoenix v1.6+ applications and
67
67
  later. If you have an older existing Phoenix app and you wish to add
68
- LiveView, see [the installation guide on HexDocs](https://hexdocs.pm/phoenix_live_view/installation.html).
68
+ LiveView, see [the previous installation guide](https://github.com/phoenixframework/phoenix_live_view/blob/v0.20.1/guides/introduction/installation.md).
69
69
 
70
70
  ## What makes LiveView unique?
71
71
 
@@ -167,6 +167,7 @@ $ mix test
167
167
  ```
168
168
 
169
169
  Running the Javascript tests:
170
+
170
171
  ```bash
171
172
  $ cd assets
172
173
  $ npm run test
@@ -17,7 +17,7 @@ let ARIA = {
17
17
  (el instanceof HTMLAreaElement && el.href !== undefined) ||
18
18
  (!el.disabled && (this.anyOf(el, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLButtonElement]))) ||
19
19
  (el instanceof HTMLIFrameElement) ||
20
- (el.tabIndex > 0 || (!interactiveOnly && el.tabIndex === 0 && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true"))
20
+ (el.tabIndex > 0 || (!interactiveOnly && el.getAttribute("tabindex") !== null && el.getAttribute("aria-hidden") !== "true"))
21
21
  )
22
22
  },
23
23
 
@@ -21,6 +21,7 @@ export const PHX_DROP_TARGET = "drop-target"
21
21
  export const PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"
22
22
  export const PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"
23
23
  export const PHX_SKIP = "data-phx-skip"
24
+ export const PHX_MAGIC_ID = "data-phx-id"
24
25
  export const PHX_PRUNE = "data-phx-prune"
25
26
  export const PHX_PAGE_LOADING = "page-loading"
26
27
  export const PHX_CONNECTED_CLASS = "phx-connected"
@@ -79,9 +80,10 @@ export const DEFAULTS = {
79
80
  // Rendered
80
81
  export const DYNAMICS = "d"
81
82
  export const STATIC = "s"
83
+ export const ROOT = "r"
82
84
  export const COMPONENTS = "c"
83
85
  export const EVENTS = "e"
84
86
  export const REPLY = "r"
85
87
  export const TITLE = "t"
86
88
  export const TEMPLATES = "p"
87
- export const STREAM = "stream"
89
+ export const STREAM = "stream"
@@ -285,10 +285,15 @@ let DOM = {
285
285
  }
286
286
  },
287
287
 
288
- maybeHideFeedback(container, input, phxFeedbackFor){
289
- if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
290
- let feedbacks = [input.name]
291
- if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
288
+ maybeHideFeedback(container, inputs, phxFeedbackFor){
289
+ 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
+ }
295
+ })
296
+ if(feedbacks.length > 0){
292
297
  let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ")
293
298
  DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))
294
299
  }
@@ -6,6 +6,7 @@ import {
6
6
  PHX_ROOT_ID,
7
7
  PHX_SESSION,
8
8
  PHX_SKIP,
9
+ PHX_MAGIC_ID,
9
10
  PHX_STATIC,
10
11
  PHX_TRIGGER_ACTION,
11
12
  PHX_UPDATE,
@@ -76,7 +77,7 @@ export default class DOMPatch {
76
77
  })
77
78
  }
78
79
 
79
- perform(){
80
+ perform(isJoinPatch){
80
81
  let {view, liveSocket, container, html} = this
81
82
  let targetContainer = this.isCIDPatch() ? this.targetCIDContainer(html) : container
82
83
  if(this.isCIDPatch() && !targetContainer){ return }
@@ -96,21 +97,19 @@ export default class DOMPatch {
96
97
 
97
98
  let externalFormTriggered = null
98
99
 
99
- let diffHTML = liveSocket.time("premorph container prep", () => {
100
- return this.buildDiffHTML(container, html, phxUpdate, targetContainer)
101
- })
102
-
103
100
  this.trackBefore("added", container)
104
101
  this.trackBefore("updated", container, container)
105
102
 
106
103
  liveSocket.time("morphdom", () => {
107
104
  this.streams.forEach(([ref, inserts, deleteIds, reset]) => {
108
105
  Object.entries(inserts).forEach(([key, [streamAt, limit]]) => {
109
- this.streamInserts[key] = {ref, streamAt, limit}
106
+ this.streamInserts[key] = {ref, streamAt, limit, resetKept: false}
110
107
  })
111
108
  if(reset !== undefined){
112
109
  DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => {
113
- if(!inserts[child.id]){
110
+ if(inserts[child.id]){
111
+ this.streamInserts[child.id].resetKept = true
112
+ } else {
114
113
  this.removeStreamChildElement(child)
115
114
  }
116
115
  })
@@ -121,10 +120,14 @@ export default class DOMPatch {
121
120
  })
122
121
  })
123
122
 
124
- morphdom(targetContainer, diffHTML, {
123
+ morphdom(targetContainer, html, {
125
124
  childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null,
126
125
  getNodeKey: (node) => {
127
- return DOM.isPhxDestroyed(node) ? null : node.id
126
+ if(DOM.isPhxDestroyed(node)){ return null }
127
+ // If we have a join patch, then by definition there was no PHX_MAGIC_ID.
128
+ // This is important to reduce the amount of elements morphdom discards.
129
+ if(isJoinPatch){ return node.id }
130
+ return node.id || (node.getAttribute && node.getAttribute(PHX_MAGIC_ID))
128
131
  },
129
132
  // skip indexing from children when container is stream
130
133
  skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM },
@@ -185,6 +188,27 @@ export default class DOMPatch {
185
188
  }
186
189
  added.push(el)
187
190
  },
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
+ },
188
212
  onNodeDiscarded: (el) => this.onNodeDiscarded(el),
189
213
  onBeforeNodeDiscarded: (el) => {
190
214
  if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
@@ -273,9 +297,7 @@ export default class DOMPatch {
273
297
  })
274
298
  }
275
299
 
276
- trackedInputs.forEach(input => {
277
- DOM.maybeHideFeedback(targetContainer, input, phxFeedbackFor)
278
- })
300
+ DOM.maybeHideFeedback(targetContainer, trackedInputs, phxFeedbackFor)
279
301
 
280
302
  liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
281
303
  DOM.dispatchEvent(document, "phx:update")
@@ -363,7 +385,7 @@ export default class DOMPatch {
363
385
  isCIDPatch(){ return this.cidPatch }
364
386
 
365
387
  skipCIDSibling(el){
366
- return el.nodeType === Node.ELEMENT_NODE && el.getAttribute(PHX_SKIP) !== null
388
+ return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP)
367
389
  }
368
390
 
369
391
  targetCIDContainer(html){
@@ -376,37 +398,5 @@ export default class DOMPatch {
376
398
  }
377
399
  }
378
400
 
379
- // builds HTML for morphdom patch
380
- // - for full patches of LiveView or a component with a single
381
- // root node, simply returns the HTML
382
- // - for patches of a component with multiple root nodes, the
383
- // parent node becomes the target container and non-component
384
- // siblings are marked as skip.
385
- buildDiffHTML(container, html, phxUpdate, targetContainer){
386
- let isCIDPatch = this.isCIDPatch()
387
- let isCIDWithSingleRoot = isCIDPatch && targetContainer.getAttribute(PHX_COMPONENT) === this.targetCID.toString()
388
- if(!isCIDPatch || isCIDWithSingleRoot){
389
- return html
390
- } else {
391
- // component patch with multiple CID roots
392
- let diffContainer = null
393
- let template = document.createElement("template")
394
- diffContainer = DOM.cloneNode(targetContainer)
395
- let [firstComponent, ...rest] = DOM.findComponentNodeList(diffContainer, this.targetCID)
396
- template.innerHTML = html
397
- rest.forEach(el => el.remove())
398
- Array.from(diffContainer.childNodes).forEach(child => {
399
- // we can only skip trackable nodes with an ID
400
- if(child.id && child.nodeType === Node.ELEMENT_NODE && child.getAttribute(PHX_COMPONENT) !== this.targetCID.toString()){
401
- child.setAttribute(PHX_SKIP, "")
402
- child.innerHTML = ""
403
- }
404
- })
405
- Array.from(template.content.childNodes).forEach(el => diffContainer.insertBefore(el, firstComponent))
406
- firstComponent.remove()
407
- return diffContainer.outerHTML
408
- }
409
- }
410
-
411
401
  indexOf(parent, child){ return Array.from(parent.children).indexOf(child) }
412
402
  }
@@ -15,6 +15,7 @@ export default class EntryUploader {
15
15
 
16
16
  error(reason){
17
17
  if(this.errored){ return }
18
+ this.uploadChannel.leave()
18
19
  this.errored = true
19
20
  clearTimeout(this.chunkTimer)
20
21
  this.entry.error(reason)
@@ -26,6 +26,16 @@ let JS = {
26
26
  return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0)
27
27
  },
28
28
 
29
+ isInViewport(el){
30
+ const rect = el.getBoundingClientRect()
31
+ return (
32
+ rect.top >= 0 &&
33
+ rect.left >= 0 &&
34
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
35
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth)
36
+ )
37
+ },
38
+
29
39
  // private
30
40
 
31
41
  // commands
@@ -522,7 +522,7 @@ export default class LiveSocket {
522
522
  if(!dead){ this.bindNav() }
523
523
  this.bindClicks()
524
524
  if(!dead){ this.bindForms() }
525
- this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, eventTarget) => {
525
+ this.bind({keyup: "keyup", keydown: "keydown"}, (e, type, view, targetEl, phxEvent, phxTarget) => {
526
526
  let matchKey = targetEl.getAttribute(this.binding(PHX_KEY))
527
527
  let pressedKey = e.key && e.key.toLowerCase() // chrome clicked autocompletes send a keydown without key
528
528
  if(matchKey && matchKey.toLowerCase() !== pressedKey){ return }
@@ -530,13 +530,13 @@ export default class LiveSocket {
530
530
  let data = {key: e.key, ...this.eventMeta(type, e, targetEl)}
531
531
  JS.exec(type, phxEvent, view, targetEl, ["push", {data}])
532
532
  })
533
- this.bind({blur: "focusout", focus: "focusin"}, (e, type, view, targetEl, phxEvent, eventTarget) => {
534
- if(!eventTarget){
533
+ this.bind({blur: "focusout", focus: "focusin"}, (e, type, view, targetEl, phxEvent, phxTarget) => {
534
+ if(!phxTarget){
535
535
  let data = {key: e.key, ...this.eventMeta(type, e, targetEl)}
536
536
  JS.exec(type, phxEvent, view, targetEl, ["push", {data}])
537
537
  }
538
538
  })
539
- this.bind({blur: "blur", focus: "focus"}, (e, type, view, targetEl, targetCtx, phxEvent, phxTarget) => {
539
+ this.bind({blur: "blur", focus: "focus"}, (e, type, view, targetEl, phxEvent, phxTarget) => {
540
540
  // blur and focus are triggered on document and window. Discard one to avoid dups
541
541
  if(phxTarget === "window"){
542
542
  let data = this.eventMeta(type, e, targetEl)
@@ -619,7 +619,7 @@ export default class LiveSocket {
619
619
  }
620
620
 
621
621
  bindClicks(){
622
- window.addEventListener("click", e => this.clickStartedAtTarget = e.target)
622
+ window.addEventListener("mousedown", e => this.clickStartedAtTarget = e.target)
623
623
  this.bindClick("click", "click", false)
624
624
  this.bindClick("mousedown", "capture-click", true)
625
625
  }
@@ -661,7 +661,7 @@ export default class LiveSocket {
661
661
  if(!(el.isSameNode(clickStartedAt) || el.contains(clickStartedAt))){
662
662
  this.withinOwners(e.target, view => {
663
663
  let phxEvent = el.getAttribute(phxClickAway)
664
- if(JS.isVisible(el)){
664
+ if(JS.isVisible(el) && JS.isInViewport(el)){
665
665
  JS.exec("click", phxEvent, view, el, ["push", {data: this.eventMeta("click", e, e.target)}])
666
666
  }
667
667
  })
@@ -703,7 +703,9 @@ export default class LiveSocket {
703
703
  let type = target && target.getAttribute(PHX_LIVE_LINK)
704
704
  if(!type || !this.isConnected() || !this.main || DOM.wantsNewTab(e)){ return }
705
705
 
706
- let href = target.href
706
+ // When wrapping an SVG element in an anchor tag, the href can be an SVGAnimatedString
707
+ let href = target.href instanceof SVGAnimatedString ? target.href.baseVal : target.href
708
+
707
709
  let linkState = target.getAttribute(PHX_LINK_STATE)
708
710
  e.preventDefault()
709
711
  e.stopImmediatePropagation() // do not bubble click to regular phx-click bindings
@@ -51,6 +51,7 @@ export default class LiveUploader {
51
51
  entry.relative_path = file.webkitRelativePath
52
52
  entry.type = file.type
53
53
  entry.size = file.size
54
+ if(typeof(file.meta) === "function"){ entry.meta = file.meta() }
54
55
  fileData[uploadRef].push(entry)
55
56
  })
56
57
  return fileData