@heartml/streetcar-elements 1.0.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/LICENSE.txt +21 -0
- package/README.md +48 -0
- package/package.json +33 -0
- package/src/htmx-extension.js +30 -0
- package/src/streetcar-elements.js +457 -0
package/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Jared White
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Ride The Streetcar 🚋
|
|
2
|
+
## Custom Elements (by the ❤️ Heartml project)
|
|
3
|
+
|
|
4
|
+
This is the client-side component of Ride The Streetcar. You will also need to install a backend (alternatively you can write your own). The [Ruby backend is here](https://codeberg.org/heartml/ride_the_streetcar), or the [JavaScript backend is here](https://codeberg.org/heartml/ride-the-streetcar-js).
|
|
5
|
+
|
|
6
|
+
> [!NOTE]
|
|
7
|
+
> A work in progress! Docs coming upon release!
|
|
8
|
+
|
|
9
|
+
## 1:1 API Completion
|
|
10
|
+
|
|
11
|
+
- [x] `document.querySelector` : `<sc-query-selector>`
|
|
12
|
+
- [x] `document.querySelectorAll` : `<sc-query-selector all>`
|
|
13
|
+
- [x] `innerHTML` : `<sc-inner-html>`
|
|
14
|
+
- [x] `outerHTML` : `<sc-outer-html>`
|
|
15
|
+
- [x] `textContent` : `<sc-text-content>`
|
|
16
|
+
- [x] `classList.toggle` : `<sc-class-list toggle>`
|
|
17
|
+
- [x] `classList.{add,remove}` : `<sc-class-list {add,remove}="..."`
|
|
18
|
+
- [x] `console.{debug,log,warn,error}` : `<sc-console {debug,log,warn,error}="...">`
|
|
19
|
+
- [x] `toggleAttribute` : `<sc-toggle-attribute>`
|
|
20
|
+
- [x] `setAttribute` : `<sc-set-attribute>`
|
|
21
|
+
- [x] `removeAttribute` : `<sc-remove-attribute>`
|
|
22
|
+
- [x] `insertAdjacentElement` : `<sc-insert where="...">`
|
|
23
|
+
- also `<sc-before>`, `<sc-prepend>`, `<sc-append>`, `<sc-after>`
|
|
24
|
+
- [x] `remove` (element) : `<sc-remove>`
|
|
25
|
+
- [x] `.{property} =` : `<sc-set-property>`
|
|
26
|
+
- [x] `.{method}(...)` (via `apply`) : `<sc-apply>`
|
|
27
|
+
- [x] `.style.{...} =` : `<sc-set-style>`
|
|
28
|
+
- [x] `location.href =` (or custom visit/redirect strategies) : `<sc-navigate>`
|
|
29
|
+
- [x] `event.dispatch` (support native or custom events with payloads) : `<sc-dispatch-event>`
|
|
30
|
+
- [x] `setTimeout` (wrap 1 or more statements in here to delay execution) : `<sc-set-timeout>`
|
|
31
|
+
|
|
32
|
+
----
|
|
33
|
+
|
|
34
|
+
## Testing
|
|
35
|
+
|
|
36
|
+
Run `npm run test` to run the test suite. It's built with Node test runner, Puppeteer, and a Node static site.
|
|
37
|
+
|
|
38
|
+
## Contributing
|
|
39
|
+
|
|
40
|
+
1. Fork it (https://codeberg.org/heartml/streetcar-elements/fork)
|
|
41
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
42
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
43
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
44
|
+
5. Create a new Pull Request
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@heartml/streetcar-elements",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Ride The Streetcar is a protocol of sorts built out of HTML and custom elements. Drive surgical state changes to your frontend DOM and JavaScript objects…from any backend.",
|
|
5
|
+
"main": "src/streetcar-elements.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test --test-reporter node-test-reporter '**/*.test.js'",
|
|
9
|
+
"test:watch": "node --test --test-reporter node-test-reporter --watch '**/*.test.js'"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/streetcar-elements.js",
|
|
13
|
+
"./src/*": "./src/*.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://codeberg.org/heartml/streetcar-elements"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [],
|
|
23
|
+
"author": "Jared White <jared@whitefusion.studio>",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"prettier": {
|
|
26
|
+
"printWidth": 100,
|
|
27
|
+
"semi": false
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"node-test-reporter": "^1.2.0",
|
|
31
|
+
"puppeteer": "^24.17.1"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('./streetcar-elements.js').StreetcarLineElement} StreetcarLineElement
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// @ts-ignore
|
|
8
|
+
import htmx from "htmx.org"
|
|
9
|
+
|
|
10
|
+
htmx.defineExtension("streetcar", {
|
|
11
|
+
onEvent: (name, event) => {
|
|
12
|
+
if (name !== "htmx:beforeOnLoad") return
|
|
13
|
+
|
|
14
|
+
/** @type {HTMLElement} */
|
|
15
|
+
const elt = event.detail.elt
|
|
16
|
+
if (elt.getAttribute("hx-swap") === "streetcar" && !elt.hasAttribute("hx-push-url")) {
|
|
17
|
+
elt.setAttribute("hx-push-url", "false")
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
isInlineSwap: (swapStyle) => swapStyle === "streetcar",
|
|
21
|
+
handleSwap: (swapStyle, _target, /** @type {Element} */ fragment) => {
|
|
22
|
+
/** @type {StreetcarLineElement} */
|
|
23
|
+
let line
|
|
24
|
+
if (swapStyle === "streetcar" && (line = fragment.querySelector("sc-line"))) {
|
|
25
|
+
document.body.append(line)
|
|
26
|
+
line.operate()
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
})
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export class StreetcarLineElement extends HTMLElement {
|
|
4
|
+
static {
|
|
5
|
+
customElements.define("sc-line", this)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
super()
|
|
10
|
+
|
|
11
|
+
this.style.display = "none" // don't paint when added to DOM
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Performs a set of one or more DOM operations and then removes itself from the DOM */
|
|
15
|
+
operate() {
|
|
16
|
+
const operationsLoop = () => {
|
|
17
|
+
for (const statement of /** @type {StreetcarStatementElement[]} */ ([...this.children])) {
|
|
18
|
+
statement.operate()
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.shouldTransition ? document.startViewTransition(() => operationsLoop()) : operationsLoop()
|
|
23
|
+
this.remove()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get shouldTransition() {
|
|
27
|
+
return this.hasAttribute("start-transition") && document.startViewTransition
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class StreetcarStatementElement extends HTMLElement {
|
|
32
|
+
/** Performs a specific DOM operation according to this statement */
|
|
33
|
+
operate() {
|
|
34
|
+
throw "Override this in statement subclass"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* If current statement is wrapped in a query, return that element
|
|
39
|
+
*
|
|
40
|
+
* @returns {StreetcarQuerySelectorElement} */
|
|
41
|
+
get queryElement() {
|
|
42
|
+
return this.closest("sc-query-selector")
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class StreetcarQuerySelectorElement extends StreetcarStatementElement {
|
|
47
|
+
static {
|
|
48
|
+
customElements.define("sc-query-selector", this)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
constructor() {
|
|
52
|
+
super()
|
|
53
|
+
|
|
54
|
+
/** @type {Element} - the element which was matched via the selectors */
|
|
55
|
+
this.resolvedElement = null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
operate() {
|
|
59
|
+
let selectors = this.selectors
|
|
60
|
+
if (selectors.startsWith("--")) {
|
|
61
|
+
const line = this.closest("sc-line")
|
|
62
|
+
selectors = window.getComputedStyle(line).getPropertyValue(selectors).slice(1, -1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resolvedElements = this.hasAttribute("all")
|
|
66
|
+
? document.querySelectorAll(selectors)
|
|
67
|
+
: [document.querySelector(selectors)]
|
|
68
|
+
if (!resolvedElements[0]) {
|
|
69
|
+
console.warn(
|
|
70
|
+
"[Streetcar] 'querySelector' failed, no element(s) found for selectors",
|
|
71
|
+
selectors,
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const rel of resolvedElements) {
|
|
77
|
+
this.resolvedElement = rel
|
|
78
|
+
for (const statement of /** @type {StreetcarStatementElement[]} */ ([...this.children])) {
|
|
79
|
+
statement.operate()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get selectors() {
|
|
85
|
+
return this.getAttribute("selectors")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class StreetcarInnerHTMLElement extends StreetcarStatementElement {
|
|
90
|
+
static {
|
|
91
|
+
customElements.define("sc-inner-html", this)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
operate() {
|
|
95
|
+
const resolvedElement = this.queryElement.resolvedElement
|
|
96
|
+
|
|
97
|
+
/** @type {HTMLTemplateElement} */
|
|
98
|
+
const tmpl = this.querySelector(":scope > template")
|
|
99
|
+
|
|
100
|
+
resolvedElement.replaceChildren(tmpl.content)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class StreetcarTextContentElement extends StreetcarStatementElement {
|
|
105
|
+
static {
|
|
106
|
+
customElements.define("sc-text-content", this)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
operate() {
|
|
110
|
+
const resolvedElement = this.queryElement.resolvedElement
|
|
111
|
+
|
|
112
|
+
/** @type {HTMLTemplateElement} */
|
|
113
|
+
const tmpl = this.querySelector(":scope > template")
|
|
114
|
+
|
|
115
|
+
resolvedElement.textContent = tmpl.textContent
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class StreetcarOuterHTMLElement extends StreetcarStatementElement {
|
|
120
|
+
static {
|
|
121
|
+
customElements.define("sc-outer-html", this)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
operate() {
|
|
125
|
+
const queryElement = this.queryElement
|
|
126
|
+
|
|
127
|
+
/** @type {HTMLTemplateElement} */
|
|
128
|
+
const tmpl = this.querySelector(":scope > template")
|
|
129
|
+
const newElement = tmpl.content.firstElementChild
|
|
130
|
+
|
|
131
|
+
queryElement.resolvedElement.replaceWith(tmpl.content)
|
|
132
|
+
queryElement.resolvedElement = newElement
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export class StreetcarConsoleElement extends StreetcarStatementElement {
|
|
137
|
+
static {
|
|
138
|
+
customElements.define("sc-console", this)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
operate() {
|
|
142
|
+
let debugMessage, logMessage, warnMessage, errorMessage
|
|
143
|
+
|
|
144
|
+
if ((debugMessage = this.getAttribute("debug"))) {
|
|
145
|
+
console.debug(debugMessage)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if ((logMessage = this.getAttribute("log"))) {
|
|
149
|
+
console.log(logMessage)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if ((warnMessage = this.getAttribute("warn"))) {
|
|
153
|
+
console.warn(warnMessage)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if ((errorMessage = this.getAttribute("error"))) {
|
|
157
|
+
console.error(errorMessage)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export class StreetcarClassListElement extends StreetcarStatementElement {
|
|
163
|
+
static {
|
|
164
|
+
customElements.define("sc-class-list", this)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
operate() {
|
|
168
|
+
if (this.hasAttribute("toggle")) {
|
|
169
|
+
this.operateToggle()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const classNames = (this.getAttribute("add") || this.getAttribute("remove"))
|
|
174
|
+
.split(",")
|
|
175
|
+
.map((str) => str.trim())
|
|
176
|
+
const resolvedElement = this.queryElement.resolvedElement
|
|
177
|
+
|
|
178
|
+
if (this.hasAttribute("add")) {
|
|
179
|
+
resolvedElement.classList.add(...classNames)
|
|
180
|
+
} else {
|
|
181
|
+
resolvedElement.classList.remove(...classNames)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
operateToggle() {
|
|
186
|
+
const className = this.getAttribute("toggle")
|
|
187
|
+
const shouldForce = this.getAttribute("force")
|
|
188
|
+
const resolvedElement = this.queryElement.resolvedElement
|
|
189
|
+
|
|
190
|
+
if (shouldForce) {
|
|
191
|
+
resolvedElement.classList.toggle(className, shouldForce === "true")
|
|
192
|
+
} else {
|
|
193
|
+
resolvedElement.classList.toggle(className)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class StreetcarSetAttributeElement extends StreetcarStatementElement {
|
|
199
|
+
static {
|
|
200
|
+
customElements.define("sc-set-attribute", this)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
operate() {
|
|
204
|
+
const name = this.getAttribute("name")
|
|
205
|
+
const value = this.getAttribute("value") || ""
|
|
206
|
+
|
|
207
|
+
this.queryElement.resolvedElement.setAttribute(name, value)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export class StreetcarRemoveAttributeElement extends StreetcarStatementElement {
|
|
212
|
+
static {
|
|
213
|
+
customElements.define("sc-remove-attribute", this)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
operate() {
|
|
217
|
+
const name = this.getAttribute("name")
|
|
218
|
+
|
|
219
|
+
this.queryElement.resolvedElement.removeAttribute(name)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export class StreetcarToggleAttributeElement extends StreetcarStatementElement {
|
|
224
|
+
static {
|
|
225
|
+
customElements.define("sc-toggle-attribute", this)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
operate() {
|
|
229
|
+
const name = this.getAttribute("name")
|
|
230
|
+
const shouldForce = this.getAttribute("force")
|
|
231
|
+
const resolvedElement = this.queryElement.resolvedElement
|
|
232
|
+
|
|
233
|
+
if (shouldForce) {
|
|
234
|
+
resolvedElement.toggleAttribute(name, shouldForce === "true")
|
|
235
|
+
} else {
|
|
236
|
+
resolvedElement.toggleAttribute(name)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export class StreetcarInsertElement extends StreetcarStatementElement {
|
|
242
|
+
static { customElements.define("sc-insert", this) } // prettier-ignore
|
|
243
|
+
operate() {
|
|
244
|
+
const resolvedElement = this.queryElement.resolvedElement
|
|
245
|
+
|
|
246
|
+
/** @type {HTMLTemplateElement} */
|
|
247
|
+
const tmpl = this.querySelector(":scope > template")
|
|
248
|
+
|
|
249
|
+
const position = this.where
|
|
250
|
+
switch (position) {
|
|
251
|
+
case "beforebegin":
|
|
252
|
+
case "afterbegin":
|
|
253
|
+
case "beforeend":
|
|
254
|
+
case "afterend":
|
|
255
|
+
resolvedElement.insertAdjacentElement(position, tmpl.content.firstElementChild)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
get where() {
|
|
260
|
+
return this.getAttribute("where")
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
export class StreetcarBeforeElement extends StreetcarInsertElement {
|
|
264
|
+
static { customElements.define("sc-before", this) } // prettier-ignore
|
|
265
|
+
get where() { return "beforebegin" } // prettier-ignore
|
|
266
|
+
}
|
|
267
|
+
export class StreetcarPrependElement extends StreetcarInsertElement {
|
|
268
|
+
static { customElements.define("sc-prepend", this) } // prettier-ignore
|
|
269
|
+
get where() { return "afterbegin" } // prettier-ignore
|
|
270
|
+
}
|
|
271
|
+
export class StreetcarAppendElement extends StreetcarInsertElement {
|
|
272
|
+
static { customElements.define("sc-append", this) } // prettier-ignore
|
|
273
|
+
get where() { return "beforeend" } // prettier-ignore
|
|
274
|
+
}
|
|
275
|
+
export class StreetcarAfterElement extends StreetcarInsertElement {
|
|
276
|
+
static { customElements.define("sc-after", this) } // prettier-ignore
|
|
277
|
+
get where() { return "afterend" } // prettier-ignore
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export class StreetcarRemoveElement extends StreetcarStatementElement {
|
|
281
|
+
static { customElements.define("sc-remove", this) } // prettier-ignore
|
|
282
|
+
operate() {
|
|
283
|
+
this.queryElement.resolvedElement.remove()
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export class StreetcarApplyElement extends StreetcarStatementElement {
|
|
288
|
+
static { customElements.define("sc-apply", this) } // prettier-ignore
|
|
289
|
+
|
|
290
|
+
get chainAttribute() {
|
|
291
|
+
return this.getAttribute("method")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
operate() {
|
|
295
|
+
let receiver
|
|
296
|
+
let firstObjectInChain
|
|
297
|
+
const element = this.queryElement?.resolvedElement // null if outside of a query
|
|
298
|
+
const method = this.chainAttribute
|
|
299
|
+
let chain = method.split(".")
|
|
300
|
+
if (!element) {
|
|
301
|
+
receiver = chain[0]
|
|
302
|
+
chain = chain.slice(1)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
switch (receiver) {
|
|
306
|
+
case "window":
|
|
307
|
+
firstObjectInChain = window
|
|
308
|
+
break
|
|
309
|
+
case "document":
|
|
310
|
+
firstObjectInChain = document
|
|
311
|
+
break
|
|
312
|
+
default:
|
|
313
|
+
firstObjectInChain = element
|
|
314
|
+
}
|
|
315
|
+
if (!firstObjectInChain) {
|
|
316
|
+
console.warn(`[Streetcar] unable to determine receiver for 'apply'`)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.operateViaChain(firstObjectInChain, chain)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
operateViaChain(firstObjectInChain, chain) {
|
|
323
|
+
/** @type {array} */
|
|
324
|
+
const args = JSON.parse(this.getAttribute("arguments") || "[]")
|
|
325
|
+
let lastObjectInChain = firstObjectInChain
|
|
326
|
+
const foundMethod = chain.reduce((lastTerm, nextTerm) => {
|
|
327
|
+
lastObjectInChain = lastTerm
|
|
328
|
+
return lastTerm[nextTerm] || {}
|
|
329
|
+
}, firstObjectInChain)
|
|
330
|
+
|
|
331
|
+
if (foundMethod instanceof Function) {
|
|
332
|
+
foundMethod.apply(lastObjectInChain, args)
|
|
333
|
+
} else {
|
|
334
|
+
console.warn(
|
|
335
|
+
`[Streetcar] 'apply' failed due to missing '${this.chainAttribute}' method for:`,
|
|
336
|
+
firstObjectInChain,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export class StreetcarSetPropertyElement extends StreetcarApplyElement {
|
|
343
|
+
static { customElements.define("sc-set-property", this) } // prettier-ignore
|
|
344
|
+
|
|
345
|
+
get chainAttribute() {
|
|
346
|
+
return this.getAttribute("name")
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
operateViaChain(firstObjectInChain, chain) {
|
|
350
|
+
let lastObjectInChain = firstObjectInChain
|
|
351
|
+
const chainBeforeProperty = chain.slice(0, -1)
|
|
352
|
+
const property = chain.at(-1)
|
|
353
|
+
const foundObject = chainBeforeProperty.reduce((lastTerm, nextTerm) => {
|
|
354
|
+
lastObjectInChain = lastTerm
|
|
355
|
+
return lastTerm[nextTerm] || {}
|
|
356
|
+
}, firstObjectInChain)
|
|
357
|
+
|
|
358
|
+
foundObject[property] = JSON.parse(this.getAttribute("value"))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export class StreetcarSetStyleElement extends StreetcarStatementElement {
|
|
363
|
+
static { customElements.define("sc-set-style", this) } // prettier-ignore
|
|
364
|
+
|
|
365
|
+
operate() {
|
|
366
|
+
const name = this.getAttribute("name")
|
|
367
|
+
const value = this.getAttribute("value")
|
|
368
|
+
|
|
369
|
+
this.queryElement.resolvedElement["style"][name] = value
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export class StreetcarDispatchEventElement extends StreetcarStatementElement {
|
|
374
|
+
static { customElements.define("sc-dispatch-event", this) } // prettier-ignore
|
|
375
|
+
|
|
376
|
+
operate() {
|
|
377
|
+
const name = this.getAttribute("name")
|
|
378
|
+
|
|
379
|
+
/** @type {CustomEventInit} */
|
|
380
|
+
const eventOptions = {}
|
|
381
|
+
if (this.hasAttribute("bubbles")) eventOptions.bubbles = true
|
|
382
|
+
if (this.hasAttribute("cancelable")) eventOptions.cancelable = true
|
|
383
|
+
if (this.hasAttribute("composed")) eventOptions.composed = true
|
|
384
|
+
if (this.hasAttribute("detail")) eventOptions.detail = JSON.parse(this.getAttribute("detail"))
|
|
385
|
+
const isCustom = typeof eventOptions.detail !== "undefined"
|
|
386
|
+
let newEvent
|
|
387
|
+
|
|
388
|
+
if (isCustom) {
|
|
389
|
+
newEvent = new CustomEvent(name, eventOptions)
|
|
390
|
+
} else {
|
|
391
|
+
newEvent = new Event(name, eventOptions)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.queryElement.resolvedElement.dispatchEvent(newEvent)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export class StreetcarNavigateElement extends StreetcarStatementElement {
|
|
399
|
+
static { customElements.define("sc-navigate", this) } // prettier-ignore
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Applications can override this static function to provide their own
|
|
403
|
+
* framework-specific navigation strategy
|
|
404
|
+
*
|
|
405
|
+
* @param {string} url
|
|
406
|
+
*/
|
|
407
|
+
static navigationStrategy(url) {
|
|
408
|
+
window.location.href = url
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
operate() {
|
|
412
|
+
const url = this.getAttribute("href")
|
|
413
|
+
|
|
414
|
+
this.constructor["navigationStrategy"](url)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export class StreetcarSetTimeoutElement extends StreetcarStatementElement {
|
|
419
|
+
static { customElements.define("sc-set-timeout", this) } // prettier-ignore
|
|
420
|
+
|
|
421
|
+
operate() {
|
|
422
|
+
const delay = parseInt(this.getAttribute("delay"), 10)
|
|
423
|
+
|
|
424
|
+
setTimeout(() => {
|
|
425
|
+
for (const statement of /** @type {StreetcarStatementElement[]} */ ([...this.children])) {
|
|
426
|
+
statement.operate()
|
|
427
|
+
}
|
|
428
|
+
}, delay)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Singleton helper:
|
|
433
|
+
const Streetcar = {
|
|
434
|
+
/**
|
|
435
|
+
* Parses an incoming HTML string and operates the first `<sc-line>` element found.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} htmlText
|
|
438
|
+
*/
|
|
439
|
+
run(htmlText) {
|
|
440
|
+
const doc = Document.parseHTMLUnsafe(htmlText)
|
|
441
|
+
this.runFragment(doc)
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Operates the first `<sc-line>` element found in the fragment.
|
|
446
|
+
*
|
|
447
|
+
* @param {Document|DocumentFragment} fragment
|
|
448
|
+
*/
|
|
449
|
+
runFragment(fragment) {
|
|
450
|
+
/** @type {StreetcarLineElement} */
|
|
451
|
+
const line = fragment.querySelector("sc-line")
|
|
452
|
+
document.body.append(line)
|
|
453
|
+
line.operate()
|
|
454
|
+
},
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export default Streetcar
|