Skip to main content

orbital_core_components/button/
button.rs

1use icondata_core::Icon as IconData;
2use leptos::{
3    either::{Either, EitherOf3},
4    prelude::*,
5};
6use orbital_base_components::{BaseButton, ComponentRef};
7use orbital_macros::component_doc;
8use orbital_style::inject_style;
9
10use super::styles::button_styles;
11use super::types::{ButtonAppearance, ButtonRef, ButtonShape, ButtonSize, ButtonType};
12use crate::Icon;
13
14fn button_icon_view(icon: IconData) -> impl IntoView {
15    view! {
16        <span class="orbital-button__icon">
17            <Icon icon=icon width="1em" height="1em" />
18        </span>
19    }
20}
21
22/// Runs a single command when activated — form submits, dialog confirmations, toolbar actions, and inline commands.
23///
24/// 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.
25///
26/// # When to use
27///
28/// - 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)
29///
30/// # Usage
31///
32/// 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).
33///
34/// # Best Practices
35///
36/// ## Do's
37///
38/// * 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)
39///
40/// ## Don'ts
41///
42/// * 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
43///
44/// # Button family
45///
46/// Orbital ships several command controls. When `Button` is not the right fit:
47///
48/// - **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.
49///
50/// # Examples
51///
52/// ## Primary button
53/// Default call-to-action on a form or dialog footer.
54/// <!-- preview -->
55/// ```rust
56/// view! {
57///     <div data-testid="button-preview">
58///         <Button appearance=ButtonAppearance::Primary>
59///             "Save"
60///         </Button>
61///     </div>
62/// }
63/// ```
64///
65/// ## Secondary outline
66/// Secondary actions beside the primary—cancel, back, or low-commit choices. Outline styling keeps emphasis below Primary without blending into the surface.
67/// <!-- preview -->
68/// ```rust
69/// view! {
70///     <div data-testid="button-secondary">
71///         <Button appearance=ButtonAppearance::Secondary>
72///             "Cancel"
73///         </Button>
74///     </div>
75/// }
76/// ```
77///
78/// ## Subtle and transparent
79/// Low-emphasis actions that blend into the surface until hovered.
80/// <!-- preview -->
81/// ```rust
82/// view! {
83///     <div data-testid="button-subtle">
84///         <Button appearance=ButtonAppearance::Subtle>"More"</Button>
85///         <Button appearance=ButtonAppearance::Transparent>"Dismiss"</Button>
86///     </div>
87/// }
88/// ```
89///
90/// ## With icon
91/// Leading icon reinforces the action (save, add, search) while the text label keeps meaning clear for sighted users and screen readers.
92/// <!-- preview -->
93/// ```rust
94/// view! {
95///     <div data-testid="button-icon">
96///         <Button appearance=ButtonAppearance::Primary icon=icondata::AiSaveOutlined>
97///             "Save"
98///         </Button>
99///     </div>
100/// }
101/// ```
102///
103/// ## Icon-only
104/// Compact affordance when toolbar space is tight. Wrap with `aria-label` in app code—the button has no visible text for assistive technologies.
105/// <!-- preview -->
106/// ```rust
107/// view! {
108///     <div data-testid="button-icon-only">
109///         <Button icon=icondata::AiSearchOutlined appearance=ButtonAppearance::Subtle />
110///     </div>
111/// }
112/// ```
113///
114/// ## Sizes
115/// Small fits toolbars and dense rows; medium is the default; large suits prominent mobile CTAs or hero actions. Set each with `size=ButtonSize::…`.
116/// <!-- preview -->
117/// ```rust
118/// use crate::{Button, ButtonSize};
119/// view! {
120///     <div data-testid="button-sizes">
121///         <Button size=ButtonSize::Small>"Small"</Button>
122///         <Button size=ButtonSize::Medium>"Medium"</Button>
123///         <Button size=ButtonSize::Large>"Large"</Button>
124///     </div>
125/// }
126/// ```
127///
128/// ## Block (full width)
129/// Stretches to the full container width—common for mobile form footers, stacked dialog actions, and narrow layouts.
130/// <!-- preview -->
131/// ```rust
132/// view! {
133///     <div data-testid="button-block">
134///         <Button block=true appearance=ButtonAppearance::Primary>
135///             "Continue"
136///         </Button>
137///     </div>
138/// }
139/// ```
140///
141/// ## Loading state
142/// Spinner replaces the icon slot and blocks clicks while async work runs. Pair with disabled form controls to prevent double submission.
143/// <!-- preview -->
144/// ```rust
145/// view! {
146///     <div data-testid="button-loading">
147///         <Button appearance=ButtonAppearance::Primary loading=true>
148///             "Saving…"
149///         </Button>
150///     </div>
151/// }
152/// ```
153///
154/// ## Click handler
155/// <!-- code-only -->
156/// ```rust
157/// use leptos::prelude::*;
158/// view! {
159///     <Button
160///         appearance=ButtonAppearance::Primary
161///         on_click=|_| {}
162///     >
163///         "Run action"
164///     </Button>
165/// }
166/// ```
167///
168/// ## Shapes
169/// Rounded is the default for labeled buttons. Circular and square fit icon-only controls in toolbars, floating actions, and compact settings grids.
170/// <!-- preview -->
171/// ```rust
172/// use crate::{Button, ButtonShape};
173/// view! {
174///     <div data-testid="button-shapes">
175///         <Button shape=ButtonShape::Rounded>"Rounded"</Button>
176///         <Button shape=ButtonShape::Circular icon=icondata::AiPlusOutlined />
177///         <Button shape=ButtonShape::Square icon=icondata::AiSettingOutlined />
178///     </div>
179/// }
180/// ```
181///
182/// ## Disabled
183/// Unavailable actions stay visibly disabled and ignore clicks. `disabled_focusable` keeps the button in tab order for tooltips or custom disabled messaging.
184/// <!-- preview -->
185/// ```rust
186/// view! {
187///     <div data-testid="button-disabled">
188///         <Button appearance=ButtonAppearance::Primary disabled=true>
189///             "Unavailable"
190///         </Button>
191///         <Button appearance=ButtonAppearance::Secondary disabled_focusable=true>
192///             "Focusable when disabled"
193///         </Button>
194///     </div>
195/// }
196/// ```
197///
198/// ## Theme: primary uses brand token
199/// Wrap in `OrbitalThemeProvider` with a custom brand palette so primary buttons use the theme's brand color token.
200/// <!-- preview -->
201/// ```rust
202/// use leptos::prelude::*;
203/// use orbital_theme::{BrandPalette, OrbitalThemeProvider, Theme, ThemeMode};
204///
205/// view! {
206///     <div data-testid="button-theme-brand">
207///         <OrbitalThemeProvider theme=RwSignal::new(Theme::with_brand(
208///             ThemeMode::Light,
209///             BrandPalette { primary: "#E3008C".to_string() },
210///         ))>
211///             <Button appearance=ButtonAppearance::Primary>"Brand action"</Button>
212///         </OrbitalThemeProvider>
213///     </div>
214/// }
215/// ```
216///
217/// ## Imperative handle
218/// ```rust,ignore
219/// // Focus or programmatically click via ButtonRef after mount.
220/// use crate::{Button, ButtonRef};
221/// use orbital_base_components::ComponentRef;
222/// let btn_ref = ComponentRef::<ButtonRef>::default();
223/// view! {
224///     <Button comp_ref=btn_ref appearance=ButtonAppearance::Primary>"Focus me"</Button>
225/// }
226/// ```
227#[component_doc(
228    category = "Inputs",
229    preview_slug = "button",
230    preview_label = "Button",
231    preview_icon = icondata::AiBorderOutlined,
232)]
233#[component(transparent)]
234pub fn Button(
235    /// Extra CSS class names merged onto the root `<button>` element.
236    #[prop(optional, into)]
237    class: MaybeProp<String>,
238    /// Visual emphasis: primary, secondary, subtle, or transparent.
239    #[prop(optional, into)]
240    appearance: Signal<ButtonAppearance>,
241    /// Border shape: rounded (default), circular, or square.
242    #[prop(optional, into)]
243    shape: Signal<ButtonShape>,
244    /// Control size.
245    #[prop(optional, into)]
246    size: Signal<ButtonSize>,
247    /// Native button `type` attribute (`submit`, `reset`, or `button`).
248    #[prop(optional, into)]
249    button_type: MaybeProp<ButtonType>,
250    /// When true, the button stretches to the full width of its container.
251    #[prop(optional, into)]
252    block: Signal<bool>,
253    /// Leading icon from the icondata catalog; omit `children` for icon-only buttons.
254    #[prop(optional, into)]
255    icon: MaybeProp<IconData>,
256    /// When true, the button does not respond to clicks or submit actions.
257    #[prop(optional, into)]
258    disabled: Signal<bool>,
259    /// When true, the button stays focusable while disabled (for tooltips or custom UX).
260    #[prop(optional, into)]
261    disabled_focusable: Signal<bool>,
262    /// When true, shows a spinner and blocks click handling until cleared.
263    #[prop(optional, into)]
264    loading: Signal<bool>,
265    /// Optional `aria-pressed` for toggle buttons.
266    #[prop(optional, into)]
267    aria_pressed: MaybeProp<String>,
268    /// Handler invoked on click when not disabled or loading.
269    #[prop(optional)]
270    on_click: Option<Callback<leptos::ev::MouseEvent>>,
271    /// Button label text; optional when `icon` alone is sufficient.
272    #[prop(optional)]
273    children: Option<Children>,
274    /// Imperative handle for `focus` and `click` on the underlying DOM button.
275    #[prop(optional)]
276    comp_ref: ComponentRef<ButtonRef>,
277) -> impl IntoView {
278    inject_style("orbital-button", button_styles());
279    let none_children = children.is_none();
280    let only_icon = Memo::new(move |_| icon.with(|i| i.is_some()) && none_children);
281    let btn_disabled = Memo::new(move |_| disabled.get());
282
283    let appearance_class =
284        Signal::derive(move || format!("orbital-button--{}", appearance.get().as_str()));
285    let shape_class = Signal::derive(move || format!("orbital-button--{}", shape.get().as_str()));
286    let size_class = Signal::derive(move || format!("orbital-button--{}", size.get().as_str()));
287
288    let modifier_class = Signal::derive(move || {
289        let mut parts = vec!["orbital-button".to_string()];
290        if btn_disabled.get() {
291            parts.push("orbital-button--disabled".to_string());
292        }
293        if block.get() {
294            parts.push("orbital-button--block".to_string());
295        }
296        if only_icon.get() {
297            parts.push("orbital-button--only-icon".to_string());
298        }
299        if icon.with(|i| i.is_some()) {
300            parts.push("orbital-button--with-icon".to_string());
301        }
302        if loading.get() {
303            parts.push("orbital-button--loading".to_string());
304        }
305        if let Some(extra) = class.get() {
306            if !extra.is_empty() {
307                parts.push(extra);
308            }
309        }
310        parts.join(" ")
311    });
312
313    let leading = move || {
314        if loading.get() {
315            EitherOf3::A(view! {
316                <span class="orbital-button__icon">
317                    <span class="orbital-button__spinner"></span>
318                </span>
319            })
320        } else if let Some(icon) = icon.get() {
321            EitherOf3::B(button_icon_view(icon))
322        } else {
323            EitherOf3::C(())
324        }
325    };
326
327    view! {
328        <BaseButton
329            class=modifier_class
330            appearance=appearance_class
331            shape=shape_class
332            size=size_class
333            button_type=button_type
334            block=block
335            disabled=disabled
336            disabled_focusable=disabled_focusable
337            loading=loading
338            aria_pressed=aria_pressed
339            nostrip:on_click=on_click
340            comp_ref=comp_ref
341        >
342            {leading}
343            {if let Some(children) = children {
344                Either::Left(children())
345            } else {
346                Either::Right(())
347            }}
348        </BaseButton>
349    }
350}