Skip to content

Widgets

Input widgets are used as the default value of a node parameter; Golit turns that parameter into an input node. Each has an ergonomic lowercase factory (use these) and an underlying class.

See the Inputs & widgets tutorial for usage and commit semantics.

Input widgets.

A widget is used as the default value of a node-function parameter. When Golit introspects the signature, a parameter whose default is a :class:Widget becomes an input node named after the parameter; everything downstream of it re-runs when its committed value changes.

Each widget knows three things: its default value, how to coerce a posted string into the typed Python value, and how to render its HTML control (wired for HTMX POST + an Alpine "local shield" for high-frequency feedback).

Widget

Widget(*, default: Any = None, label: str | None = None)

Base class for input widgets.

Source code in python/golit/widgets.py
def __init__(self, *, default: Any = None, label: str | None = None) -> None:
    self.default = default
    self.label = label
    self.name: str | None = None  # bound to the parameter name at registration

kind class-attribute instance-attribute

kind = 'input'

default instance-attribute

default = default

label instance-attribute

label = label

name instance-attribute

name: str | None = None

bind

bind(name: str) -> None

Attach this widget to a parameter/input id.

Source code in python/golit/widgets.py
def bind(self, name: str) -> None:
    """Attach this widget to a parameter/input id."""
    self.name = name
    if self.label is None:
        self.label = name.replace("_", " ").title()

coerce

coerce(raw: str) -> Any

Parse a posted form value into the typed Python value.

Source code in python/golit/widgets.py
def coerce(self, raw: str) -> Any:
    """Parse a posted form value into the typed Python value."""
    return raw

render

render(value: Any) -> str

Render the HTML control showing value.

Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    """Render the HTML control showing ``value``."""
    raise NotImplementedError

Slider

Slider(
    low: float,
    high: float,
    *,
    default: float | None = None,
    step: float = 1,
    label: str | None = None,
)

Bases: Widget

A numeric range slider. Commits on release (change); Alpine shows the live value during drag without touching the server.

Source code in python/golit/widgets.py
def __init__(
    self,
    low: float,
    high: float,
    *,
    default: float | None = None,
    step: float = 1,
    label: str | None = None,
) -> None:
    super().__init__(default=low if default is None else default, label=label)
    self.low = low
    self.high = high
    self.step = step
    self._int = all(float(x).is_integer() for x in (low, high, step, self.default))

low instance-attribute

low = low

high instance-attribute

high = high

step instance-attribute

step = step

coerce

coerce(raw: str) -> float
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> float:
    return int(float(raw)) if self._int else float(raw)

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    return (
        f'<div class="golit-widget flex flex-col gap-2" x-data="{{ v: {esc(value)} }}">'
        f'<div class="flex items-center justify-between">{self._label_html()}'
        '<output class="font-mono text-sm text-primary bg-primary-fixed px-2.5 py-0.5 '
        f'rounded-full" x-text="v">{esc(value)}</output></div>'
        f'<input type="range" name="value" min="{esc(self.low)}" max="{esc(self.high)}" '
        f'step="{esc(self.step)}" value="{esc(value)}" '
        'class="w-full accent-primary-container cursor-pointer" '
        f'x-on:input="v = $event.target.value" {self._post_attrs()}>'
        "</div>"
    )

RangeSlider

RangeSlider(
    low: float,
    high: float,
    *,
    default: tuple[float, float]
    | list[float]
    | None = None,
    step: float = 1,
    label: str | None = None,
)

Bases: Widget

A dual-handle slider selecting a numeric [low, high] band. Commits on release (change); Alpine drives the live fill, value labels, and overlap handling during the drag without touching the server.

Both handles post comma-joined into the single value, so the one-value POST contract holds; coerce splits and returns a sorted (low, high) tuple of numbers.

Source code in python/golit/widgets.py
def __init__(
    self,
    low: float,
    high: float,
    *,
    default: tuple[float, float] | list[float] | None = None,
    step: float = 1,
    label: str | None = None,
) -> None:
    lo, hi = (low, high) if default is None else default
    super().__init__(default=self._clamp(low, high, lo, hi), label=label)
    self.low = low
    self.high = high
    self.step = step
    self._int = all(
        float(x).is_integer() for x in (low, high, step, *self.default)
    )

low instance-attribute

low = low

high instance-attribute

high = high

step instance-attribute

step = step

coerce

coerce(raw: str) -> tuple[float, float]
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> tuple[float, float]:
    parts = [p for p in raw.split(",") if p.strip() != ""]
    nums = [self._num(p) for p in parts] or [self.low, self.high]
    lo, hi = sorted(nums[:2]) if len(nums) >= 2 else (nums[0], nums[0])
    return self._clamp(self.low, self.high, lo, hi)

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    lo, hi = self._clamp(self.low, self.high, *(value or self.default))
    node = esc(self.name)
    return (
        f"<div class=\"golit-widget flex flex-col gap-2\" x-data='{self._state_js(lo, hi)}' "
        f"hx-post=\"/node/{node}\" hx-trigger=\"change\" hx-swap=\"none\" "
        f"hx-vals='{self._vals_js()}' id=\"golit-{node}\">"
        '<div class="flex items-center justify-between">'
        f"{self._label_html()}"
        "<output class=\"font-mono text-sm text-primary bg-primary-fixed px-2.5 py-0.5 "
        "rounded-full\" x-text=\"lo + ' – ' + hi\">"
        f"{esc(lo)}{esc(hi)}</output></div>"
        '<div class="golit-rangeslider">'
        '<div class="golit-rs-track"></div>'
        '<div class="golit-rs-fill" :style="fill()"></div>'
        f"{self._range_input('lo', lo, top=True)}"
        f"{self._range_input('hi', hi, top=False)}</div></div>"
    )

