Skip to main content

ravenrs_widgets/
calendar.rs

1//! Calendar and date picker.
2
3use std::cell::Cell;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::{Binding, State};
9use ravenrs_style::{Semantic, TextStyle, Weight};
10
11use crate::context::{BuildCx, NodeId, Paint, Placement, QuadPaint, Semantics};
12use crate::date::Date;
13use crate::{ButtonSize, IconButton, IconName, View, util};
14
15/// Two days and the days between them, both included.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
18pub struct DateRange {
19    /// The first day.
20    pub start: Date,
21    /// The last day.
22    pub end: Date,
23}
24
25#[cfg_attr(feature = "live", ravenrs_live::expose)]
26impl DateRange {
27    /// The days between these two, in whichever order they come.
28    pub fn new(a: Date, b: Date) -> Self {
29        if a <= b { Self { start: a, end: b } } else { Self { start: b, end: a } }
30    }
31
32    /// Whether this day is in the range.
33    pub fn contains(self, date: Date) -> bool {
34        self.start <= date && date <= self.end
35    }
36
37    /// How many days it covers.
38    pub fn days(self) -> i32 {
39        (self.end.to_days() - self.start.to_days()) as i32 + 1
40    }
41}
42
43/// A month grid to pick a date. Use ‹ › to change month. From the keyboard the
44/// grid is one Tab stop: arrows move by day and week, Home and End to the ends
45/// of the week, Page Up and Page Down by month, and Enter or Space picks.
46///
47/// ```
48/// use ravenrs_reactive::State;
49/// use ravenrs_widgets::{Calendar, Date, Ui};
50///
51/// let today = Date::new(2026, 10, 31).unwrap();
52/// let picked = State::new(None::<Date>);
53/// let calendar = Calendar::new(picked.binding(), today).today(today);
54/// let mut ui = Ui::new(calendar, ravenrs_style::Theme::raven_dark());
55/// ui.update();
56/// ```
57#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
58pub struct Calendar {
59    selected: Binding<Option<Date>>,
60    initial_month: Date,
61    today: Option<Date>,
62    min: Option<Date>,
63    max: Option<Date>,
64    on_pick: Option<Rc<dyn Fn(Date)>>,
65    span: Option<DateRange>,
66}
67
68impl std::fmt::Debug for Calendar {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("Calendar").field("month", &self.initial_month).finish_non_exhaustive()
71    }
72}
73
74#[cfg_attr(feature = "live", ravenrs_live::expose)]
75impl Calendar {
76    /// `month` is the month shown first (any day of it).
77    pub fn new(selected: Binding<Option<Date>>, month: Date) -> Self {
78        Self {
79            selected,
80            initial_month: month.first_of_month(),
81            today: None,
82            min: None,
83            max: None,
84            on_pick: None,
85            span: None,
86        }
87    }
88
89    /// Marks this day with a ring.
90    pub fn today(mut self, today: Date) -> Self {
91        self.today = Some(today);
92        self
93    }
94
95    /// Days outside `min..=max` cannot be picked.
96    pub fn range(mut self, min: Option<Date>, max: Option<Date>) -> Self {
97        self.min = min;
98        self.max = max;
99        self
100    }
101
102    pub(crate) fn on_pick(mut self, f: Rc<dyn Fn(Date)>) -> Self {
103        self.on_pick = Some(f);
104        self
105    }
106
107    /// Shades these days, with both ends as if selected.
108    fn span(mut self, span: Option<DateRange>) -> Self {
109        self.span = span;
110        self
111    }
112}
113
114struct Day<'a> {
115    date: Date,
116    in_month: bool,
117    calendar: &'a Calendar,
118    /// The grid's single Tab stop.
119    active: bool,
120    /// Shared with the other days: the shown month, the keyboard cursor and
121    /// the day that takes focus on its next build.
122    keys: &'a GridKeys,
123}
124
125/// The keyboard over a month grid; an [`EventCalendar`](crate::EventCalendar) shares it.
126#[derive(Clone)]
127pub(crate) struct GridKeys {
128    pub month: Binding<Date>,
129    pub cursor: State<Option<Date>>,
130    pub focus_to: Rc<Cell<Option<Date>>>,
131}
132
133#[cfg_attr(feature = "live", ravenrs_live::expose)]
134impl GridKeys {
135    /// Moves the cursor (and the shown month, if needed) for a navigation key.
136    pub fn handler(&self, from: Date) -> crate::context::KeyHandler {
137        let keys = self.clone();
138        Rc::new(move |key, _| {
139            let to = match key {
140                Key::ArrowLeft => from.add_days(-1),
141                Key::ArrowRight => from.add_days(1),
142                Key::ArrowUp => from.add_days(-7),
143                Key::ArrowDown => from.add_days(7),
144                Key::Home => from.add_days(-i32::from(from.weekday())),
145                Key::End => from.add_days(6 - i32::from(from.weekday())),
146                Key::PageUp => from.add_months(-1),
147                Key::PageDown => from.add_months(1),
148                _ => return false,
149            };
150            if to.first_of_month() != keys.month.get() {
151                keys.month.set(to.first_of_month());
152            }
153            keys.cursor.set(Some(to));
154            keys.focus_to.set(Some(to));
155            true
156        })
157    }
158}
159
160impl View for Day<'_> {
161    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
162        let theme = *cx.theme();
163        let c = self.calendar;
164        let is_end = c.span.is_some_and(|s| s.start == self.date || s.end == self.date);
165        let inside = c.span.is_some_and(|s| s.contains(self.date));
166        let selected = c.selected.get() == Some(self.date) || is_end;
167        let enabled = c.min.is_none_or(|m| self.date >= m) && c.max.is_none_or(|m| self.date <= m);
168        let hovered = (cx.is_hovered() && enabled) || cx.is_focus_visible();
169        let ink = theme.color(Semantic::Ink);
170        let color = if selected {
171            theme.color(Semantic::OnAccent)
172        } else if !enabled {
173            theme.color(Semantic::InkMuted).with_alpha(0.4)
174        } else if self.in_month {
175            ink
176        } else {
177            theme.color(Semantic::InkMuted)
178        };
179        let weight = (selected || c.today == Some(self.date)).then_some(Weight::SEMIBOLD);
180        let label = util::label(cx, &self.date.day().to_string(), TextStyle::Callout, weight, color);
181        let fill = if selected {
182            theme.color(Semantic::Accent)
183        } else if hovered {
184            ink.with_alpha(0.08)
185        } else if inside {
186            theme.color(Semantic::Accent).with_alpha(0.14)
187        } else {
188            Color::TRANSPARENT
189        };
190        let border = (c.today == Some(self.date) && !selected)
191            .then(|| Border { width: 1.0, color: theme.color(Semantic::Accent) });
192        let node = cx.container(
193            LayoutStyle {
194                width: Some(36.0),
195                height: Some(36.0),
196                align: Align::Center,
197                justify: Justify::Center,
198                ..LayoutStyle::stack(Axis::Horizontal)
199            },
200            &[label],
201            Paint::Quad(QuadPaint { corner_radius: 9.0, border, ..QuadPaint::new(fill) }),
202        );
203        // The full date, so the day reads on its own ("31 October 2026").
204        let labels = cx.labels();
205        let name = labels.long_date(self.date);
206        let mut semantics =
207            Semantics::new(Role::Button).name(name).selected(selected || inside).disabled(!enabled);
208        if c.today == Some(self.date) {
209            semantics = semantics.description(labels.today.clone());
210        }
211        cx.semantics(node, semantics);
212        if self.keys.focus_to.get() == Some(self.date) {
213            self.keys.focus_to.set(None);
214            cx.request_focus();
215        }
216        if self.active {
217            cx.focusable(node, 9.0);
218            cx.on_key(node, self.keys.handler(self.date));
219        }
220        if enabled {
221            let (sel, date, pick) = (c.selected.clone(), self.date, c.on_pick.clone());
222            cx.on_click(
223                node,
224                Rc::new(move || {
225                    sel.set(Some(date));
226                    if let Some(p) = &pick {
227                        p(date);
228                    }
229                }),
230            );
231            cx.cursor(node, CursorIcon::Pointer);
232        }
233        node
234    }
235}
236
237impl View for Calendar {
238    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
239        let theme = *cx.theme();
240        let initial = self.selected.get().map_or(self.initial_month, Date::first_of_month);
241        let month: State<Date> = cx.use_state(move || initial);
242        let shown = month.get();
243        let labels = cx.labels();
244        let (prev, next) = (month.clone(), month.clone());
245        let back = cx.child(
246            &IconButton::new(IconName::ChevronLeft)
247                .label(labels.previous_month.clone())
248                .size(ButtonSize::Small)
249                .on_click(move || prev.update(|m| *m = m.add_months(-1))),
250        );
251        let title = util::label(
252            cx,
253            &labels.month_title(shown),
254            TextStyle::Body,
255            Some(Weight::SEMIBOLD),
256            theme.color(Semantic::Ink),
257        );
258        let title = cx.container(
259            LayoutStyle { grow: 1.0, justify: Justify::Center, ..LayoutStyle::stack(Axis::Horizontal) },
260            &[title],
261            Paint::None,
262        );
263        let forward = cx.child(
264            &IconButton::new(IconName::ChevronRight)
265                .label(labels.next_month.clone())
266                .size(ButtonSize::Small)
267                .on_click(move || next.update(|m| *m = m.add_months(1))),
268        );
269        let header = cx.container(
270            LayoutStyle {
271                align: Align::Center,
272                align_self: Some(Align::Stretch),
273                ..LayoutStyle::stack(Axis::Horizontal)
274            },
275            &[back, title, forward],
276            Paint::None,
277        );
278        let mut cells: Vec<NodeId> = labels
279            .weekdays
280            .iter()
281            .map(|d| {
282                let l = util::label(cx, d, TextStyle::Caption, None, theme.color(Semantic::InkMuted));
283                // Each day's name already says the date.
284                cx.semantics(l, Semantics::hidden());
285                cx.container(
286                    LayoutStyle {
287                        width: Some(36.0),
288                        height: Some(28.0),
289                        align: Align::Center,
290                        justify: Justify::Center,
291                        ..LayoutStyle::stack(Axis::Horizontal)
292                    },
293                    &[l],
294                    Paint::None,
295                )
296            })
297            .collect();
298        let keys = GridKeys {
299            month: month.binding(),
300            cursor: cx.use_state(|| None),
301            focus_to: cx.use_state(|| Rc::new(Cell::new(None))).get(),
302        };
303        // The Tab stop: the keyboard cursor, else the selection, else today, in
304        // the shown month; otherwise its first day.
305        let in_shown = |d: &Date| d.first_of_month() == shown;
306        let active = [keys.cursor.get(), self.selected.get(), self.today]
307            .into_iter()
308            .flatten()
309            .find(in_shown)
310            .unwrap_or(shown);
311        let start = shown.add_days(-i32::from(shown.weekday()));
312        for i in 0..42 {
313            let date = start.add_days(i);
314            let in_month = date.month() == shown.month();
315            let day = Day { date, in_month, calendar: self, active: date == active, keys: &keys };
316            cells.push(cx.keyed_child(date.to_days() as u64, &day));
317        }
318        let grid = cx.container(
319            LayoutStyle { grid_columns: Some(7), spacing: 2.0, ..LayoutStyle::stack(Axis::Vertical) },
320            &cells,
321            Paint::None,
322        );
323        cx.semantics(grid, Semantics::new(Role::Calendar).name(shown.month_title()));
324        cx.container(
325            LayoutStyle {
326                spacing: 10.0,
327                width: Some(7.0 * 36.0 + 12.0),
328                align_self: Some(Align::Fit),
329                ..LayoutStyle::stack(Axis::Vertical)
330            },
331            &[header, grid],
332            Paint::None,
333        )
334    }
335}
336
337/// A field that opens a calendar in a popover.
338#[derive(Debug)]
339#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
340pub struct DatePicker {
341    value: Binding<Option<Date>>,
342    month: Date,
343    placeholder: Option<String>,
344    today: Option<Date>,
345    width: f32,
346    fill: bool,
347    label: Option<String>,
348    state: util::EditorState,
349}
350
351#[cfg_attr(feature = "live", ravenrs_live::expose)]
352impl DatePicker {
353    /// `month` is shown when no date is selected yet.
354    pub fn new(value: Binding<Option<Date>>, month: Date) -> Self {
355        Self {
356            value,
357            month,
358            placeholder: None,
359            today: None,
360            width: 220.0,
361            fill: false,
362            label: None,
363            state: util::EditorState::default(),
364        }
365    }
366
367    /// Text shown while no date is selected ("Pick a date" by default).
368    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
369        self.placeholder = Some(text.into());
370        self
371    }
372
373    /// Marks this day with a ring in the calendar.
374    pub fn today(mut self, today: Date) -> Self {
375        self.today = Some(today);
376        self
377    }
378
379    /// Takes the width of its parent, as other fields of a form do.
380    pub fn fill(mut self) -> Self {
381        self.fill = true;
382        self
383    }
384
385    /// Field width in logical pixels (default 220).
386    pub fn width(mut self, width: f32) -> Self {
387        self.width = width;
388        self
389    }
390
391    /// Turns the control off: it fades, leaves the tab order and ignores input.
392    pub fn disabled(mut self, disabled: bool) -> Self {
393        self.state.disabled = disabled;
394        self
395    }
396
397    /// Shows the value without letting it change; it can still be focused and read.
398    pub fn read_only(mut self, read_only: bool) -> Self {
399        self.state.read_only = read_only;
400        self
401    }
402}
403
404struct CalendarPanel<'a>(&'a DatePicker, State<bool>);
405
406impl View for CalendarPanel<'_> {
407    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
408        let theme = *cx.theme();
409        let close = self.1.clone();
410        let mut cal =
411            Calendar::new(self.0.value.clone(), self.0.month).on_pick(Rc::new(move |_| close.set(false)));
412        if let Some(t) = self.0.today {
413            cal = cal.today(t);
414        }
415        let child = cx.child(&cal);
416        let node = cx.container(
417            LayoutStyle { padding: Insets::uniform(12.0), ..LayoutStyle::stack(Axis::Vertical) },
418            &[child],
419            Paint::Quad(QuadPaint {
420                corner_radius: theme.radii.lg,
421                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
422                shadow: Some(theme.elevation.raised),
423                ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
424            }),
425        );
426        cx.cursor(node, CursorIcon::Default);
427        node
428    }
429}
430
431#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
432impl DatePicker {
433    /// The name screen readers announce, such as "Due date". Inside a
434    /// [`crate::FormField`], its label is used instead.
435    pub fn label(mut self, label: impl Into<String>) -> Self {
436        self.label = Some(label.into());
437        self
438    }
439}
440
441impl View for DatePicker {
442    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
443        let theme = *cx.theme();
444        let open: State<bool> = cx.use_state(|| false);
445        let state = self.state;
446        let is_open = open.get() && state.accepts_input();
447        let hovered = cx.is_hovered() && state.accepts_input();
448        let icon = util::icon(cx, IconName::Calendar, 16.0, state.ink(theme.color(Semantic::InkMuted)));
449        let labels = cx.labels();
450        let (text, color) = match self.value.get() {
451            Some(d) => (labels.short_date(d), theme.color(Semantic::Ink)),
452            None => {
453                let hint = self.placeholder.clone().unwrap_or_else(|| labels.pick_date.clone());
454                (hint, theme.color(Semantic::InkMuted))
455            }
456        };
457        let label = util::label(cx, &text, TextStyle::Body, None, state.ink(color));
458        let border = if is_open {
459            Border { width: 1.5, color: theme.color(Semantic::Accent) }
460        } else {
461            let base = theme.color(Semantic::Border);
462            Border {
463                width: 1.0,
464                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
465            }
466        };
467        let node = cx.container(
468            LayoutStyle {
469                width: (!self.fill).then_some(self.width),
470                height: Some(40.0),
471                padding: Insets::symmetric(0.0, 12.0),
472                spacing: 10.0,
473                align: Align::Center,
474                align_self: Some(if self.fill { Align::Stretch } else { Align::Fit }),
475                ..LayoutStyle::stack(Axis::Horizontal)
476            },
477            &[icon, label],
478            Paint::Quad(QuadPaint {
479                corner_radius: theme.radii.md,
480                border: Some(border),
481                ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
482            }),
483        );
484        cx.semantics(label, Semantics::hidden());
485        let value = self.value.get().map(|d| labels.long_date(d)).unwrap_or_default();
486        let mut semantics = Semantics::new(Role::ComboBox).value(value).expanded(is_open);
487        if let Some(label) = &self.label {
488            semantics = semantics.name(label.clone());
489        }
490        cx.semantics(node, state.semantics(semantics));
491        if state.disabled {
492            return node;
493        }
494        if !state.accepts_input() {
495            cx.focusable(node, theme.radii.md);
496            return node;
497        }
498        let toggle = open.clone();
499        cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
500        cx.focusable(node, theme.radii.md);
501        cx.cursor(node, CursorIcon::Pointer);
502        if is_open {
503            let close = open.clone();
504            cx.overlay(
505                node,
506                Placement::Below,
507                &CalendarPanel(self, open.clone()),
508                Some(Rc::new(move || close.set(false))),
509            );
510        }
511        node
512    }
513}
514
515/// A field that opens two months to pick where a period starts and ends, with
516/// ready-made periods on the side. The first click starts the period, the second ends it.
517///
518/// ```
519/// use ravenrs_reactive::State;
520/// use ravenrs_widgets::{Date, DateRange, DateRangePicker, Ui};
521///
522/// let today = Date::new(2026, 11, 2).unwrap();
523/// let period = State::new(None::<DateRange>);
524/// let picker = DateRangePicker::new(period.binding(), today)
525///     .preset("Last 7 days", DateRange::new(today.add_days(-6), today));
526/// let mut ui = Ui::new(picker, ravenrs_style::Theme::raven_dark());
527/// ui.update();
528/// ```
529#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
530pub struct DateRangePicker {
531    value: Binding<Option<DateRange>>,
532    month: Date,
533    today: Option<Date>,
534    min: Option<Date>,
535    max: Option<Date>,
536    presets: Vec<(String, DateRange)>,
537    placeholder: Option<String>,
538    width: f32,
539    label: Option<String>,
540    state: util::EditorState,
541}
542
543impl std::fmt::Debug for DateRangePicker {
544    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545        f.debug_struct("DateRangePicker").field("month", &self.month).finish_non_exhaustive()
546    }
547}
548
549#[cfg_attr(feature = "live", ravenrs_live::expose)]
550impl DateRangePicker {
551    /// `month` is shown when no period is chosen yet.
552    pub fn new(value: Binding<Option<DateRange>>, month: Date) -> Self {
553        Self {
554            value,
555            month,
556            today: None,
557            min: None,
558            max: None,
559            presets: Vec::new(),
560            placeholder: None,
561            width: 340.0,
562            label: None,
563            state: util::EditorState::default(),
564        }
565    }
566
567    /// A ready-made period, picked with one click.
568    pub fn preset(mut self, name: impl Into<String>, range: DateRange) -> Self {
569        self.presets.push((name.into(), range));
570        self
571    }
572
573    /// Marks this day with a ring in the calendars.
574    pub fn today(mut self, today: Date) -> Self {
575        self.today = Some(today);
576        self
577    }
578
579    /// Days outside `min..=max` cannot be picked.
580    pub fn range(mut self, min: Option<Date>, max: Option<Date>) -> Self {
581        self.min = min;
582        self.max = max;
583        self
584    }
585
586    /// Text shown while no period is chosen.
587    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
588        self.placeholder = Some(text.into());
589        self
590    }
591
592    /// Field width in logical pixels (default 340).
593    pub fn width(mut self, width: f32) -> Self {
594        self.width = width;
595        self
596    }
597
598    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
599    pub fn label(mut self, label: impl Into<String>) -> Self {
600        self.label = Some(label.into());
601        self
602    }
603
604    /// Turns the control off: it fades, leaves the tab order and ignores input.
605    pub fn disabled(mut self, disabled: bool) -> Self {
606        self.state.disabled = disabled;
607        self
608    }
609
610    /// Shows the value without letting it change; it can still be focused and read.
611    pub fn read_only(mut self, read_only: bool) -> Self {
612        self.state.read_only = read_only;
613        self
614    }
615}
616
617struct RangePanel<'a>(&'a DateRangePicker, State<bool>);
618
619impl View for RangePanel<'_> {
620    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
621        let theme = *cx.theme();
622        let picker = self.0;
623        let started: State<Option<Date>> = cx.use_state(|| None);
624        let pending = started.get();
625        let chosen = picker.value.get();
626        let span = match pending {
627            Some(day) => Some(DateRange::new(day, day)),
628            None => chosen,
629        };
630        let first = chosen.map_or(picker.month, |r| r.start).first_of_month();
631        let pick: Rc<dyn Fn(Date)> = {
632            let (started, value, close) = (started.clone(), picker.value.clone(), self.1.clone());
633            Rc::new(move |day| match started.get() {
634                None => started.set(Some(day)),
635                Some(from) => {
636                    started.set(None);
637                    value.set(Some(DateRange::new(from, day)));
638                    close.set(false);
639                }
640            })
641        };
642        // A calendar's selection is the period's, which its own binding must not change.
643        let inert = || Binding::new(|| None, |_: Option<Date>| {});
644        let calendar = |month: Date| {
645            let mut calendar =
646                Calendar::new(inert(), month).range(picker.min, picker.max).on_pick(pick.clone()).span(span);
647            if let Some(today) = picker.today {
648                calendar = calendar.today(today);
649            }
650            calendar
651        };
652        let mut parts = Vec::new();
653        if !picker.presets.is_empty() {
654            let mut rows = Vec::with_capacity(picker.presets.len());
655            for (i, (name, range)) in picker.presets.iter().enumerate() {
656                let (value, close, range) = (picker.value.clone(), self.1.clone(), *range);
657                let button = crate::Button::new(name.clone())
658                    .style(crate::ButtonStyle::Ghost)
659                    .size(ButtonSize::Small)
660                    .on_click(move || {
661                        value.set(Some(range));
662                        close.set(false);
663                    });
664                rows.push(cx.keyed_child(i as u64, &button));
665            }
666            parts.push(cx.container(
667                LayoutStyle { spacing: 2.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
668                &rows,
669                Paint::None,
670            ));
671        }
672        parts.push(cx.keyed_child(1 << 32, &calendar(first)));
673        // The second month needs room, which a phone does not have.
674        if cx.viewport().width >= 720.0 {
675            parts.push(cx.keyed_child(1 << 33, &calendar(first.add_months(1))));
676        }
677        let node = cx.container(
678            LayoutStyle {
679                padding: Insets::uniform(12.0),
680                spacing: 16.0,
681                ..LayoutStyle::stack(Axis::Horizontal)
682            },
683            &parts,
684            Paint::Quad(QuadPaint {
685                corner_radius: theme.radii.lg,
686                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
687                shadow: Some(theme.elevation.raised),
688                ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
689            }),
690        );
691        cx.cursor(node, CursorIcon::Default);
692        node
693    }
694}
695
696impl View for DateRangePicker {
697    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
698        let theme = *cx.theme();
699        let open: State<bool> = cx.use_state(|| false);
700        let state = self.state;
701        let is_open = open.get() && state.accepts_input();
702        let hovered = cx.is_hovered() && state.accepts_input();
703        let icon = util::icon(cx, IconName::Calendar, 16.0, state.ink(theme.color(Semantic::InkMuted)));
704        let labels = cx.labels();
705        let chosen = self.value.get();
706        let (text, color) = match chosen {
707            Some(r) => (
708                format!("{} – {}", labels.short_date(r.start), labels.short_date(r.end)),
709                theme.color(Semantic::Ink),
710            ),
711            None => (
712                self.placeholder.clone().unwrap_or_else(|| labels.pick_date_range.clone()),
713                theme.color(Semantic::InkMuted),
714            ),
715        };
716        let label = cx.text_truncated(
717            &text,
718            &util::attrs(&theme, TextStyle::Body, None),
719            state.ink(color),
720            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
721        );
722        cx.semantics(label, Semantics::hidden());
723        let mut children = vec![icon, label];
724        if chosen.is_some() && state.accepts_input() {
725            let value = self.value.clone();
726            children.push(
727                cx.child(
728                    &IconButton::new(IconName::X)
729                        .label(labels.clear.clone())
730                        .size(ButtonSize::Small)
731                        .on_click(move || value.set(None)),
732                ),
733            );
734        }
735        let border = if is_open {
736            Border { width: 1.5, color: theme.color(Semantic::Accent) }
737        } else {
738            let base = theme.color(Semantic::Border);
739            Border {
740                width: 1.0,
741                color: state.ink(if hovered { base.lerp(theme.color(Semantic::Ink), 0.25) } else { base }),
742            }
743        };
744        let node = cx.container(
745            LayoutStyle {
746                width: Some(self.width),
747                height: Some(40.0),
748                padding: Insets::symmetric(0.0, 12.0),
749                spacing: 10.0,
750                align: Align::Center,
751                align_self: Some(Align::Fit),
752                ..LayoutStyle::stack(Axis::Horizontal)
753            },
754            &children,
755            Paint::Quad(QuadPaint {
756                corner_radius: theme.radii.md,
757                border: Some(border),
758                ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
759            }),
760        );
761        let mut semantics = Semantics::new(Role::ComboBox).value(text).expanded(is_open);
762        if let Some(label) = &self.label {
763            semantics = semantics.name(label.clone());
764        }
765        cx.semantics(node, state.semantics(semantics));
766        if state.disabled {
767            return node;
768        }
769        cx.focusable(node, theme.radii.md);
770        if !state.accepts_input() {
771            return node;
772        }
773        let toggle = open.clone();
774        cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
775        let opener = open.clone();
776        cx.on_key(
777            node,
778            Rc::new(move |key, _| {
779                let opens = matches!(key, Key::ArrowDown | Key::ArrowUp);
780                if opens {
781                    opener.set(true);
782                }
783                opens
784            }),
785        );
786        cx.cursor(node, CursorIcon::Pointer);
787        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
788        if is_open {
789            was_open.set(true);
790            let close = open.clone();
791            cx.overlay(
792                node,
793                Placement::Below,
794                &RangePanel(self, open.clone()),
795                Some(Rc::new(move || close.set(false))),
796            );
797        } else if was_open.replace(false) {
798            cx.request_focus();
799        }
800        node
801    }
802}