use std::cell::OnceCell;
use std::sync::{Arc, Mutex};
use blinc_core::Color;
use blinc_core::reactive::Signal;
use blinc_core::reactive::{ReactiveGraph, computed, signal};
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::{CursorStyle, RenderProps};
use blinc_layout::prelude::*;
use blinc_layout::region::Row;
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_layout::visual_animation::VisualAnimationConfig;
use blinc_layout::widgets::css_motion::CssMotion;
use blinc_theme::{ColorScheme, ColorToken, RadiusToken, ThemeState};
use blinc_layout::selector::query_motion;
use blinc_layout::{InstanceKey, Interaction};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TabsSize {
Small,
#[default]
Medium,
Large,
}
impl TabsSize {
fn height(&self) -> f32 {
match self {
TabsSize::Small => 32.0,
TabsSize::Medium => 40.0,
TabsSize::Large => 48.0,
}
}
fn font_size(&self) -> f32 {
match self {
TabsSize::Small => 13.0,
TabsSize::Medium => 14.0,
TabsSize::Large => 16.0,
}
}
fn padding_x(&self) -> f32 {
match self {
TabsSize::Small => 12.0,
TabsSize::Medium => 16.0,
TabsSize::Large => 20.0,
}
}
fn icon_size(&self) -> f32 {
match self {
TabsSize::Small => 14.0,
TabsSize::Medium => 16.0,
TabsSize::Large => 18.0,
}
}
fn badge_font_size(&self) -> f32 {
match self {
TabsSize::Small => 10.0,
TabsSize::Medium => 11.0,
TabsSize::Large => 12.0,
}
}
}
#[derive(Clone)]
pub struct TabMenuItem {
value: String,
label: Option<String>,
icon: Option<String>,
badge: Option<String>,
disabled: bool,
custom_content: Option<Arc<dyn Fn(bool) -> Div + Send + Sync>>,
}
impl std::fmt::Debug for TabMenuItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabMenuItem")
.field("value", &self.value)
.field("label", &self.label)
.field("icon", &self.icon.is_some())
.field("badge", &self.badge)
.field("disabled", &self.disabled)
.field("custom_content", &self.custom_content.is_some())
.finish()
}
}
impl TabMenuItem {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
label: None,
icon: None,
badge: None,
disabled: false,
custom_content: None,
}
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn icon(mut self, svg: impl Into<String>) -> Self {
self.icon = Some(svg.into());
self
}
pub fn badge(mut self, badge: impl Into<String>) -> Self {
self.badge = Some(badge.into());
self
}
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
pub fn content<F>(mut self, builder: F) -> Self
where
F: Fn(bool) -> Div + Send + Sync + 'static,
{
self.custom_content = Some(Arc::new(builder));
self
}
pub fn value(&self) -> &str {
&self.value
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
}
pub fn tab_item(value: impl Into<String>) -> TabMenuItem {
TabMenuItem::new(value)
}
pub type TabContentFn = Arc<dyn Fn() -> Div + Send + Sync>;
#[derive(Clone)]
struct TabItem {
menu_item: TabMenuItem,
content: TabContentFn,
}
impl std::fmt::Debug for TabItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabItem")
.field("menu_item", &self.menu_item)
.finish()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TabsTransition {
None,
Fade,
#[default]
Slide,
SlideLeft,
SlideRight,
SlideUp,
SlideDown,
}
impl TabsTransition {
fn attr(&self) -> Option<&'static str> {
match self {
TabsTransition::None => None,
TabsTransition::Fade => Some("fade"),
TabsTransition::Slide => Some("slide"),
TabsTransition::SlideLeft => Some("slide-left"),
TabsTransition::SlideRight => Some("slide-right"),
TabsTransition::SlideUp => Some("slide-up"),
TabsTransition::SlideDown => Some("slide-down"),
}
}
}
#[derive(Clone)]
#[allow(clippy::type_complexity)]
struct TabsConfig {
state: Signal<String>,
tabs: Vec<TabItem>,
size: TabsSize,
default_value: Option<String>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
transition: TabsTransition,
}
impl std::fmt::Debug for TabsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabsConfig")
.field("tabs", &self.tabs)
.field("size", &self.size)
.field("default_value", &self.default_value)
.finish()
}
}
pub struct Tabs {
inner: Div,
}
impl std::fmt::Debug for Tabs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Tabs").finish()
}
}
pub struct TabsBuilder {
key: InstanceKey,
config: TabsConfig,
classes: Vec<std::sync::Arc<str>>,
user_id: Option<String>,
built: OnceCell<Tabs>,
}
impl std::fmt::Debug for TabsBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TabsBuilder")
.field("config", &self.config)
.finish()
}
}
impl TabsBuilder {
#[track_caller]
pub fn new(state: Signal<String>) -> Self {
Self {
key: InstanceKey::new("tabs"),
config: TabsConfig {
state,
tabs: Vec::new(),
size: TabsSize::default(),
default_value: None,
on_change: None,
transition: TabsTransition::default(),
},
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
pub fn with_key(key: impl Into<String>, state: Signal<String>) -> Self {
Self {
key: InstanceKey::explicit(key),
config: TabsConfig {
state,
tabs: Vec::new(),
size: TabsSize::default(),
default_value: None,
on_change: None,
transition: TabsTransition::default(),
},
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
pub fn tab<F>(mut self, value: impl Into<String>, label: impl Into<String>, content: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
let value_str = value.into();
let label_str = label.into();
self.config.tabs.push(TabItem {
menu_item: TabMenuItem::new(value_str).label(label_str),
content: Arc::new(content),
});
self
}
pub fn tab_item<F>(mut self, item: TabMenuItem, content: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
self.config.tabs.push(TabItem {
menu_item: item,
content: Arc::new(content),
});
self
}
pub fn tab_disabled<F>(
mut self,
value: impl Into<String>,
label: impl Into<String>,
content: F,
) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
let value_str = value.into();
let label_str = label.into();
self.config.tabs.push(TabItem {
menu_item: TabMenuItem::new(value_str).label(label_str).disabled(),
content: Arc::new(content),
});
self
}
pub fn size(mut self, size: TabsSize) -> Self {
self.config.size = size;
self
}
pub fn default_value(mut self, value: impl Into<String>) -> Self {
self.config.default_value = Some(value.into());
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 transition(mut self, transition: TabsTransition) -> Self {
self.config.transition = transition;
self
}
pub fn class(mut self, name: impl AsRef<str>) -> Self {
self.classes.push(blinc_core::intern::intern(name.as_ref()));
self
}
pub fn id(mut self, id: &str) -> Self {
self.user_id = Some(id.to_string());
self
}
fn get_or_build(&self) -> &Tabs {
::blinc_layout::build_once::build_once(&self.built, || self.build_component())
}
fn build_component(&self) -> Tabs {
let theme = ThemeState::get();
let config = &self.config;
let current_value = config.state.try_get().unwrap_or_default();
if current_value.is_empty() {
if let Some(ref default) = config.default_value {
config.state.set(default.clone());
} else if let Some(first_tab) = config.tabs.first() {
let first_enabled = config
.tabs
.iter()
.find(|t| !t.menu_item.is_disabled())
.map(|t| t.menu_item.value().to_string())
.unwrap_or_else(|| first_tab.menu_item.value().to_string());
config.state.set(first_enabled);
}
}
let content_margin = theme.spacing().space_1 / 4.0;
let size = config.size;
let tabs_for_buttons = config.tabs.clone();
let state_for_buttons = config.state;
let on_change = config.on_change.clone();
let transition = config.transition;
let trigger_key = self.key.derive("tab_triggers");
let motion_base_key_str = self.key.derive("motion");
let mut tab_button_area = div()
.class("cn-tabs-list")
.w_full()
.flex_row()
.items_center();
for tab in tabs_for_buttons.iter() {
let value = tab.menu_item.value();
tab_button_area = tab_button_area.child(build_tab_trigger(
&trigger_key,
&tab.menu_item,
size,
state_for_buttons,
on_change.clone(),
));
}
let tab_content_area = {
let tabs_for_value = config.tabs.clone();
let tabs_for_panel = config.tabs.clone();
let selected = config.state;
let transition = config.transition;
let motion_base_key = self.key.derive("motion");
let order: Vec<String> = config
.tabs
.iter()
.map(|t| t.menu_item.value().to_string())
.collect();
let last = Mutex::new((config.state.try_get().unwrap_or_default(), true));
let went_on = Arc::new(move |g: &ReactiveGraph| {
let value = g.get(selected).unwrap_or_default();
let mut last = last.lock().unwrap();
if value != last.0 {
let at = |v: &str| order.iter().position(|o| o == v);
last.1 = at(&value) >= at(&last.0);
last.0 = value;
}
last.1
});
let forward = computed({
let went_on = Arc::clone(&went_on);
move |g: &ReactiveGraph| went_on(g)
});
let backward = computed(move |g: &ReactiveGraph| !went_on(g));
let shown = computed(move |g: &ReactiveGraph| {
let value = g.get(selected).unwrap_or_default();
tabs_for_value
.iter()
.any(|t| t.menu_item.value() == value)
.then_some(value)
.into_iter()
.collect::<Vec<String>>()
});
div()
.w_full()
.mt(content_margin)
.flex_grow()
.relative()
.for_each(
&shown,
|value: &String| value.clone(),
move |value: String| {
let tab = tabs_for_panel
.iter()
.find(|t| t.menu_item.value() == value)
.expect("a panel is built for a tab that exists");
let content = (tab.content)();
let Some(name) = transition.attr() else {
return Row::new(div().w_full().flex_grow().child(content));
};
let motion = Arc::new(Mutex::new(CssMotion::new(
blinc_core::intern::intern("cn-tabs-panel"),
format!("cn-tabs-panel-{}-{}", motion_base_key, value),
)));
let leaving = signal(false);
let panel = motion
.lock()
.unwrap()
.element(div().attr("data-transition", name))
.attr_when("data-direction", "next", &forward)
.attr_when("data-direction", "prev", &backward)
.class_when("cn-tabs-panel--leaving", leaving)
.w_full()
.flex_grow()
.child(content);
let exiting = Arc::clone(&motion);
let frames = std::sync::atomic::AtomicU64::new(0);
Row::new(panel).on_leave(
move || {
leaving.set(true);
exiting.lock().unwrap().exit();
},
move || {
let now =
16 * frames.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
motion.lock().unwrap().exit_done(now)
},
)
},
)
};
let mut container = div()
.w_full()
.flex_grow()
.flex_col()
.child(tab_button_area)
.child(tab_content_area);
for c in &self.classes {
container = container.class(c);
}
if let Some(ref id) = self.user_id {
container = container.id(id);
}
Tabs { inner: container }
}
}
#[allow(clippy::type_complexity)]
fn build_tab_trigger(
trigger_key: &str,
menu_item: &TabMenuItem,
size: TabsSize,
tab_state: Signal<String>,
on_change: Option<Arc<dyn Fn(&str) + Send + Sync>>,
) -> Div {
let theme = ThemeState::get();
let value = menu_item.value.clone();
let disabled = menu_item.disabled;
let icon_svg = menu_item.icon.clone();
let label_text = menu_item.label.clone();
let badge_text = menu_item.badge.clone();
let interaction = Interaction::keyed(&format!("{}:{}", trigger_key, value));
let is_active = {
let value = value.clone();
computed(move |g: &ReactiveGraph| {
!disabled && g.get(tab_state).unwrap_or_default() == value
})
};
let mut content = div()
.flex_row()
.items_center()
.gap_px(theme.spacing().space_2);
if let Some(ref icon) = icon_svg {
content = content.child(svg(icon).size(size.icon_size(), size.icon_size()));
}
if let Some(ref label) = label_text {
content = content.child(text(label).size(size.font_size()).no_cursor());
}
if let Some(ref badge_label) = badge_text {
content = content.child(
crate::components::badge::badge(badge_label)
.variant(crate::components::badge::BadgeVariant::Default),
);
}
let trigger_size_class = match size {
TabsSize::Small => "cn-tabs-trigger--sm",
TabsSize::Medium => "cn-tabs-trigger--md",
TabsSize::Large => "cn-tabs-trigger--lg",
};
let indicator_key = format!("cn-tabs-indicator-{trigger_key}");
let mut trigger = div()
.class("cn-tabs-trigger")
.class("tabindex=0")
.class(trigger_size_class)
.class_when("cn-tabs-trigger--active", &is_active)
.relative()
.flex_row()
.items_center()
.justify_center()
.cursor(if disabled {
CursorStyle::Default
} else {
CursorStyle::Pointer
})
.track(&interaction)
.show(is_active, move || {
div()
.class("cn-tabs-indicator")
.absolute()
.inset(0.0)
.animate_bounds(
VisualAnimationConfig::all()
.with_key(indicator_key.clone())
.shared()
.theme_timed(),
)
})
.child(content);
if disabled {
trigger = trigger.class("cn-tabs-trigger--disabled");
}
if !disabled {
let value_for_click = value.clone();
trigger = trigger.on_click(move |_| {
if tab_state.try_get().as_deref() == Some(value_for_click.as_str()) {
return;
}
tab_state.set(value_for_click.clone());
if let Some(ref cb) = on_change {
cb(&value_for_click);
}
});
}
trigger
}
impl ElementBuilder for TabsBuilder {
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 layout_style(&self) -> Option<&taffy::Style> {
self.get_or_build().inner.layout_style()
}
fn element_classes(&self) -> &[std::sync::Arc<str>] {
self.get_or_build().inner.element_classes()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().inner.element_id()
}
}
impl std::ops::Deref for TabsBuilder {
type Target = Div;
fn deref(&self) -> &Self::Target {
&self.get_or_build().inner
}
}
#[track_caller]
pub fn tabs(state: Signal<String>) -> TabsBuilder {
TabsBuilder::new(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tabs_size() {
assert_eq!(TabsSize::Small.height(), 32.0);
assert_eq!(TabsSize::Medium.height(), 40.0);
assert_eq!(TabsSize::Large.height(), 48.0);
}
}