use blinc_core::reactive::{ReactiveGraph, Signal, computed};
use blinc_layout::InstanceKey;
use blinc_layout::binding::{IntoReactive, Reactive};
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::RenderProps;
use blinc_layout::prelude::*;
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_theme::{SpacingToken, ThemeState};
use std::sync::Arc;
use super::label::{LabelSize, label};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RadioSize {
Small,
#[default]
Medium,
Large,
}
impl RadioSize {
fn outer_size(&self) -> f32 {
match self {
RadioSize::Small => 14.0,
RadioSize::Medium => 18.0,
RadioSize::Large => 22.0,
}
}
fn inner_size(&self) -> f32 {
match self {
RadioSize::Small => 6.0,
RadioSize::Medium => 8.0,
RadioSize::Large => 10.0,
}
}
fn border_width(&self) -> f32 {
match self {
RadioSize::Small => 1.5,
RadioSize::Medium => 2.0,
RadioSize::Large => 2.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RadioLayout {
#[default]
Vertical,
Horizontal,
}
#[derive(Clone)]
struct RadioOption {
value: String,
label: String,
disabled: bool,
}
pub struct RadioGroup {
inner: Div,
}
impl RadioGroup {
#[track_caller]
pub fn new(selected: Signal<String>) -> Self {
Self::with_config(RadioGroupConfig::new(selected))
}
fn with_config(config: RadioGroupConfig) -> Self {
let theme = ThemeState::get();
let default_gap = theme.spacing_value(SpacingToken::Space1);
let gap = config.gap.unwrap_or(default_gap);
let mut options_container = match config.layout {
RadioLayout::Vertical => div().flex_col().gap(gap).h_fit(),
RadioLayout::Horizontal => div().flex_row().gap(gap).flex_wrap().h_fit(),
};
for option in &config.options {
let button_css_id = config.css_group_id.as_ref().map(|group_id| {
let sanitized = option.value.replace(' ', "-");
format!("{group_id}-{sanitized}")
});
options_container =
options_container.child(build_radio_button(&config, option, theme, button_css_id));
}
let mut inner = if let Some(label_text) = config.label.clone() {
let spacing = theme.spacing_value(SpacingToken::Space2);
let lbl = label(label_text)
.size(LabelSize::Medium)
.disabled(config.disabled.clone());
div()
.flex_col()
.gap(spacing)
.h_fit()
.child(lbl)
.child(options_container)
} else {
options_container
};
for c in &config.classes {
inner = inner.class(c);
}
Self { inner }
}
}
fn build_radio_button(
config: &RadioGroupConfig,
option: &RadioOption,
theme: &ThemeState,
button_css_id: Option<String>,
) -> Div {
let outer_size = config.size.outer_size();
let inner_size = config.size.inner_size();
let border_width = config.size.border_width();
let option_value = option.value.clone();
let option_label = option.label.clone();
let selected = config.selected;
let on_change = config.on_change.clone();
let value_for_click = option.value.clone();
let disabled = if option.disabled {
Reactive::Const(true)
} else {
config.disabled.clone()
};
let is_selected =
computed(move |g: &ReactiveGraph| g.get(selected).unwrap_or_default() == option_value);
let circle = div()
.class("blinc-radio")
.class("cn-radio")
.class("tabindex=0")
.class_when("cn-radio--selected", &is_selected)
.attr_when("checked", "", &is_selected)
.class_when("cn-radio--disabled", disabled.clone())
.attr_when("disabled", "", disabled.clone())
.w(outer_size)
.h(outer_size)
.rounded(outer_size / 2.0)
.border_width(border_width)
.items_center()
.justify_center()
.child(
div()
.class("blinc-radio-dot")
.class("cn-radio-dot")
.w(inner_size)
.h(inner_size)
.rounded(inner_size / 2.0),
);
let mut radio = div()
.class("blinc-radio-row")
.attr_when("disabled", "", disabled.clone())
.flex_row()
.gap_px(theme.spacing_value(SpacingToken::Space4))
.items_center()
.cursor_pointer()
.child(circle)
.child(text(&option_label).class("blinc-radio-label").size(14.0));
if let Some(ref css_id) = button_css_id {
radio = radio.id(css_id);
}
radio.on_click(move |_| {
if disabled.now().unwrap_or(false) {
return;
}
if selected.try_get().as_deref() != Some(value_for_click.as_str()) {
selected.set(value_for_click.clone());
if let Some(ref callback) = on_change {
callback(&value_for_click);
}
}
})
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
struct RadioGroupConfig {
selected: Signal<String>,
options: Vec<RadioOption>,
size: RadioSize,
layout: RadioLayout,
label: Option<Reactive<String>>,
disabled: Reactive<bool>,
gap: Option<f32>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
css_group_id: Option<String>,
instance_key: InstanceKey,
classes: Vec<std::sync::Arc<str>>,
}
impl RadioGroupConfig {
#[track_caller]
fn new(selected: Signal<String>) -> Self {
Self {
selected,
instance_key: InstanceKey::new("radio-group"),
options: Vec::new(),
size: RadioSize::default(),
layout: RadioLayout::default(),
label: None,
disabled: Reactive::Const(false),
gap: None,
on_change: None,
css_group_id: None,
classes: Vec::new(),
}
}
}
pub struct RadioGroupBuilder {
config: RadioGroupConfig,
built: std::cell::OnceCell<RadioGroup>,
}
impl RadioGroupBuilder {
#[track_caller]
pub fn new(selected: Signal<String>) -> Self {
Self {
config: RadioGroupConfig::new(selected),
built: std::cell::OnceCell::new(),
}
}
fn get_or_build(&self) -> &RadioGroup {
::blinc_layout::build_once::build_once(&self.built, || {
RadioGroup::with_config(self.config.clone())
})
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.config.instance_key = InstanceKey::explicit(key);
self
}
pub fn class(mut self, name: impl AsRef<str>) -> Self {
self.config
.classes
.push(blinc_core::intern::intern(name.as_ref()));
self
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.config.css_group_id = Some(id.into());
self
}
pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
self.config.options.push(RadioOption {
value: value.into(),
label: label.into(),
disabled: false,
});
self
}
pub fn option_disabled(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
self.config.options.push(RadioOption {
value: value.into(),
label: label.into(),
disabled: true,
});
self
}
pub fn size(mut self, size: RadioSize) -> Self {
self.config.size = size;
self
}
pub fn layout(mut self, layout: RadioLayout) -> Self {
self.config.layout = layout;
self
}
pub fn horizontal(mut self) -> Self {
self.config.layout = RadioLayout::Horizontal;
self
}
pub fn vertical(mut self) -> Self {
self.config.layout = RadioLayout::Vertical;
self
}
pub fn label(mut self, label: impl IntoReactive<String>) -> Self {
self.config.label = Some(label.into_reactive());
self
}
pub fn disabled(mut self, disabled: impl IntoReactive<bool>) -> Self {
self.config.disabled = disabled.into_reactive();
self
}
pub fn gap(mut self, gap: f32) -> Self {
self.config.gap = Some(gap);
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
}
pub fn build_component(self) -> RadioGroup {
RadioGroup::with_config(self.config)
}
}
impl ElementBuilder for RadioGroup {
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()
}
}
impl ElementBuilder for RadioGroupBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.get_or_build().element_type_id()
}
fn element_classes(&self) -> &[std::sync::Arc<str>] {
self.get_or_build().element_classes()
}
}
pub fn radio_group(selected: Signal<String>) -> RadioGroupBuilder {
RadioGroupBuilder::new(selected)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_radio_sizes() {
assert_eq!(RadioSize::Small.outer_size(), 14.0);
assert_eq!(RadioSize::Medium.outer_size(), 18.0);
assert_eq!(RadioSize::Large.outer_size(), 22.0);
}
#[test]
fn test_radio_inner_sizes() {
assert_eq!(RadioSize::Small.inner_size(), 6.0);
assert_eq!(RadioSize::Medium.inner_size(), 8.0);
assert_eq!(RadioSize::Large.inner_size(), 10.0);
}
#[test]
fn test_radio_border_widths() {
assert_eq!(RadioSize::Small.border_width(), 1.5);
assert_eq!(RadioSize::Medium.border_width(), 2.0);
assert_eq!(RadioSize::Large.border_width(), 2.0);
}
fn init_theme() {
let _ = ThemeState::try_get().unwrap_or_else(|| {
ThemeState::init_default();
ThemeState::get()
});
}
}