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}