phoenix_live_view 0.18.18 → 0.19.1
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 +25 -36
- package/assets/js/phoenix_live_view/constants.js +6 -1
- package/assets/js/phoenix_live_view/dom.js +20 -8
- package/assets/js/phoenix_live_view/dom_patch.js +67 -15
- package/assets/js/phoenix_live_view/hooks.js +107 -0
- package/assets/js/phoenix_live_view/js.js +8 -5
- package/assets/js/phoenix_live_view/live_socket.js +13 -9
- package/assets/js/phoenix_live_view/rendered.js +3 -3
- package/assets/js/phoenix_live_view/view.js +49 -23
- package/assets/js/phoenix_live_view/view_hook.js +2 -2
- package/assets/package.json +1 -1
- package/package.json +1 -1
- package/priv/static/phoenix_live_view.cjs.js +245 -61
- package/priv/static/phoenix_live_view.cjs.js.map +2 -2
- package/priv/static/phoenix_live_view.esm.js +245 -61
- package/priv/static/phoenix_live_view.esm.js.map +2 -2
- package/priv/static/phoenix_live_view.js +245 -61
- package/priv/static/phoenix_live_view.min.js +6 -6
package/README.md
CHANGED
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/phoenixframework/phoenix_live_view/actions?query=workflow%3ACI) [](https://hex.pm/packages/phoenix_live_view) [](https://hexdocs.pm/phoenix_live_view)
|
|
4
4
|
|
|
5
|
-
Phoenix LiveView enables rich, real-time user experiences
|
|
6
|
-
with server-rendered HTML.
|
|
5
|
+
Phoenix LiveView enables rich, real-time user experiences with server-rendered HTML.
|
|
7
6
|
|
|
8
|
-
Visit the [https://livebeats.fly.dev](https://livebeats.fly.dev/) demo to see
|
|
9
|
-
you can build, or see a sneak peek below:
|
|
7
|
+
Visit the [https://livebeats.fly.dev](https://livebeats.fly.dev/) demo to see
|
|
8
|
+
the kinds of applications you can build, or see a sneak peek below:
|
|
10
9
|
|
|
11
10
|
https://user-images.githubusercontent.com/576796/162234098-31b580fe-e424-47e6-b01d-cd2cfcf823a9.mp4
|
|
12
11
|
|
|
@@ -19,42 +18,34 @@ steps:
|
|
|
19
18
|
$ mix archive.install hex phx_new
|
|
20
19
|
$ mix phx.new demo
|
|
21
20
|
|
|
22
|
-
##
|
|
21
|
+
## Feature highlights
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
LiveView brings a unified experience to building web applications. You no longer
|
|
24
|
+
have to split work between client and server, across different toolings, layers, and
|
|
25
|
+
abstractions. Instead, LiveView enriches the server with a declarative and powerful
|
|
26
|
+
model while keeping your code closer to your data (and ultimately your source of truth):
|
|
26
27
|
|
|
27
|
-
*
|
|
28
|
-
for function components, slots, HTML validation, and more
|
|
28
|
+
* **Declarative rendering:** Render HTML on the server over WebSockets with a declarative model, including an optional LongPolling fallback.
|
|
29
29
|
|
|
30
|
-
*
|
|
31
|
-
only what changed to the client, skipping the template
|
|
32
|
-
markup and reducing the payload. This makes LiveView
|
|
33
|
-
payloads much smaller than server-rendered HTML and on
|
|
34
|
-
par with fine-tuned SPA applications
|
|
30
|
+
* **Rich templating language:** Enjoy HEEx: a templating language that supports function components, slots, HTML validation, verified routes, and more.
|
|
35
31
|
|
|
36
|
-
*
|
|
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.
|
|
37
33
|
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
|
|
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.
|
|
41
37
|
|
|
42
|
-
*
|
|
43
|
-
commands (`Phoenix.LiveView.JS`)
|
|
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.
|
|
44
39
|
|
|
45
|
-
*
|
|
46
|
-
state, and event handling into reusable bits, which is essential
|
|
47
|
-
in large applications
|
|
40
|
+
* **Optimistic updates and transitions:** Perform optimistic updates and transitions with JavaScript commands via `Phoenix.LiveView.JS`.
|
|
48
41
|
|
|
49
|
-
*
|
|
50
|
-
minimum amount of content as users navigate between pages
|
|
42
|
+
* **Loose coupling:** Reuse more code via stateful components with loosely-coupled templates, state, and event handling — a must for enterprise application development.
|
|
51
43
|
|
|
52
|
-
*
|
|
53
|
-
interact with your application
|
|
44
|
+
* **Live navigation:** Enriched links and redirects are just more ways LiveView keeps your app light and performant. Clients load the minimum amount of content needed as users navigate around your app without any compromise in user experience.
|
|
54
45
|
|
|
55
|
-
*
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
* **Latency simulator:** Emulate how slow clients will interact with your application with the latency simulator.
|
|
47
|
+
|
|
48
|
+
* **Robust test suite:** Write tests with confidence alongside Phoenix LiveView built-in testing tools. No more running a whole browser alongside your tests.
|
|
58
49
|
|
|
59
50
|
## Official announcements
|
|
60
51
|
|
|
@@ -72,11 +63,9 @@ See our existing comprehensive [docs](https://hexdocs.pm/phoenix_live_view) and
|
|
|
72
63
|
|
|
73
64
|
## Installation
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
[installation guide on HexDocs](https://hexdocs.pm/phoenix_live_view/installation.html).
|
|
78
|
-
If you want to use the latest features, you should follow the instructions
|
|
79
|
-
given in the markdown file [here](guides/introduction/installation.md).
|
|
66
|
+
LiveView is included by default in all new Phoenix v1.6+ applications and
|
|
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).
|
|
80
69
|
|
|
81
70
|
## What makes LiveView unique?
|
|
82
71
|
|
|
@@ -115,7 +104,7 @@ anywhere else:
|
|
|
115
104
|
the system. Do you want to notify a user that their best friend
|
|
116
105
|
just connected? This is easily done without a single line of
|
|
117
106
|
custom JavaScript and with no extra external dependencies
|
|
118
|
-
(no extra databases, no extra message queues, etc.).
|
|
107
|
+
(no extra databases, no Redis, no extra message queues, etc.).
|
|
119
108
|
|
|
120
109
|
* LiveView performs change tracking: whenever you change a value on
|
|
121
110
|
the server, LiveView will send to the client only the values that
|
|
@@ -24,12 +24,16 @@ export const PHX_SKIP = "data-phx-skip"
|
|
|
24
24
|
export const PHX_PRUNE = "data-phx-prune"
|
|
25
25
|
export const PHX_PAGE_LOADING = "page-loading"
|
|
26
26
|
export const PHX_CONNECTED_CLASS = "phx-connected"
|
|
27
|
-
export const
|
|
27
|
+
export const PHX_LOADING_CLASS = "phx-loading"
|
|
28
28
|
export const PHX_NO_FEEDBACK_CLASS = "phx-no-feedback"
|
|
29
29
|
export const PHX_ERROR_CLASS = "phx-error"
|
|
30
|
+
export const PHX_CLIENT_ERROR_CLASS = "phx-client-error"
|
|
31
|
+
export const PHX_SERVER_ERROR_CLASS = "phx-server-error"
|
|
30
32
|
export const PHX_PARENT_ID = "data-phx-parent-id"
|
|
31
33
|
export const PHX_MAIN = "data-phx-main"
|
|
32
34
|
export const PHX_ROOT_ID = "data-phx-root-id"
|
|
35
|
+
export const PHX_VIEWPORT_TOP = "viewport-top"
|
|
36
|
+
export const PHX_VIEWPORT_BOTTOM = "viewport-bottom"
|
|
33
37
|
export const PHX_TRIGGER_ACTION = "trigger-action"
|
|
34
38
|
export const PHX_FEEDBACK_FOR = "feedback-for"
|
|
35
39
|
export const PHX_HAS_FOCUSED = "phx-has-focused"
|
|
@@ -49,6 +53,7 @@ export const PHX_DEBOUNCE = "debounce"
|
|
|
49
53
|
export const PHX_THROTTLE = "throttle"
|
|
50
54
|
export const PHX_UPDATE = "update"
|
|
51
55
|
export const PHX_STREAM = "stream"
|
|
56
|
+
export const PHX_STREAM_REF = "data-phx-stream"
|
|
52
57
|
export const PHX_KEY = "key"
|
|
53
58
|
export const PHX_PRIVATE = "phxPrivate"
|
|
54
59
|
export const PHX_AUTO_RECOVER = "auto-recover"
|
|
@@ -60,15 +60,22 @@ let DOM = {
|
|
|
60
60
|
|
|
61
61
|
wantsNewTab(e){
|
|
62
62
|
let wantsNewTab = e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1)
|
|
63
|
-
|
|
63
|
+
let isDownload = (e.target instanceof HTMLAnchorElement && e.target.hasAttribute("download"))
|
|
64
|
+
let isTargetBlank = e.target.getAttribute("target") === "_blank"
|
|
65
|
+
return wantsNewTab || isTargetBlank || isDownload
|
|
64
66
|
},
|
|
65
67
|
|
|
66
68
|
isUnloadableFormSubmit(e){
|
|
67
69
|
return !e.defaultPrevented && !this.wantsNewTab(e)
|
|
68
70
|
},
|
|
69
71
|
|
|
70
|
-
|
|
72
|
+
isNewPageClick(e, currentLocation){
|
|
73
|
+
let href = e.target instanceof HTMLAnchorElement ? e.target.getAttribute("href") : null
|
|
71
74
|
let url
|
|
75
|
+
|
|
76
|
+
if(e.defaultPrevented || href === null || this.wantsNewTab(e)){ return false }
|
|
77
|
+
if(href.startsWith("mailto:") || href.startsWith("tel:")){ return false }
|
|
78
|
+
|
|
72
79
|
try {
|
|
73
80
|
url = new URL(href)
|
|
74
81
|
} catch(e) {
|
|
@@ -181,6 +188,7 @@ let DOM = {
|
|
|
181
188
|
debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){
|
|
182
189
|
let debounce = el.getAttribute(phxDebounce)
|
|
183
190
|
let throttle = el.getAttribute(phxThrottle)
|
|
191
|
+
|
|
184
192
|
if(debounce === ""){ debounce = defaultDebounce }
|
|
185
193
|
if(throttle === ""){ throttle = defaultThrottle }
|
|
186
194
|
let value = debounce || throttle
|
|
@@ -259,14 +267,18 @@ let DOM = {
|
|
|
259
267
|
return currentCycle
|
|
260
268
|
},
|
|
261
269
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
270
|
+
maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom){
|
|
271
|
+
if(el.hasAttribute && (el.hasAttribute(phxViewportTop) || el.hasAttribute(phxViewportBottom))){
|
|
272
|
+
el.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll")
|
|
273
|
+
}
|
|
274
|
+
},
|
|
267
275
|
|
|
276
|
+
maybeHideFeedback(container, input, phxFeedbackFor){
|
|
268
277
|
if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
|
|
269
|
-
|
|
278
|
+
let feedbacks = [input.name]
|
|
279
|
+
if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
|
|
280
|
+
let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ")
|
|
281
|
+
DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))
|
|
270
282
|
}
|
|
271
283
|
},
|
|
272
284
|
|
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
PHX_TRIGGER_ACTION,
|
|
11
11
|
PHX_UPDATE,
|
|
12
12
|
PHX_STREAM,
|
|
13
|
+
PHX_STREAM_REF,
|
|
14
|
+
PHX_VIEWPORT_TOP,
|
|
15
|
+
PHX_VIEWPORT_BOTTOM,
|
|
13
16
|
} from "./constants"
|
|
14
17
|
|
|
15
18
|
import {
|
|
@@ -83,8 +86,11 @@ export default class DOMPatch {
|
|
|
83
86
|
let phxUpdate = liveSocket.binding(PHX_UPDATE)
|
|
84
87
|
let phxFeedbackFor = liveSocket.binding(PHX_FEEDBACK_FOR)
|
|
85
88
|
let disableWith = liveSocket.binding(PHX_DISABLE_WITH)
|
|
89
|
+
let phxViewportTop = liveSocket.binding(PHX_VIEWPORT_TOP)
|
|
90
|
+
let phxViewportBottom = liveSocket.binding(PHX_VIEWPORT_BOTTOM)
|
|
86
91
|
let phxTriggerExternal = liveSocket.binding(PHX_TRIGGER_ACTION)
|
|
87
92
|
let added = []
|
|
93
|
+
let trackedInputs = []
|
|
88
94
|
let updates = []
|
|
89
95
|
let appendPrependUpdates = []
|
|
90
96
|
|
|
@@ -98,16 +104,18 @@ export default class DOMPatch {
|
|
|
98
104
|
this.trackBefore("updated", container, container)
|
|
99
105
|
|
|
100
106
|
liveSocket.time("morphdom", () => {
|
|
101
|
-
this.streams.forEach(([inserts, deleteIds]) => {
|
|
102
|
-
|
|
107
|
+
this.streams.forEach(([ref, inserts, deleteIds, reset]) => {
|
|
108
|
+
Object.entries(inserts).forEach(([key, [streamAt, limit]]) => {
|
|
109
|
+
this.streamInserts[key] = {ref, streamAt, limit}
|
|
110
|
+
})
|
|
111
|
+
if(reset !== undefined){
|
|
112
|
+
DOM.all(container, `[${PHX_STREAM_REF}="${ref}"]`, child => {
|
|
113
|
+
this.removeStreamChildElement(child)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
103
116
|
deleteIds.forEach(id => {
|
|
104
117
|
let child = container.querySelector(`[id="${id}"]`)
|
|
105
|
-
if(child){
|
|
106
|
-
if(!this.maybePendingRemove(child)){
|
|
107
|
-
child.remove()
|
|
108
|
-
this.onNodeDiscarded(child)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
118
|
+
if(child){ this.removeStreamChildElement(child) }
|
|
111
119
|
})
|
|
112
120
|
})
|
|
113
121
|
|
|
@@ -120,8 +128,10 @@ export default class DOMPatch {
|
|
|
120
128
|
skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM },
|
|
121
129
|
// tell morphdom how to add a child
|
|
122
130
|
addChild: (parent, child) => {
|
|
123
|
-
let streamAt =
|
|
124
|
-
if(
|
|
131
|
+
let {ref, streamAt, limit} = this.getStreamInsert(child)
|
|
132
|
+
if(ref === undefined) { return parent.appendChild(child) }
|
|
133
|
+
|
|
134
|
+
DOM.putSticky(child, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
|
|
125
135
|
|
|
126
136
|
// streaming
|
|
127
137
|
if(streamAt === 0){
|
|
@@ -132,12 +142,28 @@ export default class DOMPatch {
|
|
|
132
142
|
let sibling = Array.from(parent.children)[streamAt]
|
|
133
143
|
parent.insertBefore(child, sibling)
|
|
134
144
|
}
|
|
145
|
+
let children = limit !== null && Array.from(parent.children)
|
|
146
|
+
let childrenToRemove = []
|
|
147
|
+
if(limit && limit < 0 && children.length > limit * -1){
|
|
148
|
+
childrenToRemove = children.slice(0, children.length + limit)
|
|
149
|
+
} else if(limit && limit >= 0 && children.length > limit){
|
|
150
|
+
childrenToRemove = children.slice(limit)
|
|
151
|
+
}
|
|
152
|
+
childrenToRemove.forEach(removeChild => {
|
|
153
|
+
// do not remove child as part of limit if we are re-adding it
|
|
154
|
+
if(!this.streamInserts[removeChild.id]){
|
|
155
|
+
this.removeStreamChildElement(removeChild)
|
|
156
|
+
}
|
|
157
|
+
})
|
|
135
158
|
},
|
|
136
159
|
onBeforeNodeAdded: (el) => {
|
|
160
|
+
DOM.maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom)
|
|
137
161
|
this.trackBefore("added", el)
|
|
138
162
|
return el
|
|
139
163
|
},
|
|
140
164
|
onNodeAdded: (el) => {
|
|
165
|
+
if(el.getAttribute){ this.maybeReOrderStream(el) }
|
|
166
|
+
|
|
141
167
|
// hack to fix Safari handling of img srcset and video tags
|
|
142
168
|
if(el instanceof HTMLImageElement && el.srcset){
|
|
143
169
|
el.srcset = el.srcset
|
|
@@ -147,8 +173,10 @@ export default class DOMPatch {
|
|
|
147
173
|
if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){
|
|
148
174
|
externalFormTriggered = el
|
|
149
175
|
}
|
|
150
|
-
|
|
151
|
-
DOM.
|
|
176
|
+
|
|
177
|
+
if(el.getAttribute && el.getAttribute("name") && DOM.isFormInput(el)){
|
|
178
|
+
trackedInputs.push(el)
|
|
179
|
+
}
|
|
152
180
|
// nested view handling
|
|
153
181
|
if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){
|
|
154
182
|
this.trackAfter("phxChildAdded", el)
|
|
@@ -159,7 +187,7 @@ export default class DOMPatch {
|
|
|
159
187
|
onBeforeNodeDiscarded: (el) => {
|
|
160
188
|
if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
|
|
161
189
|
if(el.parentElement !== null && el.id &&
|
|
162
|
-
|
|
190
|
+
DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){
|
|
163
191
|
return false
|
|
164
192
|
}
|
|
165
193
|
if(this.maybePendingRemove(el)){ return false }
|
|
@@ -207,7 +235,6 @@ export default class DOMPatch {
|
|
|
207
235
|
|
|
208
236
|
// input handling
|
|
209
237
|
DOM.copyPrivates(toEl, fromEl)
|
|
210
|
-
DOM.discardError(targetContainer, toEl, phxFeedbackFor)
|
|
211
238
|
|
|
212
239
|
let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
|
|
213
240
|
if(isFocusedFormEl && fromEl.type !== "hidden"){
|
|
@@ -216,13 +243,19 @@ export default class DOMPatch {
|
|
|
216
243
|
DOM.syncAttrsToProps(fromEl)
|
|
217
244
|
updates.push(fromEl)
|
|
218
245
|
DOM.applyStickyOperations(fromEl)
|
|
246
|
+
trackedInputs.push(fromEl)
|
|
219
247
|
return false
|
|
220
248
|
} else {
|
|
221
249
|
if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){
|
|
222
250
|
appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate)))
|
|
223
251
|
}
|
|
252
|
+
|
|
253
|
+
DOM.maybeAddPrivateHooks(toEl, phxViewportTop, phxViewportBottom)
|
|
224
254
|
DOM.syncAttrsToProps(toEl)
|
|
225
255
|
DOM.applyStickyOperations(toEl)
|
|
256
|
+
if(toEl.getAttribute("name") && DOM.isFormInput(toEl)){
|
|
257
|
+
trackedInputs.push(toEl)
|
|
258
|
+
}
|
|
226
259
|
this.trackBefore("updated", fromEl, toEl)
|
|
227
260
|
return true
|
|
228
261
|
}
|
|
@@ -238,6 +271,10 @@ export default class DOMPatch {
|
|
|
238
271
|
})
|
|
239
272
|
}
|
|
240
273
|
|
|
274
|
+
trackedInputs.forEach(input => {
|
|
275
|
+
DOM.maybeHideFeedback(targetContainer, input, phxFeedbackFor)
|
|
276
|
+
})
|
|
277
|
+
|
|
241
278
|
liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
|
|
242
279
|
DOM.dispatchEvent(document, "phx:update")
|
|
243
280
|
added.forEach(el => this.trackAfter("added", el))
|
|
@@ -267,10 +304,25 @@ export default class DOMPatch {
|
|
|
267
304
|
}
|
|
268
305
|
}
|
|
269
306
|
|
|
307
|
+
removeStreamChildElement(child){
|
|
308
|
+
if(!this.maybePendingRemove(child)){
|
|
309
|
+
child.remove()
|
|
310
|
+
this.onNodeDiscarded(child)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
getStreamInsert(el){
|
|
315
|
+
let insert = el.id ? this.streamInserts[el.id] : {}
|
|
316
|
+
return insert || {}
|
|
317
|
+
}
|
|
318
|
+
|
|
270
319
|
maybeReOrderStream(el){
|
|
271
|
-
let streamAt =
|
|
320
|
+
let {ref, streamAt, limit} = this.getStreamInsert(el)
|
|
272
321
|
if(streamAt === undefined){ return }
|
|
273
322
|
|
|
323
|
+
// we need to the PHX_STREAM_REF here as well as addChild is invoked only for parents
|
|
324
|
+
DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
|
|
325
|
+
|
|
274
326
|
if(streamAt === 0){
|
|
275
327
|
el.parentElement.insertBefore(el, el.parentElement.firstElementChild)
|
|
276
328
|
} else if(streamAt > 0){
|
|
@@ -57,4 +57,111 @@ 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
|
|
62
|
+
|
|
63
|
+
let isAtViewportTop = (el) => {
|
|
64
|
+
let rect = el.getBoundingClientRect()
|
|
65
|
+
return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let isAtViewportBottom = (el) => {
|
|
69
|
+
let rect = el.getBoundingClientRect()
|
|
70
|
+
return rect.right >= 0 && rect.left >= 0 && rect.bottom <= winHeight()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let isWithinViewport = (el) => {
|
|
74
|
+
let rect = el.getBoundingClientRect()
|
|
75
|
+
return rect.top >= 0 && rect.left >= 0 && rect.top <= winHeight()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
Hooks.InfiniteScroll = {
|
|
79
|
+
mounted(){
|
|
80
|
+
let scrollBefore = scrollTop()
|
|
81
|
+
let topOverran = false
|
|
82
|
+
let throttleInterval = 500
|
|
83
|
+
let pendingOp = null
|
|
84
|
+
|
|
85
|
+
let onTopOverrun = this.throttle(throttleInterval, (topEvent, firstChild) => {
|
|
86
|
+
pendingOp = () => true
|
|
87
|
+
this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id, _overran: true}, () => {
|
|
88
|
+
pendingOp = null
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
let onFirstChildAtTop = this.throttle(throttleInterval, (topEvent, firstChild) => {
|
|
93
|
+
pendingOp = () => firstChild.scrollIntoView({block: "start"})
|
|
94
|
+
this.liveSocket.execJSHookPush(this.el, topEvent, {id: firstChild.id}, () => {
|
|
95
|
+
pendingOp = null
|
|
96
|
+
if(!isWithinViewport(firstChild)){ firstChild.scrollIntoView({block: "start"}) }
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
let onLastChildAtBottom = this.throttle(throttleInterval, (bottomEvent, lastChild) => {
|
|
101
|
+
pendingOp = () => lastChild.scrollIntoView({block: "end"})
|
|
102
|
+
this.liveSocket.execJSHookPush(this.el, bottomEvent, {id: lastChild.id}, () => {
|
|
103
|
+
pendingOp = null
|
|
104
|
+
if(!isWithinViewport(lastChild)){ lastChild.scrollIntoView({block: "end"}) }
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
this.onScroll = (e) => {
|
|
109
|
+
let scrollNow = scrollTop()
|
|
110
|
+
|
|
111
|
+
if(pendingOp){
|
|
112
|
+
scrollBefore = scrollNow
|
|
113
|
+
return pendingOp()
|
|
114
|
+
}
|
|
115
|
+
let rect = this.el.getBoundingClientRect()
|
|
116
|
+
let topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top"))
|
|
117
|
+
let bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom"))
|
|
118
|
+
let lastChild = this.el.lastElementChild
|
|
119
|
+
let firstChild = this.el.firstElementChild
|
|
120
|
+
let isScrollingUp = scrollNow < scrollBefore
|
|
121
|
+
let isScrollingDown = scrollNow > scrollBefore
|
|
122
|
+
|
|
123
|
+
// el overran while scrolling up
|
|
124
|
+
if(isScrollingUp && topEvent && !topOverran && rect.top >= 0){
|
|
125
|
+
topOverran = true
|
|
126
|
+
onTopOverrun(topEvent, firstChild)
|
|
127
|
+
} else if(isScrollingDown && topOverran && rect.top <= 0){
|
|
128
|
+
topOverran = false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if(topEvent && isScrollingUp && isAtViewportTop(firstChild)){
|
|
132
|
+
onFirstChildAtTop(topEvent, firstChild)
|
|
133
|
+
} else if(bottomEvent && isScrollingDown && isAtViewportBottom(lastChild)){
|
|
134
|
+
onLastChildAtBottom(bottomEvent, lastChild)
|
|
135
|
+
}
|
|
136
|
+
scrollBefore = scrollNow
|
|
137
|
+
}
|
|
138
|
+
window.addEventListener("scroll", this.onScroll)
|
|
139
|
+
},
|
|
140
|
+
destroyed(){ window.removeEventListener("scroll", this.onScroll) },
|
|
141
|
+
|
|
142
|
+
throttle(interval, callback){
|
|
143
|
+
let lastCallAt = 0
|
|
144
|
+
let timer
|
|
145
|
+
|
|
146
|
+
return (...args) => {
|
|
147
|
+
let now = Date.now()
|
|
148
|
+
let remainingTime = interval - (now - lastCallAt)
|
|
149
|
+
|
|
150
|
+
if(remainingTime <= 0 || remainingTime > interval){
|
|
151
|
+
if(timer) {
|
|
152
|
+
clearTimeout(timer)
|
|
153
|
+
timer = null
|
|
154
|
+
}
|
|
155
|
+
lastCallAt = now
|
|
156
|
+
callback(...args)
|
|
157
|
+
} else if(!timer){
|
|
158
|
+
timer = setTimeout(() => {
|
|
159
|
+
lastCallAt = Date.now()
|
|
160
|
+
timer = null
|
|
161
|
+
callback(...args)
|
|
162
|
+
}, remainingTime)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
60
167
|
export default Hooks
|
|
@@ -5,13 +5,16 @@ let focusStack = null
|
|
|
5
5
|
|
|
6
6
|
let JS = {
|
|
7
7
|
exec(eventType, phxEvent, view, sourceEl, defaults){
|
|
8
|
-
let [defaultKind, defaultArgs] = defaults || [null, {}]
|
|
8
|
+
let [defaultKind, defaultArgs] = defaults || [null, {callback: defaults && defaults.callback}]
|
|
9
9
|
let commands = phxEvent.charAt(0) === "[" ?
|
|
10
10
|
JSON.parse(phxEvent) : [[defaultKind, defaultArgs]]
|
|
11
11
|
|
|
12
|
+
|
|
13
|
+
|
|
12
14
|
commands.forEach(([kind, args]) => {
|
|
13
15
|
if(kind === defaultKind && defaultArgs.data){
|
|
14
16
|
args.data = Object.assign(args.data || {}, defaultArgs.data)
|
|
17
|
+
args.callback = args.callback || defaultArgs.callback
|
|
15
18
|
}
|
|
16
19
|
this.filterToEls(sourceEl, args).forEach(el => {
|
|
17
20
|
this[`exec_${kind}`](eventType, phxEvent, view, sourceEl, el, args)
|
|
@@ -45,21 +48,21 @@ let JS = {
|
|
|
45
48
|
exec_push(eventType, phxEvent, view, sourceEl, el, args){
|
|
46
49
|
if(!view.isConnected()){ return }
|
|
47
50
|
|
|
48
|
-
let {event, data, target, page_loading, loading, value, dispatcher} = args
|
|
51
|
+
let {event, data, target, page_loading, loading, value, dispatcher, callback} = args
|
|
49
52
|
let pushOpts = {loading, value, target, page_loading: !!page_loading}
|
|
50
53
|
let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl
|
|
51
54
|
let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc
|
|
52
55
|
view.withinTargets(phxTarget, (targetView, targetCtx) => {
|
|
53
56
|
if(eventType === "change"){
|
|
54
|
-
let {newCid, _target
|
|
57
|
+
let {newCid, _target} = args
|
|
55
58
|
_target = _target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined)
|
|
56
59
|
if(_target){ pushOpts._target = _target }
|
|
57
60
|
targetView.pushInput(sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback)
|
|
58
61
|
} else if(eventType === "submit"){
|
|
59
62
|
let {submitter} = args
|
|
60
|
-
targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts)
|
|
63
|
+
targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts, callback)
|
|
61
64
|
} else {
|
|
62
|
-
targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts)
|
|
65
|
+
targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts, callback)
|
|
63
66
|
}
|
|
64
67
|
})
|
|
65
68
|
},
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
PHX_FEEDBACK_FOR,
|
|
99
99
|
RELOAD_JITTER_MIN,
|
|
100
100
|
RELOAD_JITTER_MAX,
|
|
101
|
+
PHX_REF,
|
|
101
102
|
} from "./constants"
|
|
102
103
|
|
|
103
104
|
import {
|
|
@@ -239,6 +240,12 @@ export default class LiveSocket {
|
|
|
239
240
|
|
|
240
241
|
// private
|
|
241
242
|
|
|
243
|
+
execJSHookPush(el, phxEvent, data, callback){
|
|
244
|
+
this.withinOwners(el, view => {
|
|
245
|
+
JS.exec("hook", phxEvent, view, el, ["push", {data, callback}])
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
242
249
|
unload(){
|
|
243
250
|
if(this.unloaded){ return }
|
|
244
251
|
if(this.main && this.isConnected()){ this.log(this.main, "socket", () => ["disconnect for page nav"]) }
|
|
@@ -411,9 +418,7 @@ export default class LiveSocket {
|
|
|
411
418
|
let removeAttr = this.binding("remove")
|
|
412
419
|
elements = elements || DOM.all(document, `[${removeAttr}]`)
|
|
413
420
|
elements.forEach(el => {
|
|
414
|
-
|
|
415
|
-
this.execJS(el, el.getAttribute(removeAttr), "remove")
|
|
416
|
-
}
|
|
421
|
+
this.execJS(el, el.getAttribute(removeAttr), "remove")
|
|
417
422
|
})
|
|
418
423
|
}
|
|
419
424
|
|
|
@@ -503,8 +508,6 @@ export default class LiveSocket {
|
|
|
503
508
|
this.boundTopLevelEvents = true
|
|
504
509
|
// enter failsafe reload if server has gone away intentionally, such as "disconnect" broadcast
|
|
505
510
|
this.socket.onClose(event => {
|
|
506
|
-
// unload when navigating href or form submit (such as for firefox)
|
|
507
|
-
if(event && event.code === 1001){ return this.unload() }
|
|
508
511
|
// failsafe reload if normal closure and we still have a main LV
|
|
509
512
|
if(event && event.code === 1000 && this.main){ return this.reloadWithJitter(this.main) }
|
|
510
513
|
})
|
|
@@ -635,14 +638,15 @@ export default class LiveSocket {
|
|
|
635
638
|
}
|
|
636
639
|
let phxEvent = target && target.getAttribute(click)
|
|
637
640
|
if(!phxEvent){
|
|
638
|
-
|
|
639
|
-
if(!capture && href !== null && !DOM.wantsNewTab(e) && DOM.isNewPageHref(href, window.location)){
|
|
640
|
-
this.unload()
|
|
641
|
-
}
|
|
641
|
+
if(!capture && DOM.isNewPageClick(e, window.location)){ this.unload() }
|
|
642
642
|
return
|
|
643
643
|
}
|
|
644
|
+
|
|
644
645
|
if(target.getAttribute("href") === "#"){ e.preventDefault() }
|
|
645
646
|
|
|
647
|
+
// noop if we are in the middle of awaiting an ack for this el already
|
|
648
|
+
if(target.hasAttribute(PHX_REF)){ return }
|
|
649
|
+
|
|
646
650
|
this.debounce(target, e, "click", () => {
|
|
647
651
|
this.withinOwners(target, view => {
|
|
648
652
|
JS.exec("click", phxEvent, view, target, ["push", {data: this.eventMeta("click", e, target)}])
|
|
@@ -172,7 +172,7 @@ export default class Rendered {
|
|
|
172
172
|
|
|
173
173
|
comprehensionToBuffer(rendered, templates, output){
|
|
174
174
|
let {[DYNAMICS]: dynamics, [STATIC]: statics, [STREAM]: stream} = rendered
|
|
175
|
-
let [_inserts, deleteIds] = stream || [{}, []]
|
|
175
|
+
let [_ref, _inserts, deleteIds, reset] = stream || [null, {}, [], null]
|
|
176
176
|
statics = this.templateStatic(statics, templates)
|
|
177
177
|
let compTemplates = templates || rendered[TEMPLATES]
|
|
178
178
|
for(let d = 0; d < dynamics.length; d++){
|
|
@@ -184,8 +184,8 @@ export default class Rendered {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
if(stream !== undefined && (rendered[DYNAMICS].length > 0 || deleteIds.length > 0)){
|
|
188
|
-
rendered[
|
|
187
|
+
if(stream !== undefined && (rendered[DYNAMICS].length > 0 || deleteIds.length > 0 || reset)){
|
|
188
|
+
delete rendered[STREAM]
|
|
189
189
|
output.streams.add(stream)
|
|
190
190
|
}
|
|
191
191
|
}
|