use std::cell::OnceCell;
use std::sync::Arc;
use blinc_core::context_state::BlincContextState;
use blinc_core::State;
use blinc_layout::click_outside;
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::{CursorStyle, RenderProps};
use blinc_layout::prelude::*;
use blinc_layout::stateful::{stateful_with_key, ButtonState};
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_layout::widgets::text_input::SharedTextInputData;
use blinc_theme::{ColorToken, RadiusToken, SpacingToken, ThemeState};
use super::label::{label, LabelSize};
use blinc_layout::InstanceKey;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ComboboxSize {
Small,
#[default]
Medium,
Large,
}
impl ComboboxSize {
fn height(&self) -> f32 {
match self {
ComboboxSize::Small => 32.0,
ComboboxSize::Medium => 40.0,
ComboboxSize::Large => 48.0,
}
}
fn font_size(&self) -> f32 {
match self {
ComboboxSize::Small => 13.0,
ComboboxSize::Medium => 14.0,
ComboboxSize::Large => 16.0,
}
}
fn padding(&self) -> f32 {
match self {
ComboboxSize::Small => 8.0,
ComboboxSize::Medium => 12.0,
ComboboxSize::Large => 16.0,
}
}
}
pub type OptionContentFn = Arc<dyn Fn() -> Div + Send + Sync>;
#[derive(Clone)]
pub struct ComboboxOption {
pub value: String,
pub label: String,
pub content: Option<OptionContentFn>,
pub disabled: bool,
}
impl std::fmt::Debug for ComboboxOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ComboboxOption")
.field("value", &self.value)
.field("label", &self.label)
.field("content", &self.content.is_some())
.field("disabled", &self.disabled)
.finish()
}
}
impl ComboboxOption {
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
content: None,
disabled: false,
}
}
pub fn content<F>(mut self, f: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
self.content = Some(Arc::new(f));
self
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
pub fn matches(&self, query: &str) -> bool {
if query.is_empty() {
return true;
}
let query_lower = query.to_lowercase();
self.label.to_lowercase().contains(&query_lower)
|| self.value.to_lowercase().contains(&query_lower)
}
}
pub struct Combobox {
inner: Div,
}
impl Combobox {
fn from_config(instance_key: &str, config: ComboboxConfig) -> Self {
let theme = ThemeState::get();
let height = config.size.height();
let font_size = config.size.font_size();
let padding = config.size.padding();
let radius = theme.radius(RadiusToken::Sm);
let bg = theme.color(ColorToken::Surface);
let border = theme.color(ColorToken::Border);
let border_hover = theme.color(ColorToken::BorderHover);
let border_focus = theme.color(ColorToken::BorderFocus);
let text_color = theme.color(ColorToken::TextPrimary);
let text_tertiary = theme.color(ColorToken::TextTertiary);
let surface_elevated = theme.color(ColorToken::SurfaceElevated);
let disabled = config.disabled;
let open_key = format!("{}_open", instance_key);
let open_state = BlincContextState::get().use_state_keyed(&open_key, || false);
let search_key = format!("{}_search", instance_key);
let search_input_data: SharedTextInputData = BlincContextState::get()
.use_state_keyed(&search_key, || {
blinc_layout::widgets::text_input::text_input_data()
})
.get();
let search_query_key = format!("{}_search_query", instance_key);
let search_query_state: State<String> =
BlincContextState::get().use_state_keyed(&search_query_key, String::new);
let dropdown_width = config.width.unwrap_or(200.0);
let value_state_for_display = config.value_state.clone();
let open_state_for_display = open_state.clone();
let options_for_display = config.options.clone();
let placeholder_for_display = config.placeholder.clone();
let search_data_for_display = search_input_data.clone();
let options_for_dropdown = config.options.clone();
let on_change_for_dropdown = config.on_change.clone();
let value_state_for_dropdown = config.value_state.clone();
let open_state_for_dropdown = open_state.clone();
let search_data_for_dropdown = search_input_data.clone();
let search_query_for_dropdown = search_query_state.clone();
let allow_custom = config.allow_custom;
let placeholder_for_content = config.placeholder.clone();
let chevron_svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>"#;
let select_btn_key = format!("{}_btn", instance_key);
let instance_key_owned = instance_key.to_string();
let wrapper_id = format!("cn-combobox-{}", instance_key);
let wrapper_id_for_state = wrapper_id.clone();
let open_state_for_dismiss = open_state.clone();
let search_data_for_dismiss = search_input_data.clone();
let search_query_for_dismiss = search_query_state.clone();
let combobox_element = stateful_with_key::<ButtonState>(&select_btn_key)
.deps([
config.value_state.signal_id(),
open_state.signal_id(),
search_query_state.signal_id(),
])
.on_state(move |ctx| {
let state = ctx.state();
let is_open = open_state_for_display.get();
if is_open {
let dismiss_state = open_state_for_dismiss.clone();
let dismiss_search_data = search_data_for_dismiss.clone();
let dismiss_search_query = search_query_for_dismiss.clone();
click_outside::register_click_outside(
&wrapper_id_for_state,
&wrapper_id_for_state,
move || {
dismiss_state.set(false);
if let Ok(mut data) = dismiss_search_data.lock() {
data.value.clear();
data.cursor = 0;
}
dismiss_search_query.set(String::new());
},
);
} else {
click_outside::unregister_click_outside(&wrapper_id_for_state);
}
let current_val = value_state_for_display.get();
let selected_option = options_for_display
.iter()
.find(|opt| opt.value == current_val);
let display_text = if let Some(opt) = selected_option {
opt.label.clone()
} else if !current_val.is_empty() {
current_val.clone()
} else {
let search_text = search_data_for_display
.lock()
.ok()
.map(|d| d.value.clone())
.unwrap_or_default();
if !search_text.is_empty() && is_open {
search_text
} else {
placeholder_for_display
.clone()
.unwrap_or_else(|| "Search...".to_string())
}
};
let is_placeholder = selected_option.is_none() && current_val.is_empty();
let text_clr = if is_placeholder {
text_tertiary
} else {
text_color
};
let bdr = if is_open {
border_focus
} else if state == ButtonState::Hovered {
border_hover
} else {
border
};
let display_content = div().flex_1().overflow_clip().child(
text(&display_text)
.size(font_size)
.no_cursor()
.color(text_clr),
);
let mut wrapper = div()
.class("cn-combobox")
.id(&wrapper_id)
.relative()
.overflow_visible()
.w(dropdown_width);
let open_state_trigger = open_state_for_display.clone();
let search_data_trigger = search_data_for_display.clone();
let search_query_trigger = search_query_for_dropdown.clone();
let trigger = div()
.class("cn-combobox-trigger")
.flex_row()
.w_full()
.items_center()
.h(height)
.p_px(padding)
.bg(bg)
.border(1.0, bdr)
.rounded(radius)
.child(display_content)
.flex_shrink_0()
.shadow_sm()
.child(
svg(chevron_svg)
.size(16.0, 16.0)
.tint(text_tertiary)
.ml(1.0)
.flex_shrink_0(),
)
.cursor_pointer()
.on_click(move |_ctx| {
if disabled {
return;
}
let is_currently_open = open_state_trigger.get();
if is_currently_open {
if let Ok(mut data) = search_data_trigger.lock() {
data.value.clear();
data.cursor = 0;
}
search_query_trigger.set(String::new());
}
open_state_trigger.set(!is_currently_open);
});
wrapper = wrapper.child(trigger);
if is_open {
let current_selected = value_state_for_dropdown.get();
let dropdown = build_dropdown_content(
&options_for_dropdown,
¤t_selected,
&value_state_for_dropdown,
&open_state_for_dropdown,
&on_change_for_dropdown,
&instance_key_owned,
&search_data_for_dropdown,
&search_query_for_dropdown,
dropdown_width,
height,
font_size,
padding,
radius,
bg,
border,
border_focus,
text_color,
text_tertiary,
surface_elevated,
allow_custom,
&placeholder_for_content,
);
wrapper = wrapper.child(dropdown);
}
wrapper
});
let container_width = config.width.unwrap_or(dropdown_width);
let mut combobox_container = div().w(container_width).child(combobox_element);
if disabled {
combobox_container = combobox_container.opacity(0.5);
}
let inner = if let Some(ref label_text) = config.label {
let spacing = theme.spacing_value(SpacingToken::Space2);
let mut outer = div().flex_col().gap_px(spacing).w(container_width).h_fit();
let mut lbl = label(label_text).size(LabelSize::Medium);
if disabled {
lbl = lbl.disabled(true);
}
outer = outer.child(lbl).child(combobox_container);
outer
} else {
combobox_container
};
Self { inner }
}
pub fn class(mut self, name: impl Into<String>) -> Self {
self.inner = self.inner.class(name);
self
}
pub fn id(mut self, id: &str) -> Self {
self.inner = self.inner.id(id);
self
}
}
impl ElementBuilder for Combobox {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.inner.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.inner.element_type_id()
}
fn element_classes(&self) -> &[String] {
self.inner.element_classes()
}
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
struct ComboboxConfig {
value_state: State<String>,
options: Vec<ComboboxOption>,
placeholder: Option<String>,
label: Option<String>,
size: ComboboxSize,
disabled: bool,
width: Option<f32>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
allow_custom: bool,
}
impl ComboboxConfig {
fn new(value_state: State<String>) -> Self {
Self {
value_state,
options: Vec::new(),
placeholder: None,
label: None,
size: ComboboxSize::default(),
disabled: false,
width: None,
on_change: None,
allow_custom: false,
}
}
}
pub struct ComboboxBuilder {
key: InstanceKey,
config: ComboboxConfig,
built: OnceCell<Combobox>,
}
impl ComboboxBuilder {
#[track_caller]
pub fn new(value_state: &State<String>) -> Self {
Self {
key: InstanceKey::new("combobox"),
config: ComboboxConfig::new(value_state.clone()),
built: OnceCell::new(),
}
}
pub fn with_key(key: impl Into<String>, value_state: &State<String>) -> Self {
Self {
key: InstanceKey::explicit(key),
config: ComboboxConfig::new(value_state.clone()),
built: OnceCell::new(),
}
}
fn get_or_build(&self) -> &Combobox {
self.built
.get_or_init(|| Combobox::from_config(self.key.get(), self.config.clone()))
}
pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
self.config.options.push(ComboboxOption::new(value, label));
self
}
pub fn option_disabled(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
self.config
.options
.push(ComboboxOption::new(value, label).disabled());
self
}
pub fn options(mut self, options: impl IntoIterator<Item = ComboboxOption>) -> Self {
self.config.options.extend(options);
self
}
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.config.placeholder = Some(placeholder.into());
self
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.config.label = Some(label.into());
self
}
pub fn size(mut self, size: ComboboxSize) -> Self {
self.config.size = size;
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.config.disabled = disabled;
self
}
pub fn w(mut self, width: f32) -> Self {
self.config.width = Some(width);
self
}
pub fn allow_custom(mut self, allow: bool) -> Self {
self.config.allow_custom = allow;
self
}
pub fn on_change<F>(mut self, callback: F) -> Self
where
F: Fn(&str) + Send + Sync + 'static,
{
self.config.on_change = Some(Arc::new(callback));
self
}
}
impl ElementBuilder for ComboboxBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().inner.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.get_or_build().inner.element_type_id()
}
fn event_handlers(&self) -> Option<&blinc_layout::event_handler::EventHandlers> {
Some(self.get_or_build().inner.event_handlers())
}
fn element_classes(&self) -> &[String] {
self.get_or_build().inner.element_classes()
}
}
#[track_caller]
pub fn combobox(value_state: &State<String>) -> ComboboxBuilder {
ComboboxBuilder::new(value_state)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
fn build_dropdown_content(
options: &[ComboboxOption],
current_selected: &str,
value_state: &State<String>,
open_state: &State<bool>,
on_change: &Option<Arc<dyn Fn(&str) + Send + Sync>>,
key: &str,
search_data: &SharedTextInputData,
search_query_state: &State<String>,
width: f32,
trigger_height: f32,
font_size: f32,
padding: f32,
radius: f32,
bg: blinc_core::Color,
border: blinc_core::Color,
border_focus: blinc_core::Color,
text_color: blinc_core::Color,
text_tertiary: blinc_core::Color,
surface_elevated: blinc_core::Color,
allow_custom: bool,
placeholder: &Option<String>,
) -> Div {
let theme = ThemeState::get();
let dropdown_id = key;
let mut dropdown_div = div()
.class("cn-combobox-content")
.id(dropdown_id)
.flex_col()
.w(width)
.bg(bg)
.border(1.0, border)
.rounded(radius)
.shadow_lg()
.overflow_clip()
.absolute()
.top(trigger_height + 4.0)
.left(0.0)
.foreground();
let search_placeholder = placeholder
.clone()
.unwrap_or_else(|| "Type to search...".to_string());
let search_query_for_sync = search_query_state.clone();
let search_input = blinc_layout::widgets::text_input::text_input(search_data)
.w_full()
.h(trigger_height)
.text_size(font_size)
.rounded(theme.radii().radius_sm)
.placeholder(search_placeholder)
.idle_border_color(theme.color(ColorToken::Border))
.hover_border_color(theme.color(ColorToken::BorderFocus))
.focused_border_color(border_focus)
.idle_bg_color(bg)
.hover_bg_color(bg)
.focused_bg_color(bg)
.text_color(text_color)
.placeholder_color(text_tertiary)
.flex_grow()
.on_change(move |new_value: &str| {
search_query_for_sync.set(new_value.to_string());
});
let search_container = div()
.w(width)
.flex_shrink_0()
.border_bottom(1.0, border)
.child(search_input);
dropdown_div = dropdown_div.child(search_container);
let options_for_filter = options.to_vec();
let current_selected_owned = current_selected.to_string();
let value_state_for_opts = value_state.clone();
let open_state_for_opts = open_state.clone();
let on_change_for_opts = on_change.clone();
let search_data_for_opts = search_data.clone();
let search_query_for_opts = search_query_state.clone();
let key_for_opts = key.to_string();
let options_container_key = format!("{}_options_container", key);
let options_content_key = format!("{}_options_content", key);
let options_stateful = stateful_with_key::<NoState>(&options_container_key)
.deps([search_query_state.signal_id()])
.on_state(move |_ctx| {
let search_text = search_query_for_opts.get();
let filtered_options: Vec<_> = options_for_filter
.iter()
.filter(|opt| opt.matches(&search_text))
.collect();
let mut options_content = div()
.id(&options_content_key)
.flex_col()
.max_h(200.0)
.overflow_y_scroll()
.w_full();
if filtered_options.is_empty() {
let no_results = div().w_full().p_px(padding).child(
text("No results found")
.size(font_size)
.color(text_tertiary),
);
options_content = options_content.child(no_results);
if allow_custom && !search_text.is_empty() {
let custom_value = search_text.clone();
let value_state_for_custom = value_state_for_opts.clone();
let open_state_for_custom = open_state_for_opts.clone();
let on_change_for_custom = on_change_for_opts.clone();
let search_data_for_custom = search_data_for_opts.clone();
let search_query_for_custom = search_query_for_opts.clone();
let custom_item_id = format!("{}_custom", key_for_opts);
let custom_item = div()
.id(&custom_item_id)
.class("cn-combobox-item")
.w_full()
.h_fit()
.cursor(CursorStyle::Pointer)
.flex_row()
.items_center()
.bg(bg)
.child(
div().child(
text(format!("Use \"{}\"", custom_value))
.size(font_size)
.no_cursor()
.color(text_color),
),
)
.on_click(move |_ctx| {
let custom_val = search_data_for_custom
.lock()
.ok()
.map(|d| d.value.clone())
.unwrap_or_default();
value_state_for_custom.set(custom_val.clone());
open_state_for_custom.set(false);
if let Ok(mut data) = search_data_for_custom.lock() {
data.value.clear();
data.cursor = 0;
}
search_query_for_custom.set(String::new());
if let Some(ref cb) = on_change_for_custom {
cb(&custom_val);
}
});
options_content = options_content.child(custom_item);
}
} else {
for (idx, opt) in filtered_options.iter().enumerate() {
let opt_value = opt.value.clone();
let opt_label = opt.label.clone();
let opt_content = opt.content.clone();
let is_selected = opt_value == current_selected_owned;
let is_opt_disabled = opt.disabled;
let value_state_for_opt = value_state_for_opts.clone();
let open_state_for_opt = open_state_for_opts.clone();
let on_change_for_opt = on_change_for_opts.clone();
let opt_value_for_click = opt_value.clone();
let search_data_for_opt = search_data_for_opts.clone();
let search_query_for_opt = search_query_for_opts.clone();
let option_text_color = if is_opt_disabled {
text_tertiary
} else {
text_color
};
let base_bg = if is_selected { surface_elevated } else { bg };
let item_id = format!("{}_opt_{}", key_for_opts, idx);
let option_item = div()
.id(&item_id)
.class("cn-combobox-item")
.w_full()
.h_fit()
.cursor(if is_opt_disabled {
CursorStyle::NotAllowed
} else {
CursorStyle::Pointer
})
.flex_row()
.items_center()
.bg(base_bg)
.child(if let Some(ref content_fn) = opt_content {
content_fn()
} else {
div().child(
text(&opt_label)
.size(font_size)
.no_cursor()
.color(option_text_color),
)
})
.on_click(move |_ctx| {
if !is_opt_disabled {
value_state_for_opt.set(opt_value_for_click.clone());
open_state_for_opt.set(false);
if let Ok(mut data) = search_data_for_opt.lock() {
data.value.clear();
data.cursor = 0;
}
search_query_for_opt.set(String::new());
if let Some(ref cb) = on_change_for_opt {
cb(&opt_value_for_click);
}
}
});
options_content = options_content.child(option_item);
}
}
options_content
});
dropdown_div = dropdown_div.child(options_stateful);
dropdown_div
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_combobox_sizes() {
assert_eq!(ComboboxSize::Small.height(), 32.0);
assert_eq!(ComboboxSize::Medium.height(), 40.0);
assert_eq!(ComboboxSize::Large.height(), 48.0);
}
#[test]
fn test_combobox_font_sizes() {
assert_eq!(ComboboxSize::Small.font_size(), 13.0);
assert_eq!(ComboboxSize::Medium.font_size(), 14.0);
assert_eq!(ComboboxSize::Large.font_size(), 16.0);
}
#[test]
fn test_combobox_option() {
let opt = ComboboxOption::new("value", "Label");
assert_eq!(opt.value, "value");
assert_eq!(opt.label, "Label");
assert!(!opt.disabled);
let disabled_opt = opt.disabled();
assert!(disabled_opt.disabled);
}
#[test]
fn test_combobox_option_matches() {
let opt = ComboboxOption::new("us", "United States");
assert!(opt.matches(""));
assert!(opt.matches("united"));
assert!(opt.matches("STATES"));
assert!(opt.matches("Unit"));
assert!(opt.matches("us"));
assert!(opt.matches("US"));
assert!(!opt.matches("canada"));
assert!(!opt.matches("xyz"));
}
}