ev_lib 0.3.1

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

use crate::{
	cn,
	uikit::separator::{Orientation, Separator},
};
#[component]
pub fn ItemGroup(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("group/item-group flex flex-col", class);
	rsx! {
		div { role: "list", class: cls, "data-slot": "item-group", {children} }
	}
}

#[component]
pub fn ItemSeparator(#[props(default)] class: String) -> Element {
	let cls = cn!("my-0", class);
	rsx! {
		Separator { orientation: Orientation::Horizontal, class: cls }
	}
}

#[derive(strum::AsRefStr, PartialEq, TwVariant)]
#[strum(serialize_all = "kebab-case")]
pub enum ItemVariant {
	#[tw(default, class = "bg-transparent")]
	Default,
	#[tw(class = "border-border")]
	Outline,
	#[tw(class = "bg-muted/50")]
	Muted,
}
#[derive(strum::AsRefStr, PartialEq, TwVariant)]
#[strum(serialize_all = "kebab-case")]
pub enum ItemSize {
	#[tw(default, class = "p-4 gap-4")]
	Md,
	#[tw(class = "py-3 px-4 gap-2.5")]
	Sm,
}
#[component]
pub fn Item(#[props(default)] variant: ItemVariant, #[props(default)] size: ItemSize, #[props(default)] class: String, children: Element) -> Element {
	let cls = ItemClass { variant, size }.with_class(&class);
	rsx! {
		div {
			class: cls,
			"data-slot": "item",
			"data-variant": variant.as_ref(),
			"data-size": size.as_ref(),
			{children}
		}
	}
}
#[derive(strum::AsRefStr, PartialEq, TwVariant)]
#[strum(serialize_all = "kebab-case")]
#[tw(class = "flex shrink-0 items-center justify-center gap-2 \
              group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none \
              group-has-[[data-slot=item-description]]/item:translate-y-0.5")]
pub enum ItemMediaVariant {
	#[tw(default, class = "bg-transparent")]
	Default,
	#[tw(class = "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4")]
	Icon,
	#[tw(class = "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover")]
	Image,
}
#[component]
pub fn ItemMedia(#[props(default)] variant: ItemMediaVariant, #[props(default)] class: String, children: Element) -> Element {
	let cls = cn!(variant.as_class(), class);
	rsx! {
		div {
			class: cls,
			"data-slot": "item-media",
			"data-variant": variant.as_ref(),
			{children}
		}
	}
}
#[component]
pub fn ItemContent(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", class);
	rsx! {
		div { class: cls, "data-slot": "item-content", {children} }
	}
}
#[component]
pub fn ItemTitle(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("flex w-fit items-center gap-2 text-sm leading-snug font-medium", class);
	rsx! {
		div { class: cls, "data-slot": "item-title", {children} }
	}
}
#[component]
pub fn ItemDescription(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!(
		"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance \
         [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
		class
	);
	rsx! {
		p { class: cls, "data-slot": "item-description", {children} }
	}
}
#[component]
pub fn ItemActions(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("flex items-center gap-2", class);
	rsx! {
		div { class: cls, "data-slot": "item-actions", {children} }
	}
}
#[component]
pub fn ItemHeader(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("flex basis-full items-center justify-between gap-2", class);
	rsx! {
		div { class: cls, "data-slot": "item-header", {children} }
	}
}
#[component]
pub fn ItemFooter(#[props(default)] class: String, children: Element) -> Element {
	let cls = cn!("flex basis-full items-center justify-between gap-2", class);
	rsx! {
		div { class: cls, "data-slot": "item-footer", {children} }
	}
}
/// Two-axis composition (variant × size) over the shared item base.
#[derive(TwClass)]
#[tw(class = "group/item flex items-center border border-transparent text-sm rounded-md transition-colors \
              [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none \
              focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]")]
struct ItemClass {
	variant: ItemVariant,
	size: ItemSize,
}

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

	#[test]
	fn item_default_variant_and_size() {
		fn app() -> Element {
			rsx! {
				Item { "x" }
			}
		}
		let html = render(app);
		assert!(html.contains("data-slot=\"item\""), "{html}");
		assert!(html.contains("data-variant=\"default\""), "{html}");
		assert!(html.contains("data-size=\"md\""), "{html}");
		assert!(html.contains("rounded-md"), "{html}");
	}

	#[test]
	fn item_outline_and_sm_are_canon() {
		fn app() -> Element {
			rsx! {
				Item { variant: ItemVariant::Outline, size: ItemSize::Sm, "x" }
			}
		}
		let html = render(app);
		assert!(html.contains("border-border"), "{html}");
		assert!(html.contains("gap-2.5"), "{html}");
		assert!(html.contains("data-variant=\"outline\""), "{html}");
		assert!(html.contains("data-size=\"sm\""), "{html}");
	}

	#[test]
	fn media_image_variant_is_canon() {
		fn app() -> Element {
			rsx! {
				ItemMedia { variant: ItemMediaVariant::Image, "m" }
			}
		}
		let html = render(app);
		assert!(html.contains("size-10"), "{html}");
		assert!(html.contains("object-cover"), "{html}");
		assert!(html.contains("data-variant=\"image\""), "{html}");
	}

	#[test]
	fn separator_wraps_separator_with_my_0() {
		fn app() -> Element {
			rsx! { ItemSeparator {} }
		}
		let html = render(app);
		assert!(html.contains("my-0"), "{html}");
		assert!(html.contains("data-slot=\"separator\""), "{html}");
	}

	#[test]
	fn family_parts_carry_slots() {
		fn app() -> Element {
			rsx! {
				ItemGroup { "g" }
				ItemContent { "c" }
				ItemTitle { "t" }
				ItemDescription { "d" }
				ItemActions { "a" }
				ItemHeader { "h" }
				ItemFooter { "f" }
			}
		}
		let html = render(app);
		assert!(html.contains("data-slot=\"item-group\""), "{html}");
		assert!(html.contains("data-slot=\"item-content\""), "{html}");
		assert!(html.contains("data-slot=\"item-title\""), "{html}");
		assert!(html.contains("data-slot=\"item-description\""), "{html}");
		assert!(html.contains("data-slot=\"item-actions\""), "{html}");
		assert!(html.contains("data-slot=\"item-header\""), "{html}");
		assert!(html.contains("data-slot=\"item-footer\""), "{html}");
	}
}