Client scripts and behaviours in vzk
Use this guide when a vzk site needs something to happen in the browser: a
menu that opens, a gallery, a tab set. A file’s own <script> holds the
code, and five attributes wire its markup to it.
Small things need no script: a <details>, a <dialog> and a popover
open and close with no code. Reach for a script when the markup and the
stylesheet cannot express the behaviour.
A file’s client script
Section titled “A file’s client script”A file may end with one <script>. It holds one Stimulus controller,
written export default class extends Controller. A gallery, as
components/Gallery.vzk:
<script server> const { photos, step = 1 } = props</script><section class="gallery" values={{ step }}> <img :for={photo in photos} ref="slide" src="/photos/{photo.file}" alt={photo.alt}> <button on:click="next">Next</button></section><script> export default class extends Controller { index = 0 next() { this.index = (this.index + this.stepValue) % this.slideTargets.length this.slideTargets.forEach((slide, at) => { slide.hidden = at !== this.index }) } }</script>| Written | Means |
|---|---|
ref="slide" |
the controller reads the element as this.slideTarget, and all of them as this.slideTargets |
on:click="next" |
a click calls the controller’s next() |
on:keydown.esc="close" |
only the Escape key |
on:resize@window="fit" |
the event is heard on the window |
on:click="close:prevent" |
an option after the method, such as prevent, stop or once |
values={{ step }} |
a value the controller reads as this.stepValue, on a component’s root element |
use="disclosure" |
the element runs a controller written elsewhere, by its name (below) |
load="visible" |
the element’s controllers are fetched and connected only once it scrolls into view (below) |
- Write no
static targetsand nostatic values: both are made from therefand thevaluesof the file’s markup.Controllerneeds no import. use,refandon:take literal text, never a value. A command in braces (below) is the one thingon:takes that is not a method’s name.- The five are written on an element, never on a component’s tag: put the attribute on an element in the component’s own file.
- A component’s controller runs on its root element, so a component with a script has one root element, and each use of it is a controller of its own. The controller of a page or a layout runs on the body.
- The markup and the script are checked against each other on every write:
on:click="next"needs anext()on the class,this.slideTargetsneeds aref="slide", and a value the script reads needs to be given. onclickand any other attribute that holds code is refused, and so is a<script>other than the file’s own two.eval,new Functionanddocument.writeare refused.
Start in connect() only what disconnect() stops. Read an element through
a ref, not document.querySelector, and write a value into the page with
textContent, never innerHTML: each of those is a warning otherwise.
Pages change without a full load, so a controller’s connect() and
disconnect() are where work for one page starts and ends.
Commands without a controller
Section titled “Commands without a controller”An event can run one command, or a list that runs in order, with no script at all:
<button on:click={toggle("#menu")}>Menu</button><div id="menu" hidden>Links</div>
<button on:click={[show("#menu"), addClass("panel", "is-open")]}>Open</button><div id="menu" ref="panel" hidden>Links</div>The commands are show, hide, toggle, addClass, removeClass,
toggleClass, setAttribute, toggleAttribute, focus, scrollTo and
dispatch. The first argument names the target: #id finds one element on
the page, and a plain name finds a ref written once in the same file.
show, hide and toggle set hidden; the class commands take one class;
setAttribute and toggleAttribute may change title, role,
tabindex, hidden, inert, data-state, data-mode, data-active and
aria-* and nothing else; dispatch sends a namespaced event such as
menu:opened. Every argument is literal: no value from a row or a visitor
chooses a command, and a command cannot change an address, a handler or a
controller’s wiring.
A command hears a plain event on its own element. A key, a modifier or an
event heard on the window belongs to a controller’s method, and an on:
holds methods or commands, never both. A target the compiler cannot find
is an error; one that is missing when the page runs is skipped.
Loading a controller later
Section titled “Loading a controller later”An element can wait for its controllers until they are needed, so a gallery below the fold costs a visitor nothing until it is seen:
<div load="visible" use="carousel">...</div>| Written | Means |
|---|---|
load="visible" |
fetched and connected when the element scrolls into view |
load="idle" |
when the browser has a moment to spare |
load="interaction" |
on hover or the first click, key or focus, which is replayed once the controller is connected |
load="eager" |
at once, as an element with no load |
It is written on the element that runs the controllers (a component’s root
element, or the element that says use), and connect() runs when the
element wakes. An element with no load connects as the page arrives.
Behaviours the platform gives
Section titled “Behaviours the platform gives”use="disclosure" asks for the platform’s own menu, the one that opens and
closes a panel. The markup says only which element is which, and the
behaviour writes what each part carries (the role, hidden, the events):
<script server> const { links } = props</script>
<div class="md:hidden" use="disclosure"> <div class="py-3xs" ref="disclosure.control"> <p class="eyebrow text-muted">Menu</p> </div> <div class="fixed inset-0 z-10 bg-ink/3" ref="disclosure.backdrop"></div> <div class="absolute inset-x-0 top-full z-20 px-gutter py-xl bg-surface border-b border-ink/25" ref="disclosure.panel"> <div class="flex flex-col gap-sm"> <a :for={link in links} href={link.href} class="link-quiet">{link.label}</a> </div> </div></div>Open and closed is the hidden attribute, never a class.
describe_vocabulary topic=behaviors lists each behaviour and its parts: a
part it names disclosure-control is written ref="disclosure.control"
here, and the attributes it lists are not copied into a .vzk file. A part
or a method the behaviour does not have is an error.
A controller in a file of its own
Section titled “A controller in a file of its own”A controller several files share is scripts/controllers/<name>_controller.js,
asked for as use="<name>". An underscore in the file’s name is a - in
the controller’s: scripts/controllers/tab_list_controller.js is
use="tab-list". Such a file is a Stimulus controller written whole, with
its own import:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller { connect() { this.observer = new IntersectionObserver((entries) => { if (entries.some((entry) => entry.isIntersecting)) { this.element.classList.add("seen") this.observer.disconnect() } }) this.observer.observe(this.element) }
disconnect() { this.observer.disconnect() }}For a controller named carousel, ref="carousel.slide" names one of its
targets, on:click="carousel.next" calls its method, and
values.carousel={{ seconds }} on the element that says use="carousel"
gives it a value. A controller a file names and nothing provides is an
error.
Imports and packages
Section titled “Imports and packages”A client script imports from @hotwired/stimulus, from a key of the site’s
importmap.json, or from a file under the site’s scripts/, asked for by
its path without scripts/ and .js: scripts/lib/swipe.js is
import swipe from "lib/swipe". Any other import is an error.
importmap.json pins a package by a bare specifier to an https://
address. Pin an exact version, and name each host a pin fetches code from to
the owner before merging: it is somebody else’s server running code on the
site.
Fades and in-place refreshes
Section titled “Fades and in-place refreshes”Two site settings change how one page gives way to the next, and both are
off unless set: page_fades and morph_refreshes
(describe_vocabulary topic=meta). A page says its own in its server
script, and that wins either way: export const fades = false,
export const morph = true.
page_fadesfades each page into the next. It asks nothing of a script.morph_refreshesredraws a page that answers with itself in place, at the same scroll position, instead of replacing it whole. The page’s elements are kept and put back to what the page was drawn as, so a controller stays connected and anything it drew or changed is undone. A controller keeps what it owns by cancelling the morph for it:
<canvas ref="canvas"></canvas><script> export default class extends Controller { connect() { this.keep = (event) => { if (event.target === this.canvasTarget) event.preventDefault() } this.element.addEventListener("turbo:before-morph-element", this.keep) }
disconnect() { this.element.removeEventListener("turbo:before-morph-element", this.keep) } }</script>turbo:before-morph-attribute does the same for one attribute
(event.detail.attributeName), such as a class the controller toggles.
A form’s answer is such a refresh only when it is the same page and the
form’s button says so with data-turbo-action="replace". Without it, the
answer replaces the page as it always did.
Seeing it work
Section titled “Seeing it work”A branch’s preview runs the branch’s own JavaScript: its files, its pins and the controllers its pages name, as the branch has them. So a change whose effect is in a script can be looked at before it is merged.
A preview does not use Turbo, and a link in it is a full load of the live
page. So what a script does across a turbo: visit or a refresh shows on the
live site alone. Say so to the owner before merging a change whose effect is
there.
Consent
Section titled “Consent”Before setting a cookie, loading a tracker, or doing anything else a visitor
was not asked about, a script needs the visitor’s answer. A site may carry
its own bar: a form marked consent with an accept and a reject button,
drawn while consent.pending, which is while the visitor has not answered.
Without one the built-in bar asks. A file reads the answer as
consent.granted. Draw the element that runs such a controller only under
:if={consent.granted}.