use std::sync::atomic::{AtomicUsize, Ordering};
use dioxus::prelude::*;
use crate::{cn, uikit::label::Label};
#[derive(Clone, PartialEq)]
pub struct FormItemContext {
pub id: String,
}
impl FormItemContext {
pub fn form_item_id(&self) -> String {
format!("{}-form-item", self.id)
}
pub fn form_description_id(&self) -> String {
format!("{}-form-item-description", self.id)
}
pub fn form_message_id(&self) -> String {
format!("{}-form-item-message", self.id)
}
}
#[component]
pub fn Form(#[props(default)] class: String, children: Element) -> Element {
rsx! {
form { class, "data-slot": "form", {children} }
}
}
#[component]
pub fn FormItem(#[props(default)] class: String, children: Element) -> Element {
let id = use_hook(|| format!("form-item-{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)));
use_context_provider(|| FormItemContext { id });
let cls = cn!("grid gap-2", class);
rsx! {
div { class: cls, "data-slot": "form-item", {children} }
}
}
#[component]
pub fn FormLabel(#[props(default)] error: bool, #[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<FormItemContext>();
let cls = cn!(if error { "text-destructive" } else { "" }, class);
rsx! {
Label { class: cls, r#for: ctx.form_item_id(), {children} }
}
}
#[component]
pub fn FormControl(#[props(default)] error: bool, children: Element) -> Element {
let ctx = use_context::<FormItemContext>();
let described_by = if error {
format!("{} {}", ctx.form_description_id(), ctx.form_message_id())
} else {
ctx.form_description_id()
};
rsx! {
div {
"data-slot": "form-control",
id: ctx.form_item_id(),
"aria-describedby": described_by,
"aria-invalid": if error { "true" } else { "false" },
{children}
}
}
}
#[component]
pub fn FormDescription(#[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<FormItemContext>();
let cls = cn!("text-muted-foreground text-sm", class);
rsx! {
p { class: cls, "data-slot": "form-description", id: ctx.form_description_id(), {children} }
}
}
#[component]
pub fn FormMessage(#[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<FormItemContext>();
let cls = cn!("text-destructive text-sm", class);
rsx! {
p { class: cls, "data-slot": "form-message", id: ctx.form_message_id(), {children} }
}
}
static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
#[cfg(test)]
mod tests {
use super::*;
use crate::uikit::test_util::render;
#[test]
fn form_is_passthrough_with_slot() {
fn app() -> Element {
rsx! {
Form {
"body"
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"form\""), "{html}");
assert!(html.contains("<form"), "{html}");
assert!(html.contains("body"), "{html}");
}
#[test]
fn item_provides_shared_id_to_label_and_description() {
fn app() -> Element {
rsx! {
FormItem {
FormLabel { "Email" }
FormDescription { "we never share it" }
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"form-item\""), "{html}");
assert!(html.contains("grid gap-2"), "{html}");
assert!(html.contains("-form-item\""), "{html}");
assert!(html.contains("-form-item-description\""), "{html}");
}
#[test]
fn label_error_folds_destructive_into_class() {
fn app() -> Element {
rsx! {
FormItem {
FormLabel { error: true, "Email" }
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"label\""), "wraps Label: {html}");
assert!(html.contains("text-destructive"), "{html}");
}
#[test]
fn control_wires_aria_ids_and_invalid() {
fn app() -> Element {
rsx! {
FormItem {
FormControl { error: true,
input {}
}
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"form-control\""), "{html}");
assert!(html.contains("aria-invalid=\"true\""), "{html}");
assert!(html.contains("-form-item-description"), "{html}");
assert!(html.contains("-form-item-message\""), "{html}");
}
#[test]
fn message_renders_children() {
fn app() -> Element {
rsx! {
FormItem {
FormMessage { "required" }
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"form-message\""), "{html}");
assert!(html.contains("text-destructive"), "{html}");
assert!(html.contains("required"), "{html}");
}
}