NumberInput

NumberInput(
    low: float | None = None,
    high: float | None = None,
    *,
    default: float = 0,
    step: float = 1,
    label: str | None = None,
    prefix: str = "",
    suffix: str = "",
    thousands: bool = False,
)

Bases: Widget

A numeric input with optional low/high bounds. prefix/suffix add inline adornments ("$", "%") and thousands=True groups the digits when the value commits — turning it into a currency/percent field. With any of those set the value coerces from the formatted string; otherwise it's a native number spinner.

Source code in python/golit/widgets.py
def __init__(
    self,
    low: float | None = None,
    high: float | None = None,
    *,
    default: float = 0,
    step: float = 1,
    label: str | None = None,
    prefix: str = "",
    suffix: str = "",
    thousands: bool = False,
) -> None:
    super().__init__(default=default, label=label)
    self.low = low
    self.high = high
    self.step = step
    self.prefix = prefix
    self.suffix = suffix
    self.thousands = thousands
    self._int = float(step).is_integer() and float(default).is_integer()

low instance-attribute

low = low

high instance-attribute

high = high

step instance-attribute

step = step

prefix instance-attribute

prefix = prefix

suffix instance-attribute

suffix = suffix

thousands instance-attribute

thousands = thousands

coerce

coerce(raw: str) -> float
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> float:
    # Tolerate a formatted string (separators, currency/percent symbols): keep only
    # the numeric characters before parsing.
    cleaned = "".join(c for c in str(raw) if c in "0123456789.-")
    if cleaned in ("", "-", ".", "-."):
        cleaned = "0"
    return int(float(cleaned)) if self._int else float(cleaned)

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    if not self._formatted:
        return (
            f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
            f'<input type="number" name="value" value="{esc(value)}"{self._bounds()} '
            f'step="{esc(self.step)}" class="{self._INPUT_CLS}" '
            f"{self._post_attrs()}></div>"
        )
    pre, suf, pad = self._adornments()
    if self.thousands:
        return self._render_grouped(value, pre, suf, pad)
    return (
        f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
        f'<div class="relative">{pre}'
        f'<input type="number" name="value" value="{esc(value)}"{self._bounds()} '
        f'step="{esc(self.step)}" class="{self._INPUT_CLS} w-full{pad}" '
        f"{self._post_attrs()}>{suf}</div></div>"
    )

Select

Select(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
)

Bases: Widget

A dropdown of options; the value is the chosen option object.

Source code in python/golit/widgets.py
def __init__(
    self,
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
) -> None:
    super().__init__(default=options[0] if default is None else default, label=label)
    self.options = options

options instance-attribute

options = options

coerce

coerce(raw: str) -> Any
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> Any:
    for opt in self.options:
        if str(opt) == raw:
            return opt
    raise ValueError(f"{raw!r} is not a valid option for {self.name!r}")

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    opts = "".join(
        f'<option value="{esc(o)}"{" selected" if o == value else ""}>{esc(o)}</option>'
        for o in self.options
    )
    return (
        f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
        '<select name="value" class="bg-surface-container-highest border-none rounded-lg '
        'px-3 py-2 text-sm font-body text-on-surface focus:ring-2 focus:ring-primary" '
        f"{self._post_attrs()}>{opts}</select></div>"
    )

ComboBox

ComboBox(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
    placeholder: str = "Select…",
)

Bases: Widget

A searchable single-select: a trigger button plus a filterable popover. Like :class:Select the value is the chosen option object, but the options are searchable — better for long lookup lists (customers, SKUs, accounts). Picking an option commits it and closes the popover.

Selection lives in a hidden field that a request-time hx-vals reads, so the single-value POST contract holds; coerce maps the string back to the option object exactly as :class:Select does.

Source code in python/golit/widgets.py
def __init__(
    self,
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
    placeholder: str = "Select…",
) -> None:
    super().__init__(default=options[0] if default is None else default, label=label)
    self.options = options
    self.placeholder = placeholder

options instance-attribute

options = options

placeholder instance-attribute

placeholder = placeholder

coerce

