Revolving Hearts Logo. Heartml Search ⌘K

Client Elements

This is the frontend portion of the Ride The Streetcar paradigm. Become familiar with the basic premise here, then read on for installation & usage documentation.

While any backend is capable of powering Streetcar, we offer two out-of-the-box backend libraries for enhanced DX:

Installation #

npm i @heartml/streetcar-elements

Using vanilla JavaScript:

import StreetcarElements from "@heartml/streetcar-elements"

// basic example using fetch:
async function exampleRequestResponse() {
  const html = await fetch("/example/streetcar/route").then(r => r.text())
  StreetcarElements.run(html)

  // or if you have a DocumentFragment already parsed:
  // StreetcarElements.runFragment(doc)
}

Using htmx:

import "htmx.org"
import "@heartml/streetcar-elements/src/htmx-extension.js"
<form action="/example/streetcar/route" hx-swap="streetcar">
  <button type="submit">Ride the Streetcar!</button>
</form>

Conceptually, Streetcar is a way of describing actions to be executed on a page using custom elements. You can read a Streetcar payload containing serialized HTML-based statements…aka “streetcar line”…and ideally you’ll understand the operations being performed and in what order (aka common JavaScript/DOM operations encoded into markup). Some examples:

<sc-line>
  <sc-query-selector selectors="#ident_abc123"><sc-inner-html><template><p>New HTML content.</p></template></sc-inner-html></sc-query-selector>
  <sc-console log="✔ outputs to the console"></sc-console>
  <sc-query-selector selectors="--named-query">
    <sc-outer-html><template><p>Kewl</p><div>
      <template shadowrootmode="open">
        <p>I'm in the shadow DOM!</p>
      </template>
    </div></template></sc-outer-html>
    <sc-class-list toggle="success" force="true"></sc-class-list>
  </sc-query-selector>
  <sc-apply method="window.testChain.funkyTown" arguments='["take", "me", 2]'></sc-apply>
  <sc-query-selector selectors="#delete-me"><sc-remove></sc-remove></sc-query-selector>
  <sc-query-selector selectors="ul"><sc-append><template>
    <li>Item 2</li>
  </template></sc-append></sc-query-selector>
  <sc-query-selector selectors="[name=set_value]">
    <sc-set-property name="value" value='"input text"'></sc-set-property>
  </sc-query-selector>
  <sc-query-selector all selectors="ol > li, ul > li">
    <sc-set-attribute name="list-item"></sc-set-attribute>
  </sc-query-selector>
  <sc-query-selector selectors="h1">
    <sc-set-style name="backgroundColor" value="rebeccapurple"></sc-set-style>
  </sc-query-selector>
</sc-line>

All of the available Streetcar statements are “inert” until two things happen: they are placed within a Streetcar line element (<sc-line>) and that element is appended to the end of <body>. Once this happens, all of the statements are run in sequence and then the line element auto-removes itself. <sc-line> is styled as display: none so nothing visually changes. All of these web components are “under the hood” as it were.

The benefit to this approach is no JavaScript is sent over the wire and interpreted in any way. 100% of the Streetcar JS code is part of the library loaded at application start. Once a payload comes in from a server request (or possibly async via Server-Sent Events or Websockets), the HTML-based custom elements will inform the resulting logic executed by the Streetcar runtime.

Examples #

The majority of statements you’ll likely write using Ride The Streetcar will affect one or more elements in the DOM, so typically you’ll be using our version of querySelector (or querySelectorAll). For example, we can set the inner HTML of the #progress-message element:

<sc-line>
  <sc-query-selector selectors="#progress-message">
    <sc-inner-html>
      <template>
        <em>Yay!</em> Your process is complete. <button>Done</button>
      </template>
    </sc-inner-html>
  </sc-query-selector>
</sc-line>

All statement types working with HTML output need it wrapped in <template> tags so when the Streetcar statements are temporarily inserted into the DOM, the HTML doesn’t start “working” until the statement operation has been performed.

A “Streetcar line” (aka <sc-line>) can contain just a single statement or any number of statements. You also have the option of running the statement(s) inside of a View Transition (aka document.startViewTransition):

<sc-line start-transition>
  ...
</sc-line>

There’s also a way of running a “persisted” line. That is, you could define a line and ship it to the client-side HTML ahead of time. It will remain there, inert, until you kick off a persisted operation. Let’s look at how this might work:

<output id="state-display"></output>

<sc-line id="state-display-waiting">
  <sc-query-selector selectors="output#state-display">
    <sc-inner-html><template>
      <wa-icon name="hourglass"></wa-icon> <ui-label>Waiting...</ui-label>
    </template></sc-inner-html>
  <sc-query-selector>
</sc-line>

<sc-line id="state-display-loaded">
  <sc-query-selector selectors="output#state-display">
    <sc-inner-html><template>
      <wa-icon name="checkmark"></wa-icon> <ui-label>Complete!</ui-label>
    </template></sc-inner-html>
  <sc-query-selector>
</sc-line>

<!-- somewhere else in the app... -->

<script>
function renderState(el, state) {
  const lineId = `${el}-${state}`
  document.querySelector(lineId).operate({ persist: true })
}

const state = "waiting" // or "loading"

renderState("state-display", state)
</script>

By calling renderState at any time here with either the waiting or loading state, it will operate the line to update the contents of the <output> tag but maintaining the line so you can call it again and again. (Because remember, by default, a line is removed immediately from the DOM once all statements have completed.)

