Start a project

Artelier UI Kit · Rust · Inputs

Inputs

Things that take a value. Every field is a well.

AdsField

We never share it.

rust
AdsField {
    label: "Email",
    field_type: FieldType::Email,
    value: email(),
    placeholder: "you@studio.com",
    hint: "We never share it.",
    oninput: move |e: FormEvent| email.set(e.value()),
}
Source · ads/ads-rs/src/ui/field.rs
rust
//! Text field, textarea and select, and the date range.

use dioxus::prelude::*;

use super::group::SegOption;
use crate::icons::Icon;

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
    #[default]
    Text,
    Email,
    Password,
    Tel,
    Number,
    Search,
    Date,
    Textarea,
    Select,
}

impl FieldType {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Text => "text",
            Self::Email => "email",
            Self::Password => "password",
            Self::Tel => "tel",
            Self::Number => "number",
            Self::Search => "search",
            Self::Date => "date",
            Self::Textarea => "textarea",
            Self::Select => "select",
        }
    }
}

/// A labelled field: input, textarea or select, with hint and error.
/// Inputs are wells — the card shape pressed into the page. The label is
/// required and always visible: a placeholder disappears exactly when it is
/// needed, and fails hardest for screen readers. The error replaces the hint
/// rather than sitting beside it, and carries a glyph so it is not read by
/// colour alone. Figures — numbers, dates, phone numbers — are set in the
/// data face. A select's `options` are label/value pairs.
#[component]
pub fn AdsField(
    #[props(into)] label: String,
    #[props(into, optional)] value: Option<String>,
    #[props(default)] field_type: FieldType,
    #[props(into, optional)] id: Option<String>,
    #[props(into, optional)] name: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] hint: Option<String>,
    #[props(into, optional)] error: Option<String>,
    #[props(into, optional)] options: Option<Vec<SegOption>>,
    #[props(default = 4u32)] rows: u32,
    #[props(into, optional)] autocomplete: Option<String>,
    #[props(optional)] required: bool,
    #[props(optional)] disabled: bool,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-field");
    let field_id = id.filter(|i| !i.is_empty()).unwrap_or(uid);
    let msg_id = format!("{field_id}-msg");
    let error = error.filter(|e| !e.is_empty());
    let hint = hint.filter(|h| !h.is_empty());
    let described_by = (error.is_some() || hint.is_some()).then(|| msg_id.clone());
    let invalid = error.is_some().then_some("true");
    let numeric = matches!(field_type, FieldType::Number | FieldType::Date | FieldType::Tel);
    let is_invalid = if error.is_some() { "is-invalid" } else { "" };

    let control = match field_type {
        FieldType::Textarea => rsx! {
            textarea {
                id: "{field_id}",
                name: name,
                class: crate::class::join(&["ads-field", is_invalid]),
                value: value,
                placeholder: placeholder,
                rows: "{rows}",
                required: required,
                disabled: disabled,
                "aria-invalid": invalid,
                "aria-describedby": described_by,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
        },
        FieldType::Select => {
            let current = value.unwrap_or_default();
            rsx! {
                select {
                    id: "{field_id}",
                    name: name,
                    class: crate::class::join(&["ads-field", is_invalid]),
                    required: required,
                    disabled: disabled,
                    "aria-invalid": invalid,
                    "aria-describedby": described_by,
                    onchange: move |e| { if let Some(h) = &oninput { h.call(e); } },
                    if let Some(p) = placeholder.filter(|p| !p.is_empty()) {
                        option { value: "", disabled: true, selected: current.is_empty(), "{p}" }
                    }
                    for o in options.unwrap_or_default() {
                        option { key: "{o.value}", value: "{o.value}", selected: o.value == current, "{o.label}" }
                    }
                }
            }
        }
        _ => rsx! {
            input {
                id: "{field_id}",
                name: name,
                r#type: field_type.as_str(),
                class: crate::class::join(&[
                    "ads-field ads-field__input",
                    is_invalid,
                    if numeric { "is-numeric" } else { "" },
                    if field_type == FieldType::Number { "is-number" } else { "" },
                ]),
                value: value,
                placeholder: placeholder,
                required: required,
                disabled: disabled,
                autocomplete: autocomplete,
                "aria-invalid": invalid,
                "aria-describedby": described_by,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
        },
    };

    rsx! {
        div { class: "ads-field-group",
            label { r#for: "{field_id}", class: "ads-label", "{label}" }
            {control}
            if let Some(err) = &error {
                p { id: "{msg_id}", class: "ads-hint ads-field__error",
                    Icon { name: "alert", size: 13, class: "ads-field__error-glyph" }
                    "{err}"
                }
            } else if let Some(h) = &hint {
                p { id: "{msg_id}", class: "ads-hint ads-field__hint", "{h}" }
            }
        }
    }
}

fn default_presets() -> Vec<String> {
    ["7d", "30d", "90d", "Custom"].into_iter().map(String::from).collect()
}

/// The most-used control on any dashboard. ISO dates in the data face so
/// ranges sort and compare visually, presets beside them because most readers
/// want "last 30 days", not a calendar. The whole control is one named group,
/// so a screen reader hears what the dates and presets belong to.
#[component]
pub fn AdsDaterange(
    #[props(into, default = "2026-09-25".to_string())] from: String,
    #[props(into, default = "2026-10-25".to_string())] to: String,
    #[props(into, optional)] presets: Option<Vec<String>>,
    #[props(into, default = "30d".to_string())] selected: String,
    #[props(into, default = "Date range".to_string())] label: String,
    #[props(optional)] onselect: Option<EventHandler<String>>,
) -> Element {
    let presets = presets.unwrap_or_else(default_presets);
    rsx! {
        div { class: "stack gap-2", role: "group", "aria-label": "{label}",
            div { class: "ads-daterange",
                Icon { name: "compass", size: 15 }
                time { class: "num", datetime: "{from}", "{from}" }
                span { class: "sep", "aria-hidden": "true", "→" }
                span { class: "sr-only", "to" }
                time { class: "num", datetime: "{to}", "{to}" }
            }
            if !presets.is_empty() {
                div { class: "ads-segmented", role: "group", "aria-label": "Presets",
                    for p in presets.iter().cloned() {
                        button {
                            key: "{p}",
                            r#type: "button",
                            class: if p == selected { "num is-on" } else { "num" },
                            "aria-pressed": if p == selected { "true" } else { "false" },
                            onclick: {
                                let p = p.clone();
                                move |_| { if let Some(h) = &onselect { h.call(p.clone()); } }
                            },
                            "{p}"
                        }
                    }
                }
            }
        }
    }
}

AdsDaterange

to
rust
AdsDaterange {
    from: "2026-09-25",
    to: "2026-10-25",
    selected: preset(),
    onselect: move |p| preset.set(p),
}
Source · ads/ads-rs/src/ui/field.rs
rust
//! Text field, textarea and select, and the date range.

use dioxus::prelude::*;

use super::group::SegOption;
use crate::icons::Icon;

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum FieldType {
    #[default]
    Text,
    Email,
    Password,
    Tel,
    Number,
    Search,
    Date,
    Textarea,
    Select,
}

impl FieldType {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Text => "text",
            Self::Email => "email",
            Self::Password => "password",
            Self::Tel => "tel",
            Self::Number => "number",
            Self::Search => "search",
            Self::Date => "date",
            Self::Textarea => "textarea",
            Self::Select => "select",
        }
    }
}

/// A labelled field: input, textarea or select, with hint and error.
/// Inputs are wells — the card shape pressed into the page. The label is
/// required and always visible: a placeholder disappears exactly when it is
/// needed, and fails hardest for screen readers. The error replaces the hint
/// rather than sitting beside it, and carries a glyph so it is not read by
/// colour alone. Figures — numbers, dates, phone numbers — are set in the
/// data face. A select's `options` are label/value pairs.
#[component]
pub fn AdsField(
    #[props(into)] label: String,
    #[props(into, optional)] value: Option<String>,
    #[props(default)] field_type: FieldType,
    #[props(into, optional)] id: Option<String>,
    #[props(into, optional)] name: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] hint: Option<String>,
    #[props(into, optional)] error: Option<String>,
    #[props(into, optional)] options: Option<Vec<SegOption>>,
    #[props(default = 4u32)] rows: u32,
    #[props(into, optional)] autocomplete: Option<String>,
    #[props(optional)] required: bool,
    #[props(optional)] disabled: bool,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-field");
    let field_id = id.filter(|i| !i.is_empty()).unwrap_or(uid);
    let msg_id = format!("{field_id}-msg");
    let error = error.filter(|e| !e.is_empty());
    let hint = hint.filter(|h| !h.is_empty());
    let described_by = (error.is_some() || hint.is_some()).then(|| msg_id.clone());
    let invalid = error.is_some().then_some("true");
    let numeric = matches!(field_type, FieldType::Number | FieldType::Date | FieldType::Tel);
    let is_invalid = if error.is_some() { "is-invalid" } else { "" };

    let control = match field_type {
        FieldType::Textarea => rsx! {
            textarea {
                id: "{field_id}",
                name: name,
                class: crate::class::join(&["ads-field", is_invalid]),
                value: value,
                placeholder: placeholder,
                rows: "{rows}",
                required: required,
                disabled: disabled,
                "aria-invalid": invalid,
                "aria-describedby": described_by,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
        },
        FieldType::Select => {
            let current = value.unwrap_or_default();
            rsx! {
                select {
                    id: "{field_id}",
                    name: name,
                    class: crate::class::join(&["ads-field", is_invalid]),
                    required: required,
                    disabled: disabled,
                    "aria-invalid": invalid,
                    "aria-describedby": described_by,
                    onchange: move |e| { if let Some(h) = &oninput { h.call(e); } },
                    if let Some(p) = placeholder.filter(|p| !p.is_empty()) {
                        option { value: "", disabled: true, selected: current.is_empty(), "{p}" }
                    }
                    for o in options.unwrap_or_default() {
                        option { key: "{o.value}", value: "{o.value}", selected: o.value == current, "{o.label}" }
                    }
                }
            }
        }
        _ => rsx! {
            input {
                id: "{field_id}",
                name: name,
                r#type: field_type.as_str(),
                class: crate::class::join(&[
                    "ads-field ads-field__input",
                    is_invalid,
                    if numeric { "is-numeric" } else { "" },
                    if field_type == FieldType::Number { "is-number" } else { "" },
                ]),
                value: value,
                placeholder: placeholder,
                required: required,
                disabled: disabled,
                autocomplete: autocomplete,
                "aria-invalid": invalid,
                "aria-describedby": described_by,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
        },
    };

    rsx! {
        div { class: "ads-field-group",
            label { r#for: "{field_id}", class: "ads-label", "{label}" }
            {control}
            if let Some(err) = &error {
                p { id: "{msg_id}", class: "ads-hint ads-field__error",
                    Icon { name: "alert", size: 13, class: "ads-field__error-glyph" }
                    "{err}"
                }
            } else if let Some(h) = &hint {
                p { id: "{msg_id}", class: "ads-hint ads-field__hint", "{h}" }
            }
        }
    }
}

fn default_presets() -> Vec<String> {
    ["7d", "30d", "90d", "Custom"].into_iter().map(String::from).collect()
}

/// The most-used control on any dashboard. ISO dates in the data face so
/// ranges sort and compare visually, presets beside them because most readers
/// want "last 30 days", not a calendar. The whole control is one named group,
/// so a screen reader hears what the dates and presets belong to.
#[component]
pub fn AdsDaterange(
    #[props(into, default = "2026-09-25".to_string())] from: String,
    #[props(into, default = "2026-10-25".to_string())] to: String,
    #[props(into, optional)] presets: Option<Vec<String>>,
    #[props(into, default = "30d".to_string())] selected: String,
    #[props(into, default = "Date range".to_string())] label: String,
    #[props(optional)] onselect: Option<EventHandler<String>>,
) -> Element {
    let presets = presets.unwrap_or_else(default_presets);
    rsx! {
        div { class: "stack gap-2", role: "group", "aria-label": "{label}",
            div { class: "ads-daterange",
                Icon { name: "compass", size: 15 }
                time { class: "num", datetime: "{from}", "{from}" }
                span { class: "sep", "aria-hidden": "true", "→" }
                span { class: "sr-only", "to" }
                time { class: "num", datetime: "{to}", "{to}" }
            }
            if !presets.is_empty() {
                div { class: "ads-segmented", role: "group", "aria-label": "Presets",
                    for p in presets.iter().cloned() {
                        button {
                            key: "{p}",
                            r#type: "button",
                            class: if p == selected { "num is-on" } else { "num" },
                            "aria-pressed": if p == selected { "true" } else { "false" },
                            onclick: {
                                let p = p.clone();
                                move |_| { if let Some(h) = &onselect { h.call(p.clone()); } }
                            },
                            "{p}"
                        }
                    }
                }
            }
        }
    }
}

AdsChoice

rust
AdsChoice { label: "I agree to the terms", checked: agree(), onchange: move |v| agree.set(v) }
AdsChoice { kind: ChoiceKind::Switch, label: "Email me" }
Source · ads/ads-rs/src/ui/choice.rs
rust
//! Checkbox / radio / switch, range, and number stepper.

use dioxus::prelude::*;

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ChoiceKind {
    #[default]
    Checkbox,
    Radio,
    Switch,
}

impl ChoiceKind {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Checkbox => "checkbox",
            Self::Radio => "radio",
            Self::Switch => "switch",
        }
    }
}

/// Checkbox, radio or switch. A switch is for a setting that takes effect
/// immediately; a checkbox is for a value that is submitted — so a switch
/// belongs outside a form, or it should be a checkbox. The label is visible
/// and inside the hit target; pass `label`, or children when it needs a link.
/// `onchange` receives the new checked state.
#[component]
pub fn AdsChoice(
    #[props(into, optional)] label: Option<String>,
    #[props(optional)] checked: bool,
    #[props(default)] kind: ChoiceKind,
    #[props(into, optional)] name: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] disabled: bool,
    #[props(optional)] onchange: Option<EventHandler<bool>>,
    children: Element,
) -> Element {
    let input_type = if kind == ChoiceKind::Radio { "radio" } else { "checkbox" };
    let class = crate::class::join(&["ads-choice", if disabled { "is-disabled" } else { "" }]);
    rsx! {
        label { class: "{class}",
            input {
                r#type: input_type,
                role: if kind == ChoiceKind::Switch { Some("switch") } else { None },
                name: name,
                value: value,
                checked: checked,
                disabled: disabled,
                onchange: move |e: FormEvent| { if let Some(h) = &onchange { h.call(e.checked()); } },
            }
            if kind == ChoiceKind::Switch {
                span { class: "ads-switch" }
            } else {
                span { class: if kind == ChoiceKind::Radio { "ads-box is-round" } else { "ads-box" },
                    if kind == ChoiceKind::Checkbox {
                        svg {
                            view_box: "0 0 24 24",
                            width: "13",
                            height: "13",
                            fill: "none",
                            stroke: "currentColor",
                            stroke_width: "2.6",
                            stroke_linecap: "round",
                            stroke_linejoin: "round",
                            "aria-hidden": "true",
                            path { d: "M4 12.5l5 5L20 6.5" }
                        }
                    } else {
                        span { class: "ads-choice__dot" }
                    }
                }
            }
            span { class: "ads-choice__label",
                if let Some(l) = &label { "{l}" } else { {children} }
            }
        }
    }
}

/// A slider, pressed into the page, with an accent thumb. For a value where
/// the approximate position matters more than the exact figure — but the
/// figure is shown anyway, always, in the data face beside the label, because
/// somebody always needs it. The label is required and visible; it carries
/// the unit so the readout holds only the number.
#[component]
pub fn AdsRange(
    #[props(into)] label: String,
    #[props(default = 0i64)] value: i64,
    #[props(default = 0i64)] min: i64,
    #[props(default = 100i64)] max: i64,
    #[props(default = 1i64)] step: i64,
    #[props(optional)] oninput: Option<EventHandler<i64>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-range");
    rsx! {
        div { class: "ads-field-group",
            div { class: "ads-range__head",
                label { r#for: "{uid}", class: "ads-label", "{label}" }
                output { "for": "{uid}", class: "num ads-range__value", "{value}" }
            }
            input {
                id: "{uid}",
                class: "ads-range",
                r#type: "range",
                value: "{value}",
                min: "{min}",
                max: "{max}",
                step: "{step}",
                oninput: move |e: FormEvent| {
                    if let (Some(h), Ok(v)) = (&oninput, e.value().parse::<i64>()) {
                        h.call(v);
                    }
                },
            }
        }
    }
}

/// A stepper: a figure with up/down buttons, right-aligned in the data face
/// so it lines up with the values it will be compared against. The label is
/// required and visible, and carries the unit — "Max URLs", "Timeout (s)" —
/// so the field holds only the figure. The steppers stop at the bounds.
#[component]
pub fn AdsNumber(
    #[props(into)] label: String,
    #[props(default = 0i64)] value: i64,
    #[props(default = 0i64)] min: i64,
    #[props(default = i64::MAX)] max: i64,
    #[props(default = 1i64)] step: i64,
    #[props(optional)] oninput: Option<EventHandler<i64>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-number");
    let bump = move |dir: i64| {
        if let Some(h) = &oninput {
            h.call(value.saturating_add(dir.saturating_mul(step)).clamp(min, max));
        }
    };
    rsx! {
        div { class: "ads-field-group",
            label { r#for: "{uid}", class: "ads-label", "{label}" }
            div { class: "ads-number",
                input {
                    id: "{uid}",
                    r#type: "number",
                    value: "{value}",
                    min: "{min}",
                    max: if max < i64::MAX { Some(max.to_string()) } else { None },
                    step: "{step}",
                    oninput: move |e: FormEvent| {
                        // An emptied field is mid-edit, not zero.
                        if let (Some(h), Ok(v)) = (&oninput, e.value().parse::<i64>()) {
                            h.call(v);
                        }
                    },
                }
                div { class: "steps",
                    button {
                        r#type: "button",
                        "aria-label": "Increase {label}",
                        disabled: value >= max,
                        onclick: move |_| bump(1),
                        "▲"
                    }
                    button {
                        r#type: "button",
                        "aria-label": "Decrease {label}",
                        disabled: value <= min,
                        onclick: move |_| bump(-1),
                        "▼"
                    }
                }
            }
        }
    }
}

AdsRange

50
rust
AdsRange { label: "Concurrency", value: range(), oninput: move |v| range.set(v) }
Source · ads/ads-rs/src/ui/choice.rs
rust
//! Checkbox / radio / switch, range, and number stepper.

use dioxus::prelude::*;

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ChoiceKind {
    #[default]
    Checkbox,
    Radio,
    Switch,
}

impl ChoiceKind {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Checkbox => "checkbox",
            Self::Radio => "radio",
            Self::Switch => "switch",
        }
    }
}

/// Checkbox, radio or switch. A switch is for a setting that takes effect
/// immediately; a checkbox is for a value that is submitted — so a switch
/// belongs outside a form, or it should be a checkbox. The label is visible
/// and inside the hit target; pass `label`, or children when it needs a link.
/// `onchange` receives the new checked state.
#[component]
pub fn AdsChoice(
    #[props(into, optional)] label: Option<String>,
    #[props(optional)] checked: bool,
    #[props(default)] kind: ChoiceKind,
    #[props(into, optional)] name: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] disabled: bool,
    #[props(optional)] onchange: Option<EventHandler<bool>>,
    children: Element,
) -> Element {
    let input_type = if kind == ChoiceKind::Radio { "radio" } else { "checkbox" };
    let class = crate::class::join(&["ads-choice", if disabled { "is-disabled" } else { "" }]);
    rsx! {
        label { class: "{class}",
            input {
                r#type: input_type,
                role: if kind == ChoiceKind::Switch { Some("switch") } else { None },
                name: name,
                value: value,
                checked: checked,
                disabled: disabled,
                onchange: move |e: FormEvent| { if let Some(h) = &onchange { h.call(e.checked()); } },
            }
            if kind == ChoiceKind::Switch {
                span { class: "ads-switch" }
            } else {
                span { class: if kind == ChoiceKind::Radio { "ads-box is-round" } else { "ads-box" },
                    if kind == ChoiceKind::Checkbox {
                        svg {
                            view_box: "0 0 24 24",
                            width: "13",
                            height: "13",
                            fill: "none",
                            stroke: "currentColor",
                            stroke_width: "2.6",
                            stroke_linecap: "round",
                            stroke_linejoin: "round",
                            "aria-hidden": "true",
                            path { d: "M4 12.5l5 5L20 6.5" }
                        }
                    } else {
                        span { class: "ads-choice__dot" }
                    }
                }
            }
            span { class: "ads-choice__label",
                if let Some(l) = &label { "{l}" } else { {children} }
            }
        }
    }
}

/// A slider, pressed into the page, with an accent thumb. For a value where
/// the approximate position matters more than the exact figure — but the
/// figure is shown anyway, always, in the data face beside the label, because
/// somebody always needs it. The label is required and visible; it carries
/// the unit so the readout holds only the number.
#[component]
pub fn AdsRange(
    #[props(into)] label: String,
    #[props(default = 0i64)] value: i64,
    #[props(default = 0i64)] min: i64,
    #[props(default = 100i64)] max: i64,
    #[props(default = 1i64)] step: i64,
    #[props(optional)] oninput: Option<EventHandler<i64>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-range");
    rsx! {
        div { class: "ads-field-group",
            div { class: "ads-range__head",
                label { r#for: "{uid}", class: "ads-label", "{label}" }
                output { "for": "{uid}", class: "num ads-range__value", "{value}" }
            }
            input {
                id: "{uid}",
                class: "ads-range",
                r#type: "range",
                value: "{value}",
                min: "{min}",
                max: "{max}",
                step: "{step}",
                oninput: move |e: FormEvent| {
                    if let (Some(h), Ok(v)) = (&oninput, e.value().parse::<i64>()) {
                        h.call(v);
                    }
                },
            }
        }
    }
}

/// A stepper: a figure with up/down buttons, right-aligned in the data face
/// so it lines up with the values it will be compared against. The label is
/// required and visible, and carries the unit — "Max URLs", "Timeout (s)" —
/// so the field holds only the figure. The steppers stop at the bounds.
#[component]
pub fn AdsNumber(
    #[props(into)] label: String,
    #[props(default = 0i64)] value: i64,
    #[props(default = 0i64)] min: i64,
    #[props(default = i64::MAX)] max: i64,
    #[props(default = 1i64)] step: i64,
    #[props(optional)] oninput: Option<EventHandler<i64>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-number");
    let bump = move |dir: i64| {
        if let Some(h) = &oninput {
            h.call(value.saturating_add(dir.saturating_mul(step)).clamp(min, max));
        }
    };
    rsx! {
        div { class: "ads-field-group",
            label { r#for: "{uid}", class: "ads-label", "{label}" }
            div { class: "ads-number",
                input {
                    id: "{uid}",
                    r#type: "number",
                    value: "{value}",
                    min: "{min}",
                    max: if max < i64::MAX { Some(max.to_string()) } else { None },
                    step: "{step}",
                    oninput: move |e: FormEvent| {
                        // An emptied field is mid-edit, not zero.
                        if let (Some(h), Ok(v)) = (&oninput, e.value().parse::<i64>()) {
                            h.call(v);
                        }
                    },
                }
                div { class: "steps",
                    button {
                        r#type: "button",
                        "aria-label": "Increase {label}",
                        disabled: value >= max,
                        onclick: move |_| bump(1),
                        "▲"
                    }
                    button {
                        r#type: "button",
                        "aria-label": "Decrease {label}",
                        disabled: value <= min,
                        onclick: move |_| bump(-1),
                        "▼"
                    }
                }
            }
        }
    }
}

AdsNumber

rust
AdsNumber { label: "Count", value: n(), min: 0, max: 10, oninput: move |v| n.set(v) }
Source · ads/ads-rs/src/ui/choice.rs
rust
//! Checkbox / radio / switch, range, and number stepper.

use dioxus::prelude::*;

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum ChoiceKind {
    #[default]
    Checkbox,
    Radio,
    Switch,
}

impl ChoiceKind {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Checkbox => "checkbox",
            Self::Radio => "radio",
            Self::Switch => "switch",
        }
    }
}

/// Checkbox, radio or switch. A switch is for a setting that takes effect
/// immediately; a checkbox is for a value that is submitted — so a switch
/// belongs outside a form, or it should be a checkbox. The label is visible
/// and inside the hit target; pass `label`, or children when it needs a link.
/// `onchange` receives the new checked state.
#[component]
pub fn AdsChoice(
    #[props(into, optional)] label: Option<String>,
    #[props(optional)] checked: bool,
    #[props(default)] kind: ChoiceKind,
    #[props(into, optional)] name: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] disabled: bool,
    #[props(optional)] onchange: Option<EventHandler<bool>>,
    children: Element,
) -> Element {
    let input_type = if kind == ChoiceKind::Radio { "radio" } else { "checkbox" };
    let class = crate::class::join(&["ads-choice", if disabled { "is-disabled" } else { "" }]);
    rsx! {
        label { class: "{class}",
            input {
                r#type: input_type,
                role: if kind == ChoiceKind::Switch { Some("switch") } else { None },
                name: name,
                value: value,
                checked: checked,
                disabled: disabled,
                onchange: move |e: FormEvent| { if let Some(h) = &onchange { h.call(e.checked()); } },
            }
            if kind == ChoiceKind::Switch {
                span { class: "ads-switch" }
            } else {
                span { class: if kind == ChoiceKind::Radio { "ads-box is-round" } else { "ads-box" },
                    if kind == ChoiceKind::Checkbox {
                        svg {
                            view_box: "0 0 24 24",
                            width: "13",
                            height: "13",
                            fill: "none",
                            stroke: "currentColor",
                            stroke_width: "2.6",
                            stroke_linecap: "round",
                            stroke_linejoin: "round",
                            "aria-hidden": "true",
                            path { d: "M4 12.5l5 5L20 6.5" }
                        }
                    } else {
                        span { class: "ads-choice__dot" }
                    }
                }
            }
            span { class: "ads-choice__label",
                if let Some(l) = &label { "{l}" } else { {children} }
            }
        }
    }
}

/// A slider, pressed into the page, with an accent thumb. For a value where
/// the approximate position matters more than the exact figure — but the
/// figure is shown anyway, always, in the data face beside the label, because
/// somebody always needs it. The label is required and visible; it carries
/// the unit so the readout holds only the number.
#[component]
pub fn AdsRange(
    #[props(into)] label: String,
    #[props(default = 0i64)] value: i64,
    #[props(default = 0i64)] min: i64,
    #[props(default = 100i64)] max: i64,
    #[props(default = 1i64)] step: i64,
    #[props(optional)] oninput: Option<EventHandler<i64>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-range");
    rsx! {
        div { class: "ads-field-group",
            div { class: "ads-range__head",
                label { r#for: "{uid}", class: "ads-label", "{label}" }
                output { "for": "{uid}", class: "num ads-range__value", "{value}" }
            }
            input {
                id: "{uid}",
                class: "ads-range",
                r#type: "range",
                value: "{value}",
                min: "{min}",
                max: "{max}",
                step: "{step}",
                oninput: move |e: FormEvent| {
                    if let (Some(h), Ok(v)) = (&oninput, e.value().parse::<i64>()) {
                        h.call(v);
                    }
                },
            }
        }
    }
}

/// A stepper: a figure with up/down buttons, right-aligned in the data face
/// so it lines up with the values it will be compared against. The label is
/// required and visible, and carries the unit — "Max URLs", "Timeout (s)" —
/// so the field holds only the figure. The steppers stop at the bounds.
#[component]
pub fn AdsNumber(
    #[props(into)] label: String,
    #[props(default = 0i64)] value: i64,
    #[props(default = 0i64)] min: i64,
    #[props(default = i64::MAX)] max: i64,
    #[props(default = 1i64)] step: i64,
    #[props(optional)] oninput: Option<EventHandler<i64>>,
) -> Element {
    let uid = crate::blocks::use_uid("ads-number");
    let bump = move |dir: i64| {
        if let Some(h) = &oninput {
            h.call(value.saturating_add(dir.saturating_mul(step)).clamp(min, max));
        }
    };
    rsx! {
        div { class: "ads-field-group",
            label { r#for: "{uid}", class: "ads-label", "{label}" }
            div { class: "ads-number",
                input {
                    id: "{uid}",
                    r#type: "number",
                    value: "{value}",
                    min: "{min}",
                    max: if max < i64::MAX { Some(max.to_string()) } else { None },
                    step: "{step}",
                    oninput: move |e: FormEvent| {
                        // An emptied field is mid-edit, not zero.
                        if let (Some(h), Ok(v)) = (&oninput, e.value().parse::<i64>()) {
                            h.call(v);
                        }
                    },
                }
                div { class: "steps",
                    button {
                        r#type: "button",
                        "aria-label": "Increase {label}",
                        disabled: value >= max,
                        onclick: move |_| bump(1),
                        "▲"
                    }
                    button {
                        r#type: "button",
                        "aria-label": "Decrease {label}",
                        disabled: value <= min,
                        onclick: move |_| bump(-1),
                        "▼"
                    }
                }
            }
        }
    }
}

AdsTag

status: 4xx depth ≤ 3
rust
AdsTag { label: "status: 4xx", onremove: move |_| remove_filter("status: 4xx") }
Source · ads/ads-rs/src/ui/chip.rs
rust
//! Badge, status, delta, and tag.

use dioxus::prelude::*;

/// A count or code pill. The accent variant is the one number on screen that
/// matters; the quiet variant sits inside the chrome. A bare "5" means nothing
/// to a screen reader, so `label` says what is being counted ("unread").
#[component]
pub fn AdsBadge(
    #[props(into)] value: String,
    #[props(into, optional)] label: Option<String>,
    #[props(optional)] quiet: bool,
) -> Element {
    let class = crate::class::join(&["ads-badge num", if quiet { "is-quiet" } else { "" }]);
    rsx! {
        span { class: "{class}",
            "{value}"
            if let Some(l) = label.as_deref().filter(|l| !l.is_empty()) {
                span { class: "sr-only", " {l}" }
            }
        }
    }
}

/// A single state outside dense data: the three semantic colours, spoken for.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum StatusTone {
    Ok,
    Warn,
    Bad,
}

impl StatusTone {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Ok => "ok",
            Self::Warn => "warn",
            Self::Bad => "bad",
        }
    }
}

/// Severity in a monochrome system is luminance: the worse it is, the brighter
/// it is. The glyph (dot) carries it too, and the label is required, so the
/// rank survives a greyscale print and a colour-vision deficiency alike.
///
/// `grade` (1–4) is for dense data — a severity column, a heat grid. `tone` is
/// for a single state outside it (a health check, a job), where the three
/// semantic colours are allowed; it wins over `grade` when both are given.
/// There is no colour prop on purpose: state has three colours and they are
/// spoken for.
#[component]
pub fn AdsStatus(
    #[props(into)] label: String,
    #[props(default = 1u8)] grade: u8,
    #[props(optional)] tone: Option<StatusTone>,
) -> Element {
    let modifier = match tone {
        Some(t) => format!("is-{}", t.as_str()),
        None => format!("g{}", grade.clamp(1, 4)),
    };
    let class = crate::class::join(&["ads-status", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "ads-dot", "aria-hidden": "true" }
            "{label}"
        }
    }
}