coerce(raw: str) -> Any
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> Any:
    for opt in self.options:
        if str(opt) == raw:
            return opt
    raise ValueError(f"{raw!r} is not a valid option for {self.name!r}")

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    node = esc(self.name)
    sel_str = str(value) if value is not None else ""
    # The whole x-data (state + the pick() method) is html-escaped, so any option or
    # placeholder content is safe inside the single-quoted attribute; Alpine decodes it
    # before evaluating. pick() writes the choice into the hidden field and fires a
    # bubbling change so htmx posts it.
    x_data = esc(
        '{open:false,q:"",sel:' + json.dumps(sel_str)
        + ",ph:" + json.dumps(self.placeholder) + ","
        "pick(v){this.sel=v;this.open=false;this.$refs.field.value=v;"
        'this.$refs.field.dispatchEvent(new Event("change",{bubbles:true}));}}'
    )
    return (
        f"<div class=\"golit-widget flex flex-col gap-2\" x-data='{x_data}' "
        f"hx-post=\"/node/{node}\" hx-trigger=\"change\" hx-swap=\"none\" "
        f"hx-vals='{self._vals_js()}' id=\"golit-{node}\">{self._label_html()}"
        f"<input type=\"hidden\" x-ref=\"field\" value=\"{esc(sel_str)}\">"
        "<div class=\"relative\" x-on:click.outside=\"open = false\">"
        "<button type=\"button\" x-on:click=\"open = !open; if(open) q = ''\" "
        "class=\"bg-surface-container-highest border-none rounded-lg px-3 py-2 text-sm "
        "font-body text-on-surface focus:ring-2 focus:ring-primary w-full flex items-center "
        "justify-between gap-2 text-left\">"
        "<span class=\"truncate\" :class=\"!sel && 'text-on-surface-variant'\" "
        f"x-text=\"sel || ph\">{esc(self.placeholder)}</span>"
        "<svg class=\"w-4 h-4 shrink-0 text-on-surface-variant transition-transform\" "
        ":class=\"open ? 'rotate-180' : ''\" viewBox=\"0 0 20 20\" fill=\"currentColor\">"
        "<path fill-rule=\"evenodd\" d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.17l3.71-3.94a.75."
        "75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\" "
        "clip-rule=\"evenodd\"/></svg></button>"
        "<div x-show=\"open\" x-cloak x-transition "
        "class=\"absolute z-20 mt-1 w-full bg-surface-container-highest rounded-lg shadow-lg "
        "ring-1 ring-black/5 p-2\">"
        "<input type=\"text\" x-model=\"q\" placeholder=\"Search…\" x-on:change.stop "
        "class=\"bg-surface border-none rounded-md px-2.5 py-1.5 text-sm w-full mb-2 "
        "text-on-surface focus:ring-2 focus:ring-primary\">"
        f"<div class=\"flex flex-col gap-0.5 max-h-48 overflow-y-auto\">"
        f"{self._options_html()}</div>"
        "</div></div></div>"
    )

TextInput

TextInput(
    *,
    default: str = "",
    label: str | None = None,
    placeholder: str = "",
)

Bases: Widget

A single-line text input. Commits on blur or after a short typing pause.

Source code in python/golit/widgets.py
def __init__(
    self,
    *,
    default: str = "",
    label: str | None = None,
    placeholder: str = "",
) -> None:
    super().__init__(default=default, label=label)
    self.placeholder = placeholder

placeholder instance-attribute

placeholder = placeholder

coerce

coerce(raw: str) -> str
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> str:
    return raw

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    return (
        f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
        f'<input type="text" name="value" value="{esc(value)}" '
        f'placeholder="{esc(self.placeholder)}" '
        'class="bg-surface-container-highest border-none rounded-lg px-3 py-2 text-sm '
        'font-body text-on-surface focus:ring-2 focus:ring-primary" '
        f'{self._post_attrs(trigger="change, keyup changed delay:400ms")}></div>'
    )

Checkbox

Checkbox(
    *, default: bool = False, label: str | None = None
)

Bases: Widget

A boolean checkbox. An unchecked box still commits False.

Source code in python/golit/widgets.py
def __init__(self, *, default: bool = False, label: str | None = None) -> None:
    super().__init__(default=default, label=label)

coerce

coerce(raw: str) -> bool
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> bool:
    return str(raw).lower() in ("1", "true", "on", "yes")

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    checked = " checked" if value else ""
    # Post an explicit boolean via hx-vals so an unchecked box still commits.
    return (
        '<div class="golit-widget flex items-center gap-3 py-2">'
        f'<input type="checkbox" name="value"{checked} '
        'class="w-4 h-4 accent-primary-container rounded cursor-pointer" '
        f"hx-vals='js:{{value: event.target.checked}}' {self._post_attrs()}>"
        f"{self._label_html()}</div>"
    )

Upload

Upload(
    label: str | None = None, *, accept: str | None = None
)

Bases: Widget

A file upload. Coerces the posted bytes into a BytesIO that Polars readers (pl.read_csv etc.) accept directly.

Source code in python/golit/widgets.py
def __init__(self, label: str | None = None, *, accept: str | None = None) -> None:
    super().__init__(default=None, label=label)
    self.accept = accept

accept instance-attribute

accept = accept

coerce

