use std::sync::atomic::{AtomicUsize, Ordering};
use dioxus::prelude::*;
use crate::{
cn,
uikit::{FORM_DESCRIPTION, FORM_ITEM, FORM_LABEL, FORM_MESSAGE, label::Label},
};
#[derive(Clone, PartialEq)]
pub struct FormControlContext {
pub id: String,
pub described_by: String,
pub invalid: bool,
}
#[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!(FORM_ITEM, 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 { FORM_LABEL } 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()
};
let next = FormControlContext {
id: ctx.form_item_id(),
described_by,
invalid: error,
};
let mut current = use_signal(|| next.clone());
if *current.peek() != next {
current.set(next);
}
use_context_provider(|| current);
rsx! {
div { "data-slot": "form-control", {children} }
}
}
#[component]
pub fn FormDescription(#[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<FormItemContext>();
let cls = cn!(FORM_DESCRIPTION, 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!(FORM_MESSAGE, 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::{Input, Textarea, 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-accent-error"), "{html}");
}
fn tag_of<'a>(html: &'a str, slot: &str) -> &'a str {
let marker = format!("data-slot=\"{slot}\"");
let at = html.find(&marker).unwrap_or_else(|| panic!("no element with {marker}: {html}"));
let start = html[..at].rfind('<').expect("an opening tag");
let end = at + html[at..].find('>').expect("a closing bracket");
&html[start..end]
}
#[test]
fn control_wires_aria_ids_and_invalid_onto_the_input() {
fn app() -> Element {
rsx! {
FormItem {
FormLabel { error: true, "Email" }
FormControl { error: true,
Input { r#type: "email" }
}
FormDescription { "we never share it" }
FormMessage { "Invalid email" }
}
}
}
let html = render(app);
let input = tag_of(&html, "input");
let control = tag_of(&html, "form-control");
assert!(input.contains("id=\"form-item-"), "the control id belongs on the input: {input}");
assert!(!control.contains("id=\"form-item-"), "the wrapper must not take the id: {control}");
assert!(input.contains("aria-invalid=\"true\""), "{input}");
assert!(!control.contains("aria-invalid"), "{control}");
assert!(input.contains("-form-item-description"), "{input}");
assert!(input.contains("-form-item-message"), "{input}");
assert!(!control.contains("aria-describedby"), "{control}");
assert!(tag_of(&html, "label").contains("for=\"form-item-"), "{html}");
}
#[test]
fn control_describes_by_description_only_while_valid() {
fn app() -> Element {
rsx! {
FormItem {
FormControl {
Input {}
}
}
}
}
let input = tag_of(&render(app), "input").to_string();
assert!(input.contains("aria-invalid=\"false\""), "{input}");
assert!(input.contains("-form-item-description"), "{input}");
assert!(!input.contains("-form-item-message"), "a valid field must not point at the message: {input}");
}
#[test]
fn textarea_is_wired_the_same_way() {
fn app() -> Element {
rsx! {
FormItem {
FormControl { error: true,
Textarea {}
}
}
}
}
let textarea = tag_of(&render(app), "textarea").to_string();
assert!(textarea.contains("id=\"form-item-"), "{textarea}");
assert!(textarea.contains("aria-invalid=\"true\""), "{textarea}");
}
#[test]
fn a_control_outside_a_form_takes_no_ids() {
fn app() -> Element {
rsx! { Input {} }
}
let input = tag_of(&render(app), "input").to_string();
assert!(!input.contains("id=\""), "{input}");
assert!(!input.contains("aria-invalid=\""), "{input}");
assert!(!input.contains("aria-describedby=\""), "{input}");
}
#[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-accent-error"), "{html}");
assert!(html.contains("required"), "{html}");
}
}