@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 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