Start a project

Artelier UI Kit · Rust · Elements

Elements

The full set of building blocks, in rsx!.

Layout & text

Typography

Text in one of the system's type roles.

Less, but done well.

A quieter heading

rust
AdsTypography { role: "display", "Less, but done well." }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

AspectRatio

A container with a fixed aspect ratio.

16 / 9
rust
AdsAspectRatio { ratio: "16 / 9", … }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Label

A form label.

rust
AdsLabel { "Email address" }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Kbd

A keyboard key.

⌘K
rust
AdsKbd { keys: vec!["⌘".into(), "K".into()] }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Marker

A status marker.

LivePaused
rust
AdsMarker { label: "Live", color: "var(--ok)" }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Direction

An arrow rotated toward a direction.

rust
AdsDirection { to: "down" }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Item

A list item with an optional glyph.

Audience overview
rust
AdsItem { icon: "chart", "Audience overview" }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Bubble

A chat bubble.

Hello from the studio.
On it.
rust
AdsBubble { "Hello from the studio." }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Inputs

InputGroup

An input with a prefix/suffix addon.

https://.artelier.app
rust
AdsInputGroup { prefix: "€", suffix: "/mo", placeholder: "0" }
Source · ads/ads-rs/src/ui/field2.rs
rust
//! Field-side components: attachment, code block, native select, input group,
//! input otp, select, combobox.

use dioxus::prelude::*;

use crate::icons::Icon;

/// A file attachment chip.
#[component]
pub fn AdsAttachment(
    #[props(into)] name: String,
    #[props(into, optional)] size: Option<String>,
    #[props(default = true)] removable: bool,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "attachment",
            span { class: "att-icon", Icon { name: "code", size: 15 } }
            span { class: "att-text",
                span { class: "att-name", "{name}" }
                if let Some(s) = &size { span { class: "att-size num", "{s}" } }
            }
            if removable {
                button { class: "att-remove", r#type: "button", "aria-label": "Remove", onclick: move |e| { if let Some(h) = &onremove { h.call(e); } }, "×" }
            }
        }
    }
}

/// A code block with a copy button.
#[component]
pub fn AdsCodeBlock(
    #[props(into)] code: String,
) -> Element {
    rsx! {
        div { class: "codeblock",
            button { class: "cb-btn", r#type: "button", "Copy" }
            pre { class: "cb-pre", code { "{code}" } }
        }
    }
}

/// A native select, styled as a well.
#[component]
pub fn AdsNativeSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        select { class: "ads-field select", value: value, onchange: move |e| { if let Some(h) = &onchange { h.call(e); } },
            option { value: "", disabled: "true", "{placeholder}" }
            for (v, l) in &options { option { value: "{v}", "{l}" } }
        }
    }
}

/// An input with a prefix/suffix addon.
#[component]
pub fn AdsInputGroup(
    #[props(into, optional)] prefix: Option<String>,
    #[props(into, optional)] suffix: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        div { class: "input-group",
            if let Some(p) = &prefix { span { class: "addon", "{p}" } }
            input {
                class: "ig-input",
                r#type: "text",
                value: value,
                placeholder: placeholder,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
            if let Some(s) = &suffix { span { class: "addon", "{s}" } }
        }
    }
}

/// A one-time-password input.
#[component]
pub fn AdsInputOtp(
    #[props(default = 6u8)] length: u8,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let value = value.unwrap_or_default();
    let chars: Vec<char> = value.chars().collect();
    rsx! {
        div { class: "otp", role: "group", "aria-label": "One-time code",
            for i in 0..length {
                input {
                    class: "otp-box num",
                    inputmode: "numeric",
                    maxlength: "1",
                    value: chars.get(i as usize).map_or_else(String::new, |c| c.to_string()),
                    "aria-label": "Digit {i + 1}",
                }
            }
        }
    }
}

