phoenix_live_view 0.18.17 → 0.19.0
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 +26 -37
- package/assets/js/phoenix_live_view/constants.js +6 -1
- package/assets/js/phoenix_live_view/dom.js +16 -7
- package/assets/js/phoenix_live_view/dom_patch.js +60 -15
- package/assets/js/phoenix_live_view/hooks.js +107 -0
- package/assets/js/phoenix_live_view/js.js +17 -5
- package/assets/js/phoenix_live_view/live_socket.js +10 -5
- 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 +242 -57
- package/priv/static/phoenix_live_view.cjs.js.map +2 -2
- package/priv/static/phoenix_live_view.esm.js +242 -57
- package/priv/static/phoenix_live_view.esm.js.map +2 -2
- package/priv/static/phoenix_live_view.js +242 -57
- package/priv/static/phoenix_live_view.min.js +6 -6
package/README.md
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# Phoenix LiveView
|
|
2
2
|
|
|
3
|
-
[](https://github.com/phoenixframework/phoenix_live_view/actions?query=workflow%3ACI)
|
|
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,7 +60,9 @@ 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){
|
|
@@ -68,6 +70,8 @@ let DOM = {
|
|
|
68
70
|
},
|
|
69
71
|
|
|
70
72
|
isNewPageHref(href, currentLocation){
|
|
73
|
+
if(href.startsWith("mailto:") || href.startsWith("tel:")){ return false }
|
|
74
|
+
|
|
71
75
|
let url
|
|
72
76
|
try {
|
|
73
77
|
url = new URL(href)
|
|
@@ -181,6 +185,7 @@ let DOM = {
|
|
|
181
185
|
debounce(el, event, phxDebounce, defaultDebounce, phxThrottle, defaultThrottle, asyncFilter, callback){
|
|
182
186
|
let debounce = el.getAttribute(phxDebounce)
|
|
183
187
|
let throttle = el.getAttribute(phxThrottle)
|
|
188
|
+
|
|
184
189
|
if(debounce === ""){ debounce = defaultDebounce }
|
|
185
190
|
if(throttle === ""){ throttle = defaultThrottle }
|
|
186
191
|
let value = debounce || throttle
|
|
@@ -259,14 +264,18 @@ let DOM = {
|
|
|
259
264
|
return currentCycle
|
|
260
265
|
},
|
|
261
266
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom){
|
|
268
|
+
if(el.hasAttribute && (el.hasAttribute(phxViewportTop) || el.hasAttribute(phxViewportBottom))){
|
|
269
|
+
el.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll")
|
|
270
|
+
}
|
|
271
|
+
},
|
|
267
272
|
|
|
273
|
+
maybeHideFeedback(container, input, phxFeedbackFor){
|
|
268
274
|
if(!(this.private(input, PHX_HAS_FOCUSED) || this.private(input, PHX_HAS_SUBMITTED))){
|
|
269
|
-
|
|
275
|
+
let feedbacks = [input.name]
|
|
276
|
+
if(input.name.endsWith("[]")){ feedbacks.push(input.name.slice(0, -2)) }
|
|
277
|
+
let selector = feedbacks.map(f => `[${phxFeedbackFor}="${f}"]`).join(", ")
|
|
278
|
+
DOM.all(container, selector, el => el.classList.add(PHX_NO_FEEDBACK_CLASS))
|
|
270
279
|
}
|
|
271
280
|
},
|
|
272
281
|
|
|
@@ -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,21 @@ 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
|
+
if(limit && limit < 0 && children.length > limit * -1){
|
|
147
|
+
children.slice(0, children.length + limit).forEach(child => this.removeStreamChildElement(child))
|
|
148
|
+
} else if(limit && limit >= 0 && children.length > limit){
|
|
149
|
+
children.slice(limit).forEach(child => this.removeStreamChildElement(child))
|
|
150
|
+
}
|
|
135
151
|
},
|
|
136
152
|
onBeforeNodeAdded: (el) => {
|
|
153
|
+
DOM.maybeAddPrivateHooks(el, phxViewportTop, phxViewportBottom)
|
|
137
154
|
this.trackBefore("added", el)
|
|
138
155
|
return el
|
|
139
156
|
},
|
|
140
157
|
onNodeAdded: (el) => {
|
|
158
|
+
if(el.getAttribute){ this.maybeReOrderStream(el) }
|
|
159
|
+
|
|
141
160
|
// hack to fix Safari handling of img srcset and video tags
|
|
142
161
|
if(el instanceof HTMLImageElement && el.srcset){
|
|
143
162
|
el.srcset = el.srcset
|
|
@@ -147,8 +166,10 @@ export default class DOMPatch {
|
|
|
147
166
|
if(DOM.isNowTriggerFormExternal(el, phxTriggerExternal)){
|
|
148
167
|
externalFormTriggered = el
|
|
149
168
|
}
|
|
150
|
-
|
|
151
|
-
|
|
169
|
+
|
|
170
|
+
if(el.getAttribute && el.getAttribute("name")){
|
|
171
|
+
trackedInputs.push(el)
|
|
172
|
+
}
|
|
152
173
|
// nested view handling
|
|
153
174
|
if((DOM.isPhxChild(el) && view.ownsElement(el)) || DOM.isPhxSticky(el) && view.ownsElement(el.parentNode)){
|
|
154
175
|
this.trackAfter("phxChildAdded", el)
|
|
@@ -159,7 +180,7 @@ export default class DOMPatch {
|
|
|
159
180
|
onBeforeNodeDiscarded: (el) => {
|
|
160
181
|
if(el.getAttribute && el.getAttribute(PHX_PRUNE) !== null){ return true }
|
|
161
182
|
if(el.parentElement !== null && el.id &&
|
|
162
|
-
|
|
183
|
+
DOM.isPhxUpdate(el.parentElement, phxUpdate, [PHX_STREAM, "append", "prepend"])){
|
|
163
184
|
return false
|
|
164
185
|
}
|
|
165
186
|
if(this.maybePendingRemove(el)){ return false }
|
|
@@ -207,7 +228,6 @@ export default class DOMPatch {
|
|
|
207
228
|
|
|
208
229
|
// input handling
|
|
209
230
|
DOM.copyPrivates(toEl, fromEl)
|
|
210
|
-
DOM.discardError(targetContainer, toEl, phxFeedbackFor)
|
|
211
231
|
|
|
212
232
|
let isFocusedFormEl = focused && fromEl.isSameNode(focused) && DOM.isFormInput(fromEl)
|
|
213
233
|
if(isFocusedFormEl && fromEl.type !== "hidden"){
|
|
@@ -216,13 +236,19 @@ export default class DOMPatch {
|
|
|
216
236
|
DOM.syncAttrsToProps(fromEl)
|
|
217
237
|
updates.push(fromEl)
|
|
218
238
|
DOM.applyStickyOperations(fromEl)
|
|
239
|
+
trackedInputs.push(fromEl)
|
|
219
240
|
return false
|
|
220
241
|
} else {
|
|
221
242
|
if(DOM.isPhxUpdate(toEl, phxUpdate, ["append", "prepend"])){
|
|
222
243
|
appendPrependUpdates.push(new DOMPostMorphRestorer(fromEl, toEl, toEl.getAttribute(phxUpdate)))
|
|
223
244
|
}
|
|
245
|
+
|
|
246
|
+
DOM.maybeAddPrivateHooks(toEl, phxViewportTop, phxViewportBottom)
|
|
224
247
|
DOM.syncAttrsToProps(toEl)
|
|
225
248
|
DOM.applyStickyOperations(toEl)
|
|
249
|
+
if(toEl.getAttribute("name")){
|
|
250
|
+
trackedInputs.push(toEl)
|
|
251
|
+
}
|
|
226
252
|
this.trackBefore("updated", fromEl, toEl)
|
|
227
253
|
return true
|
|
228
254
|
}
|
|
@@ -238,6 +264,10 @@ export default class DOMPatch {
|
|
|
238
264
|
})
|
|
239
265
|
}
|
|
240
266
|
|
|
267
|
+
trackedInputs.forEach(input => {
|
|
268
|
+
DOM.maybeHideFeedback(targetContainer, input, phxFeedbackFor)
|
|
269
|
+
})
|
|
270
|
+
|
|
241
271
|
liveSocket.silenceEvents(() => DOM.restoreFocus(focused, selectionStart, selectionEnd))
|
|
242
272
|
DOM.dispatchEvent(document, "phx:update")
|
|
243
273
|
added.forEach(el => this.trackAfter("added", el))
|
|
@@ -267,10 +297,25 @@ export default class DOMPatch {
|
|
|
267
297
|
}
|
|
268
298
|
}
|
|
269
299
|
|
|
300
|
+
removeStreamChildElement(child){
|
|
301
|
+
if(!this.maybePendingRemove(child)){
|
|
302
|
+
child.remove()
|
|
303
|
+
this.onNodeDiscarded(child)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
getStreamInsert(el){
|
|
308
|
+
let insert = el.id ? this.streamInserts[el.id] : {}
|
|
309
|
+
return insert || {}
|
|
310
|
+
}
|
|
311
|
+
|
|
270
312
|
maybeReOrderStream(el){
|
|
271
|
-
let streamAt =
|
|
313
|
+
let {ref, streamAt, limit} = this.getStreamInsert(el)
|
|
272
314
|
if(streamAt === undefined){ return }
|
|
273
315
|
|
|
316
|
+
// we need to the PHX_STREAM_REF here as well as addChild is invoked only for parents
|
|
317
|
+
DOM.putSticky(el, PHX_STREAM_REF, el => el.setAttribute(PHX_STREAM_REF, ref))
|
|
318
|
+
|
|
274
319
|
if(streamAt === 0){
|
|
275
320
|
el.parentElement.insertBefore(el, el.parentElement.firstElementChild)
|
|
276
321
|
} 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)
|
|
@@ -27,6 +30,15 @@ let JS = {
|
|
|
27
30
|
|
|
28
31
|
// commands
|
|
29
32
|
|
|
33
|
+
exec_exec(eventType, phxEvent, view, sourceEl, el, [attr, to]){
|
|
34
|
+
let nodes = to ? DOM.all(document, to) : [sourceEl]
|
|
35
|
+
nodes.forEach(node => {
|
|
36
|
+
let encodedJS = node.getAttribute(attr)
|
|
37
|
+
if(!encodedJS){ throw new Error(`expected ${attr} to contain JS command on "${to}"`) }
|
|
38
|
+
view.liveSocket.execJS(node, encodedJS, eventType)
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
|
|
30
42
|
exec_dispatch(eventType, phxEvent, view, sourceEl, el, {to, event, detail, bubbles}){
|
|
31
43
|
detail = detail || {}
|
|
32
44
|
detail.dispatcher = sourceEl
|
|
@@ -36,21 +48,21 @@ let JS = {
|
|
|
36
48
|
exec_push(eventType, phxEvent, view, sourceEl, el, args){
|
|
37
49
|
if(!view.isConnected()){ return }
|
|
38
50
|
|
|
39
|
-
let {event, data, target, page_loading, loading, value, dispatcher} = args
|
|
51
|
+
let {event, data, target, page_loading, loading, value, dispatcher, callback} = args
|
|
40
52
|
let pushOpts = {loading, value, target, page_loading: !!page_loading}
|
|
41
53
|
let targetSrc = eventType === "change" && dispatcher ? dispatcher : sourceEl
|
|
42
54
|
let phxTarget = target || targetSrc.getAttribute(view.binding("target")) || targetSrc
|
|
43
55
|
view.withinTargets(phxTarget, (targetView, targetCtx) => {
|
|
44
56
|
if(eventType === "change"){
|
|
45
|
-
let {newCid, _target
|
|
57
|
+
let {newCid, _target} = args
|
|
46
58
|
_target = _target || (DOM.isFormInput(sourceEl) ? sourceEl.name : undefined)
|
|
47
59
|
if(_target){ pushOpts._target = _target }
|
|
48
60
|
targetView.pushInput(sourceEl, targetCtx, newCid, event || phxEvent, pushOpts, callback)
|
|
49
61
|
} else if(eventType === "submit"){
|
|
50
62
|
let {submitter} = args
|
|
51
|
-
targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts)
|
|
63
|
+
targetView.submitForm(sourceEl, targetCtx, event || phxEvent, submitter, pushOpts, callback)
|
|
52
64
|
} else {
|
|
53
|
-
targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts)
|
|
65
|
+
targetView.pushEvent(eventType, sourceEl, targetCtx, event || phxEvent, data, pushOpts, callback)
|
|
54
66
|
}
|
|
55
67
|
})
|
|
56
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
|
})
|
|
@@ -642,6 +645,8 @@ export default class LiveSocket {
|
|
|
642
645
|
return
|
|
643
646
|
}
|
|
644
647
|
if(target.getAttribute("href") === "#"){ e.preventDefault() }
|
|
648
|
+
// noop if we are in the middle of awaiting an ack for this el already
|
|
649
|
+
if(target.hasAttribute(PHX_REF)){ return }
|
|
645
650
|
|
|
646
651
|
this.debounce(target, e, "click", () => {
|
|
647
652
|
this.withinOwners(target, view => {
|
|
@@ -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
|
}
|