Skip to content

Controls: fields, switches, sliders and pickers

A form is made of controls, and every control has two halves: what it draws and what it reports. In tempestweb you declare the core's widget; the renderer picks the native HTML element and the client reports the event your handler declared.

This page is the map of both halves. 🚀

The principle: the browser already knows how

Every core control becomes the element the browser already knows how to operate — with the keyboard, focus, a screen reader and, on a phone, the right keyboard and picker:

Widget Element Event → handler What arrives
Input <div> + <input> input/change → on_change TextChangeEvent(value)
TextArea <textarea> input/change → on_change TextChangeEvent(value)
MaskedInput <input> + mask input/change → on_change TextChangeEvent(value)
PinInput <input> + one-time-code input → on_change, complete TextChangeEvent(value)
Checkbox <label> + <input type=checkbox> change → on_change ToggleEvent(checked)
Switch <label> + checkbox with role=switch change → on_change ToggleEvent(checked)
Slider <input type=range> input/change → on_change SlideEvent(value)
RangeSlider two <input type=range> input/change → on_change RangeChangeEvent(low, high)
Dropdown <select> + <option> change → on_select SelectEvent(value, index)
Autocomplete <input> + <datalist> input → on_change, select → on_select TextChangeEvent / SelectEvent
DatePicker <input type=date> change → on_change DateChangeEvent(value)
TimePicker <input type=time> change → on_change TimeChangeEvent(value)
FilePicker <input type=file> change → on_select FileSelectEvent(uri, name)
TabBar role=tablist + role=tab click → on_change RouteChangeEvent(name, params)

The same in all three modes: the renderer (client/dom.js) is shared by Mode A (WASM), Mode B (server) and Mode C (transpiled).

The event has the widget's shape, not the DOM's

A Switch gets event.checked, a Slider gets event.value, a RangeSlider gets event.low/event.high. You never read payload["value"] by hand — the runtime validates the payload into the typed event the handler declared.

Passwords: the eye comes for free

A password field is an Input with secure=True. You never ask for the show/hide eye — the renderer draws it, and the reveal is local:

from dataclasses import dataclass

from tempest_core import App, Column, Input, Style, Text, Widget


@dataclass
class State:
    """Application state."""

    password: str = ""


def make_state() -> State:
    """Build the initial state.

    Returns:
        A fresh :class:`State`.
    """
    return State()


def view(app: App[State]) -> Widget:
    """Render a password field and a live length readout.

    Args:
        app: The application handle.

    Returns:
        The widget tree for the current state.
    """

    def set_password(event: object) -> None:
        value = getattr(event, "value", "")
        app.set_state(lambda s: setattr(s, "password", value))

    return Column(
        style=Style(gap=8.0),
        children=[
            Input(
                value=app.state.password,
                placeholder="Password",
                secure=True,
                on_change=set_password,
                key="pw",
            ),
            Text(content=f"{len(app.state.password)} characters", key="count"),
        ],
    )

Press the eye and the password shows; press it again and it masks. The counter keeps reporting the same length — because the value never moves.

Why an Input is a <div> wrapping an <input>

An <input> is a void element: it takes no children, so there is nowhere to put the eye. The renderer draws the field box as the keyed element and puts the real control (plus the eye, when secure=True) inside it. That is legal because Input is an IR leaf — no patch path descends into it, and the child belongs to the renderer, not to your tree.

Revealing never talks to Python

The eye flips the control's type in the browser and nothing else. No event crosses the wire, no patch is produced, and in Mode B there is no network round-trip — which also means your state has no password_visible field to keep. It is presentation state, and it lives where it is cheap: in the data-tw-reveal attribute the base sheet reads.

One consequence worth knowing: a Replace patch on the field rebuilds the element, so the password goes back to masked.

The icon is Lucide, not Material

The glyph is eye / eye-off from the core's curated set (Icons.EYE / Icons.EYE_OFF). The Material Symbols names (visibility, visibility_off) resolve to the same glyph through an alias — the alias resolves the name, not the family.

A switch and a slider

from dataclasses import dataclass

from tempest_core import App, Column, SlideEvent, Slider, Switch, Text, ToggleEvent, Widget


@dataclass
class Prefs:
    """The preferences this screen controls.

    Attributes:
        notify: Whether notifications are on.
        volume: Playback volume, in ``[0, 100]``.
    """

    notify: bool = True
    volume: float = 70.0


def make_state() -> Prefs:
    """Build the screen's initial state."""
    return Prefs()


