Skip to main content

hypersig_ui/
tooltip.rs

1//! Tooltips: a short description shown while a button is hovered or focused.
2
3use std::fmt;
4use std::marker::PhantomData;
5
6use hypersig::NodeRef;
7use hypersig::html::{El, Event, div};
8use hypersig::style::Style;
9use hypersig::style::value::{
10    BackgroundClip, BorderStyle, Color, PositionTryFallbacks, px, raw, rem,
11};
12
13use crate::button::{ButtonSize, ButtonVariant, button, icon_button};
14use crate::id::check_name;
15use crate::token;
16
17/// A tooltip on a page whose signals are `S`, describing the button it is
18/// attached to.
19///
20/// Declare one as a `const` and use the same value for the button and the
21/// tooltip, so they agree on the tooltip's id. It follows the WAI-ARIA
22/// tooltip pattern: the tooltip has `role="tooltip"` and the button
23/// `aria-describedby` pointing at it, so a screen reader reads the text
24/// after the button's name. It opens while the pointer is on the button or
25/// the tooltip, or the button has focus, and closes when both are left or a
26/// key is pressed on the button.
27///
28/// The tooltip is a `popover="hint"`, which the browser closes on Escape
29/// without closing an open menu. browser-compat-data 8.1.6 lists hint
30/// popovers for Chrome 151 and Firefox 153, an older partial version for
31/// Chrome 133 to 150 and Firefox 149 to 152, and Safari only in its
32/// preview. A browser without them treats the value as `manual`, and there
33/// Escape closes the tooltip only while focus is on its button.
34///
35/// The button carries the attributes the tooltip needs, so build it here
36/// rather than adding a tooltip to a button made elsewhere. The tooltip
37/// carries a node ref with its name, so two tooltips with one name on a page
38/// make [`Page::render`](hypersig::Page::render) panic.
39///
40/// ```
41/// use hypersig::Signals;
42/// use hypersig_ui::{ButtonVariant, Tooltip};
43/// use serde::{Deserialize, Serialize};
44///
45/// #[derive(Signals, Serialize, Deserialize)]
46/// struct Editor {}
47///
48/// const BOLD: Tooltip<Editor> = Tooltip::new("bold-tip");
49///
50/// let button = BOLD.icon_trigger(ButtonVariant::Outline, "Bold").text("B");
51/// let tip = BOLD.tooltip("Ctrl+B");
52/// assert!(button.render().contains(r#"aria-describedby="bold-tip""#));
53/// assert!(tip.render().contains(r#"role="tooltip""#));
54/// ```
55pub struct Tooltip<S> {
56    name: &'static str,
57    marker: PhantomData<fn() -> S>,
58}
59
60impl<S> Tooltip<S> {
61    /// A tooltip whose id is `name`.
62    ///
63    /// # Panics
64    ///
65    /// Panics if `name` is empty or holds a character other than an ASCII
66    /// letter, digit, `-`, or `_`. In a `const`, that is a compile error.
67    #[must_use]
68    pub const fn new(name: &'static str) -> Self {
69        check_name(name);
70        Self {
71            name,
72            marker: PhantomData,
73        }
74    }
75
76    fn anchor(self) -> String {
77        format!("--{}", self.name)
78    }
79
80    fn node_ref(self) -> NodeRef<S> {
81        NodeRef::new(self.name)
82    }
83
84    /// A button described by the tooltip.
85    #[must_use]
86    pub fn trigger(self, variant: ButtonVariant, size: ButtonSize) -> El<S> {
87        self.attach(button(variant, size))
88    }
89
90    /// An icon button named `label` and described by the tooltip.
91    #[must_use]
92    pub fn icon_trigger(self, variant: ButtonVariant, label: impl Into<String>) -> El<S> {
93        self.attach(icon_button(variant, label))
94    }
95
96    fn attach(self, button: El<S>) -> El<S> {
97        let tip = self.node_ref();
98        button
99            .attr("aria-describedby", self.name)
100            .css(Style::new().anchor_name(raw(self.anchor())))
101            .on(Event::MouseEnter, tip.show_popover())
102            .on(Event::MouseLeave, tip.hide_popover())
103            .on(Event::Focus, tip.show_popover())
104            .on(Event::Blur, tip.hide_popover())
105            // Any key closes the tooltip, Escape among them. `on_key` would
106            // cancel Escape and keep a dialog around the button from closing.
107            .on(Event::KeyDown, tip.hide_popover())
108    }
109
110    /// The tooltip, holding `text`.
111    #[must_use]
112    pub fn tooltip(self, text: impl Into<String>) -> El<S> {
113        let tip = self.node_ref();
114        div()
115            .id(self.name)
116            .attr("popover", "hint")
117            .attr("role", "tooltip")
118            .node_ref(tip)
119            // The pointer moving from the button onto the tooltip leaves one
120            // and enters the other in the same event, so the tooltip closes
121            // and opens again before it is drawn.
122            .on(Event::MouseEnter, tip.show_popover())
123            .on(Event::MouseLeave, tip.hide_popover())
124            .css(tooltip_style(self.anchor()))
125            .text(text)
126    }
127}
128
129impl<S> Clone for Tooltip<S> {
130    fn clone(&self) -> Self {
131        *self
132    }
133}
134
135impl<S> Copy for Tooltip<S> {}
136
137impl<S> fmt::Debug for Tooltip<S> {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.debug_tuple("Tooltip").field(&self.name).finish()
140    }
141}
142
143fn tooltip_style(anchor: String) -> Style {
144    // The transparent border is the gap between the button and the visible
145    // box. It belongs to the tooltip, so the pointer crossing it stays on one
146    // of the two and the tooltip stays open.
147    Style::new()
148        .position_anchor(raw(anchor))
149        .position_area(raw("block-start"))
150        .position_try_fallbacks(PositionTryFallbacks::FlipBlock)
151        .inset(raw("auto"))
152        .margin(px(0))
153        .max_width(rem(20))
154        .padding_block(rem(0.375))
155        .padding_inline(rem(0.75))
156        .border_width(rem(0.25))
157        .border_style(BorderStyle::Solid)
158        .border_color(Color::TRANSPARENT)
159        .background_clip(BackgroundClip::PaddingBox)
160        .border_radius(rem(0.625))
161        .background_color(token::primary().var())
162        .color(token::primary_foreground().var())
163        .font_size(rem(0.75))
164        .line_height(hypersig::style::value::LineHeight::Length(rem(1)))
165}