Cookbook

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

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.