AkurAI Build
Menu

Public repository / master

AkurAI-Framework / crates/ui/src/kit/form.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

use super::{cn, icons, uid};
use crate::dom::{El, IntoView};
use crate::html;
use crate::reactive::Signal;

const FIELD: &str = "flex w-full rounded-md border border-border bg-bg px-3 text-sm text-fg shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-50";

/// A text input (`type="text"` unless overridden with `.typ(…)`).
pub fn input() -> El {
    html::input().typ("text").class(cn(&[FIELD, "h-9 py-1"]))
}

/// A multi-line text input.
pub fn textarea() -> El {
    html::textarea().class(cn(&[FIELD, "min-h-20 py-2"]))
}

/// A form label pointing at `for_id`.
pub fn label(for_id: &str, text: impl IntoView) -> El {
    html::label()
        .attr("for", for_id.to_string())
        .class("text-sm font-medium leading-none text-fg")
        .child(text)
}

/// A labelled control with an optional hint. `control` receives the id to
/// put on the input so the label and hint are wired for screen readers.
pub fn field(label_text: &str, hint: Option<&str>, control: impl FnOnce(String) -> El) -> El {
    let id = uid("field");
    let hint_id = format!("{id}-hint");
    let mut ctl = control(id.clone());
    if hint.is_some() {
        ctl = ctl.attr("aria-describedby", hint_id.clone());
    }
    html::div().class("grid gap-2").child((
        label(&id, label_text.to_string()),
        ctl,
        hint.map(|h| {
            html::p()
                .id(hint_id)
                .class("text-xs text-muted")
                .child(h.to_string())
        }),
    ))
}

/// A native `<select>` bound to `value`, styled like the inputs.
pub fn select(options: Vec<(String, String)>, value: Signal<String>) -> El {
    html::select()
        .class(cn(&[FIELD, "h-9 py-1 pr-8 appearance-none bg-bg"]))
        .children(options.into_iter().map(|(v, text)| {
            html::option()
                .attr("value", v.clone())
                .prop("selected", move || value.get() == v)
                .child(text)
        }))
        .prop("value", move || value.get())
        .on("change", move |e| value.set(e.value()))
}

/// A Radix-style checkbox: a `role="checkbox"` button with `aria-checked`
/// and `data-state`, toggled by click or Space.
pub fn checkbox(checked: Signal<bool>, label: &str) -> El {
    html::button()
        .typ("button")
        .role("checkbox")
        .attr("aria-label", label.to_string())
        .attr("aria-checked", move || checked.get().to_string())
        .attr("data-state", move || if checked.get() { "checked" } else { "unchecked" })
        .class("peer inline-flex size-5 shrink-0 items-center justify-center rounded-sm border border-accent text-bg shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent")
        .on_click(move |_| checked.update(|c| *c = !*c))
        .child(move || checked.get().then(|| icons::check().class("size-3.5")))
}

/// A Radix-style switch (`role="switch"`).
pub fn switch(on: Signal<bool>, label: &str) -> El {
    html::button()
        .typ("button")
        .role("switch")
        .attr("aria-label", label.to_string())
        .attr("aria-checked", move || on.get().to_string())
        .attr("data-state", move || if on.get() { "checked" } else { "unchecked" })
        .class("peer inline-flex h-5 w-9 shrink-0 items-center rounded-md border-2 border-transparent bg-panel-2 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent data-[state=checked]:bg-accent")
        .on_click(move |_| on.update(|v| *v = !*v))
        .child(html::span().class(move || {
            cn(&[
                "pointer-events-none block size-4 rounded-sm bg-fg shadow transition-transform",
                if on.get() { "translate-x-4" } else { "translate-x-0" },
            ])
        }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::reactive::signal;
    use crate::testing;

    #[test]
    fn checkbox_toggles_aria_state() {
        let on = testing::render_with(|| {
            let on = signal(false);
            (checkbox(on, "Done"), on)
        });
        let cb = testing::get("button[role=checkbox]");
        assert_eq!(testing::attr(cb, "aria-checked").as_deref(), Some("false"));
        testing::click(cb);
        assert!(on.get_untracked());
        assert_eq!(testing::attr(cb, "data-state").as_deref(), Some("checked"));
    }

    #[test]
    fn field_wires_label_and_hint() {
        testing::render(|| field("Email", Some("We never share it"), |id| input().id(id)));
        let input = testing::get("input");
        let id = testing::attr(input, "id").unwrap();
        assert!(testing::find(&format!("label[for={id}]")).is_some());
        let hint = testing::attr(input, "aria-describedby").unwrap();
        assert_eq!(
            testing::text(testing::get(&format!("p#{hint}"))),
            "We never share it"
        );
    }

    #[test]
    fn select_writes_back() {
        let v = testing::render_with(|| {
            let v = signal(String::from("a"));
            (
                select(vec![("a".into(), "A".into()), ("b".into(), "B".into())], v),
                v,
            )
        });
        testing::input(testing::get("select"), "b");
        assert_eq!(v.get_untracked(), "b");
    }
}