ev_lib 0.3.5

EV-invest shared Rust libraries, one per feature
Documentation
use dioxus::prelude::*;
use tailwind_fuse::{AsTailwindClass, TwVariant};

use crate::{cn, uikit::label::Label};

#[derive(strum::AsRefStr, PartialEq, TwVariant)]
#[strum(serialize_all = "kebab-case")]
#[tw(class = "group/field flex w-full gap-3 data-[invalid=true]:text-destructive")]
pub enum FieldOrientation {
	#[tw(default, class = "flex-col [&>*]:w-full [&>.sr-only]:w-auto")]
	Vertical,
	#[tw(class = "flex-row items-center [&>[data-slot=field-label]]:flex-auto \
	              has-[>[data-slot=field-content]]:items-start \
	              has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px")]
	Horizontal,
	#[tw(class = "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row \
	              @md/field-group:items-center @md/field-group:[&>*]:w-auto \
	              @md/field-group:[&>[data-slot=field-label]]:flex-auto \
	              @md/field-group:has-[>[data-slot=field-content]]:items-start \
	              @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px")]
	Responsive,
}

#[derive(strum::AsRefStr, Clone, Default, PartialEq)]
#[strum(serialize_all = "kebab-case")]
pub enum FieldLegendVariant {
	#[default]
	Legend,
	Label,
}

#[component]
pub fn FieldSet(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", class);
	rsx! {
		fieldset { class: cls, "data-slot": "field-set", {children} }
	}
}

#[component]
pub fn FieldLegend(#[props(default)] variant: FieldLegendVariant, #[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("mb-3 font-medium data-[variant=legend]:text-base data-[variant=label]:text-sm", class);
	rsx! {
		legend { class: cls, "data-slot": "field-legend", "data-variant": variant.as_ref(), {children} }
	}
}

#[component]
pub fn FieldGroup(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!(
		"group/field-group @container/field-group flex w-full flex-col gap-7 \
         data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
		class
	);
	rsx! {
		div { class: cls, "data-slot": "field-group", {children} }
	}
}

#[component]
pub fn Field(#[props(default)] orientation: FieldOrientation, #[props(default)] class: String, children: Element) -> Element {
	let cls = cn!(orientation.as_class(), class);
	rsx! {
		div {
			role: "group",
			class: cls,
			"data-slot": "field",
			"data-orientation": orientation.as_ref(),
			{children}
		}
	}
}

#[component]
pub fn FieldContent(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("group/field-content flex flex-1 flex-col gap-1.5 leading-snug", class);
	rsx! {
		div { class: cls, "data-slot": "field-content", {children} }
	}
}

#[component]
pub fn FieldLabel(#[props(default)] class: String, #[props(default)] r#for: String, children: Element) -> Element {
	let cls = cn!(
		"group/field-label peer/field-label flex w-fit gap-2 leading-snug \
         group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:w-full \
         has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border \
         [&>*]:data-[slot=field]:p-4 has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary",
		class
	);
	rsx! {
		Label { class: cls, r#for, {children} }
	}
}

#[component]
pub fn FieldTitle(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!(
		"flex w-fit items-center gap-2 text-sm leading-snug font-medium \
         group-data-[disabled=true]/field:opacity-50",
		class
	);
	rsx! {
		div { class: cls, "data-slot": "field-label", {children} }
	}
}

#[component]
pub fn FieldDescription(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!(
		"text-muted-foreground text-sm leading-normal font-normal \
         group-has-[[data-orientation=horizontal]]/field:text-balance last:mt-0 nth-last-2:-mt-1 \
         [[data-variant=legend]+&]:-mt-1.5 [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
		class
	);
	rsx! {
		p { class: cls, "data-slot": "field-description", {children} }
	}
}

#[component]
pub fn FieldSeparator(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", class);
	rsx! {
		div { class: cls, "data-slot": "field-separator", "data-content": "true",
			div { role: "separator", class: "absolute inset-0 top-1/2 shrink-0 bg-border h-px w-full" }
			span {
				class: "bg-background text-muted-foreground relative mx-auto block w-fit px-2",
				"data-slot": "field-separator-content",
				{children}
			}
		}
	}
}

#[component]
pub fn FieldError(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("text-destructive text-sm font-normal", class);
	rsx! {
		div { role: "alert", class: cls, "data-slot": "field-error", {children} }
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::uikit::test_util::render;

	#[test]
	fn field_default_is_vertical() {
		fn app() -> Element {
			rsx! { Field { "x" } }
		}
		let html = render(app);
		assert!(html.contains("data-slot=\"field\""), "{html}");
		assert!(html.contains("data-orientation=\"vertical\""), "{html}");
		assert!(html.contains("flex-col"), "{html}");
	}

	#[test]
	fn field_horizontal_orientation() {
		fn app() -> Element {
			rsx! {
				Field { orientation: FieldOrientation::Horizontal, "x" }
			}
		}
		let html = render(app);
		assert!(html.contains("data-orientation=\"horizontal\""), "{html}");
		assert!(html.contains("flex-row"), "{html}");
	}

	#[test]
	fn field_label_wraps_label_slot() {
		fn app() -> Element {
			rsx! {
				FieldLabel { r#for: "n", "Name" }
			}
		}
		let html = render(app);
		assert!(html.contains("data-slot=\"label\""), "{html}");
		assert!(html.contains("peer/field-label"), "{html}");
		assert!(html.contains("for=\"n\""), "{html}");
	}

	#[test]
	fn legend_variant_data_attr() {
		fn app() -> Element {
			rsx! {
				FieldLegend { variant: FieldLegendVariant::Label, "L" }
			}
		}
		let html = render(app);
		assert!(html.contains("data-variant=\"label\""), "{html}");
	}

	#[test]
	fn error_has_alert_role() {
		fn app() -> Element {
			rsx! {
				FieldError { "bad" }
			}
		}
		let html = render(app);
		assert!(html.contains("role=\"alert\""), "{html}");
		assert!(html.contains("text-destructive"), "{html}");
		assert!(html.contains("bad"), "{html}");
	}

	#[test]
	fn separator_renders_content() {
		fn app() -> Element {
			rsx! {
				FieldSeparator { "or" }
			}
		}
		let html = render(app);
		assert!(html.contains("data-slot=\"field-separator\""), "{html}");
		assert!(html.contains("data-slot=\"field-separator-content\""), "{html}");
		assert!(html.contains("or"), "{html}");
	}
}