Skip to main content

gpui_macos/
date_picker.rs

1use crate::{ActiveTheme, ButtonVariant, IconName, button, calendar, icon};
2use gpui_kit::base::{CalendarEvent, CalendarState, DatePicker, Popup};
3use gpui_kit::rems;
4use gpui_kit::{
5    App, Context, ElementId, Entity, FocusHandle, MouseButton, Window, div, prelude::*,
6};
7
8pub struct DatePickerState {
9    pub calendar: Entity<CalendarState>,
10    focus: FocusHandle,
11    open: bool,
12}
13
14impl DatePickerState {
15    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
16        let calendar = cx.new(|cx| CalendarState::new(window, cx));
17        cx.subscribe_in(
18            &calendar,
19            window,
20            |this, _, event: &CalendarEvent, window, cx| {
21                let CalendarEvent::Selected(date) = event;
22                if date.is_complete() {
23                    this.set_open(false, window, cx);
24                }
25            },
26        )
27        .detach();
28        cx.observe(&calendar, |_, _, cx| cx.notify()).detach();
29        Self {
30            calendar,
31            focus: cx.focus_handle(),
32            open: false,
33        }
34    }
35
36    pub fn is_open(&self) -> bool {
37        self.open
38    }
39
40    fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context<Self>) {
41        self.open = open;
42        if open {
43            self.calendar
44                .read(cx)
45                .focus_handle
46                .clone()
47                .focus(window, cx);
48        } else {
49            self.focus.focus(window, cx);
50        }
51        cx.notify();
52    }
53}
54
55pub(crate) fn init(cx: &mut App) {
56    cx.bind_keys([
57        gpui_kit::KeyBinding::new(
58            "enter",
59            gpui_kit::base::actions::Confirm { secondary: false },
60            Some("macOSDatePicker"),
61        ),
62        gpui_kit::KeyBinding::new(
63            "space",
64            gpui_kit::base::actions::Confirm { secondary: false },
65            Some("macOSDatePicker"),
66        ),
67        gpui_kit::KeyBinding::new(
68            "escape",
69            gpui_kit::base::actions::Cancel,
70            Some("macOSDatePicker"),
71        ),
72    ]);
73}
74
75/// Controlled base date picker with a styled calendar popup and focus return.
76pub fn date_picker(
77    id: impl Into<ElementId>,
78    state: &Entity<DatePickerState>,
79    cx: &App,
80) -> DatePicker {
81    let id = id.into();
82    let current = state.read(cx);
83    let open = current.open;
84    let focus = current.focus.clone();
85    let label = current
86        .calendar
87        .read(cx)
88        .date()
89        .format("%b %e, %Y")
90        .unwrap_or_else(|| "Choose a date".into());
91    let trigger = button("date-trigger", "", ButtonVariant::Outline, cx)
92        // The outer picker owns focus; registering this handle on the trigger
93        // as well reports two focused accessibility nodes in the same frame.
94        .focusable(false)
95        .accessibility_label("Choose a date")
96        .debug_selector(|| "date-picker-trigger".into())
97        .w_full()
98        .justify_start()
99        .child(div().flex_1().child(label))
100        .child(icon(IconName::Calendar).size(rems(0.875)))
101        .on_click(move |_, window, cx| {
102            // Route pointer activation through the base root so builder refinements
103            // such as `.disabled(true)` govern both keyboard and mouse behavior.
104            if open {
105                window.dispatch_action(Box::new(gpui_kit::base::actions::Cancel), cx);
106            } else {
107                window.dispatch_action(
108                    Box::new(gpui_kit::base::actions::Confirm { secondary: false }),
109                    cx,
110                );
111            }
112        });
113    let mut popup = Popup::new((id.clone(), "popup"), trigger);
114    if open {
115        let target = state.clone();
116        let close = state.clone();
117        popup = popup
118            .content_factory(move |window, cx| {
119                let current = target.read(cx);
120                let calendar_state = current.calendar.clone();
121                let dismiss = target.clone();
122                div()
123                    .id("date-calendar-popup")
124                    .key_context("macOSPopoverContent")
125                    .occlude()
126                    .mt(rems(0.25))
127                    .on_mouse_down_out(move |_, window, cx| {
128                        dismiss.update(cx, |state, cx| state.set_open(false, window, cx))
129                    })
130                    .on_action({
131                        let dismiss = target.clone();
132                        move |_: &gpui_kit::base::actions::Cancel, window, cx| {
133                            cx.stop_propagation();
134                            dismiss.update(cx, |state, cx| state.set_open(false, window, cx));
135                        }
136                    })
137                    .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
138                    .child(calendar("date-calendar", &calendar_state, cx).when(
139                        crate::native::is_glass_popup(window),
140                        |calendar| {
141                            calendar
142                                .bg(gpui_kit::transparent_black())
143                                .border_0()
144                                .shadow_none()
145                        },
146                    ))
147            })
148            .on_dismiss(move |window, cx| {
149                close.update(cx, |state, cx| {
150                    state.set_open(false, window, cx);
151                    state.focus.focus(window, cx);
152                });
153            });
154    }
155    let target = state.clone();
156    DatePicker::new(id, &focus)
157        .open(open)
158        .w(rems(15.5))
159        .key_context("macOSDatePicker")
160        .rounded(rems(0.375))
161        .focus_visible(|s| s.shadow(cx.macos().focus_shadow()))
162        .on_open_change(move |open, window, cx| {
163            target.update(cx, |state, cx| state.set_open(open, window, cx))
164        })
165        .child(popup)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use gpui_kit::px;
172    use gpui_kit::{Render, TestAppContext};
173
174    struct Harness {
175        state: Entity<DatePickerState>,
176        disabled: bool,
177    }
178    impl Render for Harness {
179        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
180            crate::focus_scope("date-picker-test")
181                .size_full()
182                .child(date_picker("date", &self.state, cx).disabled(self.disabled))
183        }
184    }
185
186    #[gpui_kit::test]
187    fn disabled_picker_rejects_pointer_and_keyboard(cx: &mut TestAppContext) {
188        cx.update(crate::init);
189        let (view, cx) = cx.add_window_view(|window, cx| Harness {
190            state: cx.new(|cx| DatePickerState::new(window, cx)),
191            disabled: true,
192        });
193        cx.update(|window, cx| window.draw(cx).clear(cx));
194        let trigger = cx.debug_bounds("date-picker-trigger").unwrap().center();
195        cx.simulate_click(trigger, Default::default());
196        cx.update(|window, cx| {
197            window.draw(cx).clear(cx);
198            assert!(!view.read(cx).state.read(cx).is_open());
199        });
200        cx.simulate_keystrokes("enter space");
201        cx.update(|_, cx| assert!(!view.read(cx).state.read(cx).is_open()));
202        view.update(cx, |this, cx| {
203            this.disabled = false;
204            cx.notify();
205        });
206        cx.update(|window, cx| window.draw(cx).clear(cx));
207        cx.simulate_click(trigger, Default::default());
208        cx.update(|window, cx| {
209            window.draw(cx).clear(cx);
210            assert!(view.read(cx).state.read(cx).is_open());
211        });
212        cx.simulate_keystrokes("escape");
213        cx.update(|_, cx| assert!(!view.read(cx).state.read(cx).is_open()));
214    }
215    #[gpui_kit::test]
216    fn pointer_selection_reopen_and_outside_dismissal(cx: &mut TestAppContext) {
217        cx.update(crate::init);
218        let (view, cx) = cx.add_window_view(|window, cx| {
219            let state = cx.new(|cx| DatePickerState::new(window, cx));
220            cx.observe(&state, |_, _, cx| cx.notify()).detach();
221            Harness {
222                state,
223                disabled: false,
224            }
225        });
226        for _ in 0..3 {
227            cx.update(|window, cx| window.draw(cx).clear(cx));
228            let trigger = cx.debug_bounds("date-picker-trigger").unwrap().center();
229            cx.simulate_click(trigger, Default::default());
230            cx.update(|window, cx| window.draw(cx).clear(cx));
231            let next = cx.debug_bounds("calendar-next").unwrap().center();
232            cx.simulate_click(next, Default::default());
233            cx.update(|window, cx| window.draw(cx).clear(cx));
234            // Header center + header half-height + section gap + weekday row
235            // + one complete week + day half-height: select the second Saturday.
236            let day = next + gpui_kit::point(px(0.), px(14. + 8. + 28. + 28. + 14.));
237            cx.simulate_click(day, Default::default());
238            cx.update(|window, cx| {
239                window.draw(cx).clear(cx);
240                let state = view.read(cx).state.read(cx);
241                assert!(!state.is_open(), "selecting a day closes the popup");
242                assert!(state.calendar.read(cx).date().is_some());
243                assert!(state.focus.is_focused(window));
244            });
245            cx.simulate_click(trigger, Default::default());
246            cx.update(|window, cx| window.draw(cx).clear(cx));
247            cx.simulate_click(gpui_kit::point(px(500.), px(400.)), Default::default());
248            cx.update(|window, cx| {
249                window.draw(cx).clear(cx);
250                assert!(!view.read(cx).state.read(cx).is_open());
251            });
252        }
253    }
254}