1use std::{
2 fmt::{Display, Formatter},
3 rc::Rc,
4};
5use web_sys::wasm_bindgen::JsCast;
6use web_sys::{wasm_bindgen::prelude::Closure, Element};
7use yew::prelude::*;
8use yewlish_attr_passer::*;
9use yewlish_presence::*;
10use yewlish_roving_focus::helpers::get_focusable_element;
11use yewlish_utils::hooks::{use_controllable_state, use_interaction_outside, use_viewport_move};
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct PopoverContext {
15 pub host: NodeRef,
16 pub is_open: bool,
17 pub on_toggle: Callback<bool>,
18}
19
20pub enum PopoverAction {
21 Open,
22 Close,
23 Toggle,
24}
25
26impl Reducible for PopoverContext {
27 type Action = PopoverAction;
28
29 fn reduce(self: Rc<PopoverContext>, action: Self::Action) -> Rc<PopoverContext> {
30 match action {
31 PopoverAction::Open => PopoverContext {
32 is_open: true,
33 ..(*self).clone()
34 }
35 .into(),
36 PopoverAction::Close => PopoverContext {
37 is_open: false,
38 ..(*self).clone()
39 }
40 .into(),
41 PopoverAction::Toggle => PopoverContext {
42 is_open: !self.is_open,
43 ..(*self).clone()
44 }
45 .into(),
46 }
47 }
48}
49
50pub type ReduciblePopoverContext = UseReducerHandle<PopoverContext>;
51
52#[derive(Clone, Debug, PartialEq, Properties)]
53pub struct PopoverProps {
54 pub children: Children,
55 #[prop_or_default]
56 pub open: Option<bool>,
57 #[prop_or_default]
58 pub on_open_change: Callback<bool>,
59 #[prop_or_default]
60 pub default_open: bool,
61 #[prop_or_default]
62 pub class: Option<AttrValue>,
63}
64
65#[function_component(Popover)]
66pub fn popover(props: &PopoverProps) -> Html {
67 let node_ref = use_node_ref();
68
69 let (is_open, dispatch) = use_controllable_state(
70 props.default_open.into(),
71 props.open,
72 props.on_open_change.clone(),
73 );
74
75 let on_toggle = use_callback(dispatch.clone(), {
76 move |new_state, dispatch| {
77 dispatch.emit(Box::new(move |_| new_state));
78 }
79 });
80
81 let context_value = use_reducer(|| PopoverContext {
82 host: node_ref.clone(),
83 is_open: *is_open.borrow(),
84 on_toggle,
85 });
86
87 use_effect_with(
88 (*(*is_open).borrow(), context_value.clone()),
89 |(is_open, context_value)| {
90 if *is_open != context_value.is_open {
91 context_value.dispatch(PopoverAction::Toggle);
92 }
93 },
94 );
95
96 html! {
97 <ContextProvider<ReduciblePopoverContext> context={context_value}>
98 <div ref={node_ref} class={&props.class}>
99 {props.children.clone()}
100 </div>
101 </ContextProvider<ReduciblePopoverContext>>
102 }
103}
104
105#[derive(Clone, Debug, PartialEq, Properties)]
106pub struct PopoverTriggerRenderAsProps {
107 pub toggle: Callback<MouseEvent>,
108 pub is_open: bool,
109 #[prop_or_default]
110 pub children: Children,
111 #[prop_or_default]
112 pub class: Option<AttrValue>,
113}
114
115#[derive(Clone, Debug, PartialEq, Properties)]
116pub struct PopoverTriggerProps {
117 #[prop_or_default]
118 pub children: Children,
119 #[prop_or_default]
120 pub class: Option<AttrValue>,
121 #[prop_or_default]
122 pub render_as: Option<Callback<PopoverTriggerRenderAsProps, Html>>,
123}
124
125#[function_component(PopoverTrigger)]
126pub fn popover_trigger(props: &PopoverTriggerProps) -> Html {
127 let context = use_context::<ReduciblePopoverContext>()
128 .expect("PopoverTrigger must be a child of Popover");
129
130 let toggle = use_callback(context.is_open, {
131 let context = context.clone();
132
133 move |_event: MouseEvent, is_open| {
134 context.on_toggle.emit(!is_open);
135 }
136 });
137
138 let data_state = use_memo(
139 context.is_open,
140 |is_open| {
141 if *is_open {
142 "open"
143 } else {
144 "closed"
145 }
146 },
147 );
148
149 let element = if let Some(render_as) = &props.render_as {
150 html! {{
151 render_as.emit(PopoverTriggerRenderAsProps {
152 children: props.children.clone(),
153 class: props.class.clone(),
154 toggle,
155 is_open: context.is_open,
156 })
157 }}
158 } else {
159 html! {
160 <button class={&props.class} onclick={&toggle}>
161 {props.children.clone()}
162 </button>
163 }
164 };
165
166 html! {
167 <AttrPasser name="popover-trigger" ..attributify! {
168 "data-state" => *data_state,
169 "role" => "button",
170 }>
171 { element }
172 </AttrPasser>
173 }
174}
175
176#[derive(Clone, Debug, PartialEq, Default)]
177pub enum PopoverSide {
178 Top,
179 Right,
180 #[default]
181 Bottom,
182 Left,
183}
184
185impl Display for PopoverSide {
186 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
187 match self {
188 PopoverSide::Top => write!(f, "top"),
189 PopoverSide::Right => write!(f, "right"),
190 PopoverSide::Bottom => write!(f, "bottom"),
191 PopoverSide::Left => write!(f, "left"),
192 }
193 }
194}
195
196#[derive(Clone, Debug, PartialEq, Default)]
197pub enum PopoverAlign {
198 Start,
199 #[default]
200 Center,
201 End,
202}
203
204impl Display for PopoverAlign {
205 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
206 match self {
207 PopoverAlign::Start => write!(f, "start"),
208 PopoverAlign::Center => write!(f, "center"),
209 PopoverAlign::End => write!(f, "end"),
210 }
211 }
212}
213
214#[derive(Clone, Debug, PartialEq, Properties)]
215pub struct PopoverContentProps {
216 #[prop_or_default]
217 pub children: Children,
218 #[prop_or_default]
219 pub class: Option<AttrValue>,
220 #[prop_or_default]
221 pub container: Option<Element>,
222 #[prop_or_default]
223 pub viewport: Option<Element>,
224 #[prop_or_default]
225 pub side: PopoverSide,
226 #[prop_or_default]
227 pub align: PopoverAlign,
228 #[prop_or_default]
229 pub on_esc_key_down: Callback<KeyboardEvent>,
230 #[prop_or_default]
231 pub on_interaction_outside: Callback<Event>,
232}
233
234#[function_component(PopoverContent)]
235pub fn popover_content(props: &PopoverContentProps) -> Html {
236 let context = use_context::<ReduciblePopoverContext>()
237 .expect("PopoverContent must be a child of Popover");
238
239 let host = props.container.clone().unwrap_or_else(|| {
240 context
241 .host
242 .cast::<Element>()
243 .expect("PopoverContent must be a child of Popover")
244 });
245
246 {
247 let context = context.clone();
248
249 use_effect_with(
250 (host.clone(), props.on_esc_key_down.clone()),
251 |(host, on_esc_key_down)| {
252 let on_esc_key_down = on_esc_key_down.clone();
253
254 let listener = Closure::wrap(Box::new(move |event: KeyboardEvent| {
255 if event.key() != "Escape" {
256 return;
257 }
258
259 on_esc_key_down.emit(event.clone());
260
261 if event.default_prevented() {
262 return;
263 }
264
265 context.on_toggle.emit(false);
266 }) as Box<dyn FnMut(_)>);
267
268 let _ = host
269 .add_event_listener_with_callback("keydown", listener.as_ref().unchecked_ref());
270
271 let host = host.clone();
272
273 move || {
274 let _ = host.remove_event_listener_with_callback(
275 "keydown",
276 listener.as_ref().unchecked_ref(),
277 );
278 }
279 },
280 );
281 }
282
283 let dom_rect = host.get_bounding_client_rect();
284 let adjusted_height = use_state(|| None::<f64>);
285
286 let auto_update_handler = use_callback(host.clone(), {
287 let adjusted_height = adjusted_height.clone();
288
289 move |(), host| {
290 let dom_rect = host.get_bounding_client_rect();
291 adjusted_height.set(dom_rect.height().into());
292 }
293 });
294
295 let style = stringify!(
296 position: fixed;
297 top: 0;
298 left: 0;
299 will-change: transform;
300 )
301 .to_string();
302
303 use_viewport_move(&context.host, auto_update_handler);
304
305 let transform = format!(
306 "transform: translate({}, {});",
307 match props.side {
308 PopoverSide::Right => format!("calc({}px + {}px)", dom_rect.x(), dom_rect.width()),
309 PopoverSide::Top | PopoverSide::Bottom => match props.align {
310 PopoverAlign::Start => format!("calc({}px)", dom_rect.x()),
311 PopoverAlign::Center => format!(
312 "calc({}px - (100% - {}px) / 2)",
313 dom_rect.x(),
314 dom_rect.width(),
315 ),
316 PopoverAlign::End =>
317 format!("calc({}px - 100% + {}px)", dom_rect.x(), dom_rect.width()),
318 },
319 PopoverSide::Left => format!("calc({}px - 100%)", dom_rect.x()),
320 },
321 match props.side {
322 PopoverSide::Top => format!("calc({}px - 100%)", dom_rect.y()),
323 PopoverSide::Bottom => format!(
324 "calc({}px + {}px)",
325 dom_rect.y(),
326 adjusted_height.unwrap_or_else(|| dom_rect.height())
327 ),
328 PopoverSide::Right | PopoverSide::Left => match props.align {
329 PopoverAlign::Start => format!("calc({}px)", dom_rect.y()),
330 PopoverAlign::Center => format!(
331 "calc({}px - {}px)",
332 dom_rect.y(),
333 adjusted_height.unwrap_or_else(|| dom_rect.height())
334 ),
335 PopoverAlign::End => format!(
336 "calc({}px + {}px - 100%)",
337 dom_rect.y(),
338 adjusted_height.unwrap_or_else(|| dom_rect.height())
339 ),
340 },
341 },
342 );
343
344 let style = format!("{style} {transform}");
345 let content_ref = use_node_ref();
346
347 use_interaction_outside(
348 {
349 let mut nodes = vec![];
350 nodes.push((&host).into());
351 nodes.push((&content_ref).into());
352
353 if props.container.is_some() {
354 nodes.push((&context.host.clone()).into());
355 }
356
357 nodes
358 },
359 {
360 let context = context.clone();
361 let on_interaction_outside = props.on_interaction_outside.clone();
362
363 move |event: Event| {
364 on_interaction_outside.emit(event.clone());
365
366 if event.default_prevented() {
367 return;
368 }
369
370 context.on_toggle.emit(false);
371 }
372 },
373 );
374
375 let focus_on_present = use_callback(content_ref.clone(), |(), content_ref| {
376 if let Some(content) = content_ref.cast::<Element>() {
377 if let Some(element) = get_focusable_element(&content) {
378 match element.focus() {
379 Ok(()) => {}
380 Err(error) => {
381 log::error!("Failed to focus the popover content: {error:?}");
382 }
383 }
384 }
385 }
386 });
387
388 let viewport = props.viewport.clone().unwrap_or_else(|| {
389 host.owner_document()
390 .and_then(|document| document.body())
391 .and_then(|body| body.dyn_into::<Element>().ok())
392 .expect("Failed to get viewport")
393 });
394
395 let side = props.side.clone();
396 let align = props.align.clone();
397
398 create_portal(
399 html! {
400 <Presence
401 r#ref={content_ref.clone()}
402 class={&props.class}
403 name="popover-content"
404 present={context.is_open}
405 class={&props.class}
406 on_present={focus_on_present}
407 render_as={Callback::from(move |presence_props: PresenceRenderAsProps| {
408 if !presence_props.presence {
409 return html! {};
410 }
411
412 html! {
413 <div
414 ref={presence_props.r#ref.clone()}
415 data-state={if context.is_open { "open" } else { "closed" }}
416 data-side={side.to_string()}
417 data-align={align.to_string()}
418 role="dialog"
419 style={style.clone()}
420 class={&presence_props.class}
421 >
422 {presence_props.children.clone()}
423 </div>
424 }
425 })}
426 >
427 {props.children.clone()}
428 </Presence>
429 },
430 viewport,
431 )
432}