Skip to main content

fret_ui_kit/primitives/
hover_card.rs

1//! Hover Card primitives (Radix-aligned outcomes).
2//!
3//! Upstream reference:
4//! - `repo-ref/primitives/packages/react/hover-card/src/hover-card.tsx`
5//!
6//! This module is intentionally thin: it provides Radix-named entry points for overlay root naming
7//! and hover overlay request wiring. Visual styling, motion, and arrow rendering belong in higher
8//! layers (e.g. shadcn recipes).
9
10use fret_core::{Px, Rect};
11use fret_runtime::Model;
12use fret_ui::element::AnyElement;
13use fret_ui::elements::GlobalElementId;
14use fret_ui::{ElementContext, UiHost};
15use std::panic::Location;
16
17use crate::declarative::ModelWatchExt;
18use crate::headless::hover_intent::{HoverIntentConfig, HoverIntentState, HoverIntentUpdate};
19use crate::primitives::popper;
20use crate::{OverlayController, OverlayPresence, OverlayRequest};
21
22/// Stable per-overlay root naming convention for hover cards.
23pub fn hover_card_root_name(id: GlobalElementId) -> String {
24    OverlayController::hover_overlay_root_name(id)
25}
26
27/// A Radix-shaped `HoverCard` root configuration surface (open state only).
28///
29/// Radix HoverCard supports a controlled/uncontrolled `open` state (`open` + `defaultOpen`). In
30/// Fret, hover-card recipes often derive open state from hover intent, but this root helper keeps
31/// a Radix-shaped option available for non-hover use cases and for strict parity tests.
32#[derive(Debug, Clone, Default)]
33pub struct HoverCardRoot {
34    open: Option<Model<bool>>,
35    default_open: bool,
36}
37
38impl HoverCardRoot {
39    pub fn new() -> Self {
40        Self::default()
41    }
42
43    /// Sets the controlled `open` model (`Some`) or selects uncontrolled mode (`None`).
44    pub fn open(mut self, open: Option<Model<bool>>) -> Self {
45        self.open = open;
46        self
47    }
48
49    /// Sets the uncontrolled initial open value (Radix `defaultOpen`).
50    pub fn default_open(mut self, default_open: bool) -> Self {
51        self.default_open = default_open;
52        self
53    }
54
55    /// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
56    pub fn use_open_model<H: UiHost>(
57        &self,
58        cx: &mut ElementContext<'_, H>,
59    ) -> crate::primitives::controllable_state::ControllableModel<bool> {
60        hover_card_use_open_model(cx, self.open.clone(), || self.default_open)
61    }
62
63    pub fn open_model<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Model<bool> {
64        self.use_open_model(cx).model()
65    }
66
67    /// Reads the current open value from the derived open model.
68    pub fn is_open<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
69        let open_model = self.open_model(cx);
70        cx.watch_model(&open_model)
71            .layout()
72            .copied()
73            .unwrap_or(false)
74    }
75}
76
77/// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
78///
79/// This is a convenience helper for authoring Radix-shaped hover-card roots:
80/// - if `controlled_open` is provided, it is used directly
81/// - otherwise an internal model is created (once) using `default_open` (Radix `defaultOpen`)
82pub fn hover_card_use_open_model<H: UiHost>(
83    cx: &mut ElementContext<'_, H>,
84    controlled_open: Option<Model<bool>>,
85    default_open: impl FnOnce() -> bool,
86) -> crate::primitives::controllable_state::ControllableModel<bool> {
87    crate::primitives::open_state::open_use_model(cx, controlled_open, default_open)
88}
89
90/// Builds an overlay request for a Radix-style hover card.
91pub fn hover_card_request(
92    id: GlobalElementId,
93    trigger: GlobalElementId,
94    open: Model<bool>,
95    presence: crate::OverlayPresence,
96    children: Vec<AnyElement>,
97) -> OverlayRequest {
98    hover_card_request_with_presence(id, trigger, open, presence, children)
99}
100
101/// Builds an overlay request for a Radix-style hover card with explicit presence semantics.
102pub fn hover_card_request_with_presence(
103    id: GlobalElementId,
104    trigger: GlobalElementId,
105    open: Model<bool>,
106    presence: OverlayPresence,
107    children: Vec<AnyElement>,
108) -> OverlayRequest {
109    let mut request = OverlayRequest::hover(id, trigger, open, presence, children);
110    request.root_name = Some(hover_card_root_name(id));
111    request
112}
113
114/// Requests a hover-card overlay for the current window.
115pub fn request_hover_card<H: UiHost>(cx: &mut ElementContext<'_, H>, request: OverlayRequest) {
116    OverlayController::request(cx, request);
117}
118
119/// Computes whether the hover card should be considered "hovered" for intent/visibility decisions.
120///
121/// Notes:
122/// - Pointer hover is level-triggered: `trigger_hovered || overlay_hovered`.
123/// - Keyboard focus should be treated as an "open affordance" for accessibility flows. In Radix,
124///   pointer-driven focus (mouse down) does *not* keep the hover card open after pointer leave.
125///   Call sites should pass `keyboard_focused` (not just `focused`).
126pub fn hover_card_hovered(
127    trigger_hovered: bool,
128    overlay_hovered: bool,
129    keyboard_focused: bool,
130) -> bool {
131    trigger_hovered || overlay_hovered || keyboard_focused
132}
133
134#[derive(Debug, Default, Clone, Copy)]
135struct HoverCardIntentDriverState {
136    last_frame_tick: Option<u64>,
137    tick: u64,
138    intent: HoverIntentState,
139    saw_active_since_open: bool,
140    last_pointer_down: bool,
141    close_suppressed_after_pointer_down: bool,
142    saw_text_selection_while_pointer_down: bool,
143}
144
145/// Updates hover-card open state using Radix-aligned hover intent policy.
146///
147/// This helper centralizes the "hover-card intent driver" logic so recipes can share it without
148/// copying per-frame state machines:
149///
150/// - open/close are driven by hover intent delays (via `HoverIntentState`),
151/// - close is suppressed if the pointer leaves while holding the mouse button down,
152/// - `defaultOpen=true` behaves like Radix: the card stays open until an "active" period is
153///   observed and then a leave edge occurs,
154/// - active text selection keeps the hover card open while selecting.
155pub fn hover_card_update_interaction<H: UiHost>(
156    cx: &mut ElementContext<'_, H>,
157    open_now: bool,
158    signal_active: bool,
159    pointer_down_on_content: bool,
160    has_text_selection: bool,
161    cfg: HoverIntentConfig,
162) -> HoverIntentUpdate {
163    let frame_tick = cx.app.frame_id().0;
164    let slot = cx.keyed_slot_id_at(Location::caller(), "hover_card_intent_driver");
165    cx.state_for(slot, HoverCardIntentDriverState::default, |st| {
166        match st.last_frame_tick {
167            None => {
168                st.last_frame_tick = Some(frame_tick);
169                st.tick = frame_tick;
170            }
171            Some(prev) if prev != frame_tick => {
172                st.last_frame_tick = Some(frame_tick);
173                st.tick = frame_tick;
174            }
175            Some(_) => {
176                // Some unit tests may not advance the runner-owned frame clock; fall back to a
177                // per-call monotonic tick so delays can still elapse deterministically.
178                st.tick = st.tick.saturating_add(1);
179            }
180        }
181
182        if st.intent.is_open() != open_now {
183            st.intent.set_open(open_now);
184            st.saw_active_since_open = false;
185            st.close_suppressed_after_pointer_down = false;
186            st.saw_text_selection_while_pointer_down = false;
187        }
188
189        if pointer_down_on_content && has_text_selection {
190            st.saw_text_selection_while_pointer_down = true;
191        }
192
193        let was_open = st.intent.is_open();
194
195        if pointer_down_on_content != st.last_pointer_down {
196            if pointer_down_on_content {
197                st.close_suppressed_after_pointer_down = false;
198            } else if was_open && !signal_active && !has_text_selection {
199                // Mirror Radix HoverCard: if the pointer left while the button is held, `onClose`
200                // does not schedule a close timer. We model that by suppressing close until the
201                // next "active -> inactive" edge.
202                if !st.saw_text_selection_while_pointer_down {
203                    st.close_suppressed_after_pointer_down = true;
204                }
205            }
206            st.last_pointer_down = pointer_down_on_content;
207            if !pointer_down_on_content {
208                st.saw_text_selection_while_pointer_down = false;
209            }
210        }
211        if st.close_suppressed_after_pointer_down && signal_active {
212            st.close_suppressed_after_pointer_down = false;
213        }
214
215        if was_open && (signal_active || pointer_down_on_content) {
216            st.saw_active_since_open = true;
217        }
218
219        // Radix HoverCard opens/closes based on enter/leave edges, not a pure level signal.
220        // If the root is open but we've never observed an "active" signal since it opened (e.g.
221        // `defaultOpen=true` on first mount), keep it open until we see at least one active
222        // period and then a leave edge.
223        let effective_hovered = if was_open {
224            signal_active
225                || pointer_down_on_content
226                || st.close_suppressed_after_pointer_down
227                || has_text_selection
228                || !st.saw_active_since_open
229        } else {
230            signal_active || pointer_down_on_content
231        };
232
233        let out = st.intent.update(effective_hovered, st.tick, cfg);
234        if !was_open && out.open {
235            st.saw_active_since_open = signal_active || pointer_down_on_content;
236        } else if was_open && !out.open {
237            st.saw_active_since_open = false;
238            st.close_suppressed_after_pointer_down = false;
239            st.saw_text_selection_while_pointer_down = false;
240        }
241
242        out
243    })
244}
245
246#[derive(Debug, Clone, Copy, PartialEq)]
247pub struct HoverCardPopperVars {
248    pub available_width: Px,
249    pub available_height: Px,
250    pub trigger_width: Px,
251    pub trigger_height: Px,
252}
253
254pub fn hover_card_popper_desired_width(outer: Rect, anchor: Rect, min_width: Px) -> Px {
255    popper::popper_desired_width(outer, anchor, min_width)
256}
257
258/// Compute Radix-like "hover card popper vars" (`--radix-hover-card-*`) for recipes.
259///
260/// Upstream Radix re-namespaces these from `@radix-ui/react-popper`:
261/// - `--radix-hover-card-content-available-width`
262/// - `--radix-hover-card-content-available-height`
263/// - `--radix-hover-card-trigger-width`
264/// - `--radix-hover-card-trigger-height`
265///
266/// In Fret, we compute the same concepts as a structured return value so recipes can constrain
267/// their content without relying on CSS variables.
268pub fn hover_card_popper_vars(
269    outer: Rect,
270    anchor: Rect,
271    min_width: Px,
272    placement: popper::PopperContentPlacement,
273) -> HoverCardPopperVars {
274    let metrics =
275        popper::popper_available_metrics_for_placement(outer, anchor, min_width, placement);
276    HoverCardPopperVars {
277        available_width: metrics.available_width,
278        available_height: metrics.available_height,
279        trigger_width: metrics.anchor_width,
280        trigger_height: metrics.anchor_height,
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    use fret_app::App;
289    use fret_core::{Point, Size};
290
291    #[test]
292    fn hover_card_root_open_model_uses_controlled_model() {
293        let window = Default::default();
294        let mut app = App::new();
295
296        let controlled = app.models_mut().insert(true);
297        fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
298            let root = HoverCardRoot::new()
299                .open(Some(controlled.clone()))
300                .default_open(false);
301            assert_eq!(root.open_model(cx), controlled);
302        });
303    }
304
305    #[test]
306    fn hover_card_request_sets_default_root_name() {
307        let mut app = App::new();
308        let open = app.models_mut().insert(true);
309        fret_ui::elements::with_element_cx(
310            &mut app,
311            Default::default(),
312            Default::default(),
313            "test",
314            move |_cx| {
315                let id = GlobalElementId(0x123);
316                let trigger = GlobalElementId(0x456);
317                let req = hover_card_request(
318                    id,
319                    trigger,
320                    open.clone(),
321                    crate::OverlayPresence::instant(true),
322                    Vec::new(),
323                );
324                let expected = hover_card_root_name(id);
325                assert_eq!(req.root_name.as_deref(), Some(expected.as_str()));
326            },
327        );
328    }
329
330    #[test]
331    fn hover_card_hovered_or_logic_matches_expectations() {
332        assert!(!hover_card_hovered(false, false, false));
333        assert!(hover_card_hovered(true, false, false));
334        assert!(hover_card_hovered(false, true, false));
335        assert!(hover_card_hovered(false, false, true));
336    }
337
338    #[test]
339    fn hover_card_popper_vars_available_height_tracks_flipped_side_space() {
340        let outer = Rect::new(
341            Point::new(Px(0.0), Px(0.0)),
342            Size::new(Px(100.0), Px(100.0)),
343        );
344        let anchor = Rect::new(
345            Point::new(Px(10.0), Px(70.0)),
346            Size::new(Px(30.0), Px(10.0)),
347        );
348
349        let placement = popper::PopperContentPlacement::new(
350            popper::LayoutDirection::Ltr,
351            popper::Side::Bottom,
352            popper::Align::Start,
353            Px(0.0),
354        );
355        let vars = hover_card_popper_vars(outer, anchor, Px(0.0), placement);
356        assert!(vars.available_height.0 > 60.0 && vars.available_height.0 < 80.0);
357    }
358
359    #[test]
360    fn hover_card_close_is_suppressed_after_pointer_down_leave_until_reenter() {
361        let window = Default::default();
362        let mut app = App::new();
363
364        fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
365            let cfg = HoverIntentConfig::new(0, 0);
366            let mut open_now = true;
367
368            open_now = hover_card_update_interaction(cx, open_now, true, true, false, cfg).open;
369            assert!(open_now);
370
371            // Pointer leaves while holding the button down.
372            open_now = hover_card_update_interaction(cx, open_now, false, true, false, cfg).open;
373            assert!(open_now);
374
375            // Release outside: close is suppressed until the next active -> inactive edge.
376            open_now = hover_card_update_interaction(cx, open_now, false, false, false, cfg).open;
377            assert!(open_now);
378
379            // Re-enter clears suppression.
380            open_now = hover_card_update_interaction(cx, open_now, true, false, false, cfg).open;
381            assert!(open_now);
382
383            // Leave closes immediately (close_delay=0).
384            open_now = hover_card_update_interaction(cx, open_now, false, false, false, cfg).open;
385            assert!(!open_now);
386        });
387    }
388
389    #[test]
390    fn hover_card_default_open_does_not_close_until_active_then_leave() {
391        let window = Default::default();
392        let mut app = App::new();
393
394        fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
395            let cfg = HoverIntentConfig::new(0, 0);
396            let mut open_now = true;
397
398            // `defaultOpen=true` should remain open until at least one active period is observed.
399            open_now = hover_card_update_interaction(cx, open_now, false, false, false, cfg).open;
400            assert!(open_now);
401
402            open_now = hover_card_update_interaction(cx, open_now, true, false, false, cfg).open;
403            assert!(open_now);
404
405            open_now = hover_card_update_interaction(cx, open_now, false, false, false, cfg).open;
406            assert!(!open_now);
407        });
408    }
409
410    #[test]
411    fn hover_card_text_selection_release_clears_without_reenter() {
412        let window = Default::default();
413        let mut app = App::new();
414
415        fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
416            let cfg = HoverIntentConfig::new(0, 0);
417            let mut open_now = true;
418
419            // While selecting text inside content, pointer-down keeps the card open.
420            open_now = hover_card_update_interaction(cx, open_now, true, true, true, cfg).open;
421            assert!(open_now);
422
423            // Leave while still pressed.
424            open_now = hover_card_update_interaction(cx, open_now, false, true, true, cfg).open;
425            assert!(open_now);
426
427            // Release outside while text selection is still active.
428            open_now = hover_card_update_interaction(cx, open_now, false, false, true, cfg).open;
429            assert!(open_now);
430
431            // Clearing selection should allow immediate close (close_delay=0).
432            open_now = hover_card_update_interaction(cx, open_now, false, false, false, cfg).open;
433            assert!(!open_now);
434        });
435    }
436
437    #[test]
438    fn hover_card_text_selection_cleared_after_stale_pointer_down_closes() {
439        let window = Default::default();
440        let mut app = App::new();
441
442        fret_ui::elements::with_element_cx(&mut app, window, Default::default(), "test", |cx| {
443            let cfg = HoverIntentConfig::new(0, 0);
444            let mut open_now = true;
445
446            open_now = hover_card_update_interaction(cx, open_now, true, true, true, cfg).open;
447            assert!(open_now);
448
449            // Pointer leaves while selection is still active.
450            open_now = hover_card_update_interaction(cx, open_now, false, true, true, cfg).open;
451            assert!(open_now);
452
453            // If selection then clears and pointer-down state is reconciled to false in the same
454            // frame, hover card should close (not arm pointer-down close suppression).
455            open_now = hover_card_update_interaction(cx, open_now, false, false, false, cfg).open;
456            assert!(!open_now);
457        });
458    }
459}