pub use popper_rs::prelude::Placement;
use popper_rs::{
prelude::{use_popper, Modifier, Offset, Options, Strategy},
state::ApplyAttributes,
};
use wasm_bindgen::{closure::Closure, JsCast};
use web_sys::{HtmlElement, MediaQueryList, MediaQueryListEvent};
use yew::{html::IntoPropValue, platform::spawn_local, prelude::*};
const MEDIA_QUERY_HOVER_NONE: &str = "(hover: none)";
const MEDIA_QUERY_ANY_HOVER_NONE: &str = "(any-hover: none)";
const MEDIA_QUERY_ANY_POINTER_NONE_OR_COARSE: &str = "(any-pointer: none) or (any-pointer: coarse)";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TooltipFocusTrigger {
#[default]
Always,
IfHoverNone,
IfAnyHoverNone,
IfAnyPointerNoneOrCoarse,
Never,
}
impl IntoPropValue<TooltipFocusTrigger> for bool {
fn into_prop_value(self) -> TooltipFocusTrigger {
if self {
TooltipFocusTrigger::Always
} else {
TooltipFocusTrigger::Never
}
}
}
impl TooltipFocusTrigger {
fn media_queries(&self) -> Option<MediaQueryList> {
let query = match self {
Self::Always | Self::Never => return None,
Self::IfHoverNone => MEDIA_QUERY_HOVER_NONE,
Self::IfAnyHoverNone => MEDIA_QUERY_ANY_HOVER_NONE,
Self::IfAnyPointerNoneOrCoarse => MEDIA_QUERY_ANY_POINTER_NONE_OR_COARSE,
};
let w = gloo_utils::window();
w.match_media(query).ok().flatten()
}
fn should_trigger(&self) -> bool {
let Some(queries) = self.media_queries() else {
return match self {
Self::Always => true,
Self::Never => false,
_ => unreachable!(),
};
};
queries.matches()
}
}
#[derive(Properties, Clone, PartialEq)]
pub struct TooltipProps {
pub target: NodeRef,
#[prop_or_default]
pub id: Option<AttrValue>,
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub placement: Placement,
#[prop_or_default]
pub fade: bool,
#[prop_or_default]
pub show: bool,
#[prop_or_default]
pub trigger_on_focus: TooltipFocusTrigger,
#[prop_or(true)]
pub trigger_on_hover: bool,
#[prop_or_default]
pub disabled: bool,
}
#[function_component]
pub fn Tooltip(props: &TooltipProps) -> Html {
let tooltip_ref = use_node_ref();
let options = use_memo(props.placement, |placement| Options {
placement: *placement,
modifiers: vec![Modifier::Offset(Offset {
skidding: 0,
distance: 6,
})],
strategy: Strategy::Fixed,
..Default::default()
});
let popper = use_popper(props.target.clone(), tooltip_ref.clone(), options).unwrap();
let focused = use_state_eq(|| false);
let focus_should_trigger = use_state_eq(|| props.trigger_on_focus.should_trigger());
let hovered = use_state_eq(|| false);
let onshow = {
let focused = focused.clone();
let hovered = hovered.clone();
Callback::from(move |evt_type: String| match evt_type.as_str() {
"mouseenter" => hovered.set(true),
"focusin" => focused.set(true),
_ => {}
})
};
let onhide = {
let focused = focused.clone();
let hovered = hovered.clone();
Callback::from(move |evt_type: String| match evt_type.as_str() {
"mouseleave" => hovered.set(false),
"focusout" => focused.set(false),
_ => {}
})
};
let focus_should_trigger_listener = {
let focus_should_trigger = focus_should_trigger.clone();
Callback::from(move |v: bool| {
focus_should_trigger.set(v);
})
};
use_effect_with(props.trigger_on_focus, |trigger_on_focus| {
let r = if let Some(media_query_list) = trigger_on_focus.media_queries() {
let media_query_list_listener = Closure::<dyn Fn(MediaQueryListEvent)>::wrap(Box::new(
move |e: MediaQueryListEvent| {
focus_should_trigger_listener.emit(e.matches());
},
));
let _ = media_query_list.add_event_listener_with_callback(
"change",
media_query_list_listener.as_ref().unchecked_ref(),
);
Some((media_query_list_listener, media_query_list))
} else {
None
};
move || {
if let Some((media_query_list_listener, media_query_list)) = r {
let _ = media_query_list.remove_event_listener_with_callback(
"change",
media_query_list_listener.as_ref().unchecked_ref(),
);
drop(media_query_list_listener);
}
}
});
if props.disabled {
focused.set(false);
hovered.set(false);
}
let show = !props.disabled
&& (props.show
|| (*focused && *focus_should_trigger)
|| (*hovered && props.trigger_on_hover));
let data_show = show.then(AttrValue::default);
use_effect_with((show, popper.instance.clone()), |(show, popper)| {
if *show {
let popper = popper.clone();
spawn_local(async move {
popper.update().await;
});
}
});
use_effect_with(
(tooltip_ref.clone(), popper.state.attributes.popper.clone()),
|(tooltip_ref, attributes)| {
tooltip_ref.apply_attributes(attributes);
},
);
use_effect_with(props.target.clone(), |target_ref| {
let show_listener = Closure::<dyn Fn(Event)>::wrap(Box::new(move |e: Event| {
onshow.emit(e.type_());
}));
let hide_listener = Closure::<dyn Fn(Event)>::wrap(Box::new(move |e: Event| {
onhide.emit(e.type_());
}));
let target_elem = target_ref.cast::<HtmlElement>();
if let Some(target_elem) = &target_elem {
let _ = target_elem.add_event_listener_with_callback(
"focusin",
show_listener.as_ref().unchecked_ref(),
);
let _ = target_elem.add_event_listener_with_callback(
"focusout",
hide_listener.as_ref().unchecked_ref(),
);
let _ = target_elem.add_event_listener_with_callback(
"mouseenter",
show_listener.as_ref().unchecked_ref(),
);
let _ = target_elem.add_event_listener_with_callback(
"mouseleave",
hide_listener.as_ref().unchecked_ref(),
);
};
move || {
if let Some(target_elem) = target_elem {
let _ = target_elem.remove_event_listener_with_callback(
"focusin",
show_listener.as_ref().unchecked_ref(),
);
let _ = target_elem.remove_event_listener_with_callback(
"focusout",
hide_listener.as_ref().unchecked_ref(),
);
let _ = target_elem.remove_event_listener_with_callback(
"mouseenter",
show_listener.as_ref().unchecked_ref(),
);
let _ = target_elem.remove_event_listener_with_callback(
"mouseleave",
hide_listener.as_ref().unchecked_ref(),
);
}
drop(show_listener);
drop(hide_listener);
}
});
use_effect_with(
(props.target.clone(), props.id.clone(), show),
|(target_ref, tooltip_id, show)| {
let Some(target_elem) = target_ref.cast::<HtmlElement>() else {
return;
};
match (tooltip_id, show) {
(Some(tooltip_id), true) => {
let _ = target_elem.set_attribute("aria-describedby", tooltip_id);
}
_ => {
let _ = target_elem.remove_attribute("aria-describedby");
}
}
},
);
let mut class = classes!["tooltip", "bs-tooltip-auto"];
if props.fade {
class.push("fade");
}
if show {
class.push("show");
}
let mut popper_style = popper.state.styles.popper.clone();
popper_style.insert("pointer-events".to_string(), "none".to_string());
create_portal(
html_nested! {
<div
ref={&tooltip_ref}
role="tooltip"
{class}
style={&popper_style}
data-show={&data_show}
id={props.id.clone()}
>
<div
class="tooltip-arrow"
data-popper-arrow="true"
style={&popper.state.styles.arrow}
/>
<div class="tooltip-inner">
{ for props.children.iter() }
</div>
</div>
},
gloo_utils::body().into(),
)
}