/// A change against the previous period. The direction glyph is mandatory — a
/// delta without one is a number nobody can read at speed — so `direction` is
/// required rather than defaulted, and it is spoken as a word too, because a
/// screen reader does not reliably read a CSS triangle.
#[component]
pub fn AdsDelta(
    #[props(into)] value: String,
    direction: DeltaDirection,
) -> Element {
    let modifier = format!("is-{}", direction.as_str());
    let class = crate::class::join(&["ads-delta num", &modifier]);
    rsx! {
        span { class: "{class}",
            span { class: "sr-only", "{direction.spoken()} " }
            "{value}"
        }
    }
}

#[derive(Default, Clone, Copy, PartialEq, Eq)]
pub enum DeltaDirection {
    Up,
    Down,
    #[default]
    Flat,
}

impl DeltaDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "flat",
        }
    }

    /// The word a screen reader says in place of the triangle.
    #[must_use]
    pub const fn spoken(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Flat => "unchanged",
        }
    }
}

/// A filter the reader applied and can take off again. Always removable: the
/// dismiss affordance is the whole difference from a status chip, which
/// describes the data and cannot be removed — for that, use `AdsStatus`.
#[component]
pub fn AdsTag(
    #[props(into)] label: String,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "ads-tag",
            "{label}"
            button {
                r#type: "button",
                "aria-label": "Remove filter {label}",
                onclick: move |e| {
                    if let Some(h) = &onremove {
                        h.call(e);
                    }
                },
                "×"
            }
        }
    }
}