def view(app: App[Prefs]) -> Widget:
    """Draw a switch and a slider bound to the state."""

    def toggle(event: ToggleEvent) -> None:
        app.set_state(lambda prefs: setattr(prefs, "notify", event.checked))

    def slide(event: SlideEvent) -> None:
        app.set_state(lambda prefs: setattr(prefs, "volume", event.value))

    return Column(
        key="prefs",
        children=[
            Switch(key="notify", label="Notifications", checked=app.state.notify, on_change=toggle),
            Slider(
                key="volume",
                value=app.state.volume,
                min_value=0.0,
                max_value=100.0,
                step=5.0,
                on_change=slide,
            ),
            Text(key="reading", content=f"Volume: {app.state.volume:.0f}%"),
        ],
    )

That trailing Text is the fastest smoke test there is: move the slider, and if the text does not change, the event never arrived.

Why a Switch is a <label>

What carries the state is a real <input type=checkbox role=switch> inside the <label>. Space toggles it, Tab reaches it, and a screen reader announces "switch, on" — for free. The label is the element that carries the key; the input inside it belongs to the renderer.

Choosing from a list

Dropdown reports on_select with the value and the index. The placeholder is a disabled leading option, and it does not shift the index:

from tempest_core import App, Dropdown, SelectEvent, Widget

_CABINS: list[str] = ["Economy", "Premium", "Business", "First"]


def view(app: App[Booking]) -> Widget:
    """Draw the cabin choice."""

    def choose(event: SelectEvent) -> None:
        def apply(booking: Booking) -> None:
            booking.cabin = event.value
            booking.cabin_index = event.index

        app.set_state(apply)

    return Dropdown(
        key="cabin",
        options=_CABINS,
        value=app.state.cabin,
        placeholder="Choose a cabin",
        on_select=choose,
    )

Autocomplete is the sibling that takes free text: its options become a <datalist> the browser draws, on_change arrives on every keystroke, and on_select arrives when the field holds exactly one of the offered options.

Date, time and file

The three pickers use the platform's own control — the browser's calendar is better than anything this renderer would draw, and on a phone it is the native picker:

from tempest_core import App, Column, DateChangeEvent, DatePicker, FilePicker, FileSelectEvent, Widget


def view(app: App[Booking]) -> Widget:
    """Draw the departure day and the attachment."""

    def when(event: DateChangeEvent) -> None:
        app.set_state(lambda b: setattr(b, "departure", event.value))

    def attach(event: FileSelectEvent) -> None:
        app.set_state(lambda b: setattr(b, "document", event.name or ""))

    return Column(
        key="trip",
        children=[
            DatePicker(key="departure", label="Departure", value=app.state.departure, on_change=when),
            FilePicker(key="doc", label="Attach ID", value=app.state.document, on_select=attach),
        ],
    )

A FilePicker's value is read-only

No page may assign the value of an <input type=file> — that is a browser protection, not a limitation here. The value you pass is displayed next to the button (the renderer reflects it into an attribute the base sheet prints), and what arrives in on_select is the file's name plus a blob: uri for its bytes.

Tabs: TabBar draws, TabView shows

They are two widgets, and that is on purpose:

from tempest_core import App, Column, RouteChangeEvent, TabBar, TabView, Widget

_TABS: list[str] = ["Overview", "Activity", "Settings"]


def view(app: App[Profile]) -> Widget:
    """Draw the tab strip above the panel."""

    def switch(event: RouteChangeEvent) -> None:
        app.set_state(lambda p: setattr(p, "tab", int(event.params.get("index", 0))))

    return Column(
        key="profile",
        children=[
            TabBar(key="tabs", tabs=_TABS, active=app.state.tab, on_change=switch),
            TabView(key="panel", tabs=_TABS, active=app.state.tab, child=_section(app.state)),
        ],
    )

Technical details — why a TabView does not draw its own tabs

A TabView holds an IR child (the panel), and a patch path addresses a child by index. A renderer-owned tab strip would take index 0 and every later patch would point at the wrong element — that is the contract's rule: a renderer-owned child is only legal inside an IR leaf.

A TabBar is a leaf, so it may draw its own buttons. What the TabView does is tell the truth about the state: role="tabpanel" plus the active tab's name in aria-label. Wire both to the same handler, as above.

The same holds for RouteDrawer: it holds two IR children (content and drawer), so its open becomes the data-tw-open attribute the base sheet uses to slide the drawer over the content. Toggling it is your own button.

Recap

  • Every core control becomes the equivalent native element — keyboard, focus and a11y come from the browser.
  • The handler receives the widget's typed event (checked, value, low/high, value/index), never a raw dict.
  • Dropdown and FilePicker report on_select; the other fields report on_change.
  • Input(secure=True) ships the show/hide eye, revealed locally — no state in your state, and no round-trip to Python.
  • TabBar draws the strip, TabView shows the panel, and both share the handler.
  • A summary Text next to the form is the quickest way to prove the two-way binding works — which is what the Booking form example does.