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
75pub 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 .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 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 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}