dioxus_heroicons/
lib.rs

1//! Dioxus components for [heroicons](https://heroicons.com/)
2//!
3//! This library provides two components. The [`Icon`] component produces the SVG for a heroicon. The
4//! [`IconButton`] component wraps the icon with an HTML `button`.
5//!
6//! In your own components, you can call them like this:
7//!
8//! ```rust
9//! use dioxus::prelude::*;
10//! use dioxus_heroicons::{Icon, IconButton, solid::Shape};
11//!
12//! #[derive(Props, PartialEq, Clone)]
13//! struct DeleteButtonProps {
14//!     foo: u8,
15//! }
16//!
17//! fn DeleteButton(props: DeleteButtonProps) -> Element {
18//!     let onclick = move |evt| {
19//!         // Delete a thing
20//!     };
21//!     let disabled = if props.foo < 42 { true } else { false };
22//!     rsx! {
23//!         IconButton {
24//!             onclick: onclick,
25//!             class: "some-css-class",
26//!             title: "Delete it",
27//!             disabled: disabled,
28//!             size: 30,
29//!             icon: Shape::Trash,
30//!         }
31//!     }
32//! }
33//!
34//! fn PointsRight() -> Element {
35//!     rsx! {
36//!         Icon {
37//!             icon: Shape::ArrowRight,
38//!             fill: "blue",
39//!         }
40//!     }
41//! }
42//! ```
43//!
44//! Check out <https://jkelleyrtp.github.io/icon-chooser/> for an icon chooser that shows you all the
45//! solid icons and lets you copy the relevant component code to the clipboard.
46
47/// This module contains all the mini icon shapes.
48pub mod mini;
49/// This module contains all the outline icon shapes.
50pub mod outline;
51/// This module contains all the solid icon shapes.
52pub mod solid;
53
54use dioxus::{events::MouseEvent, prelude::*};
55
56const DISABLED_FILL_COLOR: &str = "#9CA3AF";
57
58/// This trait is used to abstract the icon shape so you can use shapes from the [`outline`] or
59/// [`solid`] modules for any property that accepts a shape.
60pub trait IconShape: Clone + PartialEq + std::fmt::Debug {
61    fn view_box(&self) -> &str;
62    #[allow(clippy::missing_errors_doc)]
63    fn path(&self) -> Element;
64}
65
66/// The properties for the [`IconButton`] component.
67#[derive(Clone, PartialEq, Props)]
68pub struct IconButtonProps<S: IconShape + 'static> {
69    /// An optional onclick handler for the button.
70    #[props(default, strip_option)]
71    pub onclick: Option<EventHandler<MouseEvent>>,
72    #[props(default, strip_option)]
73    /// An optional class for the *button itself*.
74    pub class: Option<String>,
75    /// An optional title for the button element.
76    #[props(default, strip_option)]
77    pub title: Option<String>,
78    /// The size of the icon. This defaults to 20 pixels.
79    #[props(default = 20)]
80    pub size: u32,
81    /// The fill color to use for the icon. This defaults to "currentColor".
82    #[props(default = "currentColor".to_string())]
83    pub fill: String,
84    /// If this is true then the button's `disabled` attribute will be true, and this will be passed
85    /// to the `Icon` when it is rendered.
86    #[props(default = false)]
87    /// If this is true then the button's `disabled` attribute will be true, and this will be passed
88    /// to the `Icon` when it is rendered.
89    #[props(default = false)]
90    pub disabled: bool,
91    /// The fill color to use when `disabled` is true. This is only relevant for solid icons. This
92    /// defaults to "#9CA3AF", which is "coolGray 400" from tailwindcss.
93    #[props(default = DISABLED_FILL_COLOR.to_string())]
94    pub disabled_fill: String,
95    /// The icon shape to use.
96    pub icon: S,
97    /// An optional class for the `<span>` that is part of this component.
98    #[props(default, strip_option)]
99    pub span_class: Option<String>,
100    /// An optional class that will be passed to the [`Icon`].
101    #[props(default, strip_option)]
102    pub icon_class: Option<String>,
103    /// These are the child elements of the `IconButton` component.
104    pub children: Element,
105}
106
107/// Renders a `<button>` containing an SVG icon.
108///
109/// This component will generate HTML like this:
110///
111/// ```html
112/// <button>
113///   <svg ...>
114///   <span>
115///     Child elements go here
116///   </span>
117/// </button>
118/// ```
119///
120/// See the [`IconButtonProps`] field documentation for details on the properties it accepts.
121///
122/// Passing children is optional. This is there so you can add some additional text or other HTML
123/// to the button.
124#[allow(clippy::missing_errors_doc, non_snake_case)]
125#[component]
126pub fn IconButton<S: IconShape>(props: IconButtonProps<S>) -> Element {
127    let disabled = props.disabled;
128    let onclick = props.onclick;
129    rsx! {
130        button {
131            onclick: move |evt| if !disabled {
132                if let Some(oc) = onclick {
133                    oc.call(evt);
134                }
135            },
136            class: if let Some(class) = props.class { class },
137            title: if let Some(title) = props.title { title },
138            disabled: disabled,
139            Icon {
140                ..IconProps {
141                    class: props.icon_class,
142                    size: props.size,
143                    fill: props.fill,
144                    icon: props.icon.clone(),
145                    disabled: props.disabled,
146                    disabled_fill: props.disabled_fill,
147                },
148            },
149            if props.children != VNode::empty() {
150                span {
151                    class: if let Some(span_class) = props.span_class { span_class },
152                    { props.children }
153                },
154            }
155        },
156    }
157}
158
159/// The properties for the [`Icon`] component.
160#[derive(Clone, PartialEq, Props)]
161pub struct IconProps<S: IconShape + 'static> {
162    /// An optional class for the `<svg>` element.
163    #[props(default)]
164    pub class: Option<String>,
165    /// The size of the `<svg>` element. All the heroicons are square, so this will be turned into
166    /// the `height` and `width` attributes for the `<svg>`. Defaults to 20.
167    #[props(default = 20)]
168    pub size: u32,
169    /// The color to use for filling the icon. This is only relevant for solid icons. Defaults to
170    /// "currentColor".
171    #[props(default = "currentColor".to_string())]
172    pub fill: String,
173    /// The icon shape to use.
174    pub icon: S,
175    /// If this is true then the fill color will be the one set in
176    /// `disabled_fill` instead of `fill`.
177    #[props(default = false)]
178    pub disabled: bool,
179    /// The fill color to use when `disabled` is true. This is only relevant for solid icons. This
180    /// defaults to "#9CA3AF", which is "coolGray 400" from tailwindcss.
181    #[props(default = DISABLED_FILL_COLOR.to_string())]
182    pub disabled_fill: String,
183}
184
185/// Renders an `<svg>` element for a heroicon.
186///
187/// See the [`IconProps`] field documentation for details on the properties it accepts.
188#[allow(clippy::missing_errors_doc, non_snake_case)]
189#[component]
190pub fn Icon<S: IconShape>(props: IconProps<S>) -> Element {
191    let fill = if props.disabled {
192        props.disabled_fill
193    } else {
194        props.fill
195    };
196    rsx! {
197        svg {
198            class: if let Some(class) = props.class { class },
199            height: format_args!("{}", props.size),
200            width: format_args!("{}", props.size),
201            view_box: format_args!("{}", props.icon.view_box()),
202            fill: "{fill}",
203            { props.icon.path() }
204        }
205    }
206}
207
208#[cfg(test)]
209mod test {
210    use super::*;
211    use html_compare_rs::assert_html_eq;
212
213    #[test]
214    fn icon_default() {
215        assert_rsx_eq(
216            rsx! {
217                Icon {
218                    icon: outline::Shape::ArrowLeft,
219                },
220            },
221            rsx! {
222                svg {
223                    height: 20,
224                    width: 20,
225                    view_box: outline::VIEW_BOX,
226                    fill: "currentColor",
227                    { outline::Shape::ArrowLeft.path() },
228                },
229            },
230        );
231    }
232
233    #[test]
234    fn icon_class() {
235        assert_rsx_eq(
236            rsx! {
237                Icon {
238                    icon: outline::Shape::ArrowLeft,
239                    class: "foo",
240                },
241            },
242            rsx! {
243                svg {
244                    class: "foo",
245                    height: 20,
246                    width: 20,
247                    view_box: outline::VIEW_BOX,
248                    fill: "currentColor",
249                    { outline::Shape::ArrowLeft.path() },
250                },
251            },
252        );
253    }
254
255    #[test]
256    fn icon_disabled() {
257        assert_rsx_eq(
258            rsx! {
259                Icon {
260                    icon: outline::Shape::ArrowLeft,
261                    disabled: true,
262                },
263            },
264            rsx! {
265                svg {
266                    height: 20,
267                    width: 20,
268                    view_box: outline::VIEW_BOX,
269                    fill: DISABLED_FILL_COLOR,
270                    { outline::Shape::ArrowLeft.path() },
271                },
272            },
273        );
274    }
275
276    #[test]
277    fn icon_button_default() {
278        assert_rsx_eq(
279            rsx! {
280                IconButton {
281                    icon: outline::Shape::ArrowLeft,
282                },
283            },
284            rsx! {
285                button {
286                    svg {
287                        height: 20,
288                        width: 20,
289                        view_box: outline::VIEW_BOX,
290                        fill: "currentColor",
291                        {
292                            outline::Shape::ArrowLeft.path()
293                        },
294                    },
295                },
296            },
297        );
298    }
299
300    #[test]
301    fn icon_button_with_span_children() {
302        assert_rsx_eq(
303            rsx! {
304                IconButton {
305                    icon: outline::Shape::ArrowLeft,
306                    b {
307                        "button text"
308                    },
309                },
310            },
311            rsx! {
312                button {
313                    svg {
314                        height: 20,
315                        width: 20,
316                        view_box: outline::VIEW_BOX,
317                        fill: "currentColor",
318                        {
319                            outline::Shape::ArrowLeft.path()
320                        },
321                    },
322                    span {
323                        b {
324                            "button text"
325                        }
326                    },
327                },
328            },
329        );
330    }
331
332    #[test]
333    fn icon_button_with_props() {
334        assert_rsx_eq(
335            rsx! {
336                IconButton {
337                    class: "some-button",
338                    icon: outline::Shape::ArrowLeft,
339                    title: "Foo",
340                },
341            },
342            rsx! {
343                button {
344                    class: "some-button",
345                    title: "Foo",
346                    svg {
347                        height: 20,
348                        width: 20,
349                        view_box: outline::VIEW_BOX,
350                        fill: "currentColor",
351                        {
352                            outline::Shape::ArrowLeft.path()
353                        },
354                    },
355                },
356            },
357        );
358    }
359
360    #[test]
361    fn icon_button_disabled() {
362        assert_rsx_eq(
363            rsx! {
364                IconButton {
365                    icon: outline::Shape::ArrowLeft,
366                    disabled: true,
367                },
368            },
369            rsx! {
370                button {
371                    disabled: true,
372                    svg {
373                        height: 20,
374                        width: 20,
375                        view_box: outline::VIEW_BOX,
376                        fill: DISABLED_FILL_COLOR,
377                        {
378                            outline::Shape::ArrowLeft.path()
379                        },
380                    },
381                },
382            },
383        );
384    }
385
386    fn assert_rsx_eq(first: Element, second: Element) {
387        let first = dioxus_ssr::render_element(first);
388        let second = dioxus_ssr::render_element(second);
389        assert_html_eq!(first, second);
390    }
391}