fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Tabs` — the web tabs: controlled active state, click to switch, box
//! mode, per-tab count chips, notification signs and tooltips. Links stay
//! web-only (no router on the desktop).

use crate::theme::ActiveTheme as _;
use fluidattacks_designs_domain::tabs::{
    active_border, Variant, ACTIVE_BORDER_PX, ACTIVE_BOTTOM_PADDING, BOX_BORDER, BOX_RADIUS, GAP,
    ITEM_PADDING, SIDE_PADDING,
};
use fluidattacks_designs_domain::theme;
use fluidattacks_designs_domain::typography::{TextSize, FONT_PRIMARY};
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, rgb, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
    ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _,
    Window,
};

type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;

/// One tab: label plus the web's optional count chip and notification sign.
pub struct TabItem {
    id: ElementId,
    label: SharedString,
    active: bool,
    on_click: Option<ClickHandler>,
    tag: Option<SharedString>,
    sign: Option<crate::NotificationSign>,
    tooltip: Option<(ElementId, SharedString)>,
}

impl TabItem {
    #[must_use]
    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
        Self {
            id: id.into(),
            label: label.into(),
            active: false,
            on_click: None,
            tag: None,
            sign: None,
            tooltip: None,
        }
    }

    #[must_use]
    pub const fn active(mut self, active: bool) -> Self {
        self.active = active;
        self
    }

    #[must_use]
    pub fn on_click(
        mut self,
        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.on_click = Some(Box::new(on_click));
        self
    }

    /// The count chip rendered after the label (a default-variant tag).
    #[must_use]
    pub fn tag(mut self, tag: impl Into<SharedString>) -> Self {
        self.tag = Some(tag.into());
        self
    }

    /// The notification sign rendered after the label.
    #[must_use]
    pub const fn notification(mut self, sign: crate::NotificationSign) -> Self {
        self.sign = Some(sign);
        self
    }

    /// Wrap the tab in a tooltip (web `tooltip`); the id keeps sibling
    /// tooltips apart.
    #[must_use]
    pub fn tooltip(mut self, id: impl Into<ElementId>, tip: impl Into<SharedString>) -> Self {
        self.tooltip = Some((id.into(), tip.into()));
        self
    }
}

#[derive(IntoElement)]
pub struct Tabs {
    variant: Variant,
    borders: bool,
    boxed: bool,
    items: Vec<TabItem>,
}

impl Tabs {
    #[must_use]
    pub const fn new() -> Self {
        Self {
            variant: Variant::Primary,
            borders: true,
            boxed: false,
            items: Vec::new(),
        }
    }

    #[must_use]
    pub const fn variant(mut self, variant: Variant) -> Self {
        self.variant = variant;
        self
    }

    #[must_use]
    pub const fn borders(mut self, borders: bool) -> Self {
        self.borders = borders;
        self
    }

    /// The web `box` prop: a gray-200 hairline around a shrink-wrapped bar.
    #[must_use]
    pub const fn boxed(mut self, boxed: bool) -> Self {
        self.boxed = boxed;
        self
    }

    #[must_use]
    pub fn item(mut self, item: TabItem) -> Self {
        self.items.push(item);
        self
    }

    #[must_use]
    pub fn tab(
        self,
        id: impl Into<ElementId>,
        label: impl Into<SharedString>,
        active: bool,
        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.item(TabItem::new(id, label).active(active).on_click(on_click))
    }
}

impl Default for Tabs {
    fn default() -> Self {
        Self::new()
    }
}

fn item(mut entry: TabItem, variant: Variant, theme: &theme::Colors) -> gpui::AnyElement {
    let tooltip = entry.tooltip.take();
    let (pad_y, pad_x) = ITEM_PADDING;
    let pad_bottom = if entry.active {
        ACTIVE_BOTTOM_PADDING
    } else {
        pad_y
    };
    let hover_bg = rgb(theme.surface_muted);
    let element = div()
        .id(entry.id)
        .flex()
        .items_center()
        .gap(px(GAP))
        .pt(px(pad_y))
        .pb(px(pad_bottom))
        .px(px(pad_x))
        .font_family(FONT_PRIMARY)
        .text_size(px(TextSize::Md.px()))
        .whitespace_nowrap()
        .text_color(rgb(theme.text_strong))
        .cursor_pointer()
        .when(entry.active, |el| {
            el.border_b(px(ACTIVE_BORDER_PX))
                .border_color(rgb(active_border(variant, theme)))
        })
        // the container hover has no active exclusion on the web
        .hover(move |style| style.bg(hover_bg))
        .when_some(entry.on_click, |el, on_click| el.on_click(on_click))
        .child(entry.label)
        .when_some(entry.sign, gpui::ParentElement::child)
        .when_some(entry.tag, |el, tag| el.child(crate::Tag::new(tag)));
    match tooltip {
        Some((tooltip_id, tip)) => crate::Tooltip::new(tooltip_id, tip)
            .child(element)
            .into_any_element(),
        None => element.into_any_element(),
    }
}

impl RenderOnce for Tabs {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = cx.theme().colors;
        let boxed = self.boxed;
        let mut bar = div()
            .flex()
            .items_center()
            .gap(px(GAP))
            .bg(rgb(theme.surface))
            // full-width baseline unless boxed, which shrink-wraps (web
            // width: box ? max-content : 100%)
            .when(!boxed, gpui::Styled::w_full)
            .when(boxed, |el| {
                // gpui elements carry one border color; the box keeps its
                // gray-200 all around instead of the web's gray-300 bottom
                el.border_1()
                    .border_color(rgb(theme.shade(BOX_BORDER)))
                    .rounded(px(BOX_RADIUS))
            })
            .when(self.borders && !boxed, |el| {
                el.border_b_1()
                    .border_color(rgb(theme.border))
                    .px(px(SIDE_PADDING))
            });
        for entry in self.items {
            bar = bar.child(item(entry, self.variant, &theme));
        }
        bar
    }
}