Switch
“Our walls have no doors.”
“Every wall I’ve met said that,” Trace said.
A checkbox with role="switch". Screen readers then say “on” and “off” instead of “checked”, while the keyboard and FormData work as for an ordinary checkbox.
Choose Switch when the setting takes effect at once. If the choice should only apply when the form is submitted, Checkbox is right: a switch that does nothing until you press “Save” promises more than it delivers.
How to use it
Section titled “How to use it”Black ICE. Check local law.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.43.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.43.0/src/components/css/switch/switch.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.43.0/src/components/css/label/label.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.43.0/src/components/css/help-text/help-text.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.43.0/dist/lotek.css --><div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="auto-renew" checked /><label class="lt-label" for="auto-renew">Auto-renew</label></div>
<div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="lethal" aria-describedby="lethal-help" /><label class="lt-label" for="lethal">Lethal response</label></div><p class="lt-help-text" id="lethal-help">Black ICE. Check local law.</p>
<div class="lt-switch-row"><input class="lt-switch" type="checkbox" role="switch" id="intrusions" disabled /><label class="lt-label" for="intrusions">Report every intrusion attempt</label></div>import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/switch.css"import "@lo-tek/interfaces/label.css"import "@lo-tek/interfaces/help-text.css"lt is used in two ways, and which one depends on whether you create the element or find it.
If you create the markup, you spread the attributes in. That is the route in React, Astro and anything else that renders:
import { lt } from "@lo-tek/interfaces/react"
<div className={lt.switch.row}> <input {...lt.switch()} id="auto-renew" /> <label {...lt.label()} htmlFor="auto-renew">Auto-renew</label></div>The import goes to /react here, since the block is JSX. From there the keys are called className and htmlFor. In Astro and anything else without React you import @lo-tek/interfaces and get class and for.
If the element is already there, you set them on it. That is the route when you enhance HTML a server or a template has sent:
import { lt } from "@lo-tek/interfaces"
const toggle = document.querySelector<HTMLInputElement>("#auto-renew")
// The server sent a checkbox. Now it is a switch.lt.setAttributes(toggle, lt.switch())
// A setting the user is not allowed to change.lt.setAttributes(toggle, lt.switch({ disabled: true }))The function sets both type and role, which are the two things that must go together for the switch to be announced correctly. lt.switch.row gives the class for the row around it.
Do not disable the switch to prevent another press while a save is in progress. disabled takes focus away from a switch that has it, focus does not come back when it is enabled again, and another press of the space bar does nothing. See Saving immediately for how to block another press without it.
If you import it directly instead of through lt, it is called switchControl, since switch is a reserved word in JavaScript:
import { switchControl } from "@lo-tek/interfaces"Saving immediately
Section titled “Saving immediately”The switch promises that the change applies. So it also has to say when saving fails, and go back. fetch only rejects when the network fails, so a response with an error status has to be checked by you, otherwise a refusal from the server ends up in the branch that reports success. A redirect, as to the log-in page when the session has run out, is followed to a page that is ok, so it is checked too. In “Plain HTML” and React, another press is blocked while the save is in progress without disabled, which would take focus from the switch; in Datastar and kotlinx.html another press sends another @put, and the server decides. React uses the builder functions, Datastar its own attributes, and kotlinx.html the Datastar recipe rendered by the server, with no script of its own.
<!-- autocomplete="off" stands on the form, since HTML does not allow it on a checkbox. Without it Firefox keeps the state from before a reload. --><form autocomplete="off"> <div class="lt-switch-row"> <input class="lt-switch" type="checkbox" role="switch" id="auto-renew" /> <label class="lt-label" for="auto-renew">Auto-renew</label> </div></form><p class="lt-help-text" id="auto-renew-status" role="status"></p>
<script type="module"> const toggle = document.getElementById("auto-renew") const status = document.getElementById("auto-renew-status") let saving = false
// Another press while saving should not flip the switch. `disabled` // would block it the same way, but take focus from it. toggle.addEventListener("click", (event) => { if (saving) event.preventDefault() })
toggle.addEventListener("change", async () => { const on = toggle.checked saving = true
try { const response = await fetch("/api/subscription", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ autoRenew: on }), }) // A redirect, as to the log-in page when the session has run out, is // followed to a page that is ok, but nothing was saved. if (!response.ok || response.redirected) throw new Error(response.statusText) status.textContent = on ? "Auto-renew is on." : "Auto-renew is off. Your walls expire with the subscription." } catch { toggle.checked = !on status.textContent = "Could not save. Try again." } finally { saving = false } })</script>import { useState } from "react"import { lt } from "@lo-tek/interfaces/react"
export function AutoRenewSwitch({ initial }: { initial: boolean }) { const [on, setOn] = useState(initial) const [saving, setSaving] = useState(false) const [status, setStatus] = useState("")
async function changed(value: boolean) { // While a save is in progress, the switch stays where the state says. // `disabled` would block it the same way, but take focus from it. if (saving) return
// Flip at once, so the switch does not lag behind the finger. setOn(value) setSaving(true) setStatus("")
try { const response = await fetch("/api/subscription", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ autoRenew: value }), }) // A redirect, as to the log-in page when the session has run out, is // followed to a page that is ok, but nothing was saved. if (!response.ok || response.redirected) throw new Error(response.statusText) setStatus(value ? "Auto-renew is on." : "Auto-renew is off. Your walls expire with the subscription.") } catch { // Back to where it was. The switch promised something it did not keep. setOn(!value) setStatus("Could not save. Try again.") } finally { setSaving(false) } }
return ( <> <div className={lt.switch.row}> <input {...lt.switch()} id="auto-renew" checked={on} onChange={(e) => changed(e.currentTarget.checked)} /> <label {...lt.label()} htmlFor="auto-renew">Auto-renew</label> </div> <p {...lt.helpText()} role="status">{status}</p> </> )}checked and onChange together make the field controlled, and that is exactly what makes the rollback possible: React draws the switch from your state, not from what the user clicked. It is also what keeps the switch in place during the save: when changed does not change the state, React draws it back.
<!-- Datastar itself. Without it the `data-` attributes below do nothing. --><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
<!-- autocomplete="off" stands on the form, since HTML does not allow it on a checkbox. Without it Firefox keeps the state from before a reload until Datastar has loaded. --><form autocomplete="off"> <div class="lt-switch-row" data-signals="{renew: false, status: ''}"> <input class="lt-switch" type="checkbox" role="switch" id="auto-renew" data-bind:renew data-on:change="@put('/api/subscription')" /> <label class="lt-label" for="auto-renew">Auto-renew</label> </div></form><p class="lt-help-text" role="status" data-text="$status"></p>The server decides the outcome here. @put sends the signals, and the response sets status and, when the save failed, renew back to where it was. The rollback is therefore in the server and not in the page, as for all other state the server decides. Another press while the save is in progress sends another @put, and the server decides that one too; a disabled guard in the page would take focus from the switch.
On a Kotlin or Java server, with kotlinx.html and lo-tek-kotlinx-html, the switch is saved through Datastar, as in the Datastar tab, and the page has no script of its own. The server renders the saved choice into the signal and the checked attribute, so the switch stands right before Datastar has loaded, and autocomplete="off" on the form around it keeps Firefox from putting back the state from before a reload: HTML does not allow the attribute on a checkbox.
import kotlinx.html.*import no.lotek.*import no.lotek.html.lt
fun FlowContent.autoRenew(on: Boolean = false) { // autocomplete="off" stands on the form, since HTML does not allow it on // a checkbox. Without it Firefox keeps the state from before a reload // over the one the server rendered, until Datastar has loaded. form { autoComplete = false div { lt(Lt.switch.row) attributes["data-signals"] = "{renew: $on, status: ''}" input { lt(Lt.`switch`()) id = "auto-renew" checked = on attributes["data-bind:renew"] = "" attributes["data-on:change"] = "@put('/api/subscription')" } label { lt(Lt.label()); htmlFor = "auto-renew"; +"Auto-renew" } } } // `\$status` is Datastar's signal, not a Kotlin template. p { lt(Lt.helpText()); attributes["role"] = "status"; attributes["data-text"] = "\$status" }}The answer to @put is the outcome. @put sends the signals as JSON, {"renew": true, "status": ""}, and an answer with content-type: application/json sets the signals it names. Datastar reads it only from a 200: an answer with an error status sets nothing, and the switch would stand where the user left it. A save that went through answers with the status, and a refusal is a 200 that puts renew back and says why. So is a session that has run out: Datastar follows a redirect and morphs the log-in page it lands on into this one, with the switch still where the user left it, so a request with the Datastar-Request header is answered, not redirected:
{"renew": false, "status": "Could not save. Try again."}// Once for the whole app, in the page template. The switch is CSS alone,// so there is no web component to register.head { link(rel = "stylesheet", href = LotekWebJar.CSS) }body { autoRenew() // Datastar itself, once for the whole app. Without it the `data-` // attributes in the recipe do nothing. script(type = "module", src = "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js") {}}role="status" makes screen readers announce the result without interrupting what the user is doing.
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-switch | The switch itself |
.lt-switch-row | The row that holds the switch and the text side by side |
| Variable | Default |
|---|---|
--lt-switch-width | var(--lt-spacing-10) |
--lt-switch-height | var(--lt-spacing-6) |
The knob inside is a gradient and not a pseudo-element, because Firefox does not render ::before and ::after on an <input>. It therefore follows the height of the switch by itself. See Customising.
The switch slides between off and on. If the user has asked for reduced motion in their system, it changes state without a transition instead.
Accessibility
Section titled “Accessibility”
What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the
interface Orca reads, in Chromium and Firefox, on every change, by check-accessibility-tree.ts, and
every change the check lists as a mutation must make it fail.
- Focus
#auto-renew: focus on theswitch"Auto-renew", not checked. - Press Space: focus on the
switch"Auto-renew", not checked; "Could not save. Try again." announced once, polite.
- The label should say what the switch controls, not which state it is in. “Lethal response”, not “Lethal response is on”.
- Do not write “Off” and “On” as text next to it. The screen reader already says it, and the text contradicts itself when the user flips the switch.
- The switch has no invalid state. If switching it on is required, for example to accept terms, that belongs in a Checkbox with
required. - If the change needs confirmation or cannot be undone, use a button instead. A switch that opens a dialog is not a switch.