---
title: "A custom form control"
description: "Make a Firelight component a form-associated custom element: a switch that a page's <form> submits like a checkbox, labelled by <label for>, reset with the form, with ElementInternals bound from F#."
---

# 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.

- [MDN: ElementInternals](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
- [web.dev: More capable form controls](https://web.dev/articles/more-capable-form-controls)

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.

```fsharp
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"
```

```html
<form id="switch-form" class="switch-form">
  <label for="switch-updates">Email me product updates</label>
  <my-form-switch id="switch-updates" name="updates"></my-form-switch>
  <button>Submit</button>
  <button type="reset">Reset</button>
</form>
<p id="switch-result" role="status"></p>
<style>
  .switch-form { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
  .switch-form button { font: inherit; padding: 0.3rem 0.9rem; }
</style>
```



## How it works

- `static member formAssociated = true` tells 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 its `ElementInternals`, the object it reports to the
  form through. Call it once, in the constructor: the browser throws on a second call.
- `setFormValue` sets what the form submits under the element's `name`, and `null` leaves it out,
  as for an unticked checkbox. `Report` calls it, and `Toggle` calls `Report` before it raises
  `change`, so a listener that reads the form sees the new value. `updated` calls it after every
  other change too: a reset, or code setting `checked`.
- The element itself is the control, with no `<button>` inside. `internals.role` makes it a
  switch to assistive technology and `ariaChecked` reports 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 in `connectedCallback`, makes it focusable. It listens for its own `click`
  and `keydown`, added in the constructor: a template's `@click` only reaches elements inside the
  template, not the host.
- The browser calls `formResetCallback` when the form is reset. It goes back to the `checked`
  attribute, which stays the default, as on a native checkbox.

To F#, `LitElement` isn't an `HTMLElement`, though it is one in the browser: [Fable](https://fable.io/)'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](https://roboz0r.github.io/Firelight/guides/prerendering/) 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](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
reference covers each.

## Related

- [Form validation](https://roboz0r.github.io/Firelight/cookbook/form-validation/), for a form and its fields in one component.
- [Events: Raise an event](https://roboz0r.github.io/Firelight/guides/events/#raise-an-event). The switch raises a plain `change`
  event, as a checkbox does.
