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}