hikari-components 0.2.2

Core UI components (40+) for the Hikari design system
// hikari-components/src/basic/image.rs
//! Image component with configurable sizing and fit modes

use hikari_palette::classes::{ClassesBuilder, ImageClass, TypedClass};

use crate::prelude::*;
use crate::style_builder::{CssProperty, StyleStringBuilder};

#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ImageFit {
    Contain,
    #[default]
    Cover,
    Fill,
    None,
    ScaleDown,
}

impl ImageFit {
    pub fn as_str(&self) -> &'static str {
        match self {
            ImageFit::Contain => "contain",
            ImageFit::Cover => "cover",
            ImageFit::Fill => "fill",
            ImageFit::None => "none",
            ImageFit::ScaleDown => "scale-down",
        }
    }
}

#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ImagePlaceholder {
    #[default]
    Skeleton,
    Icon,
    None,
}

#[component]
pub fn Image(
    src: String,

    #[props(default = "Image".to_string())] alt: String,

    #[props(default = ImageFit::Cover)] fit: ImageFit,

    #[props(default)] width: Option<u32>,

    #[props(default)] height: Option<u32>,

    #[props(default)] max_width: Option<u32>,

    #[props(default = false)] responsive: bool,

    #[props(default)] placeholder: ImagePlaceholder,

    #[props(default = true)] show_loading: bool,

    #[props(default)] class: String,
) -> Element {
    let mut loaded = use_signal(|| false);
    let mut has_error = use_signal(|| false);

    let mut builder = StyleStringBuilder::new().add(CssProperty::ObjectFit, fit.as_str());

    if let Some(w) = width {
        builder = builder.add_px(CssProperty::Width, w);
    }
    if let Some(h) = height {
        builder = builder.add_px(CssProperty::Height, h);
    }
    if let Some(mw) = max_width {
        builder = builder.add_px(CssProperty::MaxWidth, mw);
    }
    if responsive {
        builder = builder
            .add(CssProperty::Width, "100%")
            .add(CssProperty::Height, "auto");
    }

    let style = builder.build_clean();

    let classes = ClassesBuilder::new()
        .add_typed(ImageClass::Image)
        .add(&class)
        .build();

    let container_style = if responsive {
        "width: 100%; position: relative; display: inline-block;".to_string()
    } else if let (Some(w), Some(h)) = (width, height) {
        format!(
            "width: {}px; height: {}px; position: relative; display: inline-block;",
            w, h
        )
    } else {
        "position: relative; display: inline-block;".to_string()
    };

    let show_placeholder = !loaded.read() || has_error.read();
    let placeholder_type = placeholder;
    let show_skeleton =
        show_placeholder && show_loading && placeholder_type == ImagePlaceholder::Skeleton;
    let show_icon_placeholder =
        show_placeholder && show_loading && placeholder_type == ImagePlaceholder::Icon;

    let handle_load = move |_| {
        loaded.set(true);
    };

    let handle_error = move |_| {
        has_error.set(true);
    };

    // Build placeholder element conditionally
    let placeholder_el = if show_skeleton {
        Some(rsx! {
            div {
                class: ClassesBuilder::new()
                    .add_typed(ImageClass::ImagePlaceholder)
                    .add_typed(ImageClass::ImageSkeleton)
                    .build(),
                style: "width: 100%; height: 100%; min-height: 100px;",
            }
        })
    } else if show_icon_placeholder {
        Some(rsx! {
            div {
                class: ClassesBuilder::new()
                    .add_typed(ImageClass::ImagePlaceholder)
                    .add_typed(ImageClass::ImageIconPlaceholder)
                    .build(),
                style: "width: 100%; height: 100%; min-height: 100px; display: flex; align-items: center; justify-content: center; background: var(--hi-color-surface);",
                svg {
                    width: "48",
                    height: "48",
                    view_box: "0 0 24 24",
                    fill: "none",
                    stroke: "var(--hi-color-text-secondary)",
                    stroke_width: "1.5",
                    rect {
                        x: "3",
                        y: "3",
                        width: "18",
                        height: "18",
                        rx: "2",
                        ry: "2",
                    }
                    circle { cx: "8.5", cy: "8.5", r: "1.5" }
                    polyline { points: "21 15 16 10 5 21" }
                }
            }
        })
    } else {
        None
    };

    rsx! {
        div {
            class: ImageClass::ImageContainer.class_name(),
            style: container_style,

            {placeholder_el.unwrap_or_else(VNode::empty)}

            img {
                class: classes,
                src,
                alt,
                style,
                onload: handle_load,
                onerror: handle_error,
            }
        }
    }
}

#[component]
pub fn Logo(
    src: String,

    #[props(default = "Logo".to_string())] alt: String,

    #[props(default = 40)] height: u32,

    #[props(default = 160)] max_width: u32,

    #[props(default)] class: String,
) -> Element {
    let style = StyleStringBuilder::new()
        .add_px(CssProperty::Height, height)
        .add_px(CssProperty::MaxWidth, max_width)
        .add(CssProperty::Width, "auto")
        .add(CssProperty::ObjectFit, "contain")
        .build_clean();

    let classes = ClassesBuilder::new()
        .add_typed(ImageClass::Logo)
        .add(&class)
        .build();

    rsx! {
        img {
            class: classes,
            src,
            alt,
            style,
        }
    }
}