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".
- One Tab stop. Only the selected tab has
tabindex="0"; the others have-1. Tab moves into the strip and on to the panel, and the arrow keys move along the strip. This is the keyboard behaviour the ARIA pattern describes. - Arrow keys. One
@keydownon the tablist handles them all.OnKeypicks the next tab, selects it, and focuses it once the update has rendered, by awaitingupdateComplete. It callspreventDefaultonly for keys it handles, so the page doesn't scroll on Home and End. - Keeping the panels.
matchpicks a different template for each tab. Lit would normally throw away the old panel's DOM and build the new one, losing what was typed.cachekeeps the DOM of each template it has shown, and puts it back when that template returns. Each panel must be its own template for that, asgeneral,notesandaboutare.
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.
Related
- Templates: Directives, for
cacheand the others. - Lifecycle: Reach the DOM after it renders, for focusing after an update.