use std::cell::OnceCell;
use std::sync::Arc;
use blinc_core::context_state::BlincContextState;
use blinc_core::owner::Owner;
use blinc_core::reactive::{ReactiveGraph, Signal, computed};
use blinc_layout::IntoReactive;
use blinc_layout::binding::Reactive;
use blinc_layout::click_outside;
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::{CursorStyle, RenderProps};
use blinc_layout::prelude::*;
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_layout::widgets::text_input::SharedTextInputData;
use blinc_theme::{ColorToken, RadiusToken, SpacingToken, ThemeState};
use super::label::{LabelSize, label};
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 text_color = theme.color(ColorToken::TextPrimary);
let text_tertiary = theme.color(ColorToken::TextTertiary);
let disabled = config.disabled.clone();
let ctx = BlincContextState::get();
let open_state = ctx.use_signal_keyed(&format!("{instance_key}_open"), || false);
let search_input_data: SharedTextInputData = ctx
.use_signal_keyed(
&format!("{instance_key}_search"),
blinc_layout::widgets::text_input::text_input_data,
)
.try_get()
.unwrap_or_else(blinc_layout::widgets::text_input::text_input_data);
let search_query_state =
ctx.use_signal_keyed(&format!("{instance_key}_search_query"), String::new);
let dropdown_width = config.width.unwrap_or(200.0);
let (value, open, query) = (config.value, open_state, search_query_state);
let options_for_display = config.options.clone();
let placeholder = config
.placeholder
.clone()
.unwrap_or_else(|| blinc_layout::binding::Reactive::Const("Search...".to_string()));
let display_text = computed(move |g: &ReactiveGraph| {
let current = g.get(value).unwrap_or_default();
if let Some(opt) = options_for_display.iter().find(|o| o.value == current) {
return opt.label.clone();
}
if !current.is_empty() {
return current;
}
let search = g.get(query).unwrap_or_default();
if !search.is_empty() && g.get(open).unwrap_or(false) {
search
} else {
placeholder.read(g).unwrap_or_default()
}
});
let options_for_placeholder = config.options.clone();
let placeholder_shown = computed(move |g: &ReactiveGraph| {
let current = g.get(value).unwrap_or_default();
current.is_empty() && !options_for_placeholder.iter().any(|o| o.value == current)
});
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 open_state_trigger = open_state;
let search_data_trigger = search_input_data.clone();
let search_query_trigger = search_query_state;
let disabled_for_click = disabled.clone();
let trigger = div()
.class("cn-combobox-trigger")
.class("tabindex=0")
.attr_when("data-state", "open", open_state)
.attr_when("disabled", "", disabled.clone())
.attr_when("data-placeholder", "", &placeholder_shown)
.flex_row()
.w_full()
.items_center()
.h(height)
.padding_x_px(padding)
.border_width(1.0)
.rounded(radius)
.child(
div().flex_1().overflow_clip().child(
Text::bound(display_text.into_reactive())
.size(font_size)
.no_wrap()
.no_cursor(),
),
)
.flex_shrink_0()
.child(
div()
.class("cn-chevron")
.ml(1.0)
.flex_shrink_0()
.child(svg(chevron_svg).size(16.0, 16.0)),
)
.cursor_pointer()
.on_click(move |_ctx| {
if disabled_for_click.now().unwrap_or(false) {
return;
}
let is_currently_open = open_state_trigger.try_get().unwrap_or(false);
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());
} else {
blinc_layout::widgets::text_input::focus_text_input(&search_data_trigger);
}
open_state_trigger.set(!is_currently_open);
});
let wrapper_id = format!("cn-combobox-{}", instance_key);
let wrapper_id_for_list = wrapper_id.clone();
let options_for_dropdown = config.options.clone();
let on_change_for_dropdown = config.on_change.clone();
let value_for_dropdown = config.value;
let open_state_for_dropdown = open_state;
let search_data_for_dropdown = search_input_data.clone();
let search_query_for_dropdown = search_query_state;
let allow_custom = config.allow_custom;
let placeholder_for_content = config.placeholder.clone();
let instance_key_owned = instance_key.to_string();
let combobox_element = div()
.class("cn-combobox")
.id(&wrapper_id)
.relative()
.overflow_visible()
.w(dropdown_width)
.child(trigger)
.show(open_state, move || {
let dismiss_state = open_state_for_dropdown;
let dismiss_search_data = search_data_for_dropdown.clone();
let dismiss_search_query = search_query_for_dropdown;
click_outside::register_click_outside(
&wrapper_id_for_list,
&wrapper_id_for_list,
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());
},
);
let registered = wrapper_id_for_list.clone();
Owner::on_cleanup(move || click_outside::unregister_click_outside(®istered));
build_dropdown_content(
&options_for_dropdown,
&value_for_dropdown.try_get().unwrap_or_default(),
value_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,
text_color,
text_tertiary,
allow_custom,
&placeholder_for_content,
)
});
let container_width = config.width.unwrap_or(dropdown_width);
let combobox_container = div()
.class("cn-combobox-box")
.attr_when("disabled", "", disabled.clone())
.w(container_width)
.child(combobox_element);
let inner = if let Some(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 lbl = label(label_text).size(LabelSize::Medium).disabled(disabled);
outer = outer.child(lbl).child(combobox_container);
outer
} else {
combobox_container
};
Self { inner }
}
pub fn class(mut self, name: impl AsRef<str>) -> 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) -> &[std::sync::Arc<str>] {
self.inner.element_classes()
}
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
struct ComboboxConfig {
value: Signal<String>,
options: Vec<ComboboxOption>,
placeholder: Option<blinc_layout::binding::Reactive<String>>,
label: Option<Reactive<String>>,
size: ComboboxSize,
disabled: Reactive<bool>,
width: Option<f32>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
allow_custom: bool,
}
impl ComboboxConfig {
fn new(value: Signal<String>) -> Self {
Self {
value,
options: Vec::new(),
placeholder: None,
label: None,
size: ComboboxSize::default(),
disabled: Reactive::Const(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: Signal<String>) -> Self {
Self {
key: InstanceKey::new("combobox"),
config: ComboboxConfig::new(value),
built: OnceCell::new(),
}
}
pub fn with_key(key: impl Into<String>, value: Signal<String>) -> Self {
Self {
key: InstanceKey::explicit(key),
config: ComboboxConfig::new(value),
built: OnceCell::new(),
}
}
fn get_or_build(&self) -> &Combobox {
::blinc_layout::build_once::build_once(&self.built, || {
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 blinc_layout::binding::IntoReactive<String>,
) -> Self {
self.config.placeholder = Some(placeholder.into_reactive());
self
}
pub fn label(mut self, label: impl IntoReactive<String>) -> Self {
self.config.label = Some(label.into_reactive());
self
}
pub fn size(mut self, size: ComboboxSize) -> Self {
self.config.size = size;
self
}
pub fn disabled(mut self, disabled: impl IntoReactive<bool>) -> Self {
self.config.disabled = disabled.into_reactive();
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) -> &[std::sync::Arc<str>] {
self.get_or_build().inner.element_classes()
}
}
#[track_caller]
pub fn combobox(value: Signal<String>) -> ComboboxBuilder {
ComboboxBuilder::new(value)
}
#[derive(Clone, PartialEq, Eq, Hash)]
enum ListRow {
Option(usize),
NoResults,
Custom(String),
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
fn build_dropdown_content(
options: &[ComboboxOption],
current_selected: &str,
value_state: Signal<String>,
open_state: Signal<bool>,
on_change: &Option<Arc<dyn Fn(&str) + Send + Sync>>,
key: &str,
search_data: &SharedTextInputData,
search_query_state: Signal<String>,
width: f32,
trigger_height: f32,
font_size: f32,
padding: f32,
radius: f32,
text_color: blinc_core::Color,
text_tertiary: blinc_core::Color,
allow_custom: bool,
placeholder: &Option<blinc_layout::binding::Reactive<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)
.rounded(radius)
.overflow_clip()
.absolute()
.top(trigger_height + 4.0)
.left(0.0)
.foreground();
let search_placeholder = placeholder
.clone()
.unwrap_or_else(|| blinc_layout::binding::Reactive::Const("Type to search...".to_string()));
let search_query_for_sync = search_query_state;
let search_input = blinc_layout::widgets::text_input::text_input(search_data)
.id(&format!("{}_search_input", key))
.h(trigger_height)
.w(width - (width * padding / width)) .text_size(font_size)
.rounded(theme.radii().radius_md)
.placeholder(search_placeholder)
.class("cn-combobox-search")
.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()
.class("cn-combobox-search-row")
.w_full()
.flex_shrink_0()
.flex_row()
.items_center()
.justify_center()
.px(padding / 8.0)
.py(padding / 8.0)
.child(search_input);
dropdown_div = dropdown_div.child(search_container);
let options_content_key = format!("{}_options_content", key);
let query = search_query_state;
let options_for_rows = options.to_vec();
let rows = computed(move |g: &ReactiveGraph| {
let search = g.get(query).unwrap_or_default();
let mut rows: Vec<ListRow> = options_for_rows
.iter()
.enumerate()
.filter(|(_, opt)| opt.matches(&search))
.map(|(idx, _)| ListRow::Option(idx))
.collect();
if rows.is_empty() {
rows.push(ListRow::NoResults);
if allow_custom && !search.is_empty() {
rows.push(ListRow::Custom(search));
}
}
rows
});
let options = options.to_vec();
let current_selected = current_selected.to_string();
let on_change = on_change.clone();
let search_data = search_data.clone();
let key = key.to_string();
let choose = Arc::new(move |chosen: String| {
value_state.set(chosen.clone());
open_state.set(false);
if let Ok(mut data) = search_data.lock() {
data.value.clear();
data.cursor = 0;
}
search_query_state.set(String::new());
if let Some(ref cb) = on_change {
cb(&chosen);
}
});
let options_content = div()
.id(&options_content_key)
.flex_col()
.max_h(200.0)
.overflow_y_scroll()
.w_full()
.for_each(
rows,
|row: &ListRow| row.clone(),
move |row: ListRow| -> Div {
match row {
ListRow::NoResults => div().w_full().p_px(padding).child(
text("No results found")
.class("cn-combobox-empty")
.size(font_size),
),
ListRow::Custom(custom_value) => {
let choose = choose.clone();
div()
.id(format!("{}_custom", key))
.class("cn-combobox-item")
.w_full()
.h_fit()
.flex_row()
.items_center()
.child(
div().child(
text(format!("Use \"{}\"", custom_value))
.size(font_size)
.no_cursor(),
),
)
.on_click(move |_ctx| choose(custom_value.clone()))
}
ListRow::Option(idx) => {
let opt = &options[idx];
let is_opt_disabled = opt.disabled;
let mut option_item = div()
.id(format!("{}_opt_{}", key, idx))
.class("cn-combobox-item")
.w_full()
.h_fit()
.attr_when("disabled", "", is_opt_disabled)
.flex_row()
.items_center();
if opt.value == current_selected {
option_item = option_item.class("cn-combobox-item--selected");
}
let choose = choose.clone();
let value = opt.value.clone();
option_item
.child(if let Some(ref content_fn) = opt.content {
content_fn()
} else {
div().child(text(&opt.label).size(font_size).no_cursor())
})
.on_click(move |_ctx| {
if !is_opt_disabled {
choose(value.clone());
}
})
}
}
},
);
dropdown_div = dropdown_div.child(options_content);
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"));
}
}