1use floating_ui_leptos::{
2 use_floating, Alignment, ApplyState, Arrow, ArrowData, ArrowOptions, AutoUpdateOptions,
3 Boundary, DetectOverflowOptions, Flip, FlipOptions, Hide, HideData, HideOptions, HideStrategy,
4 IntoReference, LimitShift, LimitShiftOptions, Middleware, MiddlewareReturn, MiddlewareState,
5 MiddlewareVec, Offset, OffsetOptions, OffsetOptionsValues, Padding, Placement, Shift,
6 ShiftOptions, Side, Size, SizeOptions, Strategy, UseFloatingOptions, UseFloatingReturn,
7 ARROW_NAME, HIDE_NAME,
8};
9use leptos::{
10 html::{AnyElement, Div},
11 *,
12};
13use radix_leptos_arrow::Arrow as ArrowPrimitive;
14use radix_leptos_compose_refs::use_composed_refs;
15use radix_leptos_primitive::Primitive;
16use radix_leptos_use_size::use_size;
17use serde::{Deserialize, Serialize};
18use web_sys::wasm_bindgen::JsCast;
19
20#[derive(Copy, Clone, Debug, PartialEq)]
21pub enum Align {
22 Start,
23 Center,
24 End,
25}
26
27impl Align {
28 pub fn alignment(self) -> Option<Alignment> {
29 match self {
30 Align::Start => Some(Alignment::Start),
31 Align::Center => None,
32 Align::End => Some(Alignment::End),
33 }
34 }
35}
36
37impl From<Option<Alignment>> for Align {
38 fn from(value: Option<Alignment>) -> Self {
39 match value {
40 Some(Alignment::Start) => Align::Start,
41 Some(Alignment::End) => Align::End,
42 None => Align::Center,
43 }
44 }
45}
46
47#[derive(Copy, Clone, Debug, PartialEq)]
48pub enum Sticky {
49 Partial,
50 Always,
51}
52
53#[derive(Copy, Clone, Debug, PartialEq)]
54pub enum UpdatePositionStrategy {
55 Optimized,
56 Always,
57}
58
59#[derive(Clone)]
60struct PopperContextValue {
61 pub anchor_ref: NodeRef<AnyElement>,
62}
63
64#[component]
65pub fn Popper(children: ChildrenFn) -> impl IntoView {
66 let anchor_ref: NodeRef<AnyElement> = NodeRef::new();
67
68 let context_value = PopperContextValue { anchor_ref };
69
70 view! {
71 <Provider value={context_value}>
72 {children()}
73 </Provider>
74 }
75}
76
77#[component]
78pub fn PopperAnchor(
79 #[prop(into, optional)] as_child: MaybeProp<bool>,
80 #[prop(optional)] node_ref: NodeRef<AnyElement>,
81 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
82 children: ChildrenFn,
83) -> impl IntoView {
84 let context: PopperContextValue = expect_context();
85 let anchor_ref = use_composed_refs(vec![node_ref, context.anchor_ref]);
86
87 view! {
88 <Primitive
89 element=html::div
90 as_child=as_child
91 node_ref=anchor_ref
92 attrs=attrs
93 >
94 {children()}
95 </Primitive>
96 }
97}
98
99#[derive(Clone)]
100struct PopperContentContextValue {
101 pub placed_side: Signal<Side>,
102 pub arrow_ref: NodeRef<AnyElement>,
103 pub arrow_x: Signal<Option<f64>>,
104 pub arrow_y: Signal<Option<f64>>,
105 pub should_hide_arrow: Signal<bool>,
106}
107
108#[component]
109pub fn PopperContent(
110 #[prop(into, optional)] side: MaybeProp<Side>,
111 #[prop(into, optional)] side_offset: MaybeProp<f64>,
112 #[prop(into, optional)] align: MaybeProp<Align>,
113 #[prop(into, optional)] align_offset: MaybeProp<f64>,
114 #[prop(into, optional)] arrow_padding: MaybeProp<f64>,
115 #[prop(into, optional)] avoid_collisions: MaybeProp<bool>,
116 #[prop(into, optional)] collision_boundary: MaybeProp<Vec<web_sys::Element>>,
117 #[prop(into, optional)] collision_padding: MaybeProp<Padding>,
118 #[prop(into, optional)] sticky: MaybeProp<Sticky>,
119 #[prop(into, optional)] hide_when_detached: MaybeProp<bool>,
120 #[prop(into, optional)] update_position_strategy: MaybeProp<UpdatePositionStrategy>,
121 #[prop(into, optional)] on_placed: Option<Callback<()>>,
122 #[prop(into, optional)] as_child: MaybeProp<bool>,
123 #[prop(optional)] node_ref: NodeRef<AnyElement>,
124 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
125 children: ChildrenFn,
126) -> impl IntoView {
127 let side = move || side.get().unwrap_or(Side::Bottom);
128 let side_offset = move || side_offset.get().unwrap_or(0.0);
129 let align = move || align.get().unwrap_or(Align::Center);
130 let align_offset = move || align_offset.get().unwrap_or(0.0);
131 let arrow_padding = move || arrow_padding.get().unwrap_or(0.0);
132 let avoid_collisions = move || avoid_collisions.get().unwrap_or(true);
133 let collision_boundary = move || collision_boundary.get().unwrap_or_default();
134 let collision_padding = move || collision_padding.get().unwrap_or(Padding::All(0.0));
135 let sticky = move || sticky.get().unwrap_or(Sticky::Partial);
136 let hide_when_detached = move || hide_when_detached.get().unwrap_or(false);
137 let update_position_strategy = move || {
138 update_position_strategy
139 .get()
140 .unwrap_or(UpdatePositionStrategy::Optimized)
141 };
142
143 let context: PopperContextValue = expect_context();
144
145 let content_ref: NodeRef<AnyElement> = NodeRef::new();
146 let composed_refs = use_composed_refs(vec![node_ref, content_ref]);
147
148 let desired_placement = Signal::derive(move || Placement::from((side(), align().alignment())));
149
150 let arrow_ref: NodeRef<AnyElement> = NodeRef::new();
151 let arrow_size = use_size(arrow_ref);
152 let arrow_width = move || {
153 arrow_size
154 .get()
155 .map(|arrow_size| arrow_size.width)
156 .unwrap_or(0.0)
157 };
158 let arrow_height = move || {
159 arrow_size
160 .get()
161 .map(|arrow_size| arrow_size.height)
162 .unwrap_or(0.0)
163 };
164
165 let floating_ref: NodeRef<Div> = NodeRef::new();
166
167 let UseFloatingReturn {
168 floating_styles,
169 placement,
170 is_positioned,
171 middleware_data,
172 ..
173 } = use_floating(
174 context.anchor_ref.into_reference(),
175 floating_ref,
176 UseFloatingOptions::default()
177 .strategy(Strategy::Fixed.into())
178 .placement(desired_placement.into())
179 .while_elements_mounted_auto_update_with_options(MaybeSignal::derive(move || {
180 AutoUpdateOptions::default()
181 .animation_frame(update_position_strategy() == UpdatePositionStrategy::Always)
182 }))
183 .middleware(MaybeProp::derive(move || {
184 let detect_overflow_options = DetectOverflowOptions::default()
185 .padding(collision_padding())
186 .boundary(Boundary::Elements(collision_boundary()))
187 .alt_boundary(!collision_boundary().is_empty());
188
189 let mut middleware: MiddlewareVec =
190 vec![Box::new(Offset::new(OffsetOptions::Values(
191 OffsetOptionsValues::default()
192 .main_axis(side_offset() + arrow_height())
193 .alignment_axis(align_offset()),
194 )))];
195
196 if avoid_collisions() {
197 let mut shift_options = ShiftOptions::default()
198 .detect_overflow(detect_overflow_options.clone())
199 .main_axis(true)
200 .cross_axis(false);
201
202 if sticky() == Sticky::Partial {
203 shift_options = shift_options
204 .limiter(Box::new(LimitShift::new(LimitShiftOptions::default())));
205 }
206
207 middleware.push(Box::new(Shift::new(shift_options)));
208
209 middleware.push(Box::new(Flip::new(
210 FlipOptions::default().detect_overflow(detect_overflow_options.clone()),
211 )));
212 }
213
214 middleware.push(Box::new(Size::new(
215 SizeOptions::default()
216 .detect_overflow(detect_overflow_options.clone())
217 .apply(&|ApplyState {
218 state,
219 available_width,
220 available_height,
221 }| {
222 let MiddlewareState {
223 elements, rects, ..
224 } = state;
225
226 let content_style = (*elements.floating)
227 .clone()
228 .unchecked_into::<web_sys::HtmlElement>()
229 .style();
230
231 content_style
232 .set_property(
233 "--radix-popper-available-width",
234 &format!("{}px", available_width),
235 )
236 .expect("Style should be updated.");
237 content_style
238 .set_property(
239 "--radix-popper-available-height",
240 &format!("{}px", available_height),
241 )
242 .expect("Style should be updated.");
243 content_style
244 .set_property(
245 "--radix-popper-anchor-width",
246 &format!("{}px", rects.reference.width),
247 )
248 .expect("Style should be updated.");
249 content_style
250 .set_property(
251 "--radix-popper-anchor-height",
252 &format!("{}px", rects.reference.height),
253 )
254 .expect("Style should be updated.");
255 }),
256 )));
257
258 middleware.push(Box::new(Arrow::new(
259 ArrowOptions::new(arrow_ref).padding(Padding::All(arrow_padding())),
260 )));
261
262 middleware.push(Box::new(TransformOrigin::new(TransformOriginOptions {
263 arrow_width: arrow_width(),
264 arrow_height: arrow_height(),
265 })));
266
267 if hide_when_detached() {
268 middleware.push(Box::new(Hide::new(
269 HideOptions::default()
270 .detect_overflow(detect_overflow_options)
271 .strategy(HideStrategy::ReferenceHidden),
272 )));
273 }
274
275 Some(middleware)
276 })),
277 );
278
279 let placed_side = Signal::derive(move || placement.get().side());
280 let placed_align = move || Align::from(placement.get().alignment());
281
282 Effect::new(move |_| {
283 if is_positioned.get() {
284 if let Some(on_placed) = on_placed {
285 on_placed.call(());
286 }
287 }
288 });
289
290 let arrow_data = move || -> Option<ArrowData> { middleware_data.get().get_as(ARROW_NAME) };
291 let arrow_x = Signal::derive(move || arrow_data().and_then(|arrow_data| arrow_data.x));
292 let arrow_y = Signal::derive(move || arrow_data().and_then(|arrow_data| arrow_data.y));
293 let cannot_center_arrow = Signal::derive(move || {
294 arrow_data().map_or(true, |arrow_data| arrow_data.center_offset != 0.0)
295 });
296
297 let (content_z_index, set_content_z_index) = create_signal::<Option<String>>(None);
298 Effect::new(move |_| {
299 if let Some(content) = content_ref.get() {
300 set_content_z_index.set(Some(
301 window()
302 .get_computed_style(&content)
303 .expect("Element is valid.")
304 .expect("Element should have computed style.")
305 .get_property_value("z-index")
306 .expect("Computed style should have z-index."),
307 ));
308 }
309 });
310
311 let transform_origin_data = move || -> Option<TransformOriginData> {
312 middleware_data.get().get_as(TRANSFORM_ORIGIN_NAME)
313 };
314 let transform_origin = move || {
315 transform_origin_data().map(|transform_origin_data| {
316 format!("{} {}", transform_origin_data.x, transform_origin_data.y)
317 })
318 };
319 let hide_data = move || -> Option<HideData> { middleware_data.get().get_as(HIDE_NAME) };
320 let reference_hidden = move || {
321 hide_data()
322 .and_then(|hide_data| hide_data.reference_hidden)
323 .unwrap_or(false)
324 };
325
326 let dir = attrs
327 .iter()
328 .find_map(|(key, value)| (*key == "dir").then_some(value.clone()));
329
330 let content_context = PopperContentContextValue {
331 placed_side,
332 arrow_ref,
333 arrow_x,
334 arrow_y,
335 should_hide_arrow: cannot_center_arrow,
336 };
337
338 let mut attrs = attrs.clone();
339 attrs.extend([
340 (
341 "data-side",
342 (move || format!("{:?}", placed_side.get()).to_lowercase()).into_attribute(),
343 ),
344 (
345 "data-align",
346 (move || format!("{:?}", placed_align()).to_lowercase()).into_attribute(),
347 ),
348 (
351 "style",
352 (move || (!is_positioned.get()).then_some("animation: none;")).into_attribute(),
353 ),
354 ]);
355
356 view! {
357 <div
358 _ref={floating_ref}
359 style:position=move || floating_styles.get().style_position()
360 style:top=move || floating_styles.get().style_top()
361 style:left=move || floating_styles.get().style_left()
362 style:transform=move || match is_positioned.get() {
363 true => floating_styles.get().style_transform(),
364 false => Some("translate(0, -200%)".into())
366 }
367 style:will-change=move || floating_styles.get().style_will_change()
368 style:min-width="max-content"
369 style:z-index=content_z_index
370 style=("--radix-popper-transform-origin", transform_origin)
371
372 style:visibility=move || reference_hidden().then_some("hidden")
375 style:pointer-events=move || reference_hidden().then_some("none")
376
377 dir={dir}
381 >
382 <Provider value={content_context}>
383 <Primitive
384 element=html::div
385 as_child=as_child
386 node_ref=composed_refs
387 attrs=attrs
388 >
389 {children()}
390 </Primitive>
391 </Provider>
392 </div>
393 }
394}
395
396#[component]
397pub fn PopperArrow(
398 #[prop(into, optional)] width: MaybeProp<f64>,
399 #[prop(into, optional)] height: MaybeProp<f64>,
400 #[prop(into, optional)] as_child: MaybeProp<bool>,
401 #[prop(optional)] node_ref: NodeRef<AnyElement>,
402 #[prop(attrs)] attrs: Vec<(&'static str, Attribute)>,
403 #[prop(optional)] children: Option<ChildrenFn>,
404) -> impl IntoView {
405 let children = StoredValue::new(children);
406
407 let content_context: PopperContentContextValue = expect_context();
408 let arrow_ref = content_context.arrow_ref;
409 let base_side = move || content_context.placed_side.get().opposite();
410
411 let mut attrs = attrs.clone();
412 attrs.extend([("style", "display: block".into_attribute())]);
413
414 view! {
415 <span
419 style:position="absolute"
420 style:left=move || match base_side() {
421 Side::Left => Some("0px".into()),
422 _ => content_context.arrow_x.get().map(|arrow_x| format!("{}px", arrow_x))
423 }
424 style:top=move || match base_side() {
425 Side::Top => Some("0px".into()),
426 _ => content_context.arrow_y.get().map(|arrow_y| format!("{}px", arrow_y))
427 }
428 style:right=move || match base_side() {
429 Side::Right => Some("0px"),
430 _ => None
431 }
432 style:bottom=move || match base_side() {
433 Side::Bottom => Some("0px"),
434 _ => None
435 }
436 style:transform-origin=move || match content_context.placed_side.get() {
437 Side::Top => "",
438 Side::Right => "0 0",
439 Side::Bottom => "center 0",
440 Side::Left => "100% 0",
441 }
442 style:transform=move || match content_context.placed_side.get() {
443 Side::Top => "translateY(100%)",
444 Side::Right => "translateY(50%) rotate(90deg) translateX(-50%)",
445 Side::Bottom => "rotate(180deg)",
446 Side::Left => "translateY(50%) rotate(-90deg) translateX(50%)",
447 }
448 style:visibility=move || content_context.should_hide_arrow.get().then_some("hidden")
449 >
450 <ArrowPrimitive width=width height=height as_child=as_child node_ref=node_ref attrs=attrs>
451 {children.with_value(|children| children.as_ref().map(|children| children()))}
452 </ArrowPrimitive>
453 </span>
454 }
455 .into_any()
456 .node_ref(arrow_ref)
457}
458
459const TRANSFORM_ORIGIN_NAME: &str = "transformOrigin";
460
461#[derive(Clone)]
463struct TransformOriginOptions {
464 arrow_width: f64,
465 arrow_height: f64,
466}
467
468#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
470struct TransformOriginData {
471 pub x: String,
472 pub y: String,
473}
474
475#[derive(Clone)]
476struct TransformOrigin {
477 options: TransformOriginOptions,
478}
479
480impl TransformOrigin {
481 fn new(options: TransformOriginOptions) -> Self {
482 Self { options }
483 }
484}
485
486impl Middleware<web_sys::Element, web_sys::Window> for TransformOrigin {
487 fn name(&self) -> &'static str {
488 TRANSFORM_ORIGIN_NAME
489 }
490
491 fn compute(
492 &self,
493 state: MiddlewareState<web_sys::Element, web_sys::Window>,
494 ) -> MiddlewareReturn {
495 let MiddlewareState {
496 placement,
497 rects,
498 middleware_data,
499 ..
500 } = state;
501
502 let arrow_data: Option<ArrowData> = middleware_data.get_as(ARROW_NAME);
503 let cannot_center_arrow = arrow_data
504 .as_ref()
505 .map_or(true, |arrow_data| arrow_data.center_offset != 0.0);
506 let is_arrow_hidden = cannot_center_arrow;
507 let arrow_width = match is_arrow_hidden {
508 true => 0.0,
509 false => self.options.arrow_width,
510 };
511 let arrow_height = match is_arrow_hidden {
512 true => 0.0,
513 false => self.options.arrow_height,
514 };
515
516 let placed_side = placement.side();
517 let placed_align = Align::from(placement.alignment());
518 let no_arrow_align = match placed_align {
519 Align::Start => "0%",
520 Align::Center => "50%",
521 Align::End => "100%",
522 };
523
524 let arrow_x_center = arrow_data
525 .as_ref()
526 .and_then(|arrow_data| arrow_data.x)
527 .unwrap_or(0.0)
528 + arrow_width / 2.0;
529 let arrow_y_center = arrow_data
530 .as_ref()
531 .and_then(|arrow_data| arrow_data.y)
532 .unwrap_or(0.0)
533 + arrow_height / 2.0;
534
535 let (x, y) = match placed_side {
536 Side::Top => (
537 match is_arrow_hidden {
538 true => no_arrow_align.into(),
539 false => format!("{}px", arrow_x_center),
540 },
541 format!("{}px", rects.floating.height + arrow_height),
542 ),
543 Side::Right => (
544 format!("{}px", -arrow_height),
545 match is_arrow_hidden {
546 true => no_arrow_align.into(),
547 false => format!("{}px", arrow_y_center),
548 },
549 ),
550 Side::Bottom => (
551 match is_arrow_hidden {
552 true => no_arrow_align.into(),
553 false => format!("{}px", arrow_x_center),
554 },
555 format!("{}px", -arrow_height),
556 ),
557 Side::Left => (
558 format!("{}px", rects.floating.width + arrow_height),
559 match is_arrow_hidden {
560 true => no_arrow_align.into(),
561 false => format!("{}px", arrow_y_center),
562 },
563 ),
564 };
565
566 MiddlewareReturn {
567 x: None,
568 y: None,
569 data: Some(
570 serde_json::to_value(TransformOriginData { x, y })
571 .expect("Data should be valid JSON."),
572 ),
573 reset: None,
574 }
575 }
576}