-
Notifications
You must be signed in to change notification settings - Fork 429
/
stream_element.js
190 lines (166 loc) · 4.57 KB
/
stream_element.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
import { StreamActions } from "../core/streams/stream_actions"
import { nextRepaint } from "../util"
// <turbo-stream action=replace target=id><template>...
/**
* Renders updates to the page from a stream of messages.
*
* Using the `action` attribute, this can be configured one of eight ways:
*
* - `after` - inserts the result after the target
* - `append` - appends the result to the target
* - `before` - inserts the result before the target
* - `prepend` - prepends the result to the target
* - `refresh` - initiates a page refresh
* - `remove` - removes the target
* - `replace` - replaces the outer HTML of the target
* - `update` - replaces the inner HTML of the target
*
* @customElement turbo-stream
* @example
* <turbo-stream action="append" target="dom_id">
* <template>
* Content to append to target designated with the dom_id.
* </template>
* </turbo-stream>
*/
export class StreamElement extends HTMLElement {
static async renderElement(newElement) {
await newElement.performAction()
}
async connectedCallback() {
try {
await this.render()
} catch (error) {
console.error(error)
} finally {
this.disconnect()
}
}
async render() {
return (this.renderPromise ??= (async () => {
const event = this.beforeRenderEvent
if (this.dispatchEvent(event)) {
await nextRepaint()
await event.detail.render(this)
}
})())
}
disconnect() {
try {
this.remove()
// eslint-disable-next-line no-empty
} catch {}
}
/**
* Removes duplicate children (by ID)
*/
removeDuplicateTargetChildren() {
this.duplicateChildren.forEach((c) => c.remove())
}
/**
* Gets the list of duplicate children (i.e. those with the same ID)
*/
get duplicateChildren() {
const existingChildren = this.targetElements.flatMap((e) => [...e.children]).filter((c) => !!c.id)
const newChildrenIds = [...(this.templateContent?.children || [])].filter((c) => !!c.id).map((c) => c.id)
return existingChildren.filter((c) => newChildrenIds.includes(c.id))
}
/**
* Gets the action function to be performed.
*/
get performAction() {
if (this.action) {
const actionFunction = StreamActions[this.action]
if (actionFunction) {
return actionFunction
}
this.#raise("unknown action")
}
this.#raise("action attribute is missing")
}
/**
* Gets the target elements which the template will be rendered to.
*/
get targetElements() {
if (this.target) {
return this.targetElementsById
} else if (this.targets) {
return this.targetElementsByQuery
} else {
this.#raise("target or targets attribute is missing")
}
}
/**
* Gets the contents of the main `<template>`.
*/
get templateContent() {
return this.templateElement.content.cloneNode(true)
}
/**
* Gets the main `<template>` used for rendering
*/
get templateElement() {
if (this.firstElementChild === null) {
const template = this.ownerDocument.createElement("template")
this.appendChild(template)
return template
} else if (this.firstElementChild instanceof HTMLTemplateElement) {
return this.firstElementChild
}
this.#raise("first child element must be a <template> element")
}
/**
* Gets the current action.
*/
get action() {
return this.getAttribute("action")
}
/**
* Gets the current target (an element ID) to which the result will
* be rendered.
*/
get target() {
return this.getAttribute("target")
}
/**
* Gets the current "targets" selector (a CSS selector)
*/
get targets() {
return this.getAttribute("targets")
}
/**
* Reads the request-id attribute
*/
get requestId() {
return this.getAttribute("request-id")
}
#raise(message) {
throw new Error(`${this.description}: ${message}`)
}
get description() {
return (this.outerHTML.match(/<[^>]+>/) ?? [])[0] ?? "<turbo-stream>"
}
get beforeRenderEvent() {
return new CustomEvent("turbo:before-stream-render", {
bubbles: true,
cancelable: true,
detail: { newStream: this, render: StreamElement.renderElement }
})
}
get targetElementsById() {
const element = this.ownerDocument?.getElementById(this.target)
if (element !== null) {
return [element]
} else {
return []
}
}
get targetElementsByQuery() {
const elements = this.ownerDocument?.querySelectorAll(this.targets)
if (elements.length !== 0) {
return Array.prototype.slice.call(elements)
} else {
return []
}
}
}