Skip to main content

fc_dioxus/components/
dialog.rs

1//! Modal and non-modal dialogs, side sheets, and bottom sheets, from dialog.css.
2use crate::{
3    core::{
4        assets,
5        attrs::{component_attributes, FcData, Kind},
6        platform::{DomBridge, DomError},
7    },
8    prelude::*,
9    theme::use_stylesheets,
10};
11const BEM: Bem = Bem::new("fc-dialog");
12fc_modifier! {
13    /// Dialog widths.
14    pub enum DialogSize for BEM {
15        /// Default.
16        Md => None,
17        /// Narrow, for a short confirmation.
18        Sm => "sm",
19        /// Wide, for forms and settings.
20        Lg => "lg",
21        /// The whole viewport.
22        Fullscreen => "fullscreen",
23    }
24}
25fc_modifier! {
26    /// Where a dialog sits.
27    pub enum DialogPlacement for BEM {
28        /// Centred.
29        Center => None,
30        /// Docked to the inline end, full height.
31        Sheet => "sheet",
32        /// Docked to the block end.
33        Bottom => "bottom",
34    }
35}
36
37/// A native `<dialog>`, controlled by `open`.
38///
39/// Modal by default: when `open` turns true it is shown with `showModal()` after mount, so
40/// the platform supplies the top layer, inert background, focus, and Escape. Escape and the
41/// close button call `onclose`; the owner sets `open` to false. Hosts without
42/// `showModal` render it open in place instead. With `modal: false` it is always the
43/// in-place, non-modal form.
44#[component]
45pub fn Dialog(
46    open: bool,
47    onclose: EventHandler<()>,
48    title: Element,
49    #[props(default = HeadingLevel::H2)] heading: HeadingLevel,
50    #[props(default)] description: Option<Element>,
51    #[props(default)] size: DialogSize,
52    #[props(default)] placement: DialogPlacement,
53    #[props(default = true)] modal: bool,
54    /// Names the close button; "Close" when omitted.
55    #[props(default)]
56    close_label: Option<AccessibleName>,
57    #[props(default)] footer: Option<Element>,
58    /// The drag handle drawn on a bottom sheet. Decorative.
59    #[props(default)]
60    handle: bool,
61    /// Make a body that may scroll reachable by keyboard.
62    #[props(default)]
63    scrollable: bool,
64    /// The runtime id for `data-fc-id`; it also marks the dialog `data-fc-kind="dialog"`.
65    #[props(default)]
66    fc_id: Option<Id>,
67    #[props(default)] class: Option<String>,
68    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
69    children: Element,
70) -> Element {
71    let id = use_id();
72    let title_id = id.suffixed("title");
73    let description_id = description.is_some().then(|| id.suffixed("description"));
74    let mut inline = use_signal(|| !modal);
75    let effect_id = id.clone();
76    use_effect(use_reactive!(|open, modal| {
77        let id = effect_id.clone();
78        spawn(async move {
79            if !modal {
80                inline.set(true);
81                return;
82            }
83            let result = if open {
84                DomBridge.show_modal(&id).await
85            } else {
86                DomBridge.close_dialog(&id).await
87            };
88            // Without showModal the dialog still has to appear: open it in place.
89            inline.set(matches!(result, Err(DomError::Unsupported)));
90        });
91    }));
92    let mut attributes = component_attributes(
93        attributes,
94        ClassList::new(BEM.block())
95            .with_modifier(BEM, size)
96            .with_modifier(BEM, placement)
97            .extend(class.as_deref()),
98        &["id", "open", "aria-labelledby", "aria-describedby"],
99    );
100    if let Some(fc_id) = fc_id {
101        attributes.extend(
102            FcData {
103                kind: Some(Kind::Dialog),
104                id: Some(fc_id),
105                ..FcData::default()
106            }
107            .attributes(),
108        );
109    }
110    let close_label = close_label.map_or_else(|| "Close".to_owned(), |l| l.to_string());
111    rsx! {
112        if use_stylesheets() {
113            document::Stylesheet { href: assets::COMPONENTS_BUTTON_CSS }
114            document::Stylesheet { href: assets::COMPONENTS_DIALOG_CSS }
115        }
116        dialog { id: "{id}", open: open && inline(), aria_labelledby: "{title_id}",
117            aria_describedby: description_id.as_ref().map(ToString::to_string),
118            oncancel: move |event| { event.prevent_default(); onclose.call(()); },
119            ..attributes,
120            if handle { div { class: BEM.element("handle"), aria_hidden: "true" } }
121            header { class: BEM.element("header"),
122                {super::heading_with_id(heading, BEM.element("title"), &title_id, title)}
123                button { r#type: "button", class: BEM.element("close"), aria_label: close_label, onclick: move |_| onclose.call(()), "✕" }
124            }
125            div { class: BEM.element("body"), tabindex: scrollable.then_some("0"),
126                if let (Some(description), Some(id)) = (description, description_id.clone()) {
127                    p { class: BEM.element("description"), id: "{id}", {description} }
128                }
129                {children}
130            }
131            if let Some(footer) = footer { footer { class: BEM.element("footer"), {footer} } }
132        }
133    }
134}