A custom form control
Build an on/off switch that a <form> in the page submits like a checkbox, and that a <label> names, using the browser's form-associated custom elements.
The form below is plain HTML in the page. Turn the switch on with a click or with Tab and Space, then press Submit to see what the form would send. Reset puts the switch back.
module Snippets.FormSwitch
open Fable.Core
open Fable.Core.JsInterop
open Browser
open Browser.Types
open Firelight
open type Firelight.Lit
/// An on/off switch that a <form> submits like a checkbox: `name=value` while it's on.
[<AttachMembers>]
type FormSwitch() as this =
inherit LitElement()
// The element as an HTMLElement, for its own DOM members.
let host = this.element
let internals = this.attachInternals ()
do
internals.role <- "switch"
host.addEventListener ("click", fun _ -> this.Toggle())
host.addEventListener (
"keydown",
fun e ->
if (e :?> KeyboardEvent).key = " " then
e.preventDefault ()
this.Toggle()
)
/// Tells the browser this element takes part in forms.
static member formAssociated = true
static member properties =
PropertyDeclarations.create [
"checked", PropertyDeclaration<bool>()
"value", PropertyDeclaration<string>()
]
static member styles =
css
$$"""
:host { display: inline-block; vertical-align: middle; border-radius: 1rem; cursor: pointer; }
:host(:focus-visible) { outline: 2px solid var(--fg); outline-offset: 2px; }
.track { display: block; width: 2.6rem; height: 1.5rem; border-radius: 1rem; background: var(--muted); }
.track.on { background: var(--accent); }
.thumb { display: block; width: 1.1rem; height: 1.1rem; margin: 0.2rem; border-radius: 50%;
background: var(--bg); transition: transform 0.15s; }
.on .thumb { transform: translateX(1.1rem); }
"""
member val ``checked`` = false with get, set
member val value = "on" with get, set
/// Tells the form and assistive technology the current state.
member this.Report() =
internals.ariaChecked <- string this.``checked``
internals.setFormValue (if this.``checked`` then this.value else null)
member this.Toggle() =
this.``checked`` <- not this.``checked``
// Before the event, so a listener that reads the form sees the new value.
this.Report()
this.dispatch (Event.Create("change", jsOptions<EventInit> (fun o -> o.bubbles <- true)))
/// The browser calls this when the form is reset: go back to the `checked` attribute.
member this.formResetCallback() = this.``checked`` <- host.hasAttribute "checked"
override this.connectedCallback() =
base.connectedCallback ()
if not (host.hasAttribute "tabindex") then
host.tabIndex <- 0
// After any change, including from code that sets `checked` or `value`.
override this.updated _ = this.Report()
override this.render() =
html $"""<span class="track {if this.``checked`` then "on" else ""}"><span class="thumb"></span></span>"""
defineElement<FormSwitch> "my-form-switch"
How it works
static member formAssociated = truetells the browser the element takes part in forms. With[<AttachMembers>]it becomes a static property of the JavaScript class, where the browser looks for it when the element is defined.this.attachInternals ()gives the element itsElementInternals, the object it reports to the form through. Call it once, in the constructor: the browser throws on a second call.setFormValuesets what the form submits under the element'sname, andnullleaves it out, as for an unticked checkbox.Reportcalls it, andTogglecallsReportbefore it raiseschange, so a listener that reads the form sees the new value.updatedcalls it after every other change too: a reset, or code settingchecked.- The element itself is the control, with no
<button>inside.internals.rolemakes it a switch to assistive technology andariaCheckedreports its state. Form-associated elements are labelable, so<label for>names it, as it would an<input>, and clicking the label clicks it. - A
tabindex, added inconnectedCallback, makes it focusable. It listens for its ownclickandkeydown, added in the constructor: a template's@clickonly reaches elements inside the template, not the host. - The browser calls
formResetCallbackwhen the form is reset. It goes back to thecheckedattribute, which stays the default, as on a native checkbox.
To F#, LitElement isn't an HTMLElement, though it is one in the browser: Fable's
browser bindings declare HTMLElement as an interface, which a class can't inherit. this.element
is the component as an HTMLElement, for the host's own DOM members, such as addEventListener,
hasAttribute or tabIndex. It compiles to this.
The role is set in the constructor, so the prerendered HTML has it too:
Lit SSR copies the ARIA properties set on ElementInternals there into attributes.
What this leaves out
A complete control also handles disabled (formDisabledCallback), reports invalid values with
setValidity, and restores its state when the browser restores the form
(formStateRestoreCallback). MDN's ElementInternals
reference covers each.
Related
- Form validation, for a form and its fields in one component.
- Events: Raise an event. The switch raises a plain
changeevent, as a checkbox does.