image_rs/
dioxus.rs

1#![doc = include_str!("../DIOXUS.md")]
2
3use crate::common::{
4    AriaLive, AriaPressed, CrossOrigin, Decoding, FetchPriority, Layout, Loading, ObjectFit,
5    Position, ReferrerPolicy,
6};
7use dioxus::prelude::*;
8use gloo_net::http::Request;
9use web_sys::IntersectionObserverEntry;
10use web_sys::js_sys;
11use web_sys::wasm_bindgen::JsCast;
12use web_sys::wasm_bindgen::prelude::*;
13use web_sys::{IntersectionObserver, IntersectionObserverInit};
14
15/// Properties for the `Image` component.
16///
17/// The `Image` component allows you to display an image with various customization options
18/// for layout, styling, and behavior. It supports fallback images, lazy loading, and custom
19/// callbacks for error handling and loading completion.
20///
21/// This component is highly flexible, providing support for multiple image layouts,
22/// object-fit, object-position, ARIA attributes, and more.
23///
24/// # See Also
25/// - [MDN img Element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img)
26#[derive(Props, Clone, PartialEq)]
27pub struct ImageProps {
28    /// The source URL of the image.
29    ///
30    /// This is the URL of the image to be displayed. This property is required for loading
31    /// an image. If not provided, the image will not be displayed.
32    #[props(default = "")]
33    pub src: &'static str,
34
35    /// The alternative text for the image.
36    ///
37    /// This is the alt text for the image, which is used for accessibility purposes.
38    /// If not provided, the alt text will be empty.
39    #[props(default = "")]
40    pub alt: &'static str,
41
42    /// Optional fallback image.
43    ///
44    /// This image will be displayed if the main image fails to load. If not provided,
45    /// the image will attempt to load without a fallback.
46    #[props(default = "")]
47    pub fallback_src: &'static str,
48
49    /// The width of the image.
50    ///
51    /// Specifies the width of the image in pixels. It is typically used for responsive
52    /// layouts. Defaults to an empty string if not provided.
53    #[props(default = "")]
54    pub width: &'static str,
55
56    /// The height of the image.
57    ///
58    /// Specifies the height of the image in pixels. Like `width`, it is often used for
59    /// responsive layouts. Defaults to an empty string if not provided.
60    #[props(default = "")]
61    pub height: &'static str,
62
63    // Common props
64    /// The style attribute for the image.
65    ///
66    /// Allows you to apply custom inline CSS styles to the image. Defaults to an empty string.
67    #[props(default = "")]
68    pub style: &'static str,
69
70    /// The CSS class for the image.
71    ///
72    /// This can be used to apply custom CSS classes to the image for styling purposes.
73    /// Defaults to an empty string if not provided.
74    #[props(default = "")]
75    pub class: &'static str,
76
77    /// The sizes attribute for the image.
78    ///
79    /// This is used to define different image sizes for different viewport widths, helping
80    /// with responsive images. Defaults to an empty string if not provided.
81    #[props(default = "")]
82    pub sizes: &'static str,
83
84    /// The quality attribute for the image.
85    ///
86    /// Allows you to set the quality of the image (e.g., "low", "medium", "high"). Defaults
87    /// to an empty string if not provided.
88    #[props(default = "")]
89    pub quality: &'static str,
90
91    /// Indicates if the image should have priority loading.
92    ///
93    /// This controls whether the image should be loaded eagerly (immediately) or lazily
94    #[props(default)]
95    pub loading: Loading,
96
97    /// The placeholder attribute for the image.
98    ///
99    /// Allows you to specify a placeholder image URL or data URL to show while the main
100    /// image is loading. Defaults to an empty string.
101    #[props(default = "")]
102    pub placeholder: &'static str,
103
104    /// Callback function for handling loading completion.
105    ///
106    /// This callback is triggered once the image has finished loading. This is useful for
107    /// actions that should happen after the image has been fully loaded, such as hiding
108    /// a loading spinner. Defaults to a no-op.
109    #[props(default)]
110    pub on_load: Callback<()>,
111
112    // Advanced Props
113    /// The object-fit attribute for the image.
114    ///
115    /// Determines how the image should be resized to fit its container. Common values include
116    /// "contain", "cover", "fill", etc. Defaults to an empty string.
117    #[props(default)]
118    pub object_fit: ObjectFit,
119
120    /// The object-position attribute for the image.
121    ///
122    /// Specifies how the image should be positioned within its container when `object-fit` is set.
123    /// The available options are:
124    /// - `Position::Center`: Centers the image within the container.
125    /// - `Position::Top`: Aligns the image to the top of the container.
126    /// - `Position::Bottom`: Aligns the image to the bottom of the container.
127    /// - `Position::Left`: Aligns the image to the left of the container.
128    /// - `Position::Right`: Aligns the image to the right of the container.
129    /// - `Position::TopLeft`: Aligns the image to the top-left of the container.
130    /// - `Position::TopRight`: Aligns the image to the top-right of the container.
131    /// - `Position::BottomLeft`: Aligns the image to the bottom-left of the container.
132    /// - `Position::BottomRight`: Aligns the image to the bottom-right of the container.
133    ///
134    /// Defaults to `Position::Center`.
135    #[props(default)]
136    pub object_position: Position,
137
138    /// Callback function for handling errors during image loading.
139    ///
140    /// This callback is triggered if the image fails to load, allowing you to handle
141    /// error states (e.g., displaying a fallback image or showing an error message).
142    #[props(default)]
143    pub on_error: Callback<String>,
144
145    /// The decoding attribute for the image.
146    ///
147    /// Specifies how the image should be decoded. The available options are:
148    /// - `Decoding::Auto`: The image decoding behavior is automatically decided by the browser.
149    /// - `Decoding::Sync`: The image is decoded synchronously (blocking other tasks).
150    /// - `Decoding::Async`: The image is decoded asynchronously (non-blocking).
151    ///
152    /// Defaults to `Decoding::Auto`.
153    #[props(default)]
154    pub decoding: Decoding,
155
156    /// The blur data URL for placeholder image.
157    ///
158    /// This is used to display a low-quality blurred version of the image while the full
159    /// image is loading. Defaults to an empty string.
160    #[props(default = "")]
161    pub blur_data_url: &'static str,
162
163    /// The lazy boundary for lazy loading.
164    ///
165    /// Defines the distance (in pixels) from the viewport at which the image should start
166    /// loading. Defaults to an empty string.
167    #[props(default = "")]
168    pub lazy_boundary: &'static str,
169
170    /// Indicates if the image should be unoptimized.
171    ///
172    /// If set to `true`, the image will be loaded without any optimization applied (e.g.,
173    /// no resizing or compression). Defaults to `false`.
174    #[props(default = false)]
175    pub unoptimized: bool,
176
177    /// Image layout.
178    ///
179    /// Specifies how the image should be laid out within its container. Possible values
180    /// include `Layout::Fill`, `Layout::Responsive`, `Layout::Intrinsic`, `Layout::Fixed`,
181    /// `Layout::Auto`, `Layout::Stretch`, and `Layout::ScaleDown`. Defaults to `Layout::Responsive`.
182    #[props(default)]
183    pub layout: Layout,
184
185    // /// Reference to the DOM node.
186    // ///
187    // /// This is used to create a reference to the actual DOM element of the image. It is
188    // /// useful for directly manipulating the image element via JavaScript if needed.
189    // // TODO: Figure out how to pass a node ref
190    // #[prop_or_default]
191    // pub node_ref: Node,
192    /// A list of one or more image sources for responsive loading.
193    ///
194    /// Defines multiple image resources for the browser to choose from, depending on screen size, resolution,
195    /// and other factors. Each source can include width (`w`) or pixel density (`x`) descriptors.
196    #[props(default)]
197    pub srcset: &'static str,
198
199    /// Cross-origin policy to use when fetching the image.
200    ///
201    /// Determines whether the image should be fetched with CORS enabled. Useful when the image needs to be accessed
202    /// in a `<canvas>` element. Accepts `anonymous` or `use-credentials`.
203    #[props(default)]
204    pub crossorigin: CrossOrigin,
205
206    /// Referrer policy to apply when fetching the image.
207    ///
208    /// Controls how much referrer information should be included with requests made for the image resource.
209    /// Common values include `no-referrer`, `origin`, `strict-origin-when-cross-origin`, etc.
210    #[props(default)]
211    pub referrerpolicy: ReferrerPolicy,
212
213    /// The fragment identifier of the image map to use.
214    ///
215    /// Associates the image with a `<map>` element, enabling clickable regions within the image. The value
216    /// should begin with `#` and match the `name` of the corresponding map element.
217    #[props(default)]
218    pub usemap: &'static str,
219
220    /// Indicates that the image is part of a server-side image map.
221    ///
222    /// When set, clicking the image will send the click coordinates to the server. Only allowed when the image
223    /// is inside an `<a>` element with a valid `href`.
224    #[props(default)]
225    pub ismap: bool,
226
227    /// Hints the browser about the priority of fetching this image.
228    ///
229    /// Helps the browser prioritize network resource loading. Accepts `high`, `low`, or `auto` (default).
230    /// See `HTMLImageElement.fetchPriority` for more.
231    #[props(default)]
232    pub fetchpriority: FetchPriority,
233
234    /// Identifier for tracking image performance timing.
235    ///
236    /// Registers the image with the `PerformanceElementTiming` API using the given string as its ID. Useful for
237    /// performance monitoring and analytics.
238    #[props(default)]
239    pub elementtiming: &'static str,
240
241    /// URL(s) to send Attribution Reporting requests for the image.
242    ///
243    /// Indicates that the browser should send an `Attribution-Reporting-Eligible` header with the image request.
244    /// Can be a boolean or a list of URLs for attribution registration on specified servers. Experimental feature.
245    #[props(default)]
246    pub attributionsrc: &'static str,
247
248    /// Indicates the current state of the image in a navigation menu.
249    ///
250    /// Valid values are "page", "step", "location", "date", "time", "true", "false".
251    /// This is useful for enhancing accessibility in navigation menus.
252    #[props(default = "")]
253    pub aria_current: &'static str,
254
255    /// Describes the image using the ID of the element that provides a description.
256    ///
257    /// The ID of the element that describes the image. This is used for accessibility
258    /// purposes, particularly for screen readers.
259    #[props(default = "")]
260    pub aria_describedby: &'static str,
261
262    /// Indicates whether the content associated with the image is currently expanded or collapsed.
263    ///
264    /// This is typically used for ARIA-based accessibility and is represented as "true" or "false".
265    #[props(default = "")]
266    pub aria_expanded: &'static str,
267
268    /// Indicates whether the image is currently hidden from the user.
269    ///
270    /// This attribute is used for accessibility and indicates whether the image is visible
271    /// to the user or not. Valid values are "true" or "false".
272    #[props(default = "")]
273    pub aria_hidden: &'static str,
274
275    /// Indicates whether the content associated with the image is live and dynamic.
276    ///
277    /// The value can be "off", "assertive", or "polite", helping assistive technologies
278    /// determine how to handle updates to the content.
279    #[props(default)]
280    pub aria_live: AriaLive,
281
282    /// Indicates whether the image is currently pressed or selected.
283    ///
284    /// This attribute can have values like "true", "false", "mixed", or "undefined".
285    #[props(default)]
286    pub aria_pressed: AriaPressed,
287
288    /// ID of the element that the image controls or owns.
289    ///
290    /// Specifies the ID of the element that the image controls or is associated with.
291    #[props(default = "")]
292    pub aria_controls: &'static str,
293
294    /// ID of the element that labels the image.
295    ///
296    /// Specifies the ID of the element that labels the image for accessibility purposes.
297    #[props(default = "")]
298    pub aria_labelledby: &'static str,
299}
300
301impl Default for ImageProps {
302    fn default() -> Self {
303        ImageProps {
304            src: "",
305            alt: "Image",
306            width: "",
307            height: "",
308            style: "",
309            class: "",
310            sizes: "",
311            quality: "",
312            placeholder: "empty",
313            on_load: Callback::default(),
314            object_fit: ObjectFit::default(),
315            object_position: Position::default(),
316            on_error: Callback::default(),
317            decoding: Decoding::default(),
318            blur_data_url: "",
319            lazy_boundary: "100px",
320            unoptimized: false,
321            layout: Layout::default(),
322            fallback_src: "",
323            srcset: "",
324            crossorigin: CrossOrigin::default(),
325            loading: Loading::default(),
326            referrerpolicy: ReferrerPolicy::default(),
327            usemap: "",
328            ismap: false,
329            fetchpriority: FetchPriority::default(),
330            elementtiming: "",
331            attributionsrc: "",
332            aria_current: "",
333            aria_describedby: "",
334            aria_expanded: "",
335            aria_hidden: "",
336            aria_live: AriaLive::default(),
337            aria_pressed: AriaPressed::default(),
338            aria_controls: "",
339            aria_labelledby: "",
340        }
341    }
342}
343
344#[component]
345pub fn Image(props: ImageProps) -> Element {
346    // TODO: Figure out how to create a node in dioxus
347    let node_ref = Some(5);
348    let mut src = use_signal(|| props.src);
349    let on_load = props.on_load;
350    let on_error_callback = props.on_error;
351
352    // Intersection Observer effect
353    use_effect(move || {
354        // TODO: el.cast::<HtmlImageElement>()
355        let node = node_ref.as_ref();
356        if let Some(_img) = node {
357            let closure = Closure::wrap(Box::new(
358                move |entries: js_sys::Array, _: IntersectionObserver| {
359                    if let Some(entry) = entries.get(0).dyn_ref::<IntersectionObserverEntry>() {
360                        if entry.is_intersecting() {
361                            // img.set_src(props.src);
362                            on_load.call(());
363                        }
364                    }
365                },
366            )
367                as Box<dyn FnMut(js_sys::Array, IntersectionObserver)>);
368
369            let options = IntersectionObserverInit::new();
370            options.set_threshold(&js_sys::Array::of1(&0.1.into()));
371            options.set_root_margin(props.lazy_boundary);
372
373            if let Ok(observer) =
374                IntersectionObserver::new_with_options(closure.as_ref().unchecked_ref(), &options)
375            {
376                // observer.observe(&img);
377                closure.forget();
378                {
379                    observer.disconnect();
380                }
381            }
382        }
383    });
384
385    // On error handler
386    let on_error = move |_| {
387        let fallback_src = props.fallback_src;
388
389        if fallback_src.is_empty() {
390            on_error_callback.call("Image failed to load and no fallback provided.".to_string());
391            return;
392        }
393
394        spawn(async move {
395            match Request::get(fallback_src).send().await {
396                Ok(resp) if resp.ok() => {
397                    src.set(fallback_src);
398                    on_load.call(());
399                }
400                Ok(resp) => {
401                    let status = resp.status();
402                    let body = resp.text().await.unwrap_or_default();
403                    on_error_callback.call(format!(
404                        "Fallback image load failed: status {}, body {}",
405                        status, body
406                    ));
407                }
408                Err(e) => {
409                    on_error_callback.call(format!("Network error while loading fallback: {}", e));
410                }
411            }
412        });
413    };
414
415    let img_style = format!(
416        "object-fit: {:?}; object-position: {:?}; {};",
417        props.object_fit, props.object_position, props.style
418    );
419
420    let blur_style = if props.placeholder == "blur" {
421        format!(
422            "background-size: {}; background-position: {:?}; filter: blur(20px); background-image: url('{}');",
423            props.sizes, props.object_position, props.blur_data_url
424        )
425    } else {
426        "".to_string()
427    };
428
429    let full_style = format!("{img_style} {blur_style}");
430
431    let onload = move |_| {
432        props.on_load.call(());
433    };
434
435    let img_element = rsx! {
436        img {
437            src: "{src()}",
438            alt: "{props.alt}",
439            width: "{props.width}",
440            height: "{props.height}",
441            class: "{props.class}",
442            // TODO: Till Dioxus support this attribute
443            // sizes: "{props.sizes}",
444            // decoding: "{props.decoding}",
445            // TODO:
446            // loading: "{props.loading}",
447            // TODO
448            // node_ref: node_ref,
449            style: "{full_style}",
450            onerror: on_error,
451            aria_current: "{props.aria_current}",
452            aria_describedby: "{props.aria_describedby}",
453            aria_expanded: "{props.aria_expanded}",
454            aria_hidden: "{props.aria_hidden}",
455            aria_live: "{props.aria_live.as_str()}",
456            aria_pressed: "{props.aria_pressed.as_str()}",
457            aria_controls: "{props.aria_controls}",
458            aria_labelledby: "{props.aria_labelledby}",
459            role: "img",
460            style: "{blur_style}",
461            crossorigin: props.crossorigin.as_str(),
462            referrerpolicy: props.referrerpolicy.as_str(),
463            // TODO
464            // fetchpriority: "{props.fetchpriority.as_str()}",
465            // TODO
466            // attributionsrc: "{props.attributionsrc}",
467            onload: onload,
468            // TODO
469            // elementtiming: "{props.elementtiming}",
470            srcset: "{props.srcset}",
471            ismap: "{props.ismap}",
472            usemap: "{props.usemap}"
473        }
474    };
475
476    match props.layout {
477        Layout::Fill => rsx! {
478            span {
479                style: "display: block; position: absolute; top: 0; left: 0; bottom: 0; right: 0;",
480                {img_element},
481            }
482        },
483        Layout::Responsive => {
484            let quotient = props.height.parse::<f64>().unwrap_or(1.0)
485                / props.width.parse::<f64>().unwrap_or(1.0);
486            let padding_top = if quotient.is_finite() {
487                format!("{}%", quotient * 100.0)
488            } else {
489                "100%".to_string()
490            };
491            rsx! {
492                span {
493                    style: "display: block; position: relative;",
494                    span {
495                        style: "padding-top: {padding_top};",
496                    },
497                    {img_element},
498                },
499            }
500        }
501        Layout::Intrinsic => rsx! {
502            span {
503                style: "display: inline-block; position: relative; max-width: 100%;",
504                span {
505                    style: "max-width: 100%;",
506                    {img_element},
507                },
508                img {
509                    src: "{props.blur_data_url}",
510                    style: "display: none;",
511                    alt: "{props.alt}",
512                    aria_hidden: "true",
513                },
514            }
515        },
516        Layout::Fixed => rsx! {
517            span {
518                style: "display: inline-block; position: relative;",
519                {img_element},
520            }
521        },
522        Layout::Auto => rsx! {
523            span {
524                style: "display: inline-block; position: relative;",
525                {img_element},
526            }
527        },
528        Layout::Stretch => rsx! {
529            span {
530                style: "display: block; width: 100%; height: 100%; position: relative;",
531                {img_element},
532            }
533        },
534        Layout::ScaleDown => rsx! {
535            span {
536                style: "display: inline-block; position: relative; max-width: 100%; max-height: 100%;",
537                {img_element},
538            }
539        },
540    }
541}