Now imagine would you could do if lines didn’t simply set innerHTML but dispatched events, updated properties, called methods, and so forth. Using HTML not just to declare structures but to declare known behaviors directly associated with those structures is a bit of a new mental model…but very powerful once you wrap your head around it. It means that when you “drop down” a level to write new JavaScript, you may find you’re not writing “bespoke feature code” as much but rather “reusable operation code” that multiple features can rely on using the Streetcar paradigm.

Wondering if you really need to write out all these HTML fragments by hand all the time? That’s where our Ruby & JavaScript backend helpers come in!

Need one in your server language of choice? You could write your own and publish it as an open source project we’ll be glad to link to!


1:1 Web APIs #

As much as possible, Streetcar aims to maintain conceptual parity with existing Web/DOM APIs.

  • document.querySelector : <sc-query-selector>
  • document.querySelectorAll : <sc-query-selector all>
  • innerHTML : <sc-inner-html>
  • outerHTML : <sc-outer-html>
  • textContent : <sc-text-content>
  • classList.{add,remove,toggle} : <sc-class-list>
  • console.{debug,log,warn,error} : <sc-console>
  • toggleAttribute : <sc-toggle-attribute>
  • setAttribute : <sc-set-attribute>
  • removeAttribute : <sc-remove-attribute>
  • insertAdjacentElement : <sc-insert>
    • also <sc-before>, <sc-prepend>, <sc-append>, <sc-after>
  • remove (element) : <sc-remove>
  • .{property} = : <sc-set-property>
  • .{method}(...) (via apply) : <sc-apply>
  • .style.{...} = : <sc-set-style>
  • location.href = (or custom visit/redirect strategies) : <sc-navigate>
  • event.dispatch (support native or custom events with payloads) : <sc-dispatch-event>
  • setTimeout (wrap 1 or more statements in here to delay execution) : <sc-set-timeout>

All the Streetcar statement types are located in this source file. As you can see, each element itself is quite self-contained.

Named Queries to Avoid Server/Client Drift #

One valid complaint with “driving frontend behavior from the backend” is the age-old problem of naming things. Do you use straight HTML ids? Some other attribute-driven identification? What happens when your frontend and your backend drift out of sync?

Ride The Streetcar provides a solution for this in the form of CSS-based named queries. These are selector strings you can literally define in CSS itself as custom properties, and Streetcar’s query selector statement can look up the property and resolve to a selector.

Here’s how it works:

/* these could be scoped to specific pages either via body-based class names or individual <style> tags */
sc-line {
  --your-named-query: "body > .replace-me[data-doit='yes']"
}

Then on the backend when you define a query statement, you use that property in lieu of a standard selector:

<sc-query-selector selectors="--your-named-query">...</sc-query-selector>

This allows you to craft naming conventions which are purely intended for Streetcar scenarios and are independent of the exact shape of your DOM which may change over time, plus the selectors can live closer to where selectors might be used anyway for styling purposes.

This doesn’t currently solve the “one of many” problem where you have a list of similar items and you need to select only one of them (i.e. one record out of hundreds). We’ll be enhancing the named query feature to support per-request individualized subqueries in an update soon.


Adding Custom Statements #

You can add your own statement types to Ride The Streetcar, allowing your backend to drive literally any frontend behavior you could possibly imagine!

We provide a way to log to the console already, but as a simple example, we’ll write a custom log-this element. The convention is to use sc-c- as the tag name prefix to indicate it’s a user-supplied custom statement type.

import StreetcarElements, { StreetcarStatementElement } from "@heartml/streetcar-elements"

export class CustomStreetcarLogThisElement extends StreetcarStatementElement {
  static {
    customElements.define("sc-c-log-this", this)
  }

  /* this is the Streetcar entrypoint to your element */
  operate() {
    const message = this.getAttribute("message")

    console.log("✔ [custom]", message)
  }
}

With this in place, your backend could provide a Streetcar line containing the following:

<sc-line>
  <sc-c-log-this message="A custom log message!"></sc-c-log-this>
</sc-line>

And once that line is running, your statement operation will work its magic.

Many types of statements require being part of the Query Selector DSL. To obtained the “selected” element, use the following API:

operate() {
  const resolvedElement = this.queryElement.resolvedElement

  // do something with the element
}

In a querySelectorAll context, your operate method will be run multiple times, each time with a single element being resolved. So you don’t need to worry about looping through multiple elements, Streetcar handles that on your behalf.

There are times you may want to work with HTML code supplied via a <template> tag from the backend. Here’s the convention for doing so, showing our outerHTML solution at work:

operate() {
  const queryElement = this.queryElement

  /** @type {HTMLTemplateElement} */
  const tmpl = this.querySelector(":scope > template")
  const newElement = tmpl.content.firstElementChild
  queryElement.resolvedElement.replaceWith(tmpl.content)

  // make sure, if you do end up replacing an existing element, you update the query's resolved element:
  queryElement.resolvedElement = newElement
}

You may also want to process attributes by converting from JSON. This is straightforward to do using native browser APIs:

operate() {
  const value = JSON.parse(this.getAttribute("value"))

  // You could also use this.hasAttribute("...") if you need to check conditional behavior.
}

Community 👋😃 #

Join the Human Web Collective Discord where you can meet friendly fellow developers who love investigating and using “vanilla-adjacent” frontend APIs and ask questions about this project and beyond.

Interested in contributing to Ride The Streetcar? Bug reports, feature requests, and PRs most welcome. (Full human authorship only, please!) There are three repos to choose from depending on the issue:


Skip to content Close