/// A custom select: a button that opens a soft menu.
#[component]
pub fn AdsSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut open = use_signal(|| false);
    let label = options
        .iter()
        .find(|(v, _)| Some(v) == value.as_ref())
        .map_or_else(|| placeholder.clone(), |(_, l)| l.clone());

    rsx! {
        div { class: "select",
            button { class: "select-btn", r#type: "button", "aria-expanded": if *open.read() { "true" } else { "false" }, onclick: move |_| open.toggle(),
                span { "{label}" }
                Icon { name: "chevron", size: 14 }
            }
            if *open.read() {
                div { class: "select-menu",
                    for (v, l) in &options {
                        button {
                            class: "opt",
                            class: if Some(v) == value.as_ref() { "opt on" } else { "opt" },
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

/// A combobox: a text field that filters a list as you type.
#[component]
pub fn AdsCombobox(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, default = "Search…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut query = use_signal(|| String::new());
    let q = query.read();
    let qs = q.as_str();
    let filtered = options.iter().filter(|(_, l)| l.to_lowercase().contains(&qs.to_lowercase())).collect::<Vec<_>>();

    rsx! {
        div { class: "combo",
            input { class: "combo-input", r#type: "text", placeholder: "{placeholder}", value: "{qs}", oninput: move |e| query.set(e.value()) }
            if !filtered.is_empty() {
                div { class: "combo-menu",
                    for (v, l) in &filtered {
                        button {
                            class: "opt",
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

InputOtp

A one-time-password input.

One-time code
rust
AdsInputOtp { length: 6 }
Source · ads/ads-rs/src/ui/field2.rs
rust
//! Field-side components: attachment, code block, native select, input group,
//! input otp, select, combobox.

use dioxus::prelude::*;

use crate::icons::Icon;

/// A file attachment chip.
#[component]
pub fn AdsAttachment(
    #[props(into)] name: String,
    #[props(into, optional)] size: Option<String>,
    #[props(default = true)] removable: bool,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "attachment",
            span { class: "att-icon", Icon { name: "code", size: 15 } }
            span { class: "att-text",
                span { class: "att-name", "{name}" }
                if let Some(s) = &size { span { class: "att-size num", "{s}" } }
            }
            if removable {
                button { class: "att-remove", r#type: "button", "aria-label": "Remove", onclick: move |e| { if let Some(h) = &onremove { h.call(e); } }, "×" }
            }
        }
    }
}

/// A code block with a copy button.
#[component]
pub fn AdsCodeBlock(
    #[props(into)] code: String,
) -> Element {
    rsx! {
        div { class: "codeblock",
            button { class: "cb-btn", r#type: "button", "Copy" }
            pre { class: "cb-pre", code { "{code}" } }
        }
    }
}

/// A native select, styled as a well.
#[component]
pub fn AdsNativeSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        select { class: "ads-field select", value: value, onchange: move |e| { if let Some(h) = &onchange { h.call(e); } },
            option { value: "", disabled: "true", "{placeholder}" }
            for (v, l) in &options { option { value: "{v}", "{l}" } }
        }
    }
}

/// An input with a prefix/suffix addon.
#[component]
pub fn AdsInputGroup(
    #[props(into, optional)] prefix: Option<String>,
    #[props(into, optional)] suffix: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        div { class: "input-group",
            if let Some(p) = &prefix { span { class: "addon", "{p}" } }
            input {
                class: "ig-input",
                r#type: "text",
                value: value,
                placeholder: placeholder,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
            if let Some(s) = &suffix { span { class: "addon", "{s}" } }
        }
    }
}

/// A one-time-password input.
#[component]
pub fn AdsInputOtp(
    #[props(default = 6u8)] length: u8,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let value = value.unwrap_or_default();
    let chars: Vec<char> = value.chars().collect();
    rsx! {
        div { class: "otp", role: "group", "aria-label": "One-time code",
            for i in 0..length {
                input {
                    class: "otp-box num",
                    inputmode: "numeric",
                    maxlength: "1",
                    value: chars.get(i as usize).map_or_else(String::new, |c| c.to_string()),
                    "aria-label": "Digit {i + 1}",
                }
            }
        }
    }
}

/// A custom select: a button that opens a soft menu.
#[component]
pub fn AdsSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut open = use_signal(|| false);
    let label = options
        .iter()
        .find(|(v, _)| Some(v) == value.as_ref())
        .map_or_else(|| placeholder.clone(), |(_, l)| l.clone());

    rsx! {
        div { class: "select",
            button { class: "select-btn", r#type: "button", "aria-expanded": if *open.read() { "true" } else { "false" }, onclick: move |_| open.toggle(),
                span { "{label}" }
                Icon { name: "chevron", size: 14 }
            }
            if *open.read() {
                div { class: "select-menu",
                    for (v, l) in &options {
                        button {
                            class: "opt",
                            class: if Some(v) == value.as_ref() { "opt on" } else { "opt" },
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

/// A combobox: a text field that filters a list as you type.
#[component]
pub fn AdsCombobox(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, default = "Search…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut query = use_signal(|| String::new());
    let q = query.read();
    let qs = q.as_str();
    let filtered = options.iter().filter(|(_, l)| l.to_lowercase().contains(&qs.to_lowercase())).collect::<Vec<_>>();

    rsx! {
        div { class: "combo",
            input { class: "combo-input", r#type: "text", placeholder: "{placeholder}", value: "{qs}", oninput: move |e| query.set(e.value()) }
            if !filtered.is_empty() {
                div { class: "combo-menu",
                    for (v, l) in &filtered {
                        button {
                            class: "opt",
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

NativeSelect

A native select, styled as a well.

rust
AdsNativeSelect { options: vec![("desktop".into(), "Desktop".into())] }
Source · ads/ads-rs/src/ui/field2.rs
rust
//! Field-side components: attachment, code block, native select, input group,
//! input otp, select, combobox.

use dioxus::prelude::*;

use crate::icons::Icon;

/// A file attachment chip.
#[component]
pub fn AdsAttachment(
    #[props(into)] name: String,
    #[props(into, optional)] size: Option<String>,
    #[props(default = true)] removable: bool,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "attachment",
            span { class: "att-icon", Icon { name: "code", size: 15 } }
            span { class: "att-text",
                span { class: "att-name", "{name}" }
                if let Some(s) = &size { span { class: "att-size num", "{s}" } }
            }
            if removable {
                button { class: "att-remove", r#type: "button", "aria-label": "Remove", onclick: move |e| { if let Some(h) = &onremove { h.call(e); } }, "×" }
            }
        }
    }
}

/// A code block with a copy button.
#[component]
pub fn AdsCodeBlock(
    #[props(into)] code: String,
) -> Element {
    rsx! {
        div { class: "codeblock",
            button { class: "cb-btn", r#type: "button", "Copy" }
            pre { class: "cb-pre", code { "{code}" } }
        }
    }
}

/// A native select, styled as a well.
#[component]
pub fn AdsNativeSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        select { class: "ads-field select", value: value, onchange: move |e| { if let Some(h) = &onchange { h.call(e); } },
            option { value: "", disabled: "true", "{placeholder}" }
            for (v, l) in &options { option { value: "{v}", "{l}" } }
        }
    }
}

/// An input with a prefix/suffix addon.
#[component]
pub fn AdsInputGroup(
    #[props(into, optional)] prefix: Option<String>,
    #[props(into, optional)] suffix: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        div { class: "input-group",
            if let Some(p) = &prefix { span { class: "addon", "{p}" } }
            input {
                class: "ig-input",
                r#type: "text",
                value: value,
                placeholder: placeholder,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
            if let Some(s) = &suffix { span { class: "addon", "{s}" } }
        }
    }
}

/// A one-time-password input.
#[component]
pub fn AdsInputOtp(
    #[props(default = 6u8)] length: u8,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let value = value.unwrap_or_default();
    let chars: Vec<char> = value.chars().collect();
    rsx! {
        div { class: "otp", role: "group", "aria-label": "One-time code",
            for i in 0..length {
                input {
                    class: "otp-box num",
                    inputmode: "numeric",
                    maxlength: "1",
                    value: chars.get(i as usize).map_or_else(String::new, |c| c.to_string()),
                    "aria-label": "Digit {i + 1}",
                }
            }
        }
    }
}

/// A custom select: a button that opens a soft menu.
#[component]
pub fn AdsSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut open = use_signal(|| false);
    let label = options
        .iter()
        .find(|(v, _)| Some(v) == value.as_ref())
        .map_or_else(|| placeholder.clone(), |(_, l)| l.clone());

    rsx! {
        div { class: "select",
            button { class: "select-btn", r#type: "button", "aria-expanded": if *open.read() { "true" } else { "false" }, onclick: move |_| open.toggle(),
                span { "{label}" }
                Icon { name: "chevron", size: 14 }
            }
            if *open.read() {
                div { class: "select-menu",
                    for (v, l) in &options {
                        button {
                            class: "opt",
                            class: if Some(v) == value.as_ref() { "opt on" } else { "opt" },
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

/// A combobox: a text field that filters a list as you type.
#[component]
pub fn AdsCombobox(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, default = "Search…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut query = use_signal(|| String::new());
    let q = query.read();
    let qs = q.as_str();
    let filtered = options.iter().filter(|(_, l)| l.to_lowercase().contains(&qs.to_lowercase())).collect::<Vec<_>>();

    rsx! {
        div { class: "combo",
            input { class: "combo-input", r#type: "text", placeholder: "{placeholder}", value: "{qs}", oninput: move |e| query.set(e.value()) }
            if !filtered.is_empty() {
                div { class: "combo-menu",
                    for (v, l) in &filtered {
                        button {
                            class: "opt",
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

Select

A custom select.

Billing
rust
AdsSelect { options: vec![("monthly".into(), "Monthly".into())] }
Source · ads/ads-rs/src/ui/field2.rs
rust
//! Field-side components: attachment, code block, native select, input group,
//! input otp, select, combobox.

use dioxus::prelude::*;

use crate::icons::Icon;

/// A file attachment chip.
#[component]
pub fn AdsAttachment(
    #[props(into)] name: String,
    #[props(into, optional)] size: Option<String>,
    #[props(default = true)] removable: bool,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "attachment",
            span { class: "att-icon", Icon { name: "code", size: 15 } }
            span { class: "att-text",
                span { class: "att-name", "{name}" }
                if let Some(s) = &size { span { class: "att-size num", "{s}" } }
            }
            if removable {
                button { class: "att-remove", r#type: "button", "aria-label": "Remove", onclick: move |e| { if let Some(h) = &onremove { h.call(e); } }, "×" }
            }
        }
    }
}

/// A code block with a copy button.
#[component]
pub fn AdsCodeBlock(
    #[props(into)] code: String,
) -> Element {
    rsx! {
        div { class: "codeblock",
            button { class: "cb-btn", r#type: "button", "Copy" }
            pre { class: "cb-pre", code { "{code}" } }
        }
    }
}

/// A native select, styled as a well.
#[component]
pub fn AdsNativeSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        select { class: "ads-field select", value: value, onchange: move |e| { if let Some(h) = &onchange { h.call(e); } },
            option { value: "", disabled: "true", "{placeholder}" }
            for (v, l) in &options { option { value: "{v}", "{l}" } }
        }
    }
}

/// An input with a prefix/suffix addon.
#[component]
pub fn AdsInputGroup(
    #[props(into, optional)] prefix: Option<String>,
    #[props(into, optional)] suffix: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        div { class: "input-group",
            if let Some(p) = &prefix { span { class: "addon", "{p}" } }
            input {
                class: "ig-input",
                r#type: "text",
                value: value,
                placeholder: placeholder,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
            if let Some(s) = &suffix { span { class: "addon", "{s}" } }
        }
    }
}

/// A one-time-password input.
#[component]
pub fn AdsInputOtp(
    #[props(default = 6u8)] length: u8,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let value = value.unwrap_or_default();
    let chars: Vec<char> = value.chars().collect();
    rsx! {
        div { class: "otp", role: "group", "aria-label": "One-time code",
            for i in 0..length {
                input {
                    class: "otp-box num",
                    inputmode: "numeric",
                    maxlength: "1",
                    value: chars.get(i as usize).map_or_else(String::new, |c| c.to_string()),
                    "aria-label": "Digit {i + 1}",
                }
            }
        }
    }
}

/// A custom select: a button that opens a soft menu.
#[component]
pub fn AdsSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut open = use_signal(|| false);
    let label = options
        .iter()
        .find(|(v, _)| Some(v) == value.as_ref())
        .map_or_else(|| placeholder.clone(), |(_, l)| l.clone());

    rsx! {
        div { class: "select",
            button { class: "select-btn", r#type: "button", "aria-expanded": if *open.read() { "true" } else { "false" }, onclick: move |_| open.toggle(),
                span { "{label}" }
                Icon { name: "chevron", size: 14 }
            }
            if *open.read() {
                div { class: "select-menu",
                    for (v, l) in &options {
                        button {
                            class: "opt",
                            class: if Some(v) == value.as_ref() { "opt on" } else { "opt" },
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

/// A combobox: a text field that filters a list as you type.
#[component]
pub fn AdsCombobox(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, default = "Search…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut query = use_signal(|| String::new());
    let q = query.read();
    let qs = q.as_str();
    let filtered = options.iter().filter(|(_, l)| l.to_lowercase().contains(&qs.to_lowercase())).collect::<Vec<_>>();

    rsx! {
        div { class: "combo",
            input { class: "combo-input", r#type: "text", placeholder: "{placeholder}", value: "{qs}", oninput: move |e| query.set(e.value()) }
            if !filtered.is_empty() {
                div { class: "combo-menu",
                    for (v, l) in &filtered {
                        button {
                            class: "opt",
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

Combobox

A text field that filters a list as you type.

rust
AdsCombobox { options: vec![("crawlstat".into(), "CrawlStat".into())] }
Source · ads/ads-rs/src/ui/field2.rs
rust
//! Field-side components: attachment, code block, native select, input group,
//! input otp, select, combobox.

use dioxus::prelude::*;

use crate::icons::Icon;

/// A file attachment chip.
#[component]
pub fn AdsAttachment(
    #[props(into)] name: String,
    #[props(into, optional)] size: Option<String>,
    #[props(default = true)] removable: bool,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "attachment",
            span { class: "att-icon", Icon { name: "code", size: 15 } }
            span { class: "att-text",
                span { class: "att-name", "{name}" }
                if let Some(s) = &size { span { class: "att-size num", "{s}" } }
            }
            if removable {
                button { class: "att-remove", r#type: "button", "aria-label": "Remove", onclick: move |e| { if let Some(h) = &onremove { h.call(e); } }, "×" }
            }
        }
    }
}

/// A code block with a copy button.
#[component]
pub fn AdsCodeBlock(
    #[props(into)] code: String,
) -> Element {
    rsx! {
        div { class: "codeblock",
            button { class: "cb-btn", r#type: "button", "Copy" }
            pre { class: "cb-pre", code { "{code}" } }
        }
    }
}

/// A native select, styled as a well.
#[component]
pub fn AdsNativeSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        select { class: "ads-field select", value: value, onchange: move |e| { if let Some(h) = &onchange { h.call(e); } },
            option { value: "", disabled: "true", "{placeholder}" }
            for (v, l) in &options { option { value: "{v}", "{l}" } }
        }
    }
}

/// An input with a prefix/suffix addon.
#[component]
pub fn AdsInputGroup(
    #[props(into, optional)] prefix: Option<String>,
    #[props(into, optional)] suffix: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        div { class: "input-group",
            if let Some(p) = &prefix { span { class: "addon", "{p}" } }
            input {
                class: "ig-input",
                r#type: "text",
                value: value,
                placeholder: placeholder,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
            if let Some(s) = &suffix { span { class: "addon", "{s}" } }
        }
    }
}

/// A one-time-password input.
#[component]
pub fn AdsInputOtp(
    #[props(default = 6u8)] length: u8,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let value = value.unwrap_or_default();
    let chars: Vec<char> = value.chars().collect();
    rsx! {
        div { class: "otp", role: "group", "aria-label": "One-time code",
            for i in 0..length {
                input {
                    class: "otp-box num",
                    inputmode: "numeric",
                    maxlength: "1",
                    value: chars.get(i as usize).map_or_else(String::new, |c| c.to_string()),
                    "aria-label": "Digit {i + 1}",
                }
            }
        }
    }
}

/// A custom select: a button that opens a soft menu.
#[component]
pub fn AdsSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut open = use_signal(|| false);
    let label = options
        .iter()
        .find(|(v, _)| Some(v) == value.as_ref())
        .map_or_else(|| placeholder.clone(), |(_, l)| l.clone());

    rsx! {
        div { class: "select",
            button { class: "select-btn", r#type: "button", "aria-expanded": if *open.read() { "true" } else { "false" }, onclick: move |_| open.toggle(),
                span { "{label}" }
                Icon { name: "chevron", size: 14 }
            }
            if *open.read() {
                div { class: "select-menu",
                    for (v, l) in &options {
                        button {
                            class: "opt",
                            class: if Some(v) == value.as_ref() { "opt on" } else { "opt" },
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

/// A combobox: a text field that filters a list as you type.
#[component]
pub fn AdsCombobox(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, default = "Search…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut query = use_signal(|| String::new());
    let q = query.read();
    let qs = q.as_str();
    let filtered = options.iter().filter(|(_, l)| l.to_lowercase().contains(&qs.to_lowercase())).collect::<Vec<_>>();

    rsx! {
        div { class: "combo",
            input { class: "combo-input", r#type: "text", placeholder: "{placeholder}", value: "{qs}", oninput: move |e| query.set(e.value()) }
            if !filtered.is_empty() {
                div { class: "combo-menu",
                    for (v, l) in &filtered {
                        button {
                            class: "opt",
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

Attachment

A file attachment chip.

report.pdf1.2 MB
rust
AdsAttachment { name: "report.pdf", size: "1.2 MB" }
Source · ads/ads-rs/src/ui/field2.rs
rust
//! Field-side components: attachment, code block, native select, input group,
//! input otp, select, combobox.

use dioxus::prelude::*;

use crate::icons::Icon;

/// A file attachment chip.
#[component]
pub fn AdsAttachment(
    #[props(into)] name: String,
    #[props(into, optional)] size: Option<String>,
    #[props(default = true)] removable: bool,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "attachment",
            span { class: "att-icon", Icon { name: "code", size: 15 } }
            span { class: "att-text",
                span { class: "att-name", "{name}" }
                if let Some(s) = &size { span { class: "att-size num", "{s}" } }
            }
            if removable {
                button { class: "att-remove", r#type: "button", "aria-label": "Remove", onclick: move |e| { if let Some(h) = &onremove { h.call(e); } }, "×" }
            }
        }
    }
}

/// A code block with a copy button.
#[component]
pub fn AdsCodeBlock(
    #[props(into)] code: String,
) -> Element {
    rsx! {
        div { class: "codeblock",
            button { class: "cb-btn", r#type: "button", "Copy" }
            pre { class: "cb-pre", code { "{code}" } }
        }
    }
}

/// A native select, styled as a well.
#[component]
pub fn AdsNativeSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        select { class: "ads-field select", value: value, onchange: move |e| { if let Some(h) = &onchange { h.call(e); } },
            option { value: "", disabled: "true", "{placeholder}" }
            for (v, l) in &options { option { value: "{v}", "{l}" } }
        }
    }
}

/// An input with a prefix/suffix addon.
#[component]
pub fn AdsInputGroup(
    #[props(into, optional)] prefix: Option<String>,
    #[props(into, optional)] suffix: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        div { class: "input-group",
            if let Some(p) = &prefix { span { class: "addon", "{p}" } }
            input {
                class: "ig-input",
                r#type: "text",
                value: value,
                placeholder: placeholder,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
            if let Some(s) = &suffix { span { class: "addon", "{s}" } }
        }
    }
}

/// A one-time-password input.
#[component]
pub fn AdsInputOtp(
    #[props(default = 6u8)] length: u8,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let value = value.unwrap_or_default();
    let chars: Vec<char> = value.chars().collect();
    rsx! {
        div { class: "otp", role: "group", "aria-label": "One-time code",
            for i in 0..length {
                input {
                    class: "otp-box num",
                    inputmode: "numeric",
                    maxlength: "1",
                    value: chars.get(i as usize).map_or_else(String::new, |c| c.to_string()),
                    "aria-label": "Digit {i + 1}",
                }
            }
        }
    }
}

/// A custom select: a button that opens a soft menu.
#[component]
pub fn AdsSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut open = use_signal(|| false);
    let label = options
        .iter()
        .find(|(v, _)| Some(v) == value.as_ref())
        .map_or_else(|| placeholder.clone(), |(_, l)| l.clone());

    rsx! {
        div { class: "select",
            button { class: "select-btn", r#type: "button", "aria-expanded": if *open.read() { "true" } else { "false" }, onclick: move |_| open.toggle(),
                span { "{label}" }
                Icon { name: "chevron", size: 14 }
            }
            if *open.read() {
                div { class: "select-menu",
                    for (v, l) in &options {
                        button {
                            class: "opt",
                            class: if Some(v) == value.as_ref() { "opt on" } else { "opt" },
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

/// A combobox: a text field that filters a list as you type.
#[component]
pub fn AdsCombobox(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, default = "Search…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut query = use_signal(|| String::new());
    let q = query.read();
    let qs = q.as_str();
    let filtered = options.iter().filter(|(_, l)| l.to_lowercase().contains(&qs.to_lowercase())).collect::<Vec<_>>();

    rsx! {
        div { class: "combo",
            input { class: "combo-input", r#type: "text", placeholder: "{placeholder}", value: "{qs}", oninput: move |e| query.set(e.value()) }
            if !filtered.is_empty() {
                div { class: "combo-menu",
                    for (v, l) in &filtered {
                        button {
                            class: "opt",
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

CodeBlock

A code block with a copy button.

let chrome = grey;
rust
AdsCodeBlock { code: "const n = 42" }
Source · ads/ads-rs/src/ui/field2.rs
rust
//! Field-side components: attachment, code block, native select, input group,
//! input otp, select, combobox.

use dioxus::prelude::*;

use crate::icons::Icon;

/// A file attachment chip.
#[component]
pub fn AdsAttachment(
    #[props(into)] name: String,
    #[props(into, optional)] size: Option<String>,
    #[props(default = true)] removable: bool,
    #[props(optional)] onremove: Option<EventHandler<MouseEvent>>,
) -> Element {
    rsx! {
        span { class: "attachment",
            span { class: "att-icon", Icon { name: "code", size: 15 } }
            span { class: "att-text",
                span { class: "att-name", "{name}" }
                if let Some(s) = &size { span { class: "att-size num", "{s}" } }
            }
            if removable {
                button { class: "att-remove", r#type: "button", "aria-label": "Remove", onclick: move |e| { if let Some(h) = &onremove { h.call(e); } }, "×" }
            }
        }
    }
}

/// A code block with a copy button.
#[component]
pub fn AdsCodeBlock(
    #[props(into)] code: String,
) -> Element {
    rsx! {
        div { class: "codeblock",
            button { class: "cb-btn", r#type: "button", "Copy" }
            pre { class: "cb-pre", code { "{code}" } }
        }
    }
}

/// A native select, styled as a well.
#[component]
pub fn AdsNativeSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        select { class: "ads-field select", value: value, onchange: move |e| { if let Some(h) = &onchange { h.call(e); } },
            option { value: "", disabled: "true", "{placeholder}" }
            for (v, l) in &options { option { value: "{v}", "{l}" } }
        }
    }
}

/// An input with a prefix/suffix addon.
#[component]
pub fn AdsInputGroup(
    #[props(into, optional)] prefix: Option<String>,
    #[props(into, optional)] suffix: Option<String>,
    #[props(into, optional)] placeholder: Option<String>,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] oninput: Option<EventHandler<FormEvent>>,
) -> Element {
    rsx! {
        div { class: "input-group",
            if let Some(p) = &prefix { span { class: "addon", "{p}" } }
            input {
                class: "ig-input",
                r#type: "text",
                value: value,
                placeholder: placeholder,
                oninput: move |e| { if let Some(h) = &oninput { h.call(e); } },
            }
            if let Some(s) = &suffix { span { class: "addon", "{s}" } }
        }
    }
}

/// A one-time-password input.
#[component]
pub fn AdsInputOtp(
    #[props(default = 6u8)] length: u8,
    #[props(into, optional)] value: Option<String>,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let value = value.unwrap_or_default();
    let chars: Vec<char> = value.chars().collect();
    rsx! {
        div { class: "otp", role: "group", "aria-label": "One-time code",
            for i in 0..length {
                input {
                    class: "otp-box num",
                    inputmode: "numeric",
                    maxlength: "1",
                    value: chars.get(i as usize).map_or_else(String::new, |c| c.to_string()),
                    "aria-label": "Digit {i + 1}",
                }
            }
        }
    }
}

/// A custom select: a button that opens a soft menu.
#[component]
pub fn AdsSelect(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, optional)] value: Option<String>,
    #[props(into, default = "Choose…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut open = use_signal(|| false);
    let label = options
        .iter()
        .find(|(v, _)| Some(v) == value.as_ref())
        .map_or_else(|| placeholder.clone(), |(_, l)| l.clone());

    rsx! {
        div { class: "select",
            button { class: "select-btn", r#type: "button", "aria-expanded": if *open.read() { "true" } else { "false" }, onclick: move |_| open.toggle(),
                span { "{label}" }
                Icon { name: "chevron", size: 14 }
            }
            if *open.read() {
                div { class: "select-menu",
                    for (v, l) in &options {
                        button {
                            class: "opt",
                            class: if Some(v) == value.as_ref() { "opt on" } else { "opt" },
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

/// A combobox: a text field that filters a list as you type.
#[component]
pub fn AdsCombobox(
    #[props(into)] options: Vec<(String, String)>,
    #[props(into, default = "Search…".to_string())] placeholder: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
) -> Element {
    let mut query = use_signal(|| String::new());
    let q = query.read();
    let qs = q.as_str();
    let filtered = options.iter().filter(|(_, l)| l.to_lowercase().contains(&qs.to_lowercase())).collect::<Vec<_>>();

    rsx! {
        div { class: "combo",
            input { class: "combo-input", r#type: "text", placeholder: "{placeholder}", value: "{qs}", oninput: move |e| query.set(e.value()) }
            if !filtered.is_empty() {
                div { class: "combo-menu",
                    for (v, l) in &filtered {
                        button {
                            class: "opt",
                            r#type: "button",
                            onclick: { let h = onchange.clone(); let val = v.clone(); move |_| { if let Some(h) = &h { h.call(val.clone()); } } },
                            "{l}"
                        }
                    }
                }
            }
        }
    }
}

Overlays & navigation

HoverCard

A card that appears beside its trigger on hover.

rust
AdsHoverCard { trigger: rsx! { AdsButton { variant: ButtonVariant::Quiet, "Hover" } }, "The card." }
Source · ads/ads-rs/src/ui/overlay2.rs
rust
//! Overlay components: hover card, sheet, context menu.

use dioxus::prelude::*;

/// A card that appears beside its trigger on hover.
#[component]
pub fn AdsHoverCard(
    trigger: Element,
    children: Element,
) -> Element {
    rsx! {
        div { class: "hovercard",
            span { class: "trigger", {trigger} }
            div { class: "card-pop", role: "tooltip", {children} }
        }
    }
}

/// A side sheet. `open` is controlled; `onclose` handles the scrim and button.
#[component]
pub fn AdsSheet(
    #[props(default = false)] open: bool,
    #[props(into, default = "right".to_string())] side: String,
    #[props(into, optional)] title: Option<String>,
    #[props(optional)] onclose: Option<EventHandler<MouseEvent>>,
    children: Element,
) -> Element {
    rsx! {
        if open {
            div { class: "scrim", onmousedown: move |e| { if let Some(h) = &onclose { h.call(e); } },
                div { class: "sheet {side}", role: "dialog",
                    if let Some(t) = &title {
                        header { class: "sheet-head",
                            h3 { class: "h3", "{t}" }
                            button { class: "icon-btn", r#type: "button", "aria-label": "Close", onclick: move |e| { if let Some(h) = &onclose { h.call(e); } }, "×" }
                        }
                    }
                    div { class: "sheet-body", {children} }
                }
            }
        }
    }
}

/// A context menu: a soft list of actions.
#[component]
pub fn AdsContextMenu(
    #[props(into)] items: Vec<String>,
    #[props(optional)] onselect: Option<EventHandler<usize>>,
) -> Element {
    rsx! {
        div { class: "ctx-menu", role: "menu",
            for (i, it) in items.iter().enumerate() {
                button { class: "ctx-item", r#type: "button", role: "menuitem", onclick: { let h = onselect.clone(); move |_| { if let Some(h) = &h { h.call(i); } } }, "{it}" }
            }
        }
    }
}

Sheet

A side sheet.

rust
AdsSheet { open: true, side: "right", title: "Details", "…" }
Source · ads/ads-rs/src/ui/overlay2.rs
rust
//! Overlay components: hover card, sheet, context menu.

use dioxus::prelude::*;

/// A card that appears beside its trigger on hover.
#[component]
pub fn AdsHoverCard(
    trigger: Element,
    children: Element,
) -> Element {
    rsx! {
        div { class: "hovercard",
            span { class: "trigger", {trigger} }
            div { class: "card-pop", role: "tooltip", {children} }
        }
    }
}

/// A side sheet. `open` is controlled; `onclose` handles the scrim and button.
#[component]
pub fn AdsSheet(
    #[props(default = false)] open: bool,
    #[props(into, default = "right".to_string())] side: String,
    #[props(into, optional)] title: Option<String>,
    #[props(optional)] onclose: Option<EventHandler<MouseEvent>>,
    children: Element,
) -> Element {
    rsx! {
        if open {
            div { class: "scrim", onmousedown: move |e| { if let Some(h) = &onclose { h.call(e); } },
                div { class: "sheet {side}", role: "dialog",
                    if let Some(t) = &title {
                        header { class: "sheet-head",
                            h3 { class: "h3", "{t}" }
                            button { class: "icon-btn", r#type: "button", "aria-label": "Close", onclick: move |e| { if let Some(h) = &onclose { h.call(e); } }, "×" }
                        }
                    }
                    div { class: "sheet-body", {children} }
                }
            }
        }
    }
}

/// A context menu: a soft list of actions.
#[component]
pub fn AdsContextMenu(
    #[props(into)] items: Vec<String>,
    #[props(optional)] onselect: Option<EventHandler<usize>>,
) -> Element {
    rsx! {
        div { class: "ctx-menu", role: "menu",
            for (i, it) in items.iter().enumerate() {
                button { class: "ctx-item", r#type: "button", role: "menuitem", onclick: { let h = onselect.clone(); move |_| { if let Some(h) = &h { h.call(i); } } }, "{it}" }
            }
        }
    }
}

ContextMenu

A context menu.

Right-click this block.

Press Shift+F10 for actions
rust
AdsContextMenu { items: vec!["Rename".into(), "Delete".into()] }
Source · ads/ads-rs/src/ui/overlay2.rs
rust
//! Overlay components: hover card, sheet, context menu.

use dioxus::prelude::*;

/// A card that appears beside its trigger on hover.
#[component]
pub fn AdsHoverCard(
    trigger: Element,
    children: Element,
) -> Element {
    rsx! {
        div { class: "hovercard",
            span { class: "trigger", {trigger} }
            div { class: "card-pop", role: "tooltip", {children} }
        }
    }
}

/// A side sheet. `open` is controlled; `onclose` handles the scrim and button.
#[component]
pub fn AdsSheet(
    #[props(default = false)] open: bool,
    #[props(into, default = "right".to_string())] side: String,
    #[props(into, optional)] title: Option<String>,
    #[props(optional)] onclose: Option<EventHandler<MouseEvent>>,
    children: Element,
) -> Element {
    rsx! {
        if open {
            div { class: "scrim", onmousedown: move |e| { if let Some(h) = &onclose { h.call(e); } },
                div { class: "sheet {side}", role: "dialog",
                    if let Some(t) = &title {
                        header { class: "sheet-head",
                            h3 { class: "h3", "{t}" }
                            button { class: "icon-btn", r#type: "button", "aria-label": "Close", onclick: move |e| { if let Some(h) = &onclose { h.call(e); } }, "×" }
                        }
                    }
                    div { class: "sheet-body", {children} }
                }
            }
        }
    }
}

/// A context menu: a soft list of actions.
#[component]
pub fn AdsContextMenu(
    #[props(into)] items: Vec<String>,
    #[props(optional)] onselect: Option<EventHandler<usize>>,
) -> Element {
    rsx! {
        div { class: "ctx-menu", role: "menu",
            for (i, it) in items.iter().enumerate() {
                button { class: "ctx-item", r#type: "button", role: "menuitem", onclick: { let h = onselect.clone(); move |_| { if let Some(h) = &h { h.call(i); } } }, "{it}" }
            }
        }
    }
}

Menubar

A horizontal menu bar.

rust
AdsMenubar { items: vec![MenubarItem::group("File", vec![MenubarItem::link("New", "/")])] }
Source · ads/ads-rs/src/ui/nav2.rs
rust
//! Navigation: menu bar and navigation menu.

use dioxus::prelude::*;

use crate::icons::Icon;

/// One entry in a menubar or nav menu.
#[derive(Clone, PartialEq)]
pub struct MenubarItem {
    pub label: String,
    pub to: Option<String>,
    pub children: Vec<MenubarItem>,
}

impl MenubarItem {
    #[must_use]
    pub fn link(label: impl Into<String>, to: impl Into<String>) -> Self {
        Self { label: label.into(), to: Some(to.into()), children: Vec::new() }
    }
    #[must_use]
    pub fn group(label: impl Into<String>, children: Vec<MenubarItem>) -> Self {
        Self { label: label.into(), to: None, children }
    }
}

/// A horizontal menu bar. Items may be plain links or dropdown groups.
#[component]
pub fn AdsMenubar(
    #[props(into)] items: Vec<MenubarItem>,
) -> Element {
    rsx! {
        nav { class: "menubar", "aria-label": "Menu",
            for it in &items {
                if it.children.is_empty() {
                    a { class: "mb-item", href: it.to.as_deref().unwrap_or("#"), "{it.label}" }
                } else {
                    div { class: "mb-group",
                        button { class: "mb-item", r#type: "button", "{it.label}", Icon { name: "chevron", size: 13 } }
                        div { class: "mb-menu",
                            for c in &it.children { a { class: "mb-sub", href: c.to.as_deref().unwrap_or("#"), "{c.label}" } }
                        }
                    }
                }
            }
        }
    }
}

/// A navigation menu: links with an optional dropdown.
#[component]
pub fn AdsNavMenu(
    #[props(into)] links: Vec<MenubarItem>,
) -> Element {
    rsx! {
        nav { class: "navmenu", "aria-label": "Primary",
            for l in &links {
                div { class: "nm-group",
                    a { class: "nm-item", class: if !l.children.is_empty() { "nm-item has" } else { "nm-item" }, href: l.to.as_deref().unwrap_or("#"),
                        "{l.label}"
                        if !l.children.is_empty() { Icon { name: "chevron", size: 13 } }
                    }
                    if !l.children.is_empty() {
                        div { class: "nm-drop",
                            for c in &l.children { a { class: "nm-sub", href: c.to.as_deref().unwrap_or("#"), "{c.label}" } }
                        }
                    }
                }
            }
        }
    }
}

NavMenu

A navigation menu with dropdowns.

rust
AdsNavMenu { links: vec![MenubarItem::group("Work", vec![MenubarItem::link("CrawlStat", "/")])] }
Source · ads/ads-rs/src/ui/nav2.rs
rust
//! Navigation: menu bar and navigation menu.

use dioxus::prelude::*;

use crate::icons::Icon;

/// One entry in a menubar or nav menu.
#[derive(Clone, PartialEq)]
pub struct MenubarItem {
    pub label: String,
    pub to: Option<String>,
    pub children: Vec<MenubarItem>,
}

impl MenubarItem {
    #[must_use]
    pub fn link(label: impl Into<String>, to: impl Into<String>) -> Self {
        Self { label: label.into(), to: Some(to.into()), children: Vec::new() }
    }
    #[must_use]
    pub fn group(label: impl Into<String>, children: Vec<MenubarItem>) -> Self {
        Self { label: label.into(), to: None, children }
    }
}

/// A horizontal menu bar. Items may be plain links or dropdown groups.
#[component]
pub fn AdsMenubar(
    #[props(into)] items: Vec<MenubarItem>,
) -> Element {
    rsx! {
        nav { class: "menubar", "aria-label": "Menu",
            for it in &items {
                if it.children.is_empty() {
                    a { class: "mb-item", href: it.to.as_deref().unwrap_or("#"), "{it.label}" }
                } else {
                    div { class: "mb-group",
                        button { class: "mb-item", r#type: "button", "{it.label}", Icon { name: "chevron", size: 13 } }
                        div { class: "mb-menu",
                            for c in &it.children { a { class: "mb-sub", href: c.to.as_deref().unwrap_or("#"), "{c.label}" } }
                        }
                    }
                }
            }
        }
    }
}

/// A navigation menu: links with an optional dropdown.
#[component]
pub fn AdsNavMenu(
    #[props(into)] links: Vec<MenubarItem>,
) -> Element {
    rsx! {
        nav { class: "navmenu", "aria-label": "Primary",
            for l in &links {
                div { class: "nm-group",
                    a { class: "nm-item", class: if !l.children.is_empty() { "nm-item has" } else { "nm-item" }, href: l.to.as_deref().unwrap_or("#"),
                        "{l.label}"
                        if !l.children.is_empty() { Icon { name: "chevron", size: 13 } }
                    }
                    if !l.children.is_empty() {
                        div { class: "nm-drop",
                            for c in &l.children { a { class: "nm-sub", href: c.to.as_deref().unwrap_or("#"), "{c.label}" } }
                        }
                    }
                }
            }
        }
    }
}

Data & motion

Calendar

A month calendar.

Calendar, 2026-10

MoTuWeThFrSaSu
rust
AdsCalendar { month_label: "October" }
Source · ads/ads-rs/src/ui/widget2.rs
rust
//! Widgets: calendar, carousel, resizable, questionnaire, trace.

use std::collections::HashMap;
use std::rc::Rc;

use dioxus::html::geometry::PixelsVector2D;
use dioxus::html::ScrollBehavior;
use dioxus::prelude::*;

use super::support::{date, reduced_motion, sleep, use_synced, use_today, use_uid, ScrollBox};
use crate::icons::Icon;

const WEEKDAYS: [(&str, &str); 7] = [
    ("Mo", "Monday"),
    ("Tu", "Tuesday"),
    ("We", "Wednesday"),
    ("Th", "Thursday"),
    ("Fr", "Friday"),
    ("Sa", "Saturday"),
    ("Su", "Sunday"),
];

fn month_of(iso: &str) -> Option<String> {
    date::parse(iso).map(|_| iso[..7].to_owned())
}

/// A month grid for picking one day. Dates are ISO (the month reads
/// "2026-10", the value "2026-10-27") and every figure is in the data face so
/// the columns line up. It is a real grid: arrows move a day or a week,
/// PageUp/PageDown a month (with Shift, a year), Home/End go to the week's
/// ends, and each day is a button that reads out its full date. The chosen
/// day takes the accent; today is pressed in, so the two never depend on
/// colour to be told apart.
///
/// `value`/`onchange` and `month`/`onmonthchange` are the two Vue models;
/// either can be left unbound. `autofocus` puts focus on the roving day when
/// the grid mounts, for a calendar that opens in a popover.
#[component]
pub fn AdsCalendar(
    #[props(into, default = "Calendar".to_string())] label: String,
    #[props(into, default)] value: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
    #[props(into, optional)] month: Option<String>,
    #[props(optional)] onmonthchange: Option<EventHandler<String>>,
    #[props(default = false)] autofocus: bool,
) -> Element {
    let uid = use_uid();
    let today = use_today();
    let today_iso = today();
    let mut sel = use_synced(&value);
    let mut shown = use_signal(|| {
        month
            .clone()
            .filter(|m| date::parse_month(m).is_some())
            .or_else(|| month_of(&value))
            .or_else(|| month_of(&today.peek()))
            .unwrap_or_default()
    });
    let mut focus_iso = use_signal(|| if date::parse(&value).is_some() { value.clone() } else { String::new() });
    let mut refs = use_signal(HashMap::<String, Rc<MountedData>>::new);
    let mut did_autofocus = use_signal(|| false);

    let mut set_month = move |m: String| {
        if *shown.peek() != m {
            shown.set(m.clone());
            if let Some(h) = &onmonthchange {
                h.call(m);
            }
        }
    };

    // The caller's month wins when it changes.
    use_effect(use_reactive((&month,), move |(m,)| {
        if let Some(m) = m.filter(|m| date::parse_month(m).is_some()) {
            if *shown.peek() != m {
                shown.set(m);
            }
        }
    }));
    // A new value brings its month into view.
    use_effect(use_reactive((&value,), move |(v,)| {
        if let Some(m) = month_of(&v) {
            focus_iso.set(v);
            set_month(m);
        }
    }));

    // The month on view; until the reader's today is known it may be empty.
    let shown_now = shown();
    let cur = date::parse_month(&shown_now).or_else(|| date::parse_month(today_iso.get(..7).unwrap_or("")));
    let month_text = cur.map(|(y, m)| format!("{y:04}-{m:02}")).unwrap_or_default();

    // The roving day: the focused day if it is in this month, else the same
    // day number clamped to the month, else today, else the 1st.
    let roving = cur.map_or_else(String::new, |(y, m)| {
        let dim = date::days_in_month(y, m);
        let focus_now = focus_iso();
        let day = match date::parse(&focus_now) {
            Some(z) => date::to_civil(z).2,
            None if today_iso.starts_with(&month_text) => date::parse(&today_iso).map_or(1, |z| date::to_civil(z).2),
            None => 1,
        };
        date::iso(date::from_civil(y, m, i64::from(day.min(dim))))
    });

    let weeks: Vec<Vec<Option<String>>> = cur.map_or_else(Vec::new, |(y, m)| {
        let first = date::from_civil(y, m, 1);
        let lead = date::weekday(first) as usize;
        let count = date::days_in_month(y, m);
        let mut cells: Vec<Option<String>> = vec![None; lead];
        cells.extend((0..count).map(|d| Some(date::iso(first + i64::from(d)))));
        while cells.len() % 7 != 0 {
            cells.push(None);
        }
        cells.chunks(7).map(<[_]>::to_vec).collect()
    });

    let shift_month = move |n: i64| {
        if let Some((y, m)) = date::parse_month(&shown.peek()) {
            let z = date::add_months(date::from_civil(y, m, 1), n);
            set_month(date::iso(z)[..7].to_owned());
        }
    };
    let mut prev = shift_month;
    let mut next = shift_month;

    let focus_day = move |iso: String| {
        spawn(async move {
            sleep(0).await;
            let el = refs.peek().get(&iso).cloned();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };

    let roving_k = roving.clone();
    let current_value = sel();
    let month_id = format!("{uid}-month");

    rsx! {
        div { class: "ads-calendar", role: "group", "aria-labelledby": "{month_id}",
            div { class: "ads-calendar__head",
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Previous month",
                    onclick: move |_| prev(-1),
                    {super::support::glyph("arrow", 14, "", Some("transform: rotate(180deg)"))}
                }
                p { id: "{month_id}", class: "ads-calendar__month", "aria-live": "polite",
                    span { class: "sr-only", "{label}, " }
                    "{month_text}"
                }
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Next month",
                    onclick: move |_| next(1),
                    Icon { name: "arrow", size: 14 }
                }
            }
            table {
                class: "ads-calendar__grid",
                role: "grid",
                "aria-labelledby": "{month_id}",
                onkeydown: move |e| {
                    let Some(z) = date::parse(&roving_k) else { return };
                    let dow = i64::from(date::weekday(z));
                    let jump = if e.modifiers().contains(Modifiers::SHIFT) { 12 } else { 1 };
                    let to = match e.key() {
                        Key::ArrowLeft => z - 1,
                        Key::ArrowRight => z + 1,
                        Key::ArrowUp => z - 7,
                        Key::ArrowDown => z + 7,
                        Key::Home => z - dow,
                        Key::End => z + (6 - dow),
                        Key::PageUp => date::add_months(z, -jump),
                        Key::PageDown => date::add_months(z, jump),
                        _ => return,
                    };
                    e.prevent_default();
                    let iso = date::iso(to);
                    focus_iso.set(iso.clone());
                    set_month(iso[..7].to_owned());
                    focus_day(iso);
                },
                thead {
                    tr {
                        for (short, full) in WEEKDAYS {
                            th { key: "{short}", scope: "col", class: "ads-calendar__weekday",
                                abbr { title: "{full}", "{short}" }
                            }
                        }
                    }
                }
                tbody {
                    for (w, week) in weeks.into_iter().enumerate() {
                        tr { key: "{month_text}-{w}",
                            for (i, day) in week.into_iter().enumerate() {
                                {
                                    let chosen = day.as_deref() == Some(current_value.as_str());
                                    let selected = day.as_ref().map(|_| if chosen { "true" } else { "false" });
                                    rsx! {
                                        td {
                                            key: "{i}",
                                            role: "gridcell",
                                            "aria-selected": selected,
                                            if let Some(d) = day {
                                                {
                                                    let is_today = d == today_iso;
                                                    let is_roving = d == roving;
                                                    let class = crate::class::join(&[
                                                        "ads-calendar__day",
                                                        if chosen { "is-on" } else { "" },
                                                        if is_today { "is-today" } else { "" },
                                                    ]);
                                                    let num = d[8..].trim_start_matches('0').to_owned();
                                                    let (d_mount, d_click, d_focus) = (d.clone(), d.clone(), d.clone());
                                                    rsx! {
                                                        button {
                                                            r#type: "button",
                                                            class: "{class}",
                                                            "data-iso": "{d}",
                                                            tabindex: if is_roving { "0" } else { "-1" },
                                                            "aria-label": "{d}",
                                                            "aria-current": if is_today { Some("date") } else { None },
                                                            "aria-pressed": if chosen { "true" } else { "false" },
                                                            onmounted: move |e| {
                                                                let el = e.data();
                                                                refs.write().insert(d_mount.clone(), el.clone());
                                                                if autofocus && is_roving && !*did_autofocus.peek() {
                                                                    did_autofocus.set(true);
                                                                    spawn(async move {
                                                                        let _ = el.set_focus(true).await;
                                                                    });
                                                                }
                                                            },
                                                            onclick: move |_| {
                                                                sel.set(d_click.clone());
                                                                focus_iso.set(d_click.clone());
                                                                if let Some(h) = &onchange {
                                                                    h.call(d_click.clone());
                                                                }
                                                            },
                                                            onfocus: move |_| {
                                                                if *focus_iso.peek() != d_focus {
                                                                    focus_iso.set(d_focus.clone());
                                                                }
                                                            },
                                                            "{num}"
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

/// A horizontal rail of cards that scrolls by a page at a time. The arrows
/// are real buttons named for what they scroll, and switch off at the ends;
/// the rail is a focusable group, so the arrow keys scroll it natively. Edge
/// fades show only where there is more to see.
#[component]
pub fn AdsCarousel(#[props(into)] label: String, children: Element) -> Element {
    let mut rail = use_signal(|| None::<Rc<MountedData>>);
    let mut at_start = use_signal(|| true);
    let mut at_end = use_signal(|| true);

    let mut apply = move |b: ScrollBox| {
        let s = b.left <= 1.0;
        let e = b.left + b.client_width >= b.width - 1.0;
        if *at_start.peek() != s {
            at_start.set(s);
        }
        if *at_end.peek() != e {
            at_end.set(e);
        }
    };
    let remeasure = move || {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                if let Some(b) = ScrollBox::measure(&el).await {
                    apply(b);
                }
            });
        }
    };
    let scroll = move |dir: f64| {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                let Some(b) = ScrollBox::measure(&el).await else { return };
                let behaviour = if reduced_motion().await { ScrollBehavior::Instant } else { ScrollBehavior::Smooth };
                let _ = el
                    .scroll(PixelsVector2D::new(b.left + dir * b.client_width * 0.8, b.top), behaviour)
                    .await;
            });
        }
    };

    let start = at_start();
    let end = at_end();
    let rail_class = crate::class::join(&[
        "ads-carousel__rail",
        if start { "" } else { "is-more-start" },
        if end { "" } else { "is-more-end" },
    ]);
    let remeasure_resize = remeasure;
    rsx! {
        section { class: "ads-carousel", "aria-label": "{label}", "aria-roledescription": "carousel",
            button {
                r#type: "button",
                class: "ads-carousel__button is-prev",
                "aria-label": "Scroll {label} back",
                disabled: start,
                onclick: move |_| scroll(-1.0),
                Icon { name: "arrow", size: 15 }
            }
            div {
                class: "{rail_class}",
                tabindex: "0",
                role: "group",
                "aria-label": "{label} items",
                onmounted: move |e| {
                    rail.set(Some(e.data()));
                    remeasure();
                },
                onscroll: move |e| apply(ScrollBox::from_event(&e.data())),
                onresize: move |_| remeasure_resize(),
                {children}
            }
            button {
                r#type: "button",
                class: "ads-carousel__button",
                "aria-label": "Scroll {label} forward",
                disabled: end,
                onclick: move |_| scroll(1.0),
                Icon { name: "arrow", size: 15 }
            }
        }
    }
}

/// A panel whose width the reader sets. The handle is a focusable separator
/// with its value in pixels, so it moves from the keyboard too: arrows by 16
/// (Shift, 64), Home and End to the limits. `width`/`onwidthchange` are the
/// Vue `v-model:width`; unbound, it keeps its own width.
#[component]
pub fn AdsResizable(
    #[props(default = 280u32)] width: u32,
    #[props(default = 160u32)] min: u32,
    #[props(default = 720u32)] max: u32,
    #[props(into, default = "Resize panel".to_string())] label: String,
    #[props(optional)] onwidthchange: Option<EventHandler<u32>>,
    children: Element,
) -> Element {
    let uid = use_uid();
    let handle_id = format!("{uid}-handle");
    let mut w = use_synced(&width);
    let mut dragging = use_signal(|| false);
    let mut root = use_signal(|| None::<Rc<MountedData>>);
    let mut left = use_signal(|| None::<f64>);

    let clamp = move |n: f64| -> u32 {
        // Within u32 after the clamp, so the cast is exact.
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
        let v = n.clamp(f64::from(min), f64::from(max)).round() as u32;
        v
    };
    let mut set = move |v: u32| {
        if *w.peek() != v {
            w.set(v);
            if let Some(h) = &onwidthchange {
                h.call(v);
            }
        }
    };
    let mut set_drag = set;

    let now = w();
    let class = crate::class::join(&["ads-resizable", if dragging() { "is-dragging" } else { "" }]);
    let hid = handle_id.clone();
    rsx! {
        div {
            class: "{class}",
            style: "width:{now}px",
            onmounted: move |e| root.set(Some(e.data())),
            div { class: "ads-resizable__body", {children} }
            div {
                id: "{handle_id}",
                class: "ads-resizable__handle",
                role: "separator",
                tabindex: "0",
                "aria-orientation": "vertical",
                "aria-label": "{label}",
                "aria-valuenow": "{now}",
                "aria-valuemin": "{min}",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{now} pixels",
                onpointerdown: move |e| {
                    e.prevent_default();
                    dragging.set(true);
                    left.set(None);
                    let pid = e.pointer_id();
                    let _ = document::eval(&format!(
                        "document.getElementById('{hid}')?.setPointerCapture({pid});"
                    ));
                    let el = root.peek().clone();
                    if let Some(el) = el {
                        spawn(async move {
                            if let Ok(r) = el.get_client_rect().await {
                                left.set(Some(r.origin.x));
                            }
                        });
                    }
                },
                onpointermove: move |e| {
                    if !dragging() {
                        return;
                    }
                    if let Some(l) = *left.peek() {
                        set_drag(clamp(e.client_coordinates().x - l));
                    }
                },
                onpointerup: move |_| dragging.set(false),
                onpointercancel: move |_| dragging.set(false),
                onkeydown: move |e| {
                    let step = if e.modifiers().contains(Modifiers::SHIFT) { 64.0 } else { 16.0 };
                    let cur = f64::from(*w.peek());
                    let to = match e.key() {
                        Key::ArrowLeft => cur - step,
                        Key::ArrowRight => cur + step,
                        Key::Home => f64::from(min),
                        Key::End => f64::from(max),
                        _ => return,
                    };
                    e.prevent_default();
                    set(clamp(to));
                },
            }
        }
    }
}

/// One question in a questionnaire.
#[derive(Clone, PartialEq, Debug)]
pub struct Question {
    pub id: String,
    pub text: String,
    pub options: Vec<String>,
    /// Several answers allowed, not one.
    pub multi: bool,
}

impl Question {
    #[must_use]
    pub fn new(id: impl Into<String>, text: impl Into<String>, options: Vec<String>) -> Self {
        Self { id: id.into(), text: text.into(), options, multi: false }
    }

    /// Allow several answers.
    #[must_use]
    pub fn multi(mut self) -> Self {
        self.multi = true;
        self
    }
}

/// A short run of questions, one at a time. The chosen option is pressed in
/// with a tick and says so (`aria-pressed`); progress is greyscale, since it
/// is not the thing to click; Next stays off until there is an answer; and
/// each new question takes focus, so a screen reader hears it at once.
#[component]
pub fn AdsQuestionnaire(
    questions: Vec<Question>,
    #[props(into, default = "Send answers".to_string())] submit_label: String,
    #[props(optional)] ondone: Option<EventHandler<HashMap<String, Vec<String>>>>,
) -> Element {
    let mut step = use_signal(|| 0usize);
    let mut answers = use_signal(HashMap::<String, Vec<String>>::new);
    let mut heading = use_signal(|| None::<Rc<MountedData>>);

    let total = questions.len();
    let at = step().min(total.saturating_sub(1));
    let current = questions.get(at).cloned();
    #[allow(clippy::cast_precision_loss)]
    let progress = if total == 0 { 0.0 } else { (at + 1) as f64 / total as f64 * 100.0 };
    let picked: Vec<String> = current.as_ref().and_then(|q| answers.read().get(&q.id).cloned()).unwrap_or_default();
    let answered = !picked.is_empty();
    let last = at + 1 >= total;

    let mut go = move |n: usize| {
        step.set(n);
        spawn(async move {
            sleep(0).await;
            let el = heading.peek().clone();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };
    let mut go_next = go;

    let (text, multi, options, qid) = match &current {
        Some(q) => (q.text.clone(), q.multi, q.options.clone(), q.id.clone()),
        None => (String::new(), false, Vec::new(), String::new()),
    };
    let shown_step = at + 1;
    rsx! {
        div { class: "ads-questionnaire",
            div { class: "ads-questionnaire__progress", "aria-hidden": "true",
                span { class: "ads-questionnaire__bar", style: "width:{progress}%" }
            }
            p { class: "ads-questionnaire__count num",
                span { class: "sr-only", "Question " }
                "{shown_step}"
                span { "aria-hidden": "true", " / " }
                span { class: "sr-only", " of " }
                "{total}"
            }
            h3 {
                class: "ads-questionnaire__question h3",
                tabindex: "-1",
                onmounted: move |e| heading.set(Some(e.data())),
                "{text}"
            }
            div {
                class: "ads-questionnaire__options",
                role: "group",
                "aria-label": if multi { "Choose any that apply" } else { "Choose one" },
                for o in options.into_iter() {
                    {
                        let on = picked.contains(&o);
                        let qid = qid.clone();
                        let opt = o.clone();
                        rsx! {
                            button {
                                key: "{o}",
                                r#type: "button",
                                class: crate::class::join(&["ads-questionnaire__option", if on { "is-on" } else { "" }]),
                                "aria-pressed": if on { "true" } else { "false" },
                                onclick: move |_| {
                                    let mut all = answers.write();
                                    let cur = all.entry(qid.clone()).or_default();
                                    if multi {
                                        if cur.contains(&opt) {
                                            cur.retain(|x| x != &opt);
                                        } else {
                                            cur.push(opt.clone());
                                        }
                                    } else {
                                        *cur = vec![opt.clone()];
                                    }
                                },
                                if on { Icon { name: "check", size: 13 } }
                                "{o}"
                            }
                        }
                    }
                }
            }
            div { class: "ads-questionnaire__foot",
                if at > 0 {
                    button { r#type: "button", class: "btn btn-quiet", onclick: move |_| go(at - 1), "Previous question" }
                }
                button {
                    r#type: "button",
                    class: "btn btn-accent",
                    disabled: !answered,
                    onclick: move |_| {
                        if !answered {
                            return;
                        }
                        if last {
                            if let Some(h) = &ondone {
                                h.call(answers.peek().clone());
                            }
                        } else {
                            go_next(at + 1);
                        }
                    },
                    if last { "{submit_label}" } else { "Next question" }
                }
            }
        }
    }
}

/// Where a trace step stands.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TraceState {
    Done,
    Now,
    Todo,
}

impl TraceState {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Done => "done",
            Self::Now => "now",
            Self::Todo => "todo",
        }
    }

    const fn spoken(self) -> &'static str {
        match self {
            Self::Done => "Done",
            Self::Now => "In progress",
            Self::Todo => "To do",
        }
    }
}

/// One step in a trace.
#[derive(Clone, PartialEq, Debug)]
pub struct TraceStep {
    pub title: String,
    pub meta: Option<String>,
    /// Left unset, the first step is done and the rest are to do.
    pub state: Option<TraceState>,
}

impl TraceStep {
    #[must_use]
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), meta: None, state: None }
    }

    #[must_use]
    pub fn state(mut self, state: TraceState) -> Self {
        self.state = Some(state);
        self
    }

    #[must_use]
    pub fn meta(mut self, meta: impl Into<String>) -> Self {
        self.meta = Some(meta.into());
        self
    }
}

/// A run of steps on a line: what has happened, what is happening, what is
/// next. The state is in the shape (a tick, a pressed dot, an empty one) and
/// in words for a screen reader, never in colour alone; the current step is
/// `aria-current="step"`.
#[component]
pub fn AdsTrace(steps: Vec<TraceStep>, #[props(into, optional)] label: Option<String>) -> Element {
    let count = steps.len();
    rsx! {
        ol { class: "ads-trace", "aria-label": label,
            for (i, s) in steps.iter().enumerate() {
                {
                    let st = s.state.unwrap_or(if i == 0 { TraceState::Done } else { TraceState::Todo });
                    let class = format!("ads-trace__step is-{}", st.as_str());
                    rsx! {
                        li {
                            key: "{s.title}",
                            class: "{class}",
                            "aria-current": if st == TraceState::Now { Some("step") } else { None },
                            span { class: "ads-trace__dot", "aria-hidden": "true",
                                if st == TraceState::Done { Icon { name: "check", size: 10 } }
                            }
                            div { class: "ads-trace__body",
                                span { class: "ads-trace__title",
                                    "{s.title}"
                                    span { class: "sr-only", ", {st.spoken()}" }
                                }
                                if let Some(m) = &s.meta {
                                    span { class: "ads-trace__meta num", "{m}" }
                                }
                            }
                            if i + 1 < count {
                                span { class: "ads-trace__line", "aria-hidden": "true" }
                            }
                        }
                    }
                }
            }
        }
    }
}

Trace

A vertical trail of steps.

  1. Discovery, Done
  2. Build, In progress
  3. Handover, To do
rust
AdsTrace { steps: vec![TraceStep::new("Discovery", "done").meta("wk 1")] }
Source · ads/ads-rs/src/ui/widget2.rs
rust
//! Widgets: calendar, carousel, resizable, questionnaire, trace.

use std::collections::HashMap;
use std::rc::Rc;

use dioxus::html::geometry::PixelsVector2D;
use dioxus::html::ScrollBehavior;
use dioxus::prelude::*;

use super::support::{date, reduced_motion, sleep, use_synced, use_today, use_uid, ScrollBox};
use crate::icons::Icon;

const WEEKDAYS: [(&str, &str); 7] = [
    ("Mo", "Monday"),
    ("Tu", "Tuesday"),
    ("We", "Wednesday"),
    ("Th", "Thursday"),
    ("Fr", "Friday"),
    ("Sa", "Saturday"),
    ("Su", "Sunday"),
];

fn month_of(iso: &str) -> Option<String> {
    date::parse(iso).map(|_| iso[..7].to_owned())
}

/// A month grid for picking one day. Dates are ISO (the month reads
/// "2026-10", the value "2026-10-27") and every figure is in the data face so
/// the columns line up. It is a real grid: arrows move a day or a week,
/// PageUp/PageDown a month (with Shift, a year), Home/End go to the week's
/// ends, and each day is a button that reads out its full date. The chosen
/// day takes the accent; today is pressed in, so the two never depend on
/// colour to be told apart.
///
/// `value`/`onchange` and `month`/`onmonthchange` are the two Vue models;
/// either can be left unbound. `autofocus` puts focus on the roving day when
/// the grid mounts, for a calendar that opens in a popover.
#[component]
pub fn AdsCalendar(
    #[props(into, default = "Calendar".to_string())] label: String,
    #[props(into, default)] value: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
    #[props(into, optional)] month: Option<String>,
    #[props(optional)] onmonthchange: Option<EventHandler<String>>,
    #[props(default = false)] autofocus: bool,
) -> Element {
    let uid = use_uid();
    let today = use_today();
    let today_iso = today();
    let mut sel = use_synced(&value);
    let mut shown = use_signal(|| {
        month
            .clone()
            .filter(|m| date::parse_month(m).is_some())
            .or_else(|| month_of(&value))
            .or_else(|| month_of(&today.peek()))
            .unwrap_or_default()
    });
    let mut focus_iso = use_signal(|| if date::parse(&value).is_some() { value.clone() } else { String::new() });
    let mut refs = use_signal(HashMap::<String, Rc<MountedData>>::new);
    let mut did_autofocus = use_signal(|| false);

    let mut set_month = move |m: String| {
        if *shown.peek() != m {
            shown.set(m.clone());
            if let Some(h) = &onmonthchange {
                h.call(m);
            }
        }
    };

    // The caller's month wins when it changes.
    use_effect(use_reactive((&month,), move |(m,)| {
        if let Some(m) = m.filter(|m| date::parse_month(m).is_some()) {
            if *shown.peek() != m {
                shown.set(m);
            }
        }
    }));
    // A new value brings its month into view.
    use_effect(use_reactive((&value,), move |(v,)| {
        if let Some(m) = month_of(&v) {
            focus_iso.set(v);
            set_month(m);
        }
    }));

    // The month on view; until the reader's today is known it may be empty.
    let shown_now = shown();
    let cur = date::parse_month(&shown_now).or_else(|| date::parse_month(today_iso.get(..7).unwrap_or("")));
    let month_text = cur.map(|(y, m)| format!("{y:04}-{m:02}")).unwrap_or_default();

    // The roving day: the focused day if it is in this month, else the same
    // day number clamped to the month, else today, else the 1st.
    let roving = cur.map_or_else(String::new, |(y, m)| {
        let dim = date::days_in_month(y, m);
        let focus_now = focus_iso();
        let day = match date::parse(&focus_now) {
            Some(z) => date::to_civil(z).2,
            None if today_iso.starts_with(&month_text) => date::parse(&today_iso).map_or(1, |z| date::to_civil(z).2),
            None => 1,
        };
        date::iso(date::from_civil(y, m, i64::from(day.min(dim))))
    });

    let weeks: Vec<Vec<Option<String>>> = cur.map_or_else(Vec::new, |(y, m)| {
        let first = date::from_civil(y, m, 1);
        let lead = date::weekday(first) as usize;
        let count = date::days_in_month(y, m);
        let mut cells: Vec<Option<String>> = vec![None; lead];
        cells.extend((0..count).map(|d| Some(date::iso(first + i64::from(d)))));
        while cells.len() % 7 != 0 {
            cells.push(None);
        }
        cells.chunks(7).map(<[_]>::to_vec).collect()
    });

    let shift_month = move |n: i64| {
        if let Some((y, m)) = date::parse_month(&shown.peek()) {
            let z = date::add_months(date::from_civil(y, m, 1), n);
            set_month(date::iso(z)[..7].to_owned());
        }
    };
    let mut prev = shift_month;
    let mut next = shift_month;

    let focus_day = move |iso: String| {
        spawn(async move {
            sleep(0).await;
            let el = refs.peek().get(&iso).cloned();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };

    let roving_k = roving.clone();
    let current_value = sel();
    let month_id = format!("{uid}-month");

    rsx! {
        div { class: "ads-calendar", role: "group", "aria-labelledby": "{month_id}",
            div { class: "ads-calendar__head",
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Previous month",
                    onclick: move |_| prev(-1),
                    {super::support::glyph("arrow", 14, "", Some("transform: rotate(180deg)"))}
                }
                p { id: "{month_id}", class: "ads-calendar__month", "aria-live": "polite",
                    span { class: "sr-only", "{label}, " }
                    "{month_text}"
                }
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Next month",
                    onclick: move |_| next(1),
                    Icon { name: "arrow", size: 14 }
                }
            }
            table {
                class: "ads-calendar__grid",
                role: "grid",
                "aria-labelledby": "{month_id}",
                onkeydown: move |e| {
                    let Some(z) = date::parse(&roving_k) else { return };
                    let dow = i64::from(date::weekday(z));
                    let jump = if e.modifiers().contains(Modifiers::SHIFT) { 12 } else { 1 };
                    let to = match e.key() {
                        Key::ArrowLeft => z - 1,
                        Key::ArrowRight => z + 1,
                        Key::ArrowUp => z - 7,
                        Key::ArrowDown => z + 7,
                        Key::Home => z - dow,
                        Key::End => z + (6 - dow),
                        Key::PageUp => date::add_months(z, -jump),
                        Key::PageDown => date::add_months(z, jump),
                        _ => return,
                    };
                    e.prevent_default();
                    let iso = date::iso(to);
                    focus_iso.set(iso.clone());
                    set_month(iso[..7].to_owned());
                    focus_day(iso);
                },
                thead {
                    tr {
                        for (short, full) in WEEKDAYS {
                            th { key: "{short}", scope: "col", class: "ads-calendar__weekday",
                                abbr { title: "{full}", "{short}" }
                            }
                        }
                    }
                }
                tbody {
                    for (w, week) in weeks.into_iter().enumerate() {
                        tr { key: "{month_text}-{w}",
                            for (i, day) in week.into_iter().enumerate() {
                                {
                                    let chosen = day.as_deref() == Some(current_value.as_str());
                                    let selected = day.as_ref().map(|_| if chosen { "true" } else { "false" });
                                    rsx! {
                                        td {
                                            key: "{i}",
                                            role: "gridcell",
                                            "aria-selected": selected,
                                            if let Some(d) = day {
                                                {
                                                    let is_today = d == today_iso;
                                                    let is_roving = d == roving;
                                                    let class = crate::class::join(&[
                                                        "ads-calendar__day",
                                                        if chosen { "is-on" } else { "" },
                                                        if is_today { "is-today" } else { "" },
                                                    ]);
                                                    let num = d[8..].trim_start_matches('0').to_owned();
                                                    let (d_mount, d_click, d_focus) = (d.clone(), d.clone(), d.clone());
                                                    rsx! {
                                                        button {
                                                            r#type: "button",
                                                            class: "{class}",
                                                            "data-iso": "{d}",
                                                            tabindex: if is_roving { "0" } else { "-1" },
                                                            "aria-label": "{d}",
                                                            "aria-current": if is_today { Some("date") } else { None },
                                                            "aria-pressed": if chosen { "true" } else { "false" },
                                                            onmounted: move |e| {
                                                                let el = e.data();
                                                                refs.write().insert(d_mount.clone(), el.clone());
                                                                if autofocus && is_roving && !*did_autofocus.peek() {
                                                                    did_autofocus.set(true);
                                                                    spawn(async move {
                                                                        let _ = el.set_focus(true).await;
                                                                    });
                                                                }
                                                            },
                                                            onclick: move |_| {
                                                                sel.set(d_click.clone());
                                                                focus_iso.set(d_click.clone());
                                                                if let Some(h) = &onchange {
                                                                    h.call(d_click.clone());
                                                                }
                                                            },
                                                            onfocus: move |_| {
                                                                if *focus_iso.peek() != d_focus {
                                                                    focus_iso.set(d_focus.clone());
                                                                }
                                                            },
                                                            "{num}"
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

/// A horizontal rail of cards that scrolls by a page at a time. The arrows
/// are real buttons named for what they scroll, and switch off at the ends;
/// the rail is a focusable group, so the arrow keys scroll it natively. Edge
/// fades show only where there is more to see.
#[component]
pub fn AdsCarousel(#[props(into)] label: String, children: Element) -> Element {
    let mut rail = use_signal(|| None::<Rc<MountedData>>);
    let mut at_start = use_signal(|| true);
    let mut at_end = use_signal(|| true);

    let mut apply = move |b: ScrollBox| {
        let s = b.left <= 1.0;
        let e = b.left + b.client_width >= b.width - 1.0;
        if *at_start.peek() != s {
            at_start.set(s);
        }
        if *at_end.peek() != e {
            at_end.set(e);
        }
    };
    let remeasure = move || {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                if let Some(b) = ScrollBox::measure(&el).await {
                    apply(b);
                }
            });
        }
    };
    let scroll = move |dir: f64| {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                let Some(b) = ScrollBox::measure(&el).await else { return };
                let behaviour = if reduced_motion().await { ScrollBehavior::Instant } else { ScrollBehavior::Smooth };
                let _ = el
                    .scroll(PixelsVector2D::new(b.left + dir * b.client_width * 0.8, b.top), behaviour)
                    .await;
            });
        }
    };

    let start = at_start();
    let end = at_end();
    let rail_class = crate::class::join(&[
        "ads-carousel__rail",
        if start { "" } else { "is-more-start" },
        if end { "" } else { "is-more-end" },
    ]);
    let remeasure_resize = remeasure;
    rsx! {
        section { class: "ads-carousel", "aria-label": "{label}", "aria-roledescription": "carousel",
            button {
                r#type: "button",
                class: "ads-carousel__button is-prev",
                "aria-label": "Scroll {label} back",
                disabled: start,
                onclick: move |_| scroll(-1.0),
                Icon { name: "arrow", size: 15 }
            }
            div {
                class: "{rail_class}",
                tabindex: "0",
                role: "group",
                "aria-label": "{label} items",
                onmounted: move |e| {
                    rail.set(Some(e.data()));
                    remeasure();
                },
                onscroll: move |e| apply(ScrollBox::from_event(&e.data())),
                onresize: move |_| remeasure_resize(),
                {children}
            }
            button {
                r#type: "button",
                class: "ads-carousel__button",
                "aria-label": "Scroll {label} forward",
                disabled: end,
                onclick: move |_| scroll(1.0),
                Icon { name: "arrow", size: 15 }
            }
        }
    }
}

/// A panel whose width the reader sets. The handle is a focusable separator
/// with its value in pixels, so it moves from the keyboard too: arrows by 16
/// (Shift, 64), Home and End to the limits. `width`/`onwidthchange` are the
/// Vue `v-model:width`; unbound, it keeps its own width.
#[component]
pub fn AdsResizable(
    #[props(default = 280u32)] width: u32,
    #[props(default = 160u32)] min: u32,
    #[props(default = 720u32)] max: u32,
    #[props(into, default = "Resize panel".to_string())] label: String,
    #[props(optional)] onwidthchange: Option<EventHandler<u32>>,
    children: Element,
) -> Element {
    let uid = use_uid();
    let handle_id = format!("{uid}-handle");
    let mut w = use_synced(&width);
    let mut dragging = use_signal(|| false);
    let mut root = use_signal(|| None::<Rc<MountedData>>);
    let mut left = use_signal(|| None::<f64>);

    let clamp = move |n: f64| -> u32 {
        // Within u32 after the clamp, so the cast is exact.
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
        let v = n.clamp(f64::from(min), f64::from(max)).round() as u32;
        v
    };
    let mut set = move |v: u32| {
        if *w.peek() != v {
            w.set(v);
            if let Some(h) = &onwidthchange {
                h.call(v);
            }
        }
    };
    let mut set_drag = set;

    let now = w();
    let class = crate::class::join(&["ads-resizable", if dragging() { "is-dragging" } else { "" }]);
    let hid = handle_id.clone();
    rsx! {
        div {
            class: "{class}",
            style: "width:{now}px",
            onmounted: move |e| root.set(Some(e.data())),
            div { class: "ads-resizable__body", {children} }
            div {
                id: "{handle_id}",
                class: "ads-resizable__handle",
                role: "separator",
                tabindex: "0",
                "aria-orientation": "vertical",
                "aria-label": "{label}",
                "aria-valuenow": "{now}",
                "aria-valuemin": "{min}",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{now} pixels",
                onpointerdown: move |e| {
                    e.prevent_default();
                    dragging.set(true);
                    left.set(None);
                    let pid = e.pointer_id();
                    let _ = document::eval(&format!(
                        "document.getElementById('{hid}')?.setPointerCapture({pid});"
                    ));
                    let el = root.peek().clone();
                    if let Some(el) = el {
                        spawn(async move {
                            if let Ok(r) = el.get_client_rect().await {
                                left.set(Some(r.origin.x));
                            }
                        });
                    }
                },
                onpointermove: move |e| {
                    if !dragging() {
                        return;
                    }
                    if let Some(l) = *left.peek() {
                        set_drag(clamp(e.client_coordinates().x - l));
                    }
                },
                onpointerup: move |_| dragging.set(false),
                onpointercancel: move |_| dragging.set(false),
                onkeydown: move |e| {
                    let step = if e.modifiers().contains(Modifiers::SHIFT) { 64.0 } else { 16.0 };
                    let cur = f64::from(*w.peek());
                    let to = match e.key() {
                        Key::ArrowLeft => cur - step,
                        Key::ArrowRight => cur + step,
                        Key::Home => f64::from(min),
                        Key::End => f64::from(max),
                        _ => return,
                    };
                    e.prevent_default();
                    set(clamp(to));
                },
            }
        }
    }
}

/// One question in a questionnaire.
#[derive(Clone, PartialEq, Debug)]
pub struct Question {
    pub id: String,
    pub text: String,
    pub options: Vec<String>,
    /// Several answers allowed, not one.
    pub multi: bool,
}

impl Question {
    #[must_use]
    pub fn new(id: impl Into<String>, text: impl Into<String>, options: Vec<String>) -> Self {
        Self { id: id.into(), text: text.into(), options, multi: false }
    }

    /// Allow several answers.
    #[must_use]
    pub fn multi(mut self) -> Self {
        self.multi = true;
        self
    }
}

/// A short run of questions, one at a time. The chosen option is pressed in
/// with a tick and says so (`aria-pressed`); progress is greyscale, since it
/// is not the thing to click; Next stays off until there is an answer; and
/// each new question takes focus, so a screen reader hears it at once.
#[component]
pub fn AdsQuestionnaire(
    questions: Vec<Question>,
    #[props(into, default = "Send answers".to_string())] submit_label: String,
    #[props(optional)] ondone: Option<EventHandler<HashMap<String, Vec<String>>>>,
) -> Element {
    let mut step = use_signal(|| 0usize);
    let mut answers = use_signal(HashMap::<String, Vec<String>>::new);
    let mut heading = use_signal(|| None::<Rc<MountedData>>);

    let total = questions.len();
    let at = step().min(total.saturating_sub(1));
    let current = questions.get(at).cloned();
    #[allow(clippy::cast_precision_loss)]
    let progress = if total == 0 { 0.0 } else { (at + 1) as f64 / total as f64 * 100.0 };
    let picked: Vec<String> = current.as_ref().and_then(|q| answers.read().get(&q.id).cloned()).unwrap_or_default();
    let answered = !picked.is_empty();
    let last = at + 1 >= total;

    let mut go = move |n: usize| {
        step.set(n);
        spawn(async move {
            sleep(0).await;
            let el = heading.peek().clone();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };
    let mut go_next = go;

    let (text, multi, options, qid) = match &current {
        Some(q) => (q.text.clone(), q.multi, q.options.clone(), q.id.clone()),
        None => (String::new(), false, Vec::new(), String::new()),
    };
    let shown_step = at + 1;
    rsx! {
        div { class: "ads-questionnaire",
            div { class: "ads-questionnaire__progress", "aria-hidden": "true",
                span { class: "ads-questionnaire__bar", style: "width:{progress}%" }
            }
            p { class: "ads-questionnaire__count num",
                span { class: "sr-only", "Question " }
                "{shown_step}"
                span { "aria-hidden": "true", " / " }
                span { class: "sr-only", " of " }
                "{total}"
            }
            h3 {
                class: "ads-questionnaire__question h3",
                tabindex: "-1",
                onmounted: move |e| heading.set(Some(e.data())),
                "{text}"
            }
            div {
                class: "ads-questionnaire__options",
                role: "group",
                "aria-label": if multi { "Choose any that apply" } else { "Choose one" },
                for o in options.into_iter() {
                    {
                        let on = picked.contains(&o);
                        let qid = qid.clone();
                        let opt = o.clone();
                        rsx! {
                            button {
                                key: "{o}",
                                r#type: "button",
                                class: crate::class::join(&["ads-questionnaire__option", if on { "is-on" } else { "" }]),
                                "aria-pressed": if on { "true" } else { "false" },
                                onclick: move |_| {
                                    let mut all = answers.write();
                                    let cur = all.entry(qid.clone()).or_default();
                                    if multi {
                                        if cur.contains(&opt) {
                                            cur.retain(|x| x != &opt);
                                        } else {
                                            cur.push(opt.clone());
                                        }
                                    } else {
                                        *cur = vec![opt.clone()];
                                    }
                                },
                                if on { Icon { name: "check", size: 13 } }
                                "{o}"
                            }
                        }
                    }
                }
            }
            div { class: "ads-questionnaire__foot",
                if at > 0 {
                    button { r#type: "button", class: "btn btn-quiet", onclick: move |_| go(at - 1), "Previous question" }
                }
                button {
                    r#type: "button",
                    class: "btn btn-accent",
                    disabled: !answered,
                    onclick: move |_| {
                        if !answered {
                            return;
                        }
                        if last {
                            if let Some(h) = &ondone {
                                h.call(answers.peek().clone());
                            }
                        } else {
                            go_next(at + 1);
                        }
                    },
                    if last { "{submit_label}" } else { "Next question" }
                }
            }
        }
    }
}

/// Where a trace step stands.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TraceState {
    Done,
    Now,
    Todo,
}

impl TraceState {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Done => "done",
            Self::Now => "now",
            Self::Todo => "todo",
        }
    }

    const fn spoken(self) -> &'static str {
        match self {
            Self::Done => "Done",
            Self::Now => "In progress",
            Self::Todo => "To do",
        }
    }
}

/// One step in a trace.
#[derive(Clone, PartialEq, Debug)]
pub struct TraceStep {
    pub title: String,
    pub meta: Option<String>,
    /// Left unset, the first step is done and the rest are to do.
    pub state: Option<TraceState>,
}

impl TraceStep {
    #[must_use]
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), meta: None, state: None }
    }

    #[must_use]
    pub fn state(mut self, state: TraceState) -> Self {
        self.state = Some(state);
        self
    }

    #[must_use]
    pub fn meta(mut self, meta: impl Into<String>) -> Self {
        self.meta = Some(meta.into());
        self
    }
}

/// A run of steps on a line: what has happened, what is happening, what is
/// next. The state is in the shape (a tick, a pressed dot, an empty one) and
/// in words for a screen reader, never in colour alone; the current step is
/// `aria-current="step"`.
#[component]
pub fn AdsTrace(steps: Vec<TraceStep>, #[props(into, optional)] label: Option<String>) -> Element {
    let count = steps.len();
    rsx! {
        ol { class: "ads-trace", "aria-label": label,
            for (i, s) in steps.iter().enumerate() {
                {
                    let st = s.state.unwrap_or(if i == 0 { TraceState::Done } else { TraceState::Todo });
                    let class = format!("ads-trace__step is-{}", st.as_str());
                    rsx! {
                        li {
                            key: "{s.title}",
                            class: "{class}",
                            "aria-current": if st == TraceState::Now { Some("step") } else { None },
                            span { class: "ads-trace__dot", "aria-hidden": "true",
                                if st == TraceState::Done { Icon { name: "check", size: 10 } }
                            }
                            div { class: "ads-trace__body",
                                span { class: "ads-trace__title",
                                    "{s.title}"
                                    span { class: "sr-only", ", {st.spoken()}" }
                                }
                                if let Some(m) = &s.meta {
                                    span { class: "ads-trace__meta num", "{m}" }
                                }
                            }
                            if i + 1 < count {
                                span { class: "ads-trace__line", "aria-hidden": "true" }
                            }
                        }
                    }
                }
            }
        }
    }
}

Questionnaire

One question at a time.

Question 1 of 1

What do you need?

rust
AdsQuestionnaire { questions: vec![Question::new("q1", "What do you need?", vec!["A site".into()])] }
Source · ads/ads-rs/src/ui/widget2.rs
rust
//! Widgets: calendar, carousel, resizable, questionnaire, trace.

use std::collections::HashMap;
use std::rc::Rc;

use dioxus::html::geometry::PixelsVector2D;
use dioxus::html::ScrollBehavior;
use dioxus::prelude::*;

use super::support::{date, reduced_motion, sleep, use_synced, use_today, use_uid, ScrollBox};
use crate::icons::Icon;

const WEEKDAYS: [(&str, &str); 7] = [
    ("Mo", "Monday"),
    ("Tu", "Tuesday"),
    ("We", "Wednesday"),
    ("Th", "Thursday"),
    ("Fr", "Friday"),
    ("Sa", "Saturday"),
    ("Su", "Sunday"),
];

fn month_of(iso: &str) -> Option<String> {
    date::parse(iso).map(|_| iso[..7].to_owned())
}

/// A month grid for picking one day. Dates are ISO (the month reads
/// "2026-10", the value "2026-10-27") and every figure is in the data face so
/// the columns line up. It is a real grid: arrows move a day or a week,
/// PageUp/PageDown a month (with Shift, a year), Home/End go to the week's
/// ends, and each day is a button that reads out its full date. The chosen
/// day takes the accent; today is pressed in, so the two never depend on
/// colour to be told apart.
///
/// `value`/`onchange` and `month`/`onmonthchange` are the two Vue models;
/// either can be left unbound. `autofocus` puts focus on the roving day when
/// the grid mounts, for a calendar that opens in a popover.
#[component]
pub fn AdsCalendar(
    #[props(into, default = "Calendar".to_string())] label: String,
    #[props(into, default)] value: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
    #[props(into, optional)] month: Option<String>,
    #[props(optional)] onmonthchange: Option<EventHandler<String>>,
    #[props(default = false)] autofocus: bool,
) -> Element {
    let uid = use_uid();
    let today = use_today();
    let today_iso = today();
    let mut sel = use_synced(&value);
    let mut shown = use_signal(|| {
        month
            .clone()
            .filter(|m| date::parse_month(m).is_some())
            .or_else(|| month_of(&value))
            .or_else(|| month_of(&today.peek()))
            .unwrap_or_default()
    });
    let mut focus_iso = use_signal(|| if date::parse(&value).is_some() { value.clone() } else { String::new() });
    let mut refs = use_signal(HashMap::<String, Rc<MountedData>>::new);
    let mut did_autofocus = use_signal(|| false);

    let mut set_month = move |m: String| {
        if *shown.peek() != m {
            shown.set(m.clone());
            if let Some(h) = &onmonthchange {
                h.call(m);
            }
        }
    };

    // The caller's month wins when it changes.
    use_effect(use_reactive((&month,), move |(m,)| {
        if let Some(m) = m.filter(|m| date::parse_month(m).is_some()) {
            if *shown.peek() != m {
                shown.set(m);
            }
        }
    }));
    // A new value brings its month into view.
    use_effect(use_reactive((&value,), move |(v,)| {
        if let Some(m) = month_of(&v) {
            focus_iso.set(v);
            set_month(m);
        }
    }));

    // The month on view; until the reader's today is known it may be empty.
    let shown_now = shown();
    let cur = date::parse_month(&shown_now).or_else(|| date::parse_month(today_iso.get(..7).unwrap_or("")));
    let month_text = cur.map(|(y, m)| format!("{y:04}-{m:02}")).unwrap_or_default();

    // The roving day: the focused day if it is in this month, else the same
    // day number clamped to the month, else today, else the 1st.
    let roving = cur.map_or_else(String::new, |(y, m)| {
        let dim = date::days_in_month(y, m);
        let focus_now = focus_iso();
        let day = match date::parse(&focus_now) {
            Some(z) => date::to_civil(z).2,
            None if today_iso.starts_with(&month_text) => date::parse(&today_iso).map_or(1, |z| date::to_civil(z).2),
            None => 1,
        };
        date::iso(date::from_civil(y, m, i64::from(day.min(dim))))
    });

    let weeks: Vec<Vec<Option<String>>> = cur.map_or_else(Vec::new, |(y, m)| {
        let first = date::from_civil(y, m, 1);
        let lead = date::weekday(first) as usize;
        let count = date::days_in_month(y, m);
        let mut cells: Vec<Option<String>> = vec![None; lead];
        cells.extend((0..count).map(|d| Some(date::iso(first + i64::from(d)))));
        while cells.len() % 7 != 0 {
            cells.push(None);
        }
        cells.chunks(7).map(<[_]>::to_vec).collect()
    });

    let shift_month = move |n: i64| {
        if let Some((y, m)) = date::parse_month(&shown.peek()) {
            let z = date::add_months(date::from_civil(y, m, 1), n);
            set_month(date::iso(z)[..7].to_owned());
        }
    };
    let mut prev = shift_month;
    let mut next = shift_month;

    let focus_day = move |iso: String| {
        spawn(async move {
            sleep(0).await;
            let el = refs.peek().get(&iso).cloned();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };

    let roving_k = roving.clone();
    let current_value = sel();
    let month_id = format!("{uid}-month");

    rsx! {
        div { class: "ads-calendar", role: "group", "aria-labelledby": "{month_id}",
            div { class: "ads-calendar__head",
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Previous month",
                    onclick: move |_| prev(-1),
                    {super::support::glyph("arrow", 14, "", Some("transform: rotate(180deg)"))}
                }
                p { id: "{month_id}", class: "ads-calendar__month", "aria-live": "polite",
                    span { class: "sr-only", "{label}, " }
                    "{month_text}"
                }
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Next month",
                    onclick: move |_| next(1),
                    Icon { name: "arrow", size: 14 }
                }
            }
            table {
                class: "ads-calendar__grid",
                role: "grid",
                "aria-labelledby": "{month_id}",
                onkeydown: move |e| {
                    let Some(z) = date::parse(&roving_k) else { return };
                    let dow = i64::from(date::weekday(z));
                    let jump = if e.modifiers().contains(Modifiers::SHIFT) { 12 } else { 1 };
                    let to = match e.key() {
                        Key::ArrowLeft => z - 1,
                        Key::ArrowRight => z + 1,
                        Key::ArrowUp => z - 7,
                        Key::ArrowDown => z + 7,
                        Key::Home => z - dow,
                        Key::End => z + (6 - dow),
                        Key::PageUp => date::add_months(z, -jump),
                        Key::PageDown => date::add_months(z, jump),
                        _ => return,
                    };
                    e.prevent_default();
                    let iso = date::iso(to);
                    focus_iso.set(iso.clone());
                    set_month(iso[..7].to_owned());
                    focus_day(iso);
                },
                thead {
                    tr {
                        for (short, full) in WEEKDAYS {
                            th { key: "{short}", scope: "col", class: "ads-calendar__weekday",
                                abbr { title: "{full}", "{short}" }
                            }
                        }
                    }
                }
                tbody {
                    for (w, week) in weeks.into_iter().enumerate() {
                        tr { key: "{month_text}-{w}",
                            for (i, day) in week.into_iter().enumerate() {
                                {
                                    let chosen = day.as_deref() == Some(current_value.as_str());
                                    let selected = day.as_ref().map(|_| if chosen { "true" } else { "false" });
                                    rsx! {
                                        td {
                                            key: "{i}",
                                            role: "gridcell",
                                            "aria-selected": selected,
                                            if let Some(d) = day {
                                                {
                                                    let is_today = d == today_iso;
                                                    let is_roving = d == roving;
                                                    let class = crate::class::join(&[
                                                        "ads-calendar__day",
                                                        if chosen { "is-on" } else { "" },
                                                        if is_today { "is-today" } else { "" },
                                                    ]);
                                                    let num = d[8..].trim_start_matches('0').to_owned();
                                                    let (d_mount, d_click, d_focus) = (d.clone(), d.clone(), d.clone());
                                                    rsx! {
                                                        button {
                                                            r#type: "button",
                                                            class: "{class}",
                                                            "data-iso": "{d}",
                                                            tabindex: if is_roving { "0" } else { "-1" },
                                                            "aria-label": "{d}",
                                                            "aria-current": if is_today { Some("date") } else { None },
                                                            "aria-pressed": if chosen { "true" } else { "false" },
                                                            onmounted: move |e| {
                                                                let el = e.data();
                                                                refs.write().insert(d_mount.clone(), el.clone());
                                                                if autofocus && is_roving && !*did_autofocus.peek() {
                                                                    did_autofocus.set(true);
                                                                    spawn(async move {
                                                                        let _ = el.set_focus(true).await;
                                                                    });
                                                                }
                                                            },
                                                            onclick: move |_| {
                                                                sel.set(d_click.clone());
                                                                focus_iso.set(d_click.clone());
                                                                if let Some(h) = &onchange {
                                                                    h.call(d_click.clone());
                                                                }
                                                            },
                                                            onfocus: move |_| {
                                                                if *focus_iso.peek() != d_focus {
                                                                    focus_iso.set(d_focus.clone());
                                                                }
                                                            },
                                                            "{num}"
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

/// A horizontal rail of cards that scrolls by a page at a time. The arrows
/// are real buttons named for what they scroll, and switch off at the ends;
/// the rail is a focusable group, so the arrow keys scroll it natively. Edge
/// fades show only where there is more to see.
#[component]
pub fn AdsCarousel(#[props(into)] label: String, children: Element) -> Element {
    let mut rail = use_signal(|| None::<Rc<MountedData>>);
    let mut at_start = use_signal(|| true);
    let mut at_end = use_signal(|| true);

    let mut apply = move |b: ScrollBox| {
        let s = b.left <= 1.0;
        let e = b.left + b.client_width >= b.width - 1.0;
        if *at_start.peek() != s {
            at_start.set(s);
        }
        if *at_end.peek() != e {
            at_end.set(e);
        }
    };
    let remeasure = move || {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                if let Some(b) = ScrollBox::measure(&el).await {
                    apply(b);
                }
            });
        }
    };
    let scroll = move |dir: f64| {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                let Some(b) = ScrollBox::measure(&el).await else { return };
                let behaviour = if reduced_motion().await { ScrollBehavior::Instant } else { ScrollBehavior::Smooth };
                let _ = el
                    .scroll(PixelsVector2D::new(b.left + dir * b.client_width * 0.8, b.top), behaviour)
                    .await;
            });
        }
    };

    let start = at_start();
    let end = at_end();
    let rail_class = crate::class::join(&[
        "ads-carousel__rail",
        if start { "" } else { "is-more-start" },
        if end { "" } else { "is-more-end" },
    ]);
    let remeasure_resize = remeasure;
    rsx! {
        section { class: "ads-carousel", "aria-label": "{label}", "aria-roledescription": "carousel",
            button {
                r#type: "button",
                class: "ads-carousel__button is-prev",
                "aria-label": "Scroll {label} back",
                disabled: start,
                onclick: move |_| scroll(-1.0),
                Icon { name: "arrow", size: 15 }
            }
            div {
                class: "{rail_class}",
                tabindex: "0",
                role: "group",
                "aria-label": "{label} items",
                onmounted: move |e| {
                    rail.set(Some(e.data()));
                    remeasure();
                },
                onscroll: move |e| apply(ScrollBox::from_event(&e.data())),
                onresize: move |_| remeasure_resize(),
                {children}
            }
            button {
                r#type: "button",
                class: "ads-carousel__button",
                "aria-label": "Scroll {label} forward",
                disabled: end,
                onclick: move |_| scroll(1.0),
                Icon { name: "arrow", size: 15 }
            }
        }
    }
}

/// A panel whose width the reader sets. The handle is a focusable separator
/// with its value in pixels, so it moves from the keyboard too: arrows by 16
/// (Shift, 64), Home and End to the limits. `width`/`onwidthchange` are the
/// Vue `v-model:width`; unbound, it keeps its own width.
#[component]
pub fn AdsResizable(
    #[props(default = 280u32)] width: u32,
    #[props(default = 160u32)] min: u32,
    #[props(default = 720u32)] max: u32,
    #[props(into, default = "Resize panel".to_string())] label: String,
    #[props(optional)] onwidthchange: Option<EventHandler<u32>>,
    children: Element,
) -> Element {
    let uid = use_uid();
    let handle_id = format!("{uid}-handle");
    let mut w = use_synced(&width);
    let mut dragging = use_signal(|| false);
    let mut root = use_signal(|| None::<Rc<MountedData>>);
    let mut left = use_signal(|| None::<f64>);

    let clamp = move |n: f64| -> u32 {
        // Within u32 after the clamp, so the cast is exact.
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
        let v = n.clamp(f64::from(min), f64::from(max)).round() as u32;
        v
    };
    let mut set = move |v: u32| {
        if *w.peek() != v {
            w.set(v);
            if let Some(h) = &onwidthchange {
                h.call(v);
            }
        }
    };
    let mut set_drag = set;

    let now = w();
    let class = crate::class::join(&["ads-resizable", if dragging() { "is-dragging" } else { "" }]);
    let hid = handle_id.clone();
    rsx! {
        div {
            class: "{class}",
            style: "width:{now}px",
            onmounted: move |e| root.set(Some(e.data())),
            div { class: "ads-resizable__body", {children} }
            div {
                id: "{handle_id}",
                class: "ads-resizable__handle",
                role: "separator",
                tabindex: "0",
                "aria-orientation": "vertical",
                "aria-label": "{label}",
                "aria-valuenow": "{now}",
                "aria-valuemin": "{min}",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{now} pixels",
                onpointerdown: move |e| {
                    e.prevent_default();
                    dragging.set(true);
                    left.set(None);
                    let pid = e.pointer_id();
                    let _ = document::eval(&format!(
                        "document.getElementById('{hid}')?.setPointerCapture({pid});"
                    ));
                    let el = root.peek().clone();
                    if let Some(el) = el {
                        spawn(async move {
                            if let Ok(r) = el.get_client_rect().await {
                                left.set(Some(r.origin.x));
                            }
                        });
                    }
                },
                onpointermove: move |e| {
                    if !dragging() {
                        return;
                    }
                    if let Some(l) = *left.peek() {
                        set_drag(clamp(e.client_coordinates().x - l));
                    }
                },
                onpointerup: move |_| dragging.set(false),
                onpointercancel: move |_| dragging.set(false),
                onkeydown: move |e| {
                    let step = if e.modifiers().contains(Modifiers::SHIFT) { 64.0 } else { 16.0 };
                    let cur = f64::from(*w.peek());
                    let to = match e.key() {
                        Key::ArrowLeft => cur - step,
                        Key::ArrowRight => cur + step,
                        Key::Home => f64::from(min),
                        Key::End => f64::from(max),
                        _ => return,
                    };
                    e.prevent_default();
                    set(clamp(to));
                },
            }
        }
    }
}

/// One question in a questionnaire.
#[derive(Clone, PartialEq, Debug)]
pub struct Question {
    pub id: String,
    pub text: String,
    pub options: Vec<String>,
    /// Several answers allowed, not one.
    pub multi: bool,
}

impl Question {
    #[must_use]
    pub fn new(id: impl Into<String>, text: impl Into<String>, options: Vec<String>) -> Self {
        Self { id: id.into(), text: text.into(), options, multi: false }
    }

    /// Allow several answers.
    #[must_use]
    pub fn multi(mut self) -> Self {
        self.multi = true;
        self
    }
}

/// A short run of questions, one at a time. The chosen option is pressed in
/// with a tick and says so (`aria-pressed`); progress is greyscale, since it
/// is not the thing to click; Next stays off until there is an answer; and
/// each new question takes focus, so a screen reader hears it at once.
#[component]
pub fn AdsQuestionnaire(
    questions: Vec<Question>,
    #[props(into, default = "Send answers".to_string())] submit_label: String,
    #[props(optional)] ondone: Option<EventHandler<HashMap<String, Vec<String>>>>,
) -> Element {
    let mut step = use_signal(|| 0usize);
    let mut answers = use_signal(HashMap::<String, Vec<String>>::new);
    let mut heading = use_signal(|| None::<Rc<MountedData>>);

    let total = questions.len();
    let at = step().min(total.saturating_sub(1));
    let current = questions.get(at).cloned();
    #[allow(clippy::cast_precision_loss)]
    let progress = if total == 0 { 0.0 } else { (at + 1) as f64 / total as f64 * 100.0 };
    let picked: Vec<String> = current.as_ref().and_then(|q| answers.read().get(&q.id).cloned()).unwrap_or_default();
    let answered = !picked.is_empty();
    let last = at + 1 >= total;

    let mut go = move |n: usize| {
        step.set(n);
        spawn(async move {
            sleep(0).await;
            let el = heading.peek().clone();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };
    let mut go_next = go;

    let (text, multi, options, qid) = match &current {
        Some(q) => (q.text.clone(), q.multi, q.options.clone(), q.id.clone()),
        None => (String::new(), false, Vec::new(), String::new()),
    };
    let shown_step = at + 1;
    rsx! {
        div { class: "ads-questionnaire",
            div { class: "ads-questionnaire__progress", "aria-hidden": "true",
                span { class: "ads-questionnaire__bar", style: "width:{progress}%" }
            }
            p { class: "ads-questionnaire__count num",
                span { class: "sr-only", "Question " }
                "{shown_step}"
                span { "aria-hidden": "true", " / " }
                span { class: "sr-only", " of " }
                "{total}"
            }
            h3 {
                class: "ads-questionnaire__question h3",
                tabindex: "-1",
                onmounted: move |e| heading.set(Some(e.data())),
                "{text}"
            }
            div {
                class: "ads-questionnaire__options",
                role: "group",
                "aria-label": if multi { "Choose any that apply" } else { "Choose one" },
                for o in options.into_iter() {
                    {
                        let on = picked.contains(&o);
                        let qid = qid.clone();
                        let opt = o.clone();
                        rsx! {
                            button {
                                key: "{o}",
                                r#type: "button",
                                class: crate::class::join(&["ads-questionnaire__option", if on { "is-on" } else { "" }]),
                                "aria-pressed": if on { "true" } else { "false" },
                                onclick: move |_| {
                                    let mut all = answers.write();
                                    let cur = all.entry(qid.clone()).or_default();
                                    if multi {
                                        if cur.contains(&opt) {
                                            cur.retain(|x| x != &opt);
                                        } else {
                                            cur.push(opt.clone());
                                        }
                                    } else {
                                        *cur = vec![opt.clone()];
                                    }
                                },
                                if on { Icon { name: "check", size: 13 } }
                                "{o}"
                            }
                        }
                    }
                }
            }
            div { class: "ads-questionnaire__foot",
                if at > 0 {
                    button { r#type: "button", class: "btn btn-quiet", onclick: move |_| go(at - 1), "Previous question" }
                }
                button {
                    r#type: "button",
                    class: "btn btn-accent",
                    disabled: !answered,
                    onclick: move |_| {
                        if !answered {
                            return;
                        }
                        if last {
                            if let Some(h) = &ondone {
                                h.call(answers.peek().clone());
                            }
                        } else {
                            go_next(at + 1);
                        }
                    },
                    if last { "{submit_label}" } else { "Next question" }
                }
            }
        }
    }
}

/// Where a trace step stands.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TraceState {
    Done,
    Now,
    Todo,
}

impl TraceState {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Done => "done",
            Self::Now => "now",
            Self::Todo => "todo",
        }
    }

    const fn spoken(self) -> &'static str {
        match self {
            Self::Done => "Done",
            Self::Now => "In progress",
            Self::Todo => "To do",
        }
    }
}

/// One step in a trace.
#[derive(Clone, PartialEq, Debug)]
pub struct TraceStep {
    pub title: String,
    pub meta: Option<String>,
    /// Left unset, the first step is done and the rest are to do.
    pub state: Option<TraceState>,
}

impl TraceStep {
    #[must_use]
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), meta: None, state: None }
    }

    #[must_use]
    pub fn state(mut self, state: TraceState) -> Self {
        self.state = Some(state);
        self
    }

    #[must_use]
    pub fn meta(mut self, meta: impl Into<String>) -> Self {
        self.meta = Some(meta.into());
        self
    }
}

/// A run of steps on a line: what has happened, what is happening, what is
/// next. The state is in the shape (a tick, a pressed dot, an empty one) and
/// in words for a screen reader, never in colour alone; the current step is
/// `aria-current="step"`.
#[component]
pub fn AdsTrace(steps: Vec<TraceStep>, #[props(into, optional)] label: Option<String>) -> Element {
    let count = steps.len();
    rsx! {
        ol { class: "ads-trace", "aria-label": label,
            for (i, s) in steps.iter().enumerate() {
                {
                    let st = s.state.unwrap_or(if i == 0 { TraceState::Done } else { TraceState::Todo });
                    let class = format!("ads-trace__step is-{}", st.as_str());
                    rsx! {
                        li {
                            key: "{s.title}",
                            class: "{class}",
                            "aria-current": if st == TraceState::Now { Some("step") } else { None },
                            span { class: "ads-trace__dot", "aria-hidden": "true",
                                if st == TraceState::Done { Icon { name: "check", size: 10 } }
                            }
                            div { class: "ads-trace__body",
                                span { class: "ads-trace__title",
                                    "{s.title}"
                                    span { class: "sr-only", ", {st.spoken()}" }
                                }
                                if let Some(m) = &s.meta {
                                    span { class: "ads-trace__meta num", "{m}" }
                                }
                            }
                            if i + 1 < count {
                                span { class: "ads-trace__line", "aria-hidden": "true" }
                            }
                        }
                    }
                }
            }
        }
    }
}

Resizable

A panel you can drag wider.

Drag the right edge.

rust
AdsResizable { "Drag the edge." }
Source · ads/ads-rs/src/ui/widget2.rs
rust
//! Widgets: calendar, carousel, resizable, questionnaire, trace.

use std::collections::HashMap;
use std::rc::Rc;

use dioxus::html::geometry::PixelsVector2D;
use dioxus::html::ScrollBehavior;
use dioxus::prelude::*;

use super::support::{date, reduced_motion, sleep, use_synced, use_today, use_uid, ScrollBox};
use crate::icons::Icon;

const WEEKDAYS: [(&str, &str); 7] = [
    ("Mo", "Monday"),
    ("Tu", "Tuesday"),
    ("We", "Wednesday"),
    ("Th", "Thursday"),
    ("Fr", "Friday"),
    ("Sa", "Saturday"),
    ("Su", "Sunday"),
];

fn month_of(iso: &str) -> Option<String> {
    date::parse(iso).map(|_| iso[..7].to_owned())
}

/// A month grid for picking one day. Dates are ISO (the month reads
/// "2026-10", the value "2026-10-27") and every figure is in the data face so
/// the columns line up. It is a real grid: arrows move a day or a week,
/// PageUp/PageDown a month (with Shift, a year), Home/End go to the week's
/// ends, and each day is a button that reads out its full date. The chosen
/// day takes the accent; today is pressed in, so the two never depend on
/// colour to be told apart.
///
/// `value`/`onchange` and `month`/`onmonthchange` are the two Vue models;
/// either can be left unbound. `autofocus` puts focus on the roving day when
/// the grid mounts, for a calendar that opens in a popover.
#[component]
pub fn AdsCalendar(
    #[props(into, default = "Calendar".to_string())] label: String,
    #[props(into, default)] value: String,
    #[props(optional)] onchange: Option<EventHandler<String>>,
    #[props(into, optional)] month: Option<String>,
    #[props(optional)] onmonthchange: Option<EventHandler<String>>,
    #[props(default = false)] autofocus: bool,
) -> Element {
    let uid = use_uid();
    let today = use_today();
    let today_iso = today();
    let mut sel = use_synced(&value);
    let mut shown = use_signal(|| {
        month
            .clone()
            .filter(|m| date::parse_month(m).is_some())
            .or_else(|| month_of(&value))
            .or_else(|| month_of(&today.peek()))
            .unwrap_or_default()
    });
    let mut focus_iso = use_signal(|| if date::parse(&value).is_some() { value.clone() } else { String::new() });
    let mut refs = use_signal(HashMap::<String, Rc<MountedData>>::new);
    let mut did_autofocus = use_signal(|| false);

    let mut set_month = move |m: String| {
        if *shown.peek() != m {
            shown.set(m.clone());
            if let Some(h) = &onmonthchange {
                h.call(m);
            }
        }
    };

    // The caller's month wins when it changes.
    use_effect(use_reactive((&month,), move |(m,)| {
        if let Some(m) = m.filter(|m| date::parse_month(m).is_some()) {
            if *shown.peek() != m {
                shown.set(m);
            }
        }
    }));
    // A new value brings its month into view.
    use_effect(use_reactive((&value,), move |(v,)| {
        if let Some(m) = month_of(&v) {
            focus_iso.set(v);
            set_month(m);
        }
    }));

    // The month on view; until the reader's today is known it may be empty.
    let shown_now = shown();
    let cur = date::parse_month(&shown_now).or_else(|| date::parse_month(today_iso.get(..7).unwrap_or("")));
    let month_text = cur.map(|(y, m)| format!("{y:04}-{m:02}")).unwrap_or_default();

    // The roving day: the focused day if it is in this month, else the same
    // day number clamped to the month, else today, else the 1st.
    let roving = cur.map_or_else(String::new, |(y, m)| {
        let dim = date::days_in_month(y, m);
        let focus_now = focus_iso();
        let day = match date::parse(&focus_now) {
            Some(z) => date::to_civil(z).2,
            None if today_iso.starts_with(&month_text) => date::parse(&today_iso).map_or(1, |z| date::to_civil(z).2),
            None => 1,
        };
        date::iso(date::from_civil(y, m, i64::from(day.min(dim))))
    });

    let weeks: Vec<Vec<Option<String>>> = cur.map_or_else(Vec::new, |(y, m)| {
        let first = date::from_civil(y, m, 1);
        let lead = date::weekday(first) as usize;
        let count = date::days_in_month(y, m);
        let mut cells: Vec<Option<String>> = vec![None; lead];
        cells.extend((0..count).map(|d| Some(date::iso(first + i64::from(d)))));
        while cells.len() % 7 != 0 {
            cells.push(None);
        }
        cells.chunks(7).map(<[_]>::to_vec).collect()
    });

    let shift_month = move |n: i64| {
        if let Some((y, m)) = date::parse_month(&shown.peek()) {
            let z = date::add_months(date::from_civil(y, m, 1), n);
            set_month(date::iso(z)[..7].to_owned());
        }
    };
    let mut prev = shift_month;
    let mut next = shift_month;

    let focus_day = move |iso: String| {
        spawn(async move {
            sleep(0).await;
            let el = refs.peek().get(&iso).cloned();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };

    let roving_k = roving.clone();
    let current_value = sel();
    let month_id = format!("{uid}-month");

    rsx! {
        div { class: "ads-calendar", role: "group", "aria-labelledby": "{month_id}",
            div { class: "ads-calendar__head",
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Previous month",
                    onclick: move |_| prev(-1),
                    {super::support::glyph("arrow", 14, "", Some("transform: rotate(180deg)"))}
                }
                p { id: "{month_id}", class: "ads-calendar__month", "aria-live": "polite",
                    span { class: "sr-only", "{label}, " }
                    "{month_text}"
                }
                button {
                    r#type: "button",
                    class: "ads-calendar__nav",
                    "aria-label": "Next month",
                    onclick: move |_| next(1),
                    Icon { name: "arrow", size: 14 }
                }
            }
            table {
                class: "ads-calendar__grid",
                role: "grid",
                "aria-labelledby": "{month_id}",
                onkeydown: move |e| {
                    let Some(z) = date::parse(&roving_k) else { return };
                    let dow = i64::from(date::weekday(z));
                    let jump = if e.modifiers().contains(Modifiers::SHIFT) { 12 } else { 1 };
                    let to = match e.key() {
                        Key::ArrowLeft => z - 1,
                        Key::ArrowRight => z + 1,
                        Key::ArrowUp => z - 7,
                        Key::ArrowDown => z + 7,
                        Key::Home => z - dow,
                        Key::End => z + (6 - dow),
                        Key::PageUp => date::add_months(z, -jump),
                        Key::PageDown => date::add_months(z, jump),
                        _ => return,
                    };
                    e.prevent_default();
                    let iso = date::iso(to);
                    focus_iso.set(iso.clone());
                    set_month(iso[..7].to_owned());
                    focus_day(iso);
                },
                thead {
                    tr {
                        for (short, full) in WEEKDAYS {
                            th { key: "{short}", scope: "col", class: "ads-calendar__weekday",
                                abbr { title: "{full}", "{short}" }
                            }
                        }
                    }
                }
                tbody {
                    for (w, week) in weeks.into_iter().enumerate() {
                        tr { key: "{month_text}-{w}",
                            for (i, day) in week.into_iter().enumerate() {
                                {
                                    let chosen = day.as_deref() == Some(current_value.as_str());
                                    let selected = day.as_ref().map(|_| if chosen { "true" } else { "false" });
                                    rsx! {
                                        td {
                                            key: "{i}",
                                            role: "gridcell",
                                            "aria-selected": selected,
                                            if let Some(d) = day {
                                                {
                                                    let is_today = d == today_iso;
                                                    let is_roving = d == roving;
                                                    let class = crate::class::join(&[
                                                        "ads-calendar__day",
                                                        if chosen { "is-on" } else { "" },
                                                        if is_today { "is-today" } else { "" },
                                                    ]);
                                                    let num = d[8..].trim_start_matches('0').to_owned();
                                                    let (d_mount, d_click, d_focus) = (d.clone(), d.clone(), d.clone());
                                                    rsx! {
                                                        button {
                                                            r#type: "button",
                                                            class: "{class}",
                                                            "data-iso": "{d}",
                                                            tabindex: if is_roving { "0" } else { "-1" },
                                                            "aria-label": "{d}",
                                                            "aria-current": if is_today { Some("date") } else { None },
                                                            "aria-pressed": if chosen { "true" } else { "false" },
                                                            onmounted: move |e| {
                                                                let el = e.data();
                                                                refs.write().insert(d_mount.clone(), el.clone());
                                                                if autofocus && is_roving && !*did_autofocus.peek() {
                                                                    did_autofocus.set(true);
                                                                    spawn(async move {
                                                                        let _ = el.set_focus(true).await;
                                                                    });
                                                                }
                                                            },
                                                            onclick: move |_| {
                                                                sel.set(d_click.clone());
                                                                focus_iso.set(d_click.clone());
                                                                if let Some(h) = &onchange {
                                                                    h.call(d_click.clone());
                                                                }
                                                            },
                                                            onfocus: move |_| {
                                                                if *focus_iso.peek() != d_focus {
                                                                    focus_iso.set(d_focus.clone());
                                                                }
                                                            },
                                                            "{num}"
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

/// A horizontal rail of cards that scrolls by a page at a time. The arrows
/// are real buttons named for what they scroll, and switch off at the ends;
/// the rail is a focusable group, so the arrow keys scroll it natively. Edge
/// fades show only where there is more to see.
#[component]
pub fn AdsCarousel(#[props(into)] label: String, children: Element) -> Element {
    let mut rail = use_signal(|| None::<Rc<MountedData>>);
    let mut at_start = use_signal(|| true);
    let mut at_end = use_signal(|| true);

    let mut apply = move |b: ScrollBox| {
        let s = b.left <= 1.0;
        let e = b.left + b.client_width >= b.width - 1.0;
        if *at_start.peek() != s {
            at_start.set(s);
        }
        if *at_end.peek() != e {
            at_end.set(e);
        }
    };
    let remeasure = move || {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                if let Some(b) = ScrollBox::measure(&el).await {
                    apply(b);
                }
            });
        }
    };
    let scroll = move |dir: f64| {
        let el = rail.peek().clone();
        if let Some(el) = el {
            spawn(async move {
                let Some(b) = ScrollBox::measure(&el).await else { return };
                let behaviour = if reduced_motion().await { ScrollBehavior::Instant } else { ScrollBehavior::Smooth };
                let _ = el
                    .scroll(PixelsVector2D::new(b.left + dir * b.client_width * 0.8, b.top), behaviour)
                    .await;
            });
        }
    };

    let start = at_start();
    let end = at_end();
    let rail_class = crate::class::join(&[
        "ads-carousel__rail",
        if start { "" } else { "is-more-start" },
        if end { "" } else { "is-more-end" },
    ]);
    let remeasure_resize = remeasure;
    rsx! {
        section { class: "ads-carousel", "aria-label": "{label}", "aria-roledescription": "carousel",
            button {
                r#type: "button",
                class: "ads-carousel__button is-prev",
                "aria-label": "Scroll {label} back",
                disabled: start,
                onclick: move |_| scroll(-1.0),
                Icon { name: "arrow", size: 15 }
            }
            div {
                class: "{rail_class}",
                tabindex: "0",
                role: "group",
                "aria-label": "{label} items",
                onmounted: move |e| {
                    rail.set(Some(e.data()));
                    remeasure();
                },
                onscroll: move |e| apply(ScrollBox::from_event(&e.data())),
                onresize: move |_| remeasure_resize(),
                {children}
            }
            button {
                r#type: "button",
                class: "ads-carousel__button",
                "aria-label": "Scroll {label} forward",
                disabled: end,
                onclick: move |_| scroll(1.0),
                Icon { name: "arrow", size: 15 }
            }
        }
    }
}

/// A panel whose width the reader sets. The handle is a focusable separator
/// with its value in pixels, so it moves from the keyboard too: arrows by 16
/// (Shift, 64), Home and End to the limits. `width`/`onwidthchange` are the
/// Vue `v-model:width`; unbound, it keeps its own width.
#[component]
pub fn AdsResizable(
    #[props(default = 280u32)] width: u32,
    #[props(default = 160u32)] min: u32,
    #[props(default = 720u32)] max: u32,
    #[props(into, default = "Resize panel".to_string())] label: String,
    #[props(optional)] onwidthchange: Option<EventHandler<u32>>,
    children: Element,
) -> Element {
    let uid = use_uid();
    let handle_id = format!("{uid}-handle");
    let mut w = use_synced(&width);
    let mut dragging = use_signal(|| false);
    let mut root = use_signal(|| None::<Rc<MountedData>>);
    let mut left = use_signal(|| None::<f64>);

    let clamp = move |n: f64| -> u32 {
        // Within u32 after the clamp, so the cast is exact.
        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
        let v = n.clamp(f64::from(min), f64::from(max)).round() as u32;
        v
    };
    let mut set = move |v: u32| {
        if *w.peek() != v {
            w.set(v);
            if let Some(h) = &onwidthchange {
                h.call(v);
            }
        }
    };
    let mut set_drag = set;

    let now = w();
    let class = crate::class::join(&["ads-resizable", if dragging() { "is-dragging" } else { "" }]);
    let hid = handle_id.clone();
    rsx! {
        div {
            class: "{class}",
            style: "width:{now}px",
            onmounted: move |e| root.set(Some(e.data())),
            div { class: "ads-resizable__body", {children} }
            div {
                id: "{handle_id}",
                class: "ads-resizable__handle",
                role: "separator",
                tabindex: "0",
                "aria-orientation": "vertical",
                "aria-label": "{label}",
                "aria-valuenow": "{now}",
                "aria-valuemin": "{min}",
                "aria-valuemax": "{max}",
                "aria-valuetext": "{now} pixels",
                onpointerdown: move |e| {
                    e.prevent_default();
                    dragging.set(true);
                    left.set(None);
                    let pid = e.pointer_id();
                    let _ = document::eval(&format!(
                        "document.getElementById('{hid}')?.setPointerCapture({pid});"
                    ));
                    let el = root.peek().clone();
                    if let Some(el) = el {
                        spawn(async move {
                            if let Ok(r) = el.get_client_rect().await {
                                left.set(Some(r.origin.x));
                            }
                        });
                    }
                },
                onpointermove: move |e| {
                    if !dragging() {
                        return;
                    }
                    if let Some(l) = *left.peek() {
                        set_drag(clamp(e.client_coordinates().x - l));
                    }
                },
                onpointerup: move |_| dragging.set(false),
                onpointercancel: move |_| dragging.set(false),
                onkeydown: move |e| {
                    let step = if e.modifiers().contains(Modifiers::SHIFT) { 64.0 } else { 16.0 };
                    let cur = f64::from(*w.peek());
                    let to = match e.key() {
                        Key::ArrowLeft => cur - step,
                        Key::ArrowRight => cur + step,
                        Key::Home => f64::from(min),
                        Key::End => f64::from(max),
                        _ => return,
                    };
                    e.prevent_default();
                    set(clamp(to));
                },
            }
        }
    }
}

/// One question in a questionnaire.
#[derive(Clone, PartialEq, Debug)]
pub struct Question {
    pub id: String,
    pub text: String,
    pub options: Vec<String>,
    /// Several answers allowed, not one.
    pub multi: bool,
}

impl Question {
    #[must_use]
    pub fn new(id: impl Into<String>, text: impl Into<String>, options: Vec<String>) -> Self {
        Self { id: id.into(), text: text.into(), options, multi: false }
    }

    /// Allow several answers.
    #[must_use]
    pub fn multi(mut self) -> Self {
        self.multi = true;
        self
    }
}

/// A short run of questions, one at a time. The chosen option is pressed in
/// with a tick and says so (`aria-pressed`); progress is greyscale, since it
/// is not the thing to click; Next stays off until there is an answer; and
/// each new question takes focus, so a screen reader hears it at once.
#[component]
pub fn AdsQuestionnaire(
    questions: Vec<Question>,
    #[props(into, default = "Send answers".to_string())] submit_label: String,
    #[props(optional)] ondone: Option<EventHandler<HashMap<String, Vec<String>>>>,
) -> Element {
    let mut step = use_signal(|| 0usize);
    let mut answers = use_signal(HashMap::<String, Vec<String>>::new);
    let mut heading = use_signal(|| None::<Rc<MountedData>>);

    let total = questions.len();
    let at = step().min(total.saturating_sub(1));
    let current = questions.get(at).cloned();
    #[allow(clippy::cast_precision_loss)]
    let progress = if total == 0 { 0.0 } else { (at + 1) as f64 / total as f64 * 100.0 };
    let picked: Vec<String> = current.as_ref().and_then(|q| answers.read().get(&q.id).cloned()).unwrap_or_default();
    let answered = !picked.is_empty();
    let last = at + 1 >= total;

    let mut go = move |n: usize| {
        step.set(n);
        spawn(async move {
            sleep(0).await;
            let el = heading.peek().clone();
            if let Some(el) = el {
                let _ = el.set_focus(true).await;
            }
        });
    };
    let mut go_next = go;

    let (text, multi, options, qid) = match &current {
        Some(q) => (q.text.clone(), q.multi, q.options.clone(), q.id.clone()),
        None => (String::new(), false, Vec::new(), String::new()),
    };
    let shown_step = at + 1;
    rsx! {
        div { class: "ads-questionnaire",
            div { class: "ads-questionnaire__progress", "aria-hidden": "true",
                span { class: "ads-questionnaire__bar", style: "width:{progress}%" }
            }
            p { class: "ads-questionnaire__count num",
                span { class: "sr-only", "Question " }
                "{shown_step}"
                span { "aria-hidden": "true", " / " }
                span { class: "sr-only", " of " }
                "{total}"
            }
            h3 {
                class: "ads-questionnaire__question h3",
                tabindex: "-1",
                onmounted: move |e| heading.set(Some(e.data())),
                "{text}"
            }
            div {
                class: "ads-questionnaire__options",
                role: "group",
                "aria-label": if multi { "Choose any that apply" } else { "Choose one" },
                for o in options.into_iter() {
                    {
                        let on = picked.contains(&o);
                        let qid = qid.clone();
                        let opt = o.clone();
                        rsx! {
                            button {
                                key: "{o}",
                                r#type: "button",
                                class: crate::class::join(&["ads-questionnaire__option", if on { "is-on" } else { "" }]),
                                "aria-pressed": if on { "true" } else { "false" },
                                onclick: move |_| {
                                    let mut all = answers.write();
                                    let cur = all.entry(qid.clone()).or_default();
                                    if multi {
                                        if cur.contains(&opt) {
                                            cur.retain(|x| x != &opt);
                                        } else {
                                            cur.push(opt.clone());
                                        }
                                    } else {
                                        *cur = vec![opt.clone()];
                                    }
                                },
                                if on { Icon { name: "check", size: 13 } }
                                "{o}"
                            }
                        }
                    }
                }
            }
            div { class: "ads-questionnaire__foot",
                if at > 0 {
                    button { r#type: "button", class: "btn btn-quiet", onclick: move |_| go(at - 1), "Previous question" }
                }
                button {
                    r#type: "button",
                    class: "btn btn-accent",
                    disabled: !answered,
                    onclick: move |_| {
                        if !answered {
                            return;
                        }
                        if last {
                            if let Some(h) = &ondone {
                                h.call(answers.peek().clone());
                            }
                        } else {
                            go_next(at + 1);
                        }
                    },
                    if last { "{submit_label}" } else { "Next question" }
                }
            }
        }
    }
}

/// Where a trace step stands.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TraceState {
    Done,
    Now,
    Todo,
}

impl TraceState {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Done => "done",
            Self::Now => "now",
            Self::Todo => "todo",
        }
    }

    const fn spoken(self) -> &'static str {
        match self {
            Self::Done => "Done",
            Self::Now => "In progress",
            Self::Todo => "To do",
        }
    }
}

/// One step in a trace.
#[derive(Clone, PartialEq, Debug)]
pub struct TraceStep {
    pub title: String,
    pub meta: Option<String>,
    /// Left unset, the first step is done and the rest are to do.
    pub state: Option<TraceState>,
}

impl TraceStep {
    #[must_use]
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), meta: None, state: None }
    }

    #[must_use]
    pub fn state(mut self, state: TraceState) -> Self {
        self.state = Some(state);
        self
    }

    #[must_use]
    pub fn meta(mut self, meta: impl Into<String>) -> Self {
        self.meta = Some(meta.into());
        self
    }
}

/// A run of steps on a line: what has happened, what is happening, what is
/// next. The state is in the shape (a tick, a pressed dot, an empty one) and
/// in words for a screen reader, never in colour alone; the current step is
/// `aria-current="step"`.
#[component]
pub fn AdsTrace(steps: Vec<TraceStep>, #[props(into, optional)] label: Option<String>) -> Element {
    let count = steps.len();
    rsx! {
        ol { class: "ads-trace", "aria-label": label,
            for (i, s) in steps.iter().enumerate() {
                {
                    let st = s.state.unwrap_or(if i == 0 { TraceState::Done } else { TraceState::Todo });
                    let class = format!("ads-trace__step is-{}", st.as_str());
                    rsx! {
                        li {
                            key: "{s.title}",
                            class: "{class}",
                            "aria-current": if st == TraceState::Now { Some("step") } else { None },
                            span { class: "ads-trace__dot", "aria-hidden": "true",
                                if st == TraceState::Done { Icon { name: "check", size: 10 } }
                            }
                            div { class: "ads-trace__body",
                                span { class: "ads-trace__title",
                                    "{s.title}"
                                    span { class: "sr-only", ", {st.spoken()}" }
                                }
                                if let Some(m) = &s.meta {
                                    span { class: "ads-trace__meta num", "{m}" }
                                }
                            }
                            if i + 1 < count {
                                span { class: "ads-trace__line", "aria-hidden": "true" }
                            }
                        }
                    }
                }
            }
        }
    }
}

ScrollArea

A scrollable area.

Line 1.

Line 2.

Line 3.

Line 4.

Line 5.

Line 6.

Line 7.

Line 8.

rust
AdsScrollArea { height: "180px", … }
Source · ads/ads-rs/src/ui/messages.rs
rust
//! Messaging: message, message scroller, scroll area.

use dioxus::prelude::*;

use crate::ui::AdsAvatar;

/// A single message in a thread: avatar, name/time, and a bubble.
#[component]
pub fn AdsMessage(
    #[props(into, optional)] name: Option<String>,
    #[props(default = false)] mine: bool,
    #[props(into, optional)] time: Option<String>,
    children: Element,
) -> Element {
    rsx! {
        div { class: if mine { "msg mine" } else { "msg" },
            if let Some(n) = &name {
                if !mine { AdsAvatar { name: n.clone(), size: 32 } }
            }
            div { class: "msg-col",
                if name.is_some() || time.is_some() {
                    div { class: "msg-meta",
                        if let Some(n) = &name { span { class: "msg-name", "{n}" } }
                        if let Some(t) = &time { time { class: "msg-time num", "{t}" } }
                    }
                }
                crate::ui::AdsBubble { mine, {children} }
            }
        }
    }
}

/// A scrollable column that stays pinned to the newest message.
#[component]
pub fn AdsMessageScroller(
    children: Element,
) -> Element {
    rsx! { div { class: "scroller", {children} } }
}

/// A scrollable area with a soft, slim scrollbar.
#[component]
pub fn AdsScrollArea(
    #[props(into, optional)] height: Option<String>,
    children: Element,
) -> Element {
    rsx! { div { class: "scroll-area", style: if let Some(h) = &height { format!("height:{h}") } else { String::new() }, {children} } }
}

Spinner

A bare spinner.

rust
AdsSpinner { size: 22 }
Source · ads/ads-rs/src/ui/atom2.rs
rust
//! Small atomics: aspect ratio, bubble, item, marker, direction, label, kbd,
//! spinner, typography.

use dioxus::prelude::*;

use super::support::glyph;

/// A box that holds its proportions while its width changes, so an image or a
/// chart reserves its space before it loads and nothing below it jumps.
#[component]
pub fn AdsAspectRatio(
    #[props(into, default = "16 / 9".to_string())] ratio: String,
    children: Element,
) -> Element {
    rsx! { div { class: "ads-aspect-ratio", style: "aspect-ratio:{ratio}", {children} } }
}

/// A chat bubble. Theirs is a well; `mine` stands proud of the page instead,
/// so depth and alignment tell the two voices apart. Not the accent: a thread
/// would spend it on every other line, and the accent is for the one thing to
/// click.
#[component]
pub fn AdsBubble(#[props(default = false)] mine: bool, children: Element) -> Element {
    let class = crate::class::join(&["ads-bubble", if mine { "is-mine" } else { "" }]);
    rsx! { div { class: "{class}", {children} } }
}

/// A list item: an optional glyph, then the body. It only answers the pointer
/// when it goes somewhere. Give it `to` and it becomes a real link; without
/// one it is plain text, because a row that lights up and does nothing is a
/// promise the interface breaks.
#[component]
pub fn AdsItem(
    #[props(into, optional)] icon: Option<String>,
    #[props(default = 18u32)] icon_size: u32,
    #[props(into, optional)] to: Option<String>,
    children: Element,
) -> Element {
    let mark = icon.as_deref().map(|n| glyph(n, icon_size, "ads-item__icon", None));
    rsx! {
        if let Some(href) = to {
            a { class: "ads-item is-link", href: "{href}",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        } else {
            div { class: "ads-item",
                {mark}
                span { class: "ads-item__body", {children} }
            }
        }
    }
}

/// The state a marker reports, outside dense data.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum MarkerTone {
    #[default]
    Neutral,
    Ok,
    Warn,
    Bad,
}

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

/// A status marker: a glyph and a label, never a coloured dot on its own.
/// Status carried by colour alone fails a greyscale print and one reader in
/// twelve. The colour comes from a closed set, so a caller cannot paint the
/// chrome: `tone` for a state, or `grade` 1–4 for severity inside dense data,
/// where luminance does the work instead. Only the glyph takes the colour; the
/// label stays at full contrast, and each tone has its own shape.
#[component]
pub fn AdsMarker(
    #[props(into)] label: String,
    #[props(default)] tone: MarkerTone,
    #[props(optional)] grade: Option<u8>,
    children: Element,
) -> Element {
    let modifier = match grade {
        Some(g) => format!("is-grade-{}", g.clamp(1, 4)),
        None => format!("is-{}", tone.as_str()),
    };
    let class = crate::class::join(&["ads-marker", &modifier]);
    let mark = match (grade, tone) {
        (Some(_), _) | (None, MarkerTone::Neutral) => None,
        (None, MarkerTone::Ok) => Some(glyph("check", 13, "", None)),
        (None, MarkerTone::Warn) => Some(glyph("alert", 13, "", None)),
        (None, MarkerTone::Bad) => Some(glyph("plus", 13, "", Some("rotate: 45deg"))),
    };
    rsx! {
        span { class: "{class}",
            span { class: "ads-marker__glyph", "aria-hidden": "true",
                if let Some(m) = mark { {m} } else { span { class: "ads-marker__dot" } }
            }
            span { class: "ads-marker__label", "{label}" }
            {children}
        }
    }
}

/// Which way a direction arrow points.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ArrowDirection {
    Up,
    Down,
    Left,
    #[default]
    Right,
}

impl ArrowDirection {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Up => "up",
            Self::Down => "down",
            Self::Left => "left",
            Self::Right => "right",
        }
    }
}

/// A directional hint: an arrow turned toward `to`. Decorative by default,
/// since it usually sits beside words that already say where; give it a
/// `label` when the arrow alone carries the meaning.
#[component]
pub fn AdsDirection(
    #[props(default)] to: ArrowDirection,
    #[props(into, optional)] label: Option<String>,
) -> Element {
    let class = format!("ads-direction is-{}", to.as_str());
    let label = label.filter(|l| !l.is_empty());
    let named = label.is_some();
    rsx! {
        span {
            class: "{class}",
            role: if named { Some("img") } else { None },
            "aria-label": label,
            "aria-hidden": if named { None } else { Some("true") },
            {glyph("arrow", 15, "", None)}
        }
    }
}

/// A form label, above its field, in the label face. It is the global
/// `.ads-label`, so it cannot drift from the field labels elsewhere. A field
/// always has one: placeholder text is gone exactly when it is needed.
#[component]
pub fn AdsLabel(#[props(into, optional)] r#for: Option<String>, children: Element) -> Element {
    rsx! { label { class: "ads-label", r#for, {children} } }
}

/// A keyboard shortcut: one key, or a chord. Nested `<kbd>`s are how HTML
/// spells "press these together", and the keys sit in the data face.
#[component]
pub fn AdsKbd(keys: Vec<String>) -> Element {
    rsx! {
        kbd { class: "ads-kbd",
            for k in keys.iter() { kbd { class: "kbd", "{k}" } }
        }
    }
}

/// A bare spinner, for an inline wait of unknown shape, next to the thing that
/// is loading and never over the page. Where the shape is known, use a
/// skeleton. It is a status, so a reader hears the label rather than nothing.
#[component]
pub fn AdsSpinner(
    #[props(default = 18u32)] size: u32,
    #[props(into, default = "Loading".to_string())] label: String,
) -> Element {
    rsx! {
        span {
            class: "ads-spinner",
            style: "width:{size}px;height:{size}px",
            role: "status",
            "aria-label": "{label}",
        }
    }
}

/// A type role. Each has one face: display for headlines, UI for reading, the
/// label face for eyebrows, the data face for figures.
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeRole {
    Display,
    Title,
    Heading,
    #[default]
    Body,
    Lede,
    Eyebrow,
    Caption,
    Mono,
}

impl TypeRole {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Display => "display",
            Self::Title => "title",
            Self::Heading => "heading",
            Self::Body => "body",
            Self::Lede => "lede",
            Self::Eyebrow => "eyebrow",
            Self::Caption => "caption",
            Self::Mono => "mono",
        }
    }
}

/// The element a run of typography renders as (the Vue `as`).
#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
pub enum TypeTag {
    #[default]
    P,
    Span,
    Div,
    H1,
    H2,
    H3,
    H4,
    H5,
    H6,
}

/// Text in one of the system's type roles, so a page never invents a size.
/// The role sets the look and `tag` the element, so a display line can still
/// be the page's h1 without borrowing its size from the heading level.
#[component]
pub fn AdsTypography(
    #[props(default)] role: TypeRole,
    #[props(default)] tag: TypeTag,
    children: Element,
) -> Element {
    let class = format!("ads-typography is-{}", role.as_str());
    match tag {
        TypeTag::P => rsx! { p { class: "{class}", {children} } },
        TypeTag::Span => rsx! { span { class: "{class}", {children} } },
        TypeTag::Div => rsx! { div { class: "{class}", {children} } },
        TypeTag::H1 => rsx! { h1 { class: "{class}", {children} } },
        TypeTag::H2 => rsx! { h2 { class: "{class}", {children} } },
        TypeTag::H3 => rsx! { h3 { class: "{class}", {children} } },
        TypeTag::H4 => rsx! { h4 { class: "{class}", {children} } },
        TypeTag::H5 => rsx! { h5 { class: "{class}", {children} } },
        TypeTag::H6 => rsx! { h6 { class: "{class}", {children} } },
    }
}

Messaging

Message

A single message in a thread.

Studio
The crawl finished.
Great.
rust
AdsMessage { name: "Studio", time: "09:41", "The crawl finished." }
Source · ads/ads-rs/src/ui/messages.rs
rust
//! Messaging: message, message scroller, scroll area.

use dioxus::prelude::*;

use crate::ui::AdsAvatar;

/// A single message in a thread: avatar, name/time, and a bubble.
#[component]
pub fn AdsMessage(
    #[props(into, optional)] name: Option<String>,
    #[props(default = false)] mine: bool,
    #[props(into, optional)] time: Option<String>,
    children: Element,
) -> Element {
    rsx! {
        div { class: if mine { "msg mine" } else { "msg" },
            if let Some(n) = &name {
                if !mine { AdsAvatar { name: n.clone(), size: 32 } }
            }
            div { class: "msg-col",
                if name.is_some() || time.is_some() {
                    div { class: "msg-meta",
                        if let Some(n) = &name { span { class: "msg-name", "{n}" } }
                        if let Some(t) = &time { time { class: "msg-time num", "{t}" } }
                    }
                }
                crate::ui::AdsBubble { mine, {children} }
            }
        }
    }
}

/// A scrollable column that stays pinned to the newest message.
#[component]
pub fn AdsMessageScroller(
    children: Element,
) -> Element {
    rsx! { div { class: "scroller", {children} } }
}

/// A scrollable area with a soft, slim scrollbar.
#[component]
pub fn AdsScrollArea(
    #[props(into, optional)] height: Option<String>,
    children: Element,
) -> Element {
    rsx! { div { class: "scroll-area", style: if let Some(h) = &height { format!("height:{h}") } else { String::new() }, {children} } }
}

MessageScroller

A scrollable column pinned to the newest message.

Studio
The crawl finished.
Great.
rust
AdsMessageScroller { … }
Source · ads/ads-rs/src/ui/messages.rs
rust
//! Messaging: message, message scroller, scroll area.

use dioxus::prelude::*;

use crate::ui::AdsAvatar;

/// A single message in a thread: avatar, name/time, and a bubble.
#[component]
pub fn AdsMessage(
    #[props(into, optional)] name: Option<String>,
    #[props(default = false)] mine: bool,
    #[props(into, optional)] time: Option<String>,
    children: Element,
) -> Element {
    rsx! {
        div { class: if mine { "msg mine" } else { "msg" },
            if let Some(n) = &name {
                if !mine { AdsAvatar { name: n.clone(), size: 32 } }
            }
            div { class: "msg-col",
                if name.is_some() || time.is_some() {
                    div { class: "msg-meta",
                        if let Some(n) = &name { span { class: "msg-name", "{n}" } }
                        if let Some(t) = &time { time { class: "msg-time num", "{t}" } }
                    }
                }
                crate::ui::AdsBubble { mine, {children} }
            }
        }
    }
}

/// A scrollable column that stays pinned to the newest message.
#[component]
pub fn AdsMessageScroller(
    children: Element,
) -> Element {
    rsx! { div { class: "scroller", {children} } }
}

/// A scrollable area with a soft, slim scrollbar.
#[component]
pub fn AdsScrollArea(
    #[props(into, optional)] height: Option<String>,
    children: Element,
) -> Element {
    rsx! { div { class: "scroll-area", style: if let Some(h) = &height { format!("height:{h}") } else { String::new() }, {children} } }
}