Skip to main content

hypersig_ui/
button.rs

1//! Buttons, and the style that makes a link look like one.
2
3use hypersig::html::{El, button as button_el};
4use hypersig::style::value::{
5    AlignItems, BorderStyle, Color, Cursor, Display, FontWeight, JustifyContent,
6    TextDecorationLine, UserSelect, WhiteSpace, px, raw, rem,
7};
8use hypersig::style::{PseudoClass, Style};
9
10use crate::look;
11use crate::token;
12
13/// The look of a button.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum ButtonVariant {
17    /// Filled with [`token::primary`], for the main action.
18    Default,
19    /// Filled with [`token::destructive`], for an action that deletes.
20    Destructive,
21    /// A border on the background.
22    Outline,
23    /// Filled with [`token::secondary`].
24    Secondary,
25    /// No fill until the pointer is over it.
26    Ghost,
27    /// Underlined text.
28    Link,
29}
30
31/// The height and padding of a button.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33#[non_exhaustive]
34pub enum ButtonSize {
35    /// 2.25rem high.
36    Default,
37    /// 2rem high.
38    Sm,
39    /// 2.5rem high.
40    Lg,
41}
42
43/// The style of a button of `variant` and `size`, for an element that is not
44/// a `<button>`, such as a link that leads to a form.
45///
46/// ```
47/// use hypersig::html::a;
48/// use hypersig::{Page, Route, Signals};
49/// use hypersig_ui::{ButtonSize, ButtonVariant, button_style};
50/// use serde::{Deserialize, Serialize};
51///
52/// #[derive(Signals, Serialize, Deserialize)]
53/// struct App {}
54///
55/// # async fn signup((): (), (): ()) -> Option<Page<App>> { None }
56/// hypersig::routes! {
57///     fn pages(Router<()>);
58///     const SIGNUP: Route = "/signup" => signup;
59/// }
60///
61/// let link = a::<App>()
62///     .href(SIGNUP.link())
63///     .css(button_style(ButtonVariant::Default, ButtonSize::Lg))
64///     .text("Sign up");
65/// # let _ = (link, pages);
66/// ```
67#[must_use]
68pub fn button_style(variant: ButtonVariant, size: ButtonSize) -> Style {
69    let height = match size {
70        ButtonSize::Default => rem(2.25),
71        ButtonSize::Sm => rem(2),
72        ButtonSize::Lg => rem(2.5),
73    };
74    let padding = match size {
75        ButtonSize::Default => rem(1),
76        ButtonSize::Sm => rem(0.75),
77        ButtonSize::Lg => rem(1.5),
78    };
79    base()
80        .height(height)
81        .padding_inline(padding)
82        .merge(fill(variant))
83}
84
85/// The parts every button shares, whatever its variant and size.
86fn base() -> Style {
87    Style::new()
88        .display(Display::InlineFlex)
89        .align_items(AlignItems::Center)
90        .justify_content(JustifyContent::Center)
91        .gap(rem(0.5))
92        .white_space(WhiteSpace::Nowrap)
93        .border_width(px(1))
94        .border_style(BorderStyle::Solid)
95        .border_color(Color::TRANSPARENT)
96        .border_radius(raw(format!("calc({} - 2px)", token::radius().var())))
97        .font_weight(FontWeight::Number(500.0))
98        .cursor(Cursor::Pointer)
99        .user_select(UserSelect::None)
100        .merge(look::small_text())
101        .merge(look::transition())
102        .merge(look::focus_ring())
103        .merge(look::disabled())
104}
105
106fn fill(variant: ButtonVariant) -> Style {
107    let solid = |background: Color, text: Color, hover: u8| {
108        Style::new()
109            .background_color(background.clone())
110            .color(text)
111            .on(
112                PseudoClass::Hover,
113                Style::new().background_color(background.opacity(hover)),
114            )
115    };
116    match variant {
117        ButtonVariant::Default => solid(
118            token::primary().var(),
119            token::primary_foreground().var(),
120            90,
121        ),
122        ButtonVariant::Destructive => solid(
123            token::destructive().var(),
124            token::destructive_foreground().var(),
125            90,
126        ),
127        ButtonVariant::Secondary => solid(
128            token::secondary().var(),
129            token::secondary_foreground().var(),
130            80,
131        ),
132        ButtonVariant::Outline => Style::new()
133            .border_color(token::border().var())
134            .background_color(token::background().var())
135            .color(token::foreground().var())
136            .on(PseudoClass::Hover, hover_accent()),
137        ButtonVariant::Ghost => Style::new()
138            .background_color(Color::TRANSPARENT)
139            .color(token::foreground().var())
140            .on(PseudoClass::Hover, hover_accent()),
141        ButtonVariant::Link => Style::new()
142            .background_color(Color::TRANSPARENT)
143            .color(token::primary().var())
144            .text_underline_offset(px(4))
145            .on(
146                PseudoClass::Hover,
147                Style::new().text_decoration_line(TextDecorationLine::Underline),
148            ),
149    }
150}
151
152fn hover_accent() -> Style {
153    Style::new()
154        .background_color(token::accent().var())
155        .color(token::accent_foreground().var())
156}
157
158/// A `<button type="button">` of `variant` and `size`.
159///
160/// The type is `button`, so that a button inside a form does not submit it.
161/// The one that should is a [`submit_button`]. Disable either with
162/// [`El::disabled`], which dims it.
163///
164/// ```
165/// use hypersig::Signals;
166/// use hypersig_ui::{ButtonSize, ButtonVariant, button};
167/// use serde::{Deserialize, Serialize};
168///
169/// #[derive(Signals, Serialize, Deserialize)]
170/// struct Counter {
171///     count: i32,
172/// }
173///
174/// let count = Counter::count();
175/// let plus = button::<Counter>(ButtonVariant::Outline, ButtonSize::Sm)
176///     .on_click(count.set(count + 1))
177///     .disabled(count.ge(10))
178///     .text("Add one");
179/// assert!(plus.render().contains(r#"type="button""#));
180/// ```
181#[must_use]
182pub fn button<S>(variant: ButtonVariant, size: ButtonSize) -> El<S> {
183    button_el()
184        .attr("type", "button")
185        .css(button_style(variant, size))
186}
187
188/// A `<button type="submit">` of `variant` and `size`, which submits the
189/// form around it.
190///
191/// ```
192/// use hypersig::html::form;
193/// use hypersig::Signals;
194/// use hypersig_ui::{ButtonSize, ButtonVariant, submit_button};
195/// use serde::{Deserialize, Serialize};
196///
197/// #[derive(Signals, Serialize, Deserialize)]
198/// struct Login {
199///     sent: bool,
200/// }
201///
202/// let login = form::<Login>()
203///     .on_submit(Login::sent().set(true))
204///     .child(submit_button(ButtonVariant::Default, ButtonSize::Default).text("Log in"));
205/// assert!(login.render().contains(r#"type="submit""#));
206/// ```
207#[must_use]
208pub fn submit_button<S>(variant: ButtonVariant, size: ButtonSize) -> El<S> {
209    button_el()
210        .attr("type", "submit")
211        .css(button_style(variant, size))
212}
213
214/// A square button that shows only an icon, named by `label`.
215///
216/// The label is the accessible name, which a button whose only child is an
217/// icon has no other way to get, so this constructor takes it.
218///
219/// ```
220/// use hypersig::Signals;
221/// use hypersig_ui::{ButtonVariant, icon_button};
222/// use serde::{Deserialize, Serialize};
223///
224/// #[derive(Signals, Serialize, Deserialize)]
225/// struct App {}
226///
227/// let more = icon_button::<App>(ButtonVariant::Ghost, "More actions").text("…");
228/// assert!(more.render().contains(r#"aria-label="More actions""#));
229/// ```
230#[must_use]
231pub fn icon_button<S>(variant: ButtonVariant, label: impl Into<String>) -> El<S> {
232    button_el()
233        .attr("type", "button")
234        .attr("aria-label", label)
235        .css(
236            base()
237                .width(rem(2.25))
238                .height(rem(2.25))
239                .padding(px(0))
240                .merge(fill(variant)),
241        )
242}