coerce(raw: Any) -> BytesIO | None
Source code in python/golit/widgets.py
def coerce(self, raw: Any) -> io.BytesIO | None:
    if raw is None:
        return None
    if isinstance(raw, (bytes, bytearray)):
        return io.BytesIO(bytes(raw))
    if hasattr(raw, "read"):
        return io.BytesIO(raw.read())
    return io.BytesIO(str(raw).encode())

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    accept = f' accept="{esc(self.accept)}"' if self.accept else ""
    return (
        f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
        f'<input type="file" name="value"{accept} hx-encoding="multipart/form-data" '
        'class="text-sm text-on-surface-variant file:mr-3 file:py-2 file:px-4 file:rounded-lg '
        'file:border-0 file:bg-primary file:text-on-primary file:font-semibold '
        f'file:cursor-pointer hover:file:opacity-90" {self._post_attrs()}></div>'
    )

RadioGroup

RadioGroup(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
)

Bases: Widget

A single choice from a set, shown as radio buttons. Each radio posts its own value on change (no shared id), coerced back to the original option object.

Source code in python/golit/widgets.py
def __init__(
    self, options: list[Any], *, default: Any = None, label: str | None = None
) -> None:
    super().__init__(default=options[0] if default is None else default, label=label)
    self.options = options

options instance-attribute

options = options

coerce

coerce(raw: str) -> Any
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> Any:
    for opt in self.options:
        if str(opt) == raw:
            return opt
    raise ValueError(f"{raw!r} is not a valid option for {self.name!r}")

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    post = (
        f'hx-post="/node/{esc(self.name)}" hx-trigger="change" hx-swap="none"'
    )
    items = "".join(
        '<label class="flex items-center gap-2 text-sm cursor-pointer">'
        f'<input type="radio" name="value" value="{esc(o)}"'
        f'{" checked" if o == value else ""} '
        f'class="accent-primary-container w-4 h-4" {post}>'
        f"<span>{esc(o)}</span></label>"
        for o in self.options
    )
    return (
        f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
        f'<div class="flex flex-col gap-1.5">{items}</div></div>'
    )

Segmented

Segmented(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
)

Bases: Widget

A single choice shown as a compact horizontal toggle (a segmented control) — the same value semantics as :class:RadioGroup/:class:Select, but tuned for short option sets like Day/Week/Month. The chosen option highlights white-on-blue and commits on click.

Selection is held in a hidden field that a request-time hx-vals reads, so the single-value POST contract holds; coerce maps the string back to the option object.

Source code in python/golit/widgets.py
def __init__(
    self, options: list[Any], *, default: Any = None, label: str | None = None
) -> None:
    super().__init__(default=options[0] if default is None else default, label=label)
    self.options = options

options instance-attribute

options = options

coerce

coerce(raw: str) -> Any
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> Any:
    for opt in self.options:
        if str(opt) == raw:
            return opt
    raise ValueError(f"{raw!r} is not a valid option for {self.name!r}")

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    node = esc(self.name)
    sel_str = str(value) if value is not None else ""
    # The whole Alpine state (sel + the pick() method) is html-escaped into one
    # attribute, so option content can't break out; Alpine decodes it before
    # evaluating. pick() writes the choice to the hidden field and fires a bubbling
    # change so htmx posts it. Each segment reads its own value from data-val, so no
    # option text leaks into a JS expression.
    x_data = esc(
        "{sel:" + json.dumps(sel_str) + ","
        "pick(v){this.sel=v;this.$refs.field.value=v;"
        'this.$refs.field.dispatchEvent(new Event("change",{bubbles:true}));}}'
    )
    segments = "".join(
        f"<button type=\"button\" data-val=\"{esc(o)}\" "
        "x-on:click=\"pick($el.dataset.val)\" "
        ":class=\"sel === $el.dataset.val ? 'bg-primary text-on-primary shadow-sm' "
        ": 'text-on-surface-variant hover:text-on-surface'\" "
        "class=\"flex-1 text-sm font-medium rounded-md px-3 py-1.5 transition-colors "
        f"cursor-pointer whitespace-nowrap\">{esc(o)}</button>"
        for o in self.options
    )
    return (
        f"<div class=\"golit-widget flex flex-col gap-2\" x-data='{x_data}' "
        f"hx-post=\"/node/{node}\" hx-trigger=\"change\" hx-swap=\"none\" "
        f"hx-vals='{self._vals_js()}' id=\"golit-{node}\">{self._label_html()}"
        f"<input type=\"hidden\" x-ref=\"field\" value=\"{esc(sel_str)}\">"
        "<div class=\"flex bg-surface-container-highest rounded-lg p-1 gap-1\" "
        f"role=\"group\">{segments}</div></div>"
    )

MultiSelect

MultiSelect(
    options: list[Any],
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    display: str = "list",
)

Bases: Widget

Zero or more choices. The default display="list" is a flat checkbox column; display="dropdown" collapses the options into a searchable popover (an Alpine-powered trigger plus a filterable panel) that suits long lists.

Either way a request-time hx-vals script reads the checked boxes and posts them comma-joined, so the single-value POST contract is preserved; coerce splits and maps back to option objects.

