use leptix_core::compose_refs::use_composed_refs;
use leptix_core::dismissable_layer::use_dismissable_layer;
use leptix_core::focus_scope::use_focus_scope;
use leptix_core::portal::Portal;
use leptix_core::presence::use_presence;
use leptix_core::primitive::Primitive;
use leptos::{context::Provider, html, prelude::*};
use leptos_node_ref::AnyNodeRef;
use send_wrapper::SendWrapper;
use web_sys::wasm_bindgen::JsCast;
#[derive(Clone, Debug)]
struct DialogContextValue {
open: Signal<bool>,
on_open_change: Callback<bool>,
modal: bool,
trigger_ref: AnyNodeRef,
content_id: String,
title_id: String,
description_id: String,
}
#[component]
pub fn Dialog(
#[prop(into, optional)] open: MaybeProp<bool>,
#[prop(into, optional)] default_open: MaybeProp<bool>,
#[prop(into, optional)] on_open_change: Option<Callback<bool>>,
#[prop(into, optional)] modal: Option<bool>,
children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
let children = StoredValue::new(children.into_inner());
let modal = modal.unwrap_or(true);
let (open_state, set_open) = leptix_core::use_controllable_state::use_controllable_state(
leptix_core::use_controllable_state::UseControllableStateParams {
prop: open,
on_change: on_open_change.map(|cb| {
Callback::new(move |value: Option<bool>| {
if let Some(value) = value {
cb.run(value);
}
})
}),
default_prop: default_open,
},
);
let open = Signal::derive(move || open_state.get().unwrap_or(false));
let base_id = leptix_core::id::use_id(None).get();
let context_value = DialogContextValue {
open,
on_open_change: Callback::new(move |val: bool| {
set_open.run(Some(val));
}),
modal,
trigger_ref: AnyNodeRef::new(),
content_id: format!("{}-content", base_id),
title_id: format!("{}-title", base_id),
description_id: format!("{}-description", base_id),
};
view! {
<Provider value=context_value>
{children.with_value(|children| children())}
</Provider>
}
}
#[component]
pub fn DialogTrigger(
#[prop(into, optional)] as_child: MaybeProp<bool>,
#[prop(into, optional)] node_ref: AnyNodeRef,
children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
let children = StoredValue::new(children.into_inner());
let context = expect_context::<DialogContextValue>();
let composed_refs = use_composed_refs(vec![node_ref, context.trigger_ref]);
view! {
<Primitive
element=html::button
as_child=as_child
node_ref=composed_refs
attr:r#type="button"
attr:aria-haspopup="dialog"
attr:aria-expanded=move || context.open.get().to_string()
attr:aria-controls=move || context.open.get().then(|| context.content_id.clone())
attr:data-state=move || if context.open.get() { "open" } else { "closed" }
on:click=move |_| context.on_open_change.run(!context.open.get())
>
{children.with_value(|children| children())}
</Primitive>
}
}
#[component]
pub fn DialogPortal(
#[prop(into, optional)] container: MaybeProp<SendWrapper<web_sys::Element>>,
#[prop(into, optional)] container_ref: AnyNodeRef,
#[prop(into, optional)] _force_mount: MaybeProp<bool>,
children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
let children = StoredValue::new(children.into_inner());
let context = expect_context::<DialogContextValue>();
let context_for_portal = StoredValue::new(context.clone());
view! {
<Show when=move || context.open.get()>
<Portal container=container container_ref=container_ref>
<Provider value=context_for_portal.get_value()>
{children.with_value(|children| children())}
</Provider>
</Portal>
</Show>
}
}
#[component]
pub fn DialogOverlay(
#[prop(into, optional)] force_mount: MaybeProp<bool>,
#[prop(into, optional)] as_child: MaybeProp<bool>,
#[prop(into, optional)] node_ref: AnyNodeRef,
#[prop(optional)] children: Option<ChildrenFn>,
) -> impl IntoView {
let children = StoredValue::new(children);
let context = expect_context::<DialogContextValue>();
let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
let present = Signal::derive(move || context.open.get());
let presence = use_presence(present);
let is_modal = context.modal;
view! {
<Show when=move || is_modal && (force_mount.get() || presence.is_present.get())>
<Primitive
element=html::div
as_child=as_child
node_ref=node_ref
attr:data-state=move || if context.open.get() { "open" } else { "closed" }
attr:style="position:fixed;inset:0"
>
{children.with_value(|children| children.as_ref().map(|children| children()))}
</Primitive>
</Show>
}
}
#[component]
pub fn DialogContent(
#[prop(into, optional)]
on_open_auto_focus: Option<Callback<web_sys::Event>>,
#[prop(into, optional)]
on_close_auto_focus: Option<Callback<web_sys::Event>>,
#[prop(into, optional)] on_escape_key_down: Option<Callback<web_sys::KeyboardEvent>>,
#[prop(into, optional)] on_pointer_down_outside: Option<Callback<web_sys::PointerEvent>>,
#[prop(into, optional)] on_interact_outside: Option<Callback<web_sys::Event>>,
#[prop(into, optional)] force_mount: MaybeProp<bool>,
#[prop(into, optional)] as_child: MaybeProp<bool>,
#[prop(into, optional)] node_ref: AnyNodeRef,
children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
let children = StoredValue::new(children.into_inner());
let context = expect_context::<DialogContextValue>();
let force_mount = Signal::derive(move || force_mount.get().unwrap_or(false));
let present = Signal::derive(move || context.open.get());
let presence = use_presence(present);
let focus_scope_ref = use_focus_scope(
Signal::derive(move || context.modal && context.open.get()),
Signal::derive(move || context.modal),
on_open_auto_focus,
on_close_auto_focus,
);
let dismissable_ref = use_dismissable_layer(
on_escape_key_down,
on_pointer_down_outside,
None,
on_interact_outside,
Some(Callback::new(move |()| {
context.on_open_change.run(false);
})),
Signal::derive(move || !context.open.get()),
);
let composed_refs = use_composed_refs(vec![
node_ref,
presence.node_ref,
focus_scope_ref,
dismissable_ref,
]);
let is_modal = context.modal;
let restore_body_scroll = move || {
if let Some(body) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.body())
{
let style = body.unchecked_ref::<web_sys::HtmlElement>().style();
let _ = style.remove_property("overflow");
let _ = style.remove_property("padding-right");
}
};
Effect::new(move |_| {
let is_open = context.open.get();
if is_modal && is_open {
if let Some(window) = web_sys::window()
&& let Some(document) = window.document()
&& let Some(body) = document.body()
{
let scrollbar_width = window
.inner_width()
.ok()
.and_then(|w| w.as_f64())
.unwrap_or(0.0)
- document
.document_element()
.map(|el| el.client_width() as f64)
.unwrap_or(0.0);
let style = body.unchecked_ref::<web_sys::HtmlElement>().style();
let _ = style.set_property("overflow", "hidden");
if scrollbar_width > 0.0 {
let _ = style.set_property("padding-right", &format!("{scrollbar_width}px"));
}
}
} else if is_modal {
restore_body_scroll();
}
});
if is_modal {
on_cleanup(restore_body_scroll);
}
let aria_modal = if context.modal { Some("true") } else { None };
view! {
<Show when=move || force_mount.get() || presence.is_present.get()>
<Primitive
element=html::div
as_child=as_child
node_ref=composed_refs
attr:id=context.content_id.clone()
attr:role="dialog"
attr:aria-modal=aria_modal
attr:aria-describedby=context.description_id.clone()
attr:aria-labelledby=context.title_id.clone()
attr:data-state=move || if context.open.get() { "open" } else { "closed" }
attr:tabindex="-1"
>
{children.with_value(|children| children())}
</Primitive>
</Show>
}
}
#[component]
pub fn DialogClose(
#[prop(into, optional)] as_child: MaybeProp<bool>,
#[prop(into, optional)] node_ref: AnyNodeRef,
children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
let children = StoredValue::new(children.into_inner());
let context = expect_context::<DialogContextValue>();
view! {
<Primitive
element=html::button
as_child=as_child
node_ref=node_ref
attr:r#type="button"
on:click=move |_| context.on_open_change.run(false)
>
{children.with_value(|children| children())}
</Primitive>
}
}
#[component]
pub fn DialogTitle(
#[prop(into, optional)] as_child: MaybeProp<bool>,
#[prop(into, optional)] node_ref: AnyNodeRef,
children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
let children = StoredValue::new(children.into_inner());
let context = expect_context::<DialogContextValue>();
view! {
<Primitive
element=html::h2
as_child=as_child
node_ref=node_ref
attr:id=context.title_id.clone()
>
{children.with_value(|children| children())}
</Primitive>
}
}
#[component]
pub fn DialogDescription(
#[prop(into, optional)] as_child: MaybeProp<bool>,
#[prop(into, optional)] node_ref: AnyNodeRef,
children: TypedChildrenFn<impl IntoView + 'static>,
) -> impl IntoView {
let children = StoredValue::new(children.into_inner());
let context = expect_context::<DialogContextValue>();
view! {
<Primitive
element=html::p
as_child=as_child
node_ref=node_ref
attr:id=context.description_id.clone()
>
{children.with_value(|children| children())}
</Primitive>
}
}