Skip to main content

fret_ui_kit/primitives/
combobox.rs

1//! Combobox interaction semantics (Base UI shaped).
2//!
3//! This module is intentionally **outcome/state-machine** oriented:
4//! - open/close reasons mapping
5//! - callback gating helpers ("changed" vs "completed")
6//! - value change gating (emit only on actual changes)
7//! - reason-aware focus restore policies
8
9use std::sync::Arc;
10use std::sync::Mutex;
11
12use crate::prelude::Model;
13use fret_ui::action::{DismissReason, OnActivate, OnCloseAutoFocus, OnDismissRequest};
14use fret_ui::elements::GlobalElementId;
15
16/// Open-change reasons aligned with Base UI combobox semantics.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ComboboxOpenChangeReason {
19    TriggerPress,
20    OutsidePress,
21    ItemPress,
22    EscapeKey,
23    FocusOut,
24    None,
25}
26
27pub fn open_change_reason_from_dismiss_reason(reason: DismissReason) -> ComboboxOpenChangeReason {
28    match reason {
29        DismissReason::Escape => ComboboxOpenChangeReason::EscapeKey,
30        DismissReason::OutsidePress { .. } => ComboboxOpenChangeReason::OutsidePress,
31        DismissReason::FocusOutside => ComboboxOpenChangeReason::FocusOut,
32        DismissReason::Scroll => ComboboxOpenChangeReason::None,
33    }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ComboboxCloseAutoFocusDecision {
38    /// Do nothing and allow the primitive's default behavior.
39    Default,
40    /// Prevent the primitive's default behavior.
41    PreventDefault,
42    /// Restore focus to the combobox trigger (and prevent default).
43    RestoreTrigger,
44}
45
46/// Reason-aware focus-restore policy for combobox-like overlays.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct ComboboxCloseAutoFocusPolicy {
49    pub on_item_press: ComboboxCloseAutoFocusDecision,
50    pub on_escape: ComboboxCloseAutoFocusDecision,
51    pub on_trigger_press: ComboboxCloseAutoFocusDecision,
52    pub on_outside_press: ComboboxCloseAutoFocusDecision,
53    pub on_focus_out: ComboboxCloseAutoFocusDecision,
54    pub on_none: ComboboxCloseAutoFocusDecision,
55}
56
57impl Default for ComboboxCloseAutoFocusPolicy {
58    fn default() -> Self {
59        // shadcn/ui-like expectations:
60        // - commit restores focus to trigger (asserted by diag gates)
61        // - Escape restores focus to trigger
62        // - outside press restores focus to trigger (Radix Popover default; asserted by diag gates)
63        // - focus-out should not steal focus back to trigger
64        Self {
65            on_item_press: ComboboxCloseAutoFocusDecision::RestoreTrigger,
66            on_escape: ComboboxCloseAutoFocusDecision::RestoreTrigger,
67            on_trigger_press: ComboboxCloseAutoFocusDecision::RestoreTrigger,
68            on_outside_press: ComboboxCloseAutoFocusDecision::RestoreTrigger,
69            on_focus_out: ComboboxCloseAutoFocusDecision::PreventDefault,
70            on_none: ComboboxCloseAutoFocusDecision::Default,
71        }
72    }
73}
74
75pub fn close_auto_focus_decision_for_reason(
76    policy: ComboboxCloseAutoFocusPolicy,
77    reason: ComboboxOpenChangeReason,
78) -> ComboboxCloseAutoFocusDecision {
79    match reason {
80        ComboboxOpenChangeReason::ItemPress => policy.on_item_press,
81        ComboboxOpenChangeReason::EscapeKey => policy.on_escape,
82        ComboboxOpenChangeReason::TriggerPress => policy.on_trigger_press,
83        ComboboxOpenChangeReason::OutsidePress => policy.on_outside_press,
84        ComboboxOpenChangeReason::FocusOut => policy.on_focus_out,
85        ComboboxOpenChangeReason::None => policy.on_none,
86    }
87}
88
89/// A small listbox policy helper: clear the query when transitioning from open -> closed.
90#[derive(Debug, Default, Clone, Copy)]
91pub struct ClearQueryOnCloseState {
92    was_open: bool,
93}
94
95pub fn should_clear_query_on_close(state: &mut ClearQueryOnCloseState, open: bool) -> bool {
96    let should_clear = state.was_open && !open;
97    state.was_open = open;
98    should_clear
99}
100
101/// Tracks open-change callbacks so we can emit:
102/// - `changed` immediately on open-state change
103/// - `completed` only once presence has settled and any motion is done
104#[derive(Debug, Default, Clone)]
105pub struct OpenChangeCallbackState {
106    initialized: bool,
107    last_open: bool,
108    pending_complete: Option<bool>,
109}
110
111pub fn open_change_events(
112    state: &mut OpenChangeCallbackState,
113    open: bool,
114    present: bool,
115    animating: bool,
116) -> (Option<bool>, Option<bool>) {
117    let mut changed = None;
118    let mut completed = None;
119
120    if !state.initialized {
121        state.initialized = true;
122        state.last_open = open;
123    } else if state.last_open != open {
124        state.last_open = open;
125        state.pending_complete = Some(open);
126        changed = Some(open);
127    }
128
129    if state.pending_complete == Some(open) && present == open && !animating {
130        state.pending_complete = None;
131        completed = Some(open);
132    }
133
134    (changed, completed)
135}
136
137/// Tracks value changes for `onValueChange` so we don't emit the initial value or repeats.
138#[derive(Debug, Default, Clone)]
139pub struct ValueChangeCallbackState<T> {
140    initialized: bool,
141    last_value: Option<T>,
142}
143
144pub fn value_change_event<T: Clone + PartialEq>(
145    state: &mut ValueChangeCallbackState<T>,
146    value: Option<T>,
147) -> Option<Option<T>> {
148    if !state.initialized {
149        state.initialized = true;
150        state.last_value = value;
151        return None;
152    }
153
154    if state.last_value != value {
155        state.last_value = value.clone();
156        return Some(value);
157    }
158
159    None
160}
161
162pub type OnOpenChange = Arc<dyn Fn(bool) + Send + Sync + 'static>;
163pub type OnOpenChangeWithReason =
164    Arc<dyn Fn(bool, ComboboxOpenChangeReason) + Send + Sync + 'static>;
165
166/// A selection-commit policy for Combobox (Base UI shaped, Fret semantics).
167#[derive(Debug, Clone, Copy)]
168pub struct SelectionCommitPolicy {
169    /// If the user selects the already-selected item again, clear the value (`None`).
170    pub toggle_selected_to_none: bool,
171    /// Close the listbox after committing a selection.
172    pub close_on_commit: bool,
173    /// Clear the query after committing.
174    pub clear_query_on_commit: bool,
175}
176
177impl Default for SelectionCommitPolicy {
178    fn default() -> Self {
179        Self {
180            toggle_selected_to_none: true,
181            close_on_commit: true,
182            clear_query_on_commit: true,
183        }
184    }
185}
186
187pub fn set_open_change_reason_on_activate(
188    open_change_reason: Model<Option<ComboboxOpenChangeReason>>,
189    reason: ComboboxOpenChangeReason,
190) -> OnActivate {
191    #[allow(clippy::arc_with_non_send_sync)]
192    Arc::new(move |host, action_cx, _activate_reason| {
193        let _ = host
194            .models_mut()
195            .update(&open_change_reason, |v| *v = Some(reason));
196        host.request_redraw(action_cx.window);
197    })
198}
199
200pub fn set_open_change_reason_on_dismiss_request(
201    open_change_reason: Model<Option<ComboboxOpenChangeReason>>,
202) -> OnDismissRequest {
203    #[allow(clippy::arc_with_non_send_sync)]
204    Arc::new(move |host, action_cx, req| {
205        let reason = open_change_reason_from_dismiss_reason(req.reason);
206        let _ = host
207            .models_mut()
208            .update(&open_change_reason, |v| *v = Some(reason));
209        host.request_redraw(action_cx.window);
210    })
211}
212
213pub fn commit_selection_on_activate<T: Clone + PartialEq + 'static>(
214    policy: SelectionCommitPolicy,
215    value: Model<Option<T>>,
216    open: Model<bool>,
217    query: Model<String>,
218    open_change_reason: Model<Option<ComboboxOpenChangeReason>>,
219    selected_value: T,
220) -> OnActivate {
221    #[allow(clippy::arc_with_non_send_sync)]
222    Arc::new(move |host, action_cx, _activate_reason| {
223        let _ = host.models_mut().update(&value, |v| {
224            if policy.toggle_selected_to_none
225                && v.as_ref().is_some_and(|cur| cur == &selected_value)
226            {
227                *v = None;
228            } else {
229                *v = Some(selected_value.clone());
230            }
231        });
232        let _ = host.models_mut().update(&open_change_reason, |v| {
233            *v = Some(ComboboxOpenChangeReason::ItemPress);
234        });
235        if policy.close_on_commit {
236            let _ = host.models_mut().update(&open, |v| *v = false);
237        }
238        if policy.clear_query_on_commit {
239            let _ = host.models_mut().update(&query, |v| v.clear());
240        }
241        host.request_redraw(action_cx.window);
242    })
243}
244
245/// Commit policy for multi-select combobox recipes (chips).
246///
247/// This is a small helper for shadcn/Base UI-inspired recipes: selecting an item toggles it in a
248/// `Vec<T>` while leaving open/close and query behavior configurable.
249pub fn commit_multi_selection_on_activate<T: Clone + PartialEq + 'static>(
250    value: Model<Vec<T>>,
251    open: Model<bool>,
252    query: Model<String>,
253    open_change_reason: Model<Option<ComboboxOpenChangeReason>>,
254    selected_value: T,
255    close_on_commit: bool,
256    clear_query_on_commit: bool,
257) -> OnActivate {
258    #[allow(clippy::arc_with_non_send_sync)]
259    Arc::new(move |host, action_cx, _activate_reason| {
260        let _ = host.models_mut().update(&value, |values| {
261            if let Some(idx) = values.iter().position(|v| v == &selected_value) {
262                values.remove(idx);
263            } else {
264                values.push(selected_value.clone());
265            }
266        });
267        let _ = host.models_mut().update(&open_change_reason, |v| {
268            *v = Some(ComboboxOpenChangeReason::ItemPress);
269        });
270        if close_on_commit {
271            let _ = host.models_mut().update(&open, |v| *v = false);
272        }
273        if clear_query_on_commit {
274            let _ = host.models_mut().update(&query, |v| v.clear());
275        }
276        host.request_redraw(action_cx.window);
277    })
278}
279
280pub fn on_close_auto_focus_with_reason(
281    open_change_reason: Model<Option<ComboboxOpenChangeReason>>,
282    trigger_id: Arc<Mutex<Option<GlobalElementId>>>,
283    policy: ComboboxCloseAutoFocusPolicy,
284) -> OnCloseAutoFocus {
285    #[allow(clippy::arc_with_non_send_sync)]
286    Arc::new(move |host, _action_cx, req| {
287        let reason = host
288            .models_mut()
289            .get_copied(&open_change_reason)
290            .unwrap_or(None)
291            .unwrap_or(ComboboxOpenChangeReason::None);
292        // Avoid leaking a stale reason across programmatic open/close.
293        let _ = host.models_mut().update(&open_change_reason, |v| *v = None);
294
295        match close_auto_focus_decision_for_reason(policy, reason) {
296            ComboboxCloseAutoFocusDecision::Default => {}
297            ComboboxCloseAutoFocusDecision::PreventDefault => {
298                req.prevent_default();
299            }
300            ComboboxCloseAutoFocusDecision::RestoreTrigger => {
301                req.prevent_default();
302                let target = *trigger_id.lock().unwrap_or_else(|e| e.into_inner());
303                if let Some(target) = target {
304                    host.request_focus(target);
305                }
306            }
307        }
308    })
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn open_change_events_emit_change_and_complete_after_settle() {
317        let mut state = OpenChangeCallbackState::default();
318
319        let (changed, completed) = open_change_events(&mut state, false, false, false);
320        assert_eq!((changed, completed), (None, None));
321
322        let (changed, completed) = open_change_events(&mut state, true, true, true);
323        assert_eq!((changed, completed), (Some(true), None));
324
325        let (changed, completed) = open_change_events(&mut state, true, true, false);
326        assert_eq!((changed, completed), (None, Some(true)));
327
328        let (changed, completed) = open_change_events(&mut state, false, true, true);
329        assert_eq!((changed, completed), (Some(false), None));
330
331        let (changed, completed) = open_change_events(&mut state, false, false, false);
332        assert_eq!((changed, completed), (None, Some(false)));
333    }
334
335    #[test]
336    fn open_change_events_complete_without_animation() {
337        let mut state = OpenChangeCallbackState::default();
338
339        let _ = open_change_events(&mut state, false, false, false);
340        let (changed, completed) = open_change_events(&mut state, true, true, false);
341        assert_eq!((changed, completed), (Some(true), Some(true)));
342
343        let (changed, completed) = open_change_events(&mut state, false, false, false);
344        assert_eq!((changed, completed), (Some(false), Some(false)));
345    }
346
347    #[test]
348    fn open_change_reason_maps_dismiss_reasons() {
349        assert_eq!(
350            open_change_reason_from_dismiss_reason(DismissReason::Escape),
351            ComboboxOpenChangeReason::EscapeKey
352        );
353        assert_eq!(
354            open_change_reason_from_dismiss_reason(DismissReason::OutsidePress { pointer: None }),
355            ComboboxOpenChangeReason::OutsidePress
356        );
357        assert_eq!(
358            open_change_reason_from_dismiss_reason(DismissReason::FocusOutside),
359            ComboboxOpenChangeReason::FocusOut
360        );
361        assert_eq!(
362            open_change_reason_from_dismiss_reason(DismissReason::Scroll),
363            ComboboxOpenChangeReason::None
364        );
365    }
366
367    #[test]
368    fn value_change_event_emits_only_on_state_change() {
369        let mut state: ValueChangeCallbackState<Arc<str>> = ValueChangeCallbackState::default();
370
371        let changed = value_change_event(&mut state, None);
372        assert_eq!(changed, None);
373
374        let changed = value_change_event(&mut state, Some(Arc::from("beta")));
375        assert_eq!(changed, Some(Some(Arc::from("beta"))));
376
377        let changed = value_change_event(&mut state, Some(Arc::from("beta")));
378        assert_eq!(changed, None);
379
380        let changed = value_change_event(&mut state, Some(Arc::from("alpha")));
381        assert_eq!(changed, Some(Some(Arc::from("alpha"))));
382
383        let changed = value_change_event(&mut state, None);
384        assert_eq!(changed, Some(None));
385    }
386
387    #[test]
388    fn should_clear_query_on_close_emits_only_on_open_to_closed() {
389        let mut state = ClearQueryOnCloseState::default();
390
391        assert_eq!(should_clear_query_on_close(&mut state, false), false);
392        assert_eq!(should_clear_query_on_close(&mut state, true), false);
393        assert_eq!(should_clear_query_on_close(&mut state, true), false);
394        assert_eq!(should_clear_query_on_close(&mut state, false), true);
395        assert_eq!(should_clear_query_on_close(&mut state, false), false);
396    }
397
398    #[test]
399    fn close_auto_focus_decision_maps_reasons() {
400        let policy = ComboboxCloseAutoFocusPolicy {
401            on_item_press: ComboboxCloseAutoFocusDecision::RestoreTrigger,
402            on_escape: ComboboxCloseAutoFocusDecision::RestoreTrigger,
403            on_trigger_press: ComboboxCloseAutoFocusDecision::RestoreTrigger,
404            on_outside_press: ComboboxCloseAutoFocusDecision::PreventDefault,
405            on_focus_out: ComboboxCloseAutoFocusDecision::PreventDefault,
406            on_none: ComboboxCloseAutoFocusDecision::Default,
407        };
408
409        assert_eq!(
410            close_auto_focus_decision_for_reason(policy, ComboboxOpenChangeReason::ItemPress),
411            ComboboxCloseAutoFocusDecision::RestoreTrigger
412        );
413        assert_eq!(
414            close_auto_focus_decision_for_reason(policy, ComboboxOpenChangeReason::EscapeKey),
415            ComboboxCloseAutoFocusDecision::RestoreTrigger
416        );
417        assert_eq!(
418            close_auto_focus_decision_for_reason(policy, ComboboxOpenChangeReason::OutsidePress),
419            ComboboxCloseAutoFocusDecision::PreventDefault
420        );
421        assert_eq!(
422            close_auto_focus_decision_for_reason(policy, ComboboxOpenChangeReason::FocusOut),
423            ComboboxCloseAutoFocusDecision::PreventDefault
424        );
425        assert_eq!(
426            close_auto_focus_decision_for_reason(policy, ComboboxOpenChangeReason::None),
427            ComboboxCloseAutoFocusDecision::Default
428        );
429    }
430}