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}