orbital-core-components 0.1.1

Themed Orbital UI components for Leptos applications
Documentation
use icondata_core::Icon as IconData;
use leptos::{
    either::{Either, EitherOf3},
    prelude::*,
};
use orbital_base_components::{BaseButton, ComponentRef};
use orbital_macros::component_doc;
use orbital_style::inject_style;

use super::styles::button_styles;
use super::types::{ButtonAppearance, ButtonRef, ButtonShape, ButtonSize, ButtonType};
use crate::Icon;

fn button_icon_view(icon: IconData) -> impl IntoView {
    view! {
        <span class="orbital-button__icon">
            <Icon icon=icon width="1em" height="1em" />
        </span>
    }
}

/// Runs a single command when activated — form submits, dialog confirmations, toolbar actions, and inline commands.
///
/// Pick [`ButtonAppearance::Primary`] for the one main action on a surface. Wire async work with `loading` and `on_click`. For navigation, use [`Link`](crate::Link) instead.
///
/// # When to use
///
/// - Submitting forms, confirming dialogs, or firing one-off commands - Toolbar and card actions where a single clear primary action is needed - Icon-only affordances when space is tight (pair with `aria-label` on a wrapper)
///
/// # Usage
///
/// 1. Pick an [`ButtonAppearance`] — `Primary` for the main action, `Secondary` for alternatives. 2. Wire `on_click` with [`Callback`] when the button should run logic (not submit a native form). 3. Set `loading` while async work runs; disable the control when input is invalid or work is in-flight. 4. For E2E hooks, wrap the button in a native element with `data-testid` (see project UI rules).
///
/// # Best Practices
///
/// ## Do's
///
/// * Use `appearance=ButtonAppearance::Primary` for the main action on a surface * Show `loading` during async handlers so users see in-progress state * Disable while the form is invalid or a request is outstanding * Use `icon` for recognizable actions (save, search, add)
///
/// ## Don'ts
///
/// * Do not stack multiple primary buttons in one row * Do not use `data-testid` on the component itself — wrap with a native element * Do not use icon-only buttons without an accessible name on the wrapper
///
/// # Button family
///
/// Orbital ships several command controls. When `Button` is not the right fit:
///
/// - **Single command on click** — `Button` (this component). Use [`Link`](crate::Link) for navigation. - **Primary command plus related alternates** — [`ActionMenuButton`](crate::ActionMenuButton) (Save + Save as / Export). - **Menu of options, no primary segment** — [`MenuButton`](crate::MenuButton), or [`Menu`](crate::Menu) for custom triggers. - **Primary label plus supporting line** — [`CompoundButton`](crate::CompoundButton). - **One action pinned to the viewport** — [`FloatingButton`](crate::FloatingButton). - **Primary float plus fan-out secondaries** — [`FloatingActionsMenu`](crate::FloatingActionsMenu). - **Merge adjacent buttons visually** — [`ButtonGroup`](crate::ButtonGroup) (layout only; style each child explicitly). - **Toolbar on/off pressed state** — [`ToggleButton`](crate::ToggleButton). Prefer [`Switch`](crate::Switch) for immediate settings.
///
/// # Examples
///
/// ## Primary button
/// Default call-to-action on a form or dialog footer.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-preview">
///         <Button appearance=ButtonAppearance::Primary>
///             "Save"
///         </Button>
///     </div>
/// }
/// ```
///
/// ## Secondary outline
/// Secondary actions beside the primary—cancel, back, or low-commit choices. Outline styling keeps emphasis below Primary without blending into the surface.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-secondary">
///         <Button appearance=ButtonAppearance::Secondary>
///             "Cancel"
///         </Button>
///     </div>
/// }
/// ```
///
/// ## Subtle and transparent
/// Low-emphasis actions that blend into the surface until hovered.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-subtle">
///         <Button appearance=ButtonAppearance::Subtle>"More"</Button>
///         <Button appearance=ButtonAppearance::Transparent>"Dismiss"</Button>
///     </div>
/// }
/// ```
///
/// ## With icon
/// Leading icon reinforces the action (save, add, search) while the text label keeps meaning clear for sighted users and screen readers.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-icon">
///         <Button appearance=ButtonAppearance::Primary icon=icondata::AiSaveOutlined>
///             "Save"
///         </Button>
///     </div>
/// }
/// ```
///
/// ## Icon-only
/// Compact affordance when toolbar space is tight. Wrap with `aria-label` in app code—the button has no visible text for assistive technologies.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-icon-only">
///         <Button icon=icondata::AiSearchOutlined appearance=ButtonAppearance::Subtle />
///     </div>
/// }
/// ```
///
/// ## Sizes
/// Small fits toolbars and dense rows; medium is the default; large suits prominent mobile CTAs or hero actions. Set each with `size=ButtonSize::…`.
/// <!-- preview -->
/// ```rust
/// use crate::{Button, ButtonSize};
/// view! {
///     <div data-testid="button-sizes">
///         <Button size=ButtonSize::Small>"Small"</Button>
///         <Button size=ButtonSize::Medium>"Medium"</Button>
///         <Button size=ButtonSize::Large>"Large"</Button>
///     </div>
/// }
/// ```
///
/// ## Block (full width)
/// Stretches to the full container width—common for mobile form footers, stacked dialog actions, and narrow layouts.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-block">
///         <Button block=true appearance=ButtonAppearance::Primary>
///             "Continue"
///         </Button>
///     </div>
/// }
/// ```
///
/// ## Loading state
/// Spinner replaces the icon slot and blocks clicks while async work runs. Pair with disabled form controls to prevent double submission.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-loading">
///         <Button appearance=ButtonAppearance::Primary loading=true>
///             "Saving…"
///         </Button>
///     </div>
/// }
/// ```
///
/// ## Click handler
/// <!-- code-only -->
/// ```rust
/// use leptos::prelude::*;
/// view! {
///     <Button
///         appearance=ButtonAppearance::Primary
///         on_click=|_| {}
///     >
///         "Run action"
///     </Button>
/// }
/// ```
///
/// ## Shapes
/// Rounded is the default for labeled buttons. Circular and square fit icon-only controls in toolbars, floating actions, and compact settings grids.
/// <!-- preview -->
/// ```rust
/// use crate::{Button, ButtonShape};
/// view! {
///     <div data-testid="button-shapes">
///         <Button shape=ButtonShape::Rounded>"Rounded"</Button>
///         <Button shape=ButtonShape::Circular icon=icondata::AiPlusOutlined />
///         <Button shape=ButtonShape::Square icon=icondata::AiSettingOutlined />
///     </div>
/// }
/// ```
///
/// ## Disabled
/// Unavailable actions stay visibly disabled and ignore clicks. `disabled_focusable` keeps the button in tab order for tooltips or custom disabled messaging.
/// <!-- preview -->
/// ```rust
/// view! {
///     <div data-testid="button-disabled">
///         <Button appearance=ButtonAppearance::Primary disabled=true>
///             "Unavailable"
///         </Button>
///         <Button appearance=ButtonAppearance::Secondary disabled_focusable=true>
///             "Focusable when disabled"
///         </Button>
///     </div>
/// }
/// ```
///
/// ## Theme: primary uses brand token
/// Wrap in `OrbitalThemeProvider` with a custom brand palette so primary buttons use the theme's brand color token.
/// <!-- preview -->
/// ```rust
/// use leptos::prelude::*;
/// use orbital_theme::{BrandPalette, OrbitalThemeProvider, Theme, ThemeMode};
///
/// view! {
///     <div data-testid="button-theme-brand">
///         <OrbitalThemeProvider theme=RwSignal::new(Theme::with_brand(
///             ThemeMode::Light,
///             BrandPalette { primary: "#E3008C".to_string() },
///         ))>
///             <Button appearance=ButtonAppearance::Primary>"Brand action"</Button>
///         </OrbitalThemeProvider>
///     </div>
/// }
/// ```
///
/// ## Imperative handle
/// ```rust,ignore
/// // Focus or programmatically click via ButtonRef after mount.
/// use crate::{Button, ButtonRef};
/// use orbital_base_components::ComponentRef;
/// let btn_ref = ComponentRef::<ButtonRef>::default();
/// view! {
///     <Button comp_ref=btn_ref appearance=ButtonAppearance::Primary>"Focus me"</Button>
/// }
/// ```
#[component_doc(
    category = "Inputs",
    preview_slug = "button",
    preview_label = "Button",
    preview_icon = icondata::AiBorderOutlined,
)]
#[component(transparent)]
pub fn Button(
    /// Extra CSS class names merged onto the root `<button>` element.
    #[prop(optional, into)]
    class: MaybeProp<String>,
    /// Visual emphasis: primary, secondary, subtle, or transparent.
    #[prop(optional, into)]
    appearance: Signal<ButtonAppearance>,
    /// Border shape: rounded (default), circular, or square.
    #[prop(optional, into)]
    shape: Signal<ButtonShape>,
    /// Control size.
    #[prop(optional, into)]
    size: Signal<ButtonSize>,
    /// Native button `type` attribute (`submit`, `reset`, or `button`).
    #[prop(optional, into)]
    button_type: MaybeProp<ButtonType>,
    /// When true, the button stretches to the full width of its container.
    #[prop(optional, into)]
    block: Signal<bool>,
    /// Leading icon from the icondata catalog; omit `children` for icon-only buttons.
    #[prop(optional, into)]
    icon: MaybeProp<IconData>,
    /// When true, the button does not respond to clicks or submit actions.
    #[prop(optional, into)]
    disabled: Signal<bool>,
    /// When true, the button stays focusable while disabled (for tooltips or custom UX).
    #[prop(optional, into)]
    disabled_focusable: Signal<bool>,
    /// When true, shows a spinner and blocks click handling until cleared.
    #[prop(optional, into)]
    loading: Signal<bool>,
    /// Optional `aria-pressed` for toggle buttons.
    #[prop(optional, into)]
    aria_pressed: MaybeProp<String>,
    /// Handler invoked on click when not disabled or loading.
    #[prop(optional)]
    on_click: Option<Callback<leptos::ev::MouseEvent>>,
    /// Button label text; optional when `icon` alone is sufficient.
    #[prop(optional)]
    children: Option<Children>,
    /// Imperative handle for `focus` and `click` on the underlying DOM button.
    #[prop(optional)]
    comp_ref: ComponentRef<ButtonRef>,
) -> impl IntoView {
    inject_style("orbital-button", button_styles());
    let none_children = children.is_none();
    let only_icon = Memo::new(move |_| icon.with(|i| i.is_some()) && none_children);
    let btn_disabled = Memo::new(move |_| disabled.get());

    let appearance_class =
        Signal::derive(move || format!("orbital-button--{}", appearance.get().as_str()));
    let shape_class = Signal::derive(move || format!("orbital-button--{}", shape.get().as_str()));
    let size_class = Signal::derive(move || format!("orbital-button--{}", size.get().as_str()));

    let modifier_class = Signal::derive(move || {
        let mut parts = vec!["orbital-button".to_string()];
        if btn_disabled.get() {
            parts.push("orbital-button--disabled".to_string());
        }
        if block.get() {
            parts.push("orbital-button--block".to_string());
        }
        if only_icon.get() {
            parts.push("orbital-button--only-icon".to_string());
        }
        if icon.with(|i| i.is_some()) {
            parts.push("orbital-button--with-icon".to_string());
        }
        if loading.get() {
            parts.push("orbital-button--loading".to_string());
        }
        if let Some(extra) = class.get() {
            if !extra.is_empty() {
                parts.push(extra);
            }
        }
        parts.join(" ")
    });

    let leading = move || {
        if loading.get() {
            EitherOf3::A(view! {
                <span class="orbital-button__icon">
                    <span class="orbital-button__spinner"></span>
                </span>
            })
        } else if let Some(icon) = icon.get() {
            EitherOf3::B(button_icon_view(icon))
        } else {
            EitherOf3::C(())
        }
    };

    view! {
        <BaseButton
            class=modifier_class
            appearance=appearance_class
            shape=shape_class
            size=size_class
            button_type=button_type
            block=block
            disabled=disabled
            disabled_focusable=disabled_focusable
            loading=loading
            aria_pressed=aria_pressed
            nostrip:on_click=on_click
            comp_ref=comp_ref
        >
            {leading}
            {if let Some(children) = children {
                Either::Left(children())
            } else {
                Either::Right(())
            }}
        </BaseButton>
    }
}