Cookbook

Tabs

Build tabs that screen readers announce as tabs, that the arrow keys move between, and whose panels keep what was typed into them.

Type a note in the Notes tab, switch to About, then back: the note is still there. With the focus on a tab, the arrow keys, Home and End move between tabs.

module Snippets.SettingsTabs

open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

let private tabs = [ "general", "General"; "notes", "Notes"; "about", "About" ]

// Each panel is its own template, so `cache` can keep each one's DOM.
let private general () =
    html $"""<label><input type="checkbox"> Send me a weekly summary</label>"""

let private notes () =
    html $"""<label for="notes">Notes</label><textarea id="notes" rows="3"></textarea>"""

let private about () =
    html $"""<p>Settings, version 1.0.</p>"""

[<AttachMembers>]
type SettingsTabs() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "selected", PropertyDeclaration<string>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: block; width: 20rem; max-width: 100%; }
        [role="tablist"] { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--border); }
        [role="tab"] { font: inherit; padding: 0.4rem 0.9rem; border: 1px solid transparent; border-bottom: 0;
                       border-radius: 0.5rem 0.5rem 0 0; background: none; color: inherit; cursor: pointer; }
        [role="tab"][aria-selected="true"] { border-color: var(--border); background: var(--bg); margin-bottom: -1px; }
        [role="tabpanel"] { display: grid; gap: 0.5rem; padding: 1rem; border: 1px solid var(--border);
                            border-top: 0; background: var(--bg); }
        textarea { font: inherit; }
        p { margin: 0; }
        """

    member val selected = "general" with get, set

    member this.Select(id: string, moveFocus: bool) =
        this.selected <- id

        if moveFocus then
            promise {
                let! _ = this.updateComplete
                this.query<HTMLElement> ("#tab-" + id) |> Option.iter _.focus()
            }
            |> Promise.start

    /// Arrow keys, Home and End move between the tabs, as in a native tab strip.
    member this.OnKey(e: KeyboardEvent) =
        let ids = tabs |> List.map fst
        let i = ids |> List.findIndex ((=) this.selected)

        let next =
            match e.key with
            | "ArrowRight" -> Some ids[(i + 1) % ids.Length]
            | "ArrowLeft" -> Some ids[(i + ids.Length - 1) % ids.Length]
            | "Home" -> Some(List.head ids)
            | "End" -> Some(List.last ids)
            | _ -> None

        next
        |> Option.iter (fun id ->
            e.preventDefault ()
            this.Select(id, true)
        )

    member this.Tab(id: string, label: string) =
        let selected = id = this.selected

        // Only the selected tab is in the Tab order; the arrow keys reach the others.
        html
            $"""
        <button role="tab" id="tab-{id}" aria-selected={selected} aria-controls="panel"
            tabindex={if selected then 0 else -1} @click={fun _ -> this.Select(id, false)}>{label}</button>"""

    override this.render() =
        let panel =
            match this.selected with
            | "notes" -> notes ()
            | "about" -> about ()
            | _ -> general ()

        html
            $"""
        <div role="tablist" aria-label="Settings" @keydown={Ev.keyboard this.OnKey}>
            {tabs |> List.map this.Tab}
        </div>
        <div role="tabpanel" id="panel" aria-labelledby="tab-{this.selected}" tabindex="0">{cache panel}</div>"""

defineElement<SettingsTabs> "my-settings-tabs"

How it works

The roles do the announcing. role="tablist" holds role="tab" buttons, each with aria-selected, and role="tabpanel" holds the selected tab's content, named by that tab through aria-labelledby. A screen reader then says "General, tab, selected, 1 of 3".

cache keeps the DOM, not your data. Anything that matters after the user leaves the page belongs in state, as in Remember state across visits.

Selecting a tab on arrow keys, rather than moving the focus and waiting for Enter, suits panels that render at once. For panels that load data, the ARIA pattern suggests manual activation instead.