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");
}
}