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}