Source code in python/golit/widgets.py
def __init__(
    self,
    options: list[Any],
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    display: str = "list",
) -> None:
    super().__init__(default=list(default), label=label)
    self.options = options
    if display not in ("list", "dropdown"):
        raise ValueError(f"display must be 'list' or 'dropdown', not {display!r}")
    self.display = display

options instance-attribute

options = options

display instance-attribute

display = display

coerce

coerce(raw: str) -> list[Any]
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> list[Any]:
    chosen = set(raw.split(",")) if raw else set()
    return [opt for opt in self.options if str(opt) in chosen]

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    selected = {str(v) for v in (value or [])}
    if self.display == "dropdown":
        return self._render_dropdown(selected)
    return self._render_list(selected)

Tags

Tags(
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    placeholder: str = "Add tag…",
)

Bases: Widget

A free-form token input: the user types arbitrary values and each becomes a removable chip. Unlike :class:MultiSelect the values aren't from a fixed list — think recipients, keywords, or ad-hoc filter terms. The value is a list[str].

Enter or comma commits the draft as a chip, Backspace on an empty field removes the last one, and the × removes any chip. The chips are comma-joined into the single value (so a chip can't itself contain a comma); coerce splits and trims them back into a list.

Source code in python/golit/widgets.py
def __init__(
    self,
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    placeholder: str = "Add tag…",
) -> None:
    super().__init__(default=[str(t) for t in default], label=label)
    self.placeholder = placeholder

placeholder instance-attribute

placeholder = placeholder

coerce

coerce(raw: str) -> list[str]
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> list[str]:
    return [t.strip() for t in raw.split(",") if t.strip()]

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    node = esc(self.name)
    tags = [str(t) for t in (value or [])]
    # The whole Alpine state (chips array + the add/remove/commit/key methods) is
    # html-escaped into one attribute, so any tag content is safe; Alpine decodes it
    # before evaluating. commit() writes the comma-joined chips into the hidden field
    # and fires a bubbling change so htmx posts the list.
    x_data = esc(
        "{tags:" + json.dumps(tags) + ',draft:"",'
        "add(){const v=this.draft.trim();"
        "if(v&&this.tags.indexOf(v)===-1){this.tags.push(v);this.commit();}this.draft=\"\";},"
        "remove(i){this.tags.splice(i,1);this.commit();},"
        "commit(){this.$refs.field.value=this.tags.join(\",\");"
        "this.$refs.field.dispatchEvent(new Event(\"change\",{bubbles:true}));},"
        "key(e){if(e.key===\"Enter\"||e.key===\",\"){e.preventDefault();this.add();}"
        "else if(e.key===\"Backspace\"&&!this.draft&&this.tags.length){"
        "this.remove(this.tags.length-1);}}}"
    )
    return (
        f"<div class=\"golit-widget flex flex-col gap-2\" x-data='{x_data}' "
        f"hx-post=\"/node/{node}\" hx-trigger=\"change\" hx-swap=\"none\" "
        f"hx-vals='{self._vals_js()}' id=\"golit-{node}\">{self._label_html()}"
        f"<input type=\"hidden\" x-ref=\"field\" value=\"{esc(','.join(tags))}\">"
        "<div class=\"bg-surface-container-highest rounded-lg px-2 py-1.5 flex flex-wrap "
        "items-center gap-1.5 focus-within:ring-2 focus-within:ring-primary\">"
        "<template x-for=\"(t, i) in tags\" :key=\"i\">"
        "<span class=\"inline-flex items-center gap-1 bg-primary text-on-primary "
        "text-xs rounded-md pl-2 pr-1 py-0.5\"><span x-text=\"t\"></span>"
        "<button type=\"button\" x-on:click=\"remove(i)\" "
        "class=\"text-on-primary/70 hover:text-on-primary leading-none text-sm\">"
        "&times;</button></span></template>"
        f"<input type=\"text\" x-model=\"draft\" placeholder=\"{esc(self.placeholder)}\" "
        "x-on:keydown=\"key($event)\" x-on:blur=\"add()\" x-on:change.stop "
        "class=\"flex-1 min-w-[6rem] bg-transparent border-none text-sm text-on-surface "
        "px-1 py-0.5 focus:ring-0 focus:outline-none\"></div></div>"
    )

Switch

Switch(label: str | None = None, *, default: bool = False)

Bases: Widget

A boolean toggle (styled checkbox). Posts true/false via hx-vals so an off-state still commits.

Source code in python/golit/widgets.py
def __init__(self, label: str | None = None, *, default: bool = False) -> None:
    super().__init__(default=default, label=label)

coerce

coerce(raw: str) -> bool
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> bool:
    return str(raw).lower() in ("1", "true", "on", "yes")

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    checked = " checked" if value else ""
    return (
        '<div class="golit-widget flex items-center justify-between gap-3 py-1">'
        f"{self._label_html()}"
        '<label class="relative inline-flex items-center cursor-pointer">'
        f'<input type="checkbox"{checked} class="sr-only peer" '
        f"hx-vals='js:{{value: event.target.checked}}' {self._post_attrs()}>"
        '<div class="w-10 h-6 bg-surface-container-highest rounded-full '
        'peer-checked:bg-primary-container transition-colors"></div>'
        '<div class="absolute left-1 w-4 h-4 bg-white rounded-full shadow transition-transform '
        'peer-checked:translate-x-4"></div></label></div>'
    )

DateInput

DateInput(
    *, default: date | None = None, label: str | None = None
)

Bases: Widget

A native date picker. Coerces the ISO string to datetime.date (or None).

Source code in python/golit/widgets.py
def __init__(
    self, *, default: datetime.date | None = None, label: str | None = None
) -> None:
    super().__init__(default=default, label=label)

coerce

coerce(raw: str) -> date | None
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> datetime.date | None:
    return datetime.date.fromisoformat(raw) if raw else None

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    if isinstance(value, datetime.date):
        iso = value.isoformat()
    else:
        iso = str(value) if value else ""
    return (
        f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
        f'<input type="date" name="value" value="{esc(iso)}" '
        'class="bg-surface-container-highest border-none rounded-lg px-3 py-2 text-sm '
        'font-body text-on-surface focus:ring-2 focus:ring-primary" '
        f"{self._post_attrs()}></div>"
    )

DateRange

DateRange(
    *,
    default: tuple[date | None, date | None]
    | list[date | None]
    | None = None,
    label: str | None = None,
    low: date | None = None,
    high: date | None = None,
    presets: bool = True,
)

Bases: Widget

A start/end date range — the period filter every dashboard needs. Two native date pickers plus quick presets (7/30/90 days, MTD/QTD/YTD) computed client-side.

Both edges post comma-joined into the single value (e.g. "2026-04-01, 2026-06-30"), so the one-value POST contract holds; coerce splits them back into a (start, end) tuple of datetime.date (either side may be None when left blank).

Source code in python/golit/widgets.py
def __init__(
    self,
    *,
    default: tuple[datetime.date | None, datetime.date | None]
    | list[datetime.date | None]
    | None = None,
    label: str | None = None,
    low: datetime.date | None = None,
    high: datetime.date | None = None,
    presets: bool = True,
) -> None:
    super().__init__(default=self._normalize(default), label=label)
    self.low = low
    self.high = high
    self.presets = presets

low instance-attribute

low = low

high instance-attribute

high = high

presets instance-attribute

presets = presets

coerce

coerce(raw: str) -> tuple[date | None, date | None]
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> tuple[datetime.date | None, datetime.date | None]:
    lo, _, hi = raw.partition(",")
    return (self._parse(lo), self._parse(hi))

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    lo, hi = self._normalize(value)
    node = esc(self.name)
    return (
        f"<div class=\"golit-widget flex flex-col gap-2\" x-data='{_RANGE_JS}' "
        f"hx-post=\"/node/{node}\" hx-trigger=\"change\" hx-swap=\"none\" "
        f"hx-vals='{self._vals_js()}' id=\"golit-{node}\">{self._label_html()}"
        '<div class="flex items-center gap-2">'
        f"{self._input('lo', lo)}"
        '<span class="text-on-surface-variant text-sm shrink-0">→</span>'
        f"{self._input('hi', hi)}</div>"
        f"{self._presets_html()}</div>"
    )

TextArea

TextArea(
    *,
    default: str = "",
    label: str | None = None,
    placeholder: str = "",
    rows: int = 4,
)

Bases: Widget

A multi-line text input. Commits on blur or after a short typing pause.

Source code in python/golit/widgets.py
def __init__(
    self,
    *,
    default: str = "",
    label: str | None = None,
    placeholder: str = "",
    rows: int = 4,
) -> None:
    super().__init__(default=default, label=label)
    self.placeholder = placeholder
    self.rows = rows

placeholder instance-attribute

placeholder = placeholder

rows instance-attribute

rows = rows

coerce

coerce(raw: str) -> str
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> str:
    return raw

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    return (
        f'<div class="golit-widget flex flex-col gap-2">{self._label_html()}'
        f'<textarea name="value" rows="{esc(self.rows)}" '
        f'placeholder="{esc(self.placeholder)}" '
        'class="bg-surface-container-highest border-none rounded-lg px-3 py-2 text-sm '
        'font-body text-on-surface focus:ring-2 focus:ring-primary resize-y" '
        f'{self._post_attrs(trigger="change, keyup changed delay:400ms")}>'
        f"{esc(value)}</textarea></div>"
    )

Button

Button(label: str | None = None, *, kind: str = 'primary')

Bases: Widget

An action trigger. Each click posts a fresh nonce (Date.now()), so the input's value changes and the dirty subgraph re-runs — the reactive equivalent of "on click". The value itself is a monotonic counter a node can ignore.

Source code in python/golit/widgets.py
def __init__(self, label: str | None = None, *, kind: str = "primary") -> None:
    super().__init__(default=0, label=label)
    self.style = kind

style instance-attribute

style = kind

coerce

coerce(raw: str) -> int
Source code in python/golit/widgets.py
def coerce(self, raw: str) -> int:
    try:
        return int(float(raw))
    except (TypeError, ValueError):
        return 0

render

render(value: Any) -> str
Source code in python/golit/widgets.py
def render(self, value: Any) -> str:
    cls = self._STYLES.get(self.style, self._STYLES["primary"])
    return (
        '<div class="golit-widget flex flex-col gap-2 justify-end">'
        f'<button type="button" class="{cls} rounded-lg px-4 py-2 text-sm font-semibold '
        'transition-all w-full" '
        f"hx-vals='js:{{value: Date.now()}}' {self._post_attrs(trigger='click')}>"
        f"{esc(self.label)}</button></div>"
    )

esc

esc(value: Any) -> str

HTML-escape a value for safe interpolation into markup/attributes.

Source code in python/golit/widgets.py
def esc(value: Any) -> str:
    """HTML-escape a value for safe interpolation into markup/attributes."""
    return html.escape(str(value), quote=True)

slider

slider(
    low: float,
    high: float,
    *,
    default: float | None = None,
    step: float = 1,
    label: str | None = None,
) -> Slider

A numeric range slider. Commits on release; live drag feedback is client-side.

Source code in python/golit/widgets.py
def slider(
    low: float,
    high: float,
    *,
    default: float | None = None,
    step: float = 1,
    label: str | None = None,
) -> Slider:
    """A numeric range slider. Commits on release; live drag feedback is client-side."""
    return Slider(low, high, default=default, step=step, label=label)

rangeslider

rangeslider(
    low: float,
    high: float,
    *,
    default: tuple[float, float]
    | list[float]
    | None = None,
    step: float = 1,
    label: str | None = None,
) -> RangeSlider

A dual-handle slider selecting a [low, high] band; the value is a sorted (low, high) tuple. Commits on release; live drag feedback is client-side.

Source code in python/golit/widgets.py
def rangeslider(
    low: float,
    high: float,
    *,
    default: tuple[float, float] | list[float] | None = None,
    step: float = 1,
    label: str | None = None,
) -> RangeSlider:
    """A dual-handle slider selecting a ``[low, high]`` band; the value is a sorted
    ``(low, high)`` tuple. Commits on release; live drag feedback is client-side."""
    return RangeSlider(low, high, default=default, step=step, label=label)

number

number(
    low: float | None = None,
    high: float | None = None,
    *,
    default: float = 0,
    step: float = 1,
    label: str | None = None,
    prefix: str = "",
    suffix: str = "",
    thousands: bool = False,
) -> NumberInput

A numeric input with optional low/high bounds. prefix/suffix add adornments ("$", "%") and thousands=True groups the digits on commit — together a currency/percent field.

Source code in python/golit/widgets.py
def number(
    low: float | None = None,
    high: float | None = None,
    *,
    default: float = 0,
    step: float = 1,
    label: str | None = None,
    prefix: str = "",
    suffix: str = "",
    thousands: bool = False,
) -> NumberInput:
    """A numeric input with optional ``low``/``high`` bounds. ``prefix``/``suffix`` add
    adornments (``"$"``, ``"%"``) and ``thousands=True`` groups the digits on commit —
    together a currency/percent field."""
    return NumberInput(
        low,
        high,
        default=default,
        step=step,
        label=label,
        prefix=prefix,
        suffix=suffix,
        thousands=thousands,
    )

select

select(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
) -> Select

A dropdown of options; the value is the chosen option object.

Source code in python/golit/widgets.py
def select(options: list[Any], *, default: Any = None, label: str | None = None) -> Select:
    """A dropdown of ``options``; the value is the chosen option object."""
    return Select(options, default=default, label=label)

combobox

combobox(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
    placeholder: str = "Select…",
) -> ComboBox

A searchable single-select; the value is the chosen option object. Like select but with a filterable popover — better for long lookup lists.

Source code in python/golit/widgets.py
def combobox(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
    placeholder: str = "Select…",
) -> ComboBox:
    """A searchable single-select; the value is the chosen option object. Like
    ``select`` but with a filterable popover — better for long lookup lists."""
    return ComboBox(options, default=default, label=label, placeholder=placeholder)

text

text(
    *,
    default: str = "",
    label: str | None = None,
    placeholder: str = "",
) -> TextInput

A single-line text input. Commits on blur or after a short typing pause.

Source code in python/golit/widgets.py
def text(*, default: str = "", label: str | None = None, placeholder: str = "") -> TextInput:
    """A single-line text input. Commits on blur or after a short typing pause."""
    return TextInput(default=default, label=label, placeholder=placeholder)

checkbox

checkbox(
    *, default: bool = False, label: str | None = None
) -> Checkbox

A boolean checkbox. An unchecked box still commits False.

Source code in python/golit/widgets.py
def checkbox(*, default: bool = False, label: str | None = None) -> Checkbox:
    """A boolean checkbox. An unchecked box still commits ``False``."""
    return Checkbox(default=default, label=label)

upload

upload(
    label: str | None = None, *, accept: str | None = None
) -> Upload

A file upload, coerced to a BytesIO Polars readers accept. None until a file is chosen; accept filters the picker (e.g. ".csv").

Source code in python/golit/widgets.py
def upload(label: str | None = None, *, accept: str | None = None) -> Upload:
    """A file upload, coerced to a ``BytesIO`` Polars readers accept. ``None`` until
    a file is chosen; ``accept`` filters the picker (e.g. ``".csv"``)."""
    return Upload(label, accept=accept)

radio

radio(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
) -> RadioGroup

A single choice from options, shown as radio buttons.

Source code in python/golit/widgets.py
def radio(options: list[Any], *, default: Any = None, label: str | None = None) -> RadioGroup:
    """A single choice from ``options``, shown as radio buttons."""
    return RadioGroup(options, default=default, label=label)

segmented

segmented(
    options: list[Any],
    *,
    default: Any = None,
    label: str | None = None,
) -> Segmented

A single choice as a compact horizontal toggle (segmented control); the value is the chosen option object. Best for short sets like Day/Week/Month.

Source code in python/golit/widgets.py
def segmented(
    options: list[Any], *, default: Any = None, label: str | None = None
) -> Segmented:
    """A single choice as a compact horizontal toggle (segmented control); the value
    is the chosen option object. Best for short sets like Day/Week/Month."""
    return Segmented(options, default=default, label=label)

multiselect

multiselect(
    options: list[Any],
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    display: str = "list",
) -> MultiSelect

Zero or more of options; the value is a list. display="list" (default) is a checkbox column; display="dropdown" is a searchable popover.

Source code in python/golit/widgets.py
def multiselect(
    options: list[Any],
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    display: str = "list",
) -> MultiSelect:
    """Zero or more of ``options``; the value is a ``list``. ``display="list"``
    (default) is a checkbox column; ``display="dropdown"`` is a searchable popover."""
    return MultiSelect(options, default=default, label=label, display=display)

tags

tags(
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    placeholder: str = "Add tag…",
) -> Tags

A free-form token input; the value is a list[str] of the chips. Enter or comma adds a chip, Backspace on an empty field removes the last, × removes any.

Source code in python/golit/widgets.py
def tags(
    *,
    default: list[Any] | tuple[Any, ...] = (),
    label: str | None = None,
    placeholder: str = "Add tag…",
) -> Tags:
    """A free-form token input; the value is a ``list[str]`` of the chips. Enter or
    comma adds a chip, Backspace on an empty field removes the last, ``×`` removes any."""
    return Tags(default=default, label=label, placeholder=placeholder)

switch

switch(
    label: str | None = None, *, default: bool = False
) -> Switch

A boolean toggle (styled checkbox). An off state still commits False.

Source code in python/golit/widgets.py
def switch(label: str | None = None, *, default: bool = False) -> Switch:
    """A boolean toggle (styled checkbox). An off state still commits ``False``."""
    return Switch(label, default=default)

date

date(
    *, default: date | None = None, label: str | None = None
) -> DateInput

A native date picker; the value is a datetime.date (or None).

Source code in python/golit/widgets.py
def date(*, default: datetime.date | None = None, label: str | None = None) -> DateInput:
    """A native date picker; the value is a ``datetime.date`` (or ``None``)."""
    return DateInput(default=default, label=label)

daterange

daterange(
    *,
    default: tuple[date | None, date | None]
    | list[date | None]
    | None = None,
    label: str | None = None,
    low: date | None = None,
    high: date | None = None,
    presets: bool = True,
) -> DateRange

A start/end date range with quick presets; the value is a (start, end) tuple of datetime.date (either side may be None). low/high bound the pickers; presets=False hides the quick chips.

Source code in python/golit/widgets.py
def daterange(
    *,
    default: tuple[datetime.date | None, datetime.date | None]
    | list[datetime.date | None]
    | None = None,
    label: str | None = None,
    low: datetime.date | None = None,
    high: datetime.date | None = None,
    presets: bool = True,
) -> DateRange:
    """A start/end date range with quick presets; the value is a
    ``(start, end)`` tuple of ``datetime.date`` (either side may be ``None``).
    ``low``/``high`` bound the pickers; ``presets=False`` hides the quick chips."""
    return DateRange(default=default, label=label, low=low, high=high, presets=presets)

textarea

textarea(
    *,
    default: str = "",
    label: str | None = None,
    placeholder: str = "",
    rows: int = 4,
) -> TextArea

A multi-line text input. Commits on blur or after a short typing pause.

Source code in python/golit/widgets.py
def textarea(
    *,
    default: str = "",
    label: str | None = None,
    placeholder: str = "",
    rows: int = 4,
) -> TextArea:
    """A multi-line text input. Commits on blur or after a short typing pause."""
    return TextArea(default=default, label=label, placeholder=placeholder, rows=rows)

button

button(
    label: str | None = None, *, kind: str = "primary"
) -> Button

An action trigger — the reactive "on click". Each click posts a fresh nonce so the dirty subgraph re-runs. kind is primary/secondary/ghost.

Source code in python/golit/widgets.py
def button(label: str | None = None, *, kind: str = "primary") -> Button:
    """An action trigger — the reactive "on click". Each click posts a fresh nonce
    so the dirty subgraph re-runs. ``kind`` is primary/secondary/ghost."""
    return Button(label, kind=kind)