hikari-components 0.3.10

Core UI components (buttons, inputs, tables, modals) for the Hikari design system
// hi-components/src/basic/select.rs
// Custom Select component with Portal-based dropdown and FUI styling

use hikari_palette::classes::{ClassesBuilder, Display, Position, SelectClass};

#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
use crate::platform;
use crate::{
    feedback::{Glow, GlowProps},
    portal::{
        PortalEntry, PortalMaskMode, PortalPositionStrategy, TriggerPlacement, generate_portal_id,
        use_portal,
    },
    prelude::*,
    styled::StyledComponent,
};

pub struct SelectComponent;

#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub enum SelectSize {
    #[default]
    Md,
    Sm,
    Lg,
}

#[define_props]
pub struct SelectOption {
    #[default]
    pub label: String,

    #[default]
    pub value: String,
}

#[define_props]
pub struct SelectProps {
    #[default]
    pub options: Vec<SelectOption>,

    #[default]
    pub placeholder: Option<String>,

    #[default]
    pub size: SelectSize,

    #[default]
    pub disabled: bool,

    #[default]
    pub class: String,

    #[default]
    pub value: Option<String>,

    #[default]
    pub on_change: Option<EventHandler<String>>,
}

///
///
///
///
///
#[component]
pub fn Select(props: SelectProps) -> Element {
    let portal = use_portal();
    let internal_value = use_signal(|| props.value.clone().unwrap_or_default());
    let dropdown_id = use_signal(String::new);
    let open = use_signal(|| false);

    // Sync controlled value
    let controlled_value = props.value.clone();
    let internal_value_for_effect = internal_value.clone();
    use_effect(move || {
        if let Some(ref v) = controlled_value {
            internal_value_for_effect.set(v.clone());
        }
    });

    // Track open state from portal entries
    let entries = portal.entries.clone();
    let dropdown_id_sync = dropdown_id.clone();
    let open_sync = open.clone();
    use_effect(move || {
        let current_id = dropdown_id_sync.read();
        if !current_id.is_empty() {
            let is_in_entries = entries.read().iter().any(|entry| {
                if let PortalEntry::Dropdown { id, .. } = entry {
                    id == &*current_id
                } else {
                    false
                }
            });
            open_sync.set(is_in_entries);
        }
    });

    let current_value = internal_value.get();
    let selected_label = props
        .options
        .iter()
        .find(|o| o.value == current_value)
        .map(|o| o.label.clone());

    let size_class = match props.size {
        SelectSize::Sm => SelectClass::Sm,
        SelectSize::Md => SelectClass::Md,
        SelectSize::Lg => SelectClass::Lg,
    };

    let open_for_classes = open.clone();
    let trigger_classes = ClassesBuilder::new()
        .add(SelectClass::SelectTrigger)
        .add(size_class)
        .add_if(SelectClass::Disabled, || props.disabled)
        .add_if(SelectClass::Open, move || open_for_classes.get())
        .add_raw(&props.class)
        .build();

    // Build the options menu for sharing in the click handler
    let options_for_menu = props.options.clone();
    let on_change = props.on_change.clone();

    let open_for_click = open.clone();
    let dropdown_id_for_click = dropdown_id.clone();
    let portal_remove = portal.remove_entry.clone();
    let portal_add = portal.add_entry.clone();
    let internal_value_for_click = internal_value.clone();
    let dropdown_id_for_click2 = dropdown_id.clone();
    let handle_trigger_click = move |e: MouseEvent| {
        e.stop_propagation();

        if props.disabled {
            return;
        }

        let current_open = open_for_click.get();

        if current_open {
            // Close
            let id = dropdown_id_for_click.get();
            if !id.is_empty() {
                portal_remove.call(id);
            }
        } else {
            // Open dropdown via Portal
            let id = generate_portal_id();
            dropdown_id_for_click.set(id.clone());

            // Get trigger rect for positioning
            let trigger_rect_opt = {
                #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
                {
                    if let Some(target_el) =
                        platform::get_target_element_from_event(e.client_x, e.client_y)
                    {
                        platform::get_bounding_rect_by_class_impl("hi-select-trigger", &target_el)
                            .map(|rect| (rect.x, rect.y, rect.width, rect.height))
                    } else {
                        None
                    }
                }

                #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
                {
                    None::<(f64, f64, f64, f64)>
                }
            };

            let opts = options_for_menu.clone();
            let portal_inner = portal_remove.clone();
            let id_inner = id.clone();
            let internal_value_clone2 = internal_value_for_click.clone();

            let menu_content = rsx! {
                div {
                    class: "hi-select-dropdown",
                    // Match trigger width via inline style if we have the rect
                    style: if let Some((_, _, w, _)) = trigger_rect_opt { format!("width: {w}px;") } else { String::new() },

                    for opt in opts.iter() {
                        {
                            let value = opt.value.clone();
                            let label = opt.label.clone();
                            let is_selected = value == *internal_value_clone2.read();
                            let value_for_click = value.clone();
                            let on_change_item = on_change.clone();
                            let portal_close = portal_inner.clone();
                            let id_close = id_inner.clone();

                            // Create click handler for this option
                            let internal_value_clone3 = internal_value_for_click.clone();
                            let click_handler = EventHandler::new(move |e: MouseEvent| {
                                e.stop_propagation();
                                internal_value_clone3.set(value_for_click.clone());
                                if let Some(handler) = on_change_item.as_ref() {
                                    handler.call(value_for_click.clone());
                                }
                                portal_close.call(id_close.clone());
                            });

                            rsx! {
                                Glow {
                                    block: true,
                                    blur: crate::GlowBlur::Light,
                                    intensity: crate::GlowIntensity::Soft,

                                    div {
                                        class: if is_selected { "hi-select-option hi-select-option-selected" } else { "hi-select-option" },
                                        onclick: click_handler,
                                        "{label}"
                                    }
                                }
                            }
                        }
                    }
                }
            };

            let entry = PortalEntry::Dropdown {
                id,
                strategy: PortalPositionStrategy::TriggerBased {
                    placement: TriggerPlacement::BottomLeft,
                },
                mask_mode: PortalMaskMode::Transparent,
                children: menu_content,
                trigger_rect: trigger_rect_opt,
                close_on_select: true,
            };

            portal_add.call(entry);
        }
    };

    let wrapper_classes = ClassesBuilder::new()
        .add(Position::Relative)
        .add(Display::InlineBlock)
        .build();

    rsx! {
        div { class: wrapper_classes,

            div { class: trigger_classes, onclick: handle_trigger_click,

                span { class: if selected_label.is_some() { "hi-select-value" } else { "hi-select-placeholder" },
                    "{if let Some(label) = &selected_label { label.clone() } else { props.placeholder.clone().unwrap_or_else(|| \"请选择\".to_string()) }}"
                }

                // Chevron arrow
                span {
                    class: "hi-select-arrow",
                    dangerous_inner_html: r#"<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>"#,
                }
            }
        }
    }
}

impl StyledComponent for SelectComponent {
    fn styles() -> &'static str {
        include_str!(concat!(env!("OUT_DIR"), "/styles/select.css"))
    }

    fn name() -> &'static str {
        "select"
    }
}