Skip to main content

orbital_date_pickers/pickers/date_range_picker/
mod.rs

1//! [`DateRangePicker`] — range field with popover calendar panel.
2
3mod styles;
4
5use leptos::prelude::*;
6use orbital_base_components::OverlayDismiss;
7use orbital_core_components::{
8    Icon, Popover, PopoverPosition, PopoverSize, PopoverTrigger, PopoverTriggerType,
9};
10use orbital_macros::component_doc;
11use orbital_theme::use_theme_options;
12
13use crate::building_blocks::{
14    DateRangeCalendar, DateRangeCalendarAppearance, DateRangeCalendarBind, DateRangeField,
15    DateRangeFieldAppearance, DateRangeFieldBind, DateRangePickerAppearance, DateRangePickerBind,
16};
17use crate::shared::{
18    layout_root_classes, picker_style_sheet, DateRangePickerSlots, OpenTriggerSlot,
19    PickerFieldContext, PickerFieldSlot, PickerFieldSlotHost,
20};
21use styles::date_range_picker_styles;
22
23/// Range field with an anchored dual-month calendar panel, bound to [`DateTimeRange`].
24///
25/// DateRangePicker composes [`DateRangeField`](crate::DateRangeField) and
26/// [`DateRangeCalendar`](crate::DateRangeCalendar) in a click popover. See
27/// See the crate README for when to pick range pickers vs fields.
28///
29/// # When to use
30///
31/// - Flight or hotel booking flows that need a visible calendar span
32/// - Report filters where users pick start and end days in one control
33///
34/// # Usage
35///
36/// 1. Wrap the tree in [`DatetimeLocale`](crate::DatetimeLocale) when timezone or format vary.
37/// 2. Bind `Option<DateTimeRange>` through [`DateRangePickerBind`].
38/// 3. Document the feature with [`DatePickerFeatures::RANGE_PICKERS`] — there is no runtime license check.
39///
40/// # Best Practices
41///
42/// ## Do's
43///
44/// - Wrap in [`Field`](orbital_core_components::Field) with a clear label like "Travel dates".
45///
46/// ## Don'ts
47///
48/// - Do not submit until both endpoints are set — `None` means an incomplete range.
49///
50/// # Examples
51///
52/// ## Flight booking range
53/// Default range field with two-month calendar panel and bind readout.
54/// <!-- preview -->
55/// ```rust
56/// use crate::DateTimeRange;
57/// use orbital_base_components::ToUnixSeconds;
58/// use crate::preview::{PickerPreviewExample, PickerPreviewKnobs};
59/// let value = RwSignal::new(None::<DateTimeRange>);
60/// view! {
61///     <PickerPreviewExample data_testid="date-range-picker-preview">
62///         <PickerPreviewKnobs />
63///         <DateRangePicker bind=value />
64///         <div data-testid="date-range-picker-preview-VALUE">{move || match value.get() {
65///             Some(range) => [range.start.to_unix_seconds().to_string(), range.end.to_unix_seconds().to_string()].join(","),
66///             None => "none".to_string(),
67///         }}</div>
68///     </PickerPreviewExample>
69/// }
70/// ```
71///
72/// ## Bind readout
73/// Completing a calendar range updates the bound value.
74/// <!-- preview -->
75/// ```rust
76/// use crate::DateTimeRange;
77/// use orbital_base_components::ToUnixSeconds;
78/// use crate::preview::PickerPreviewExample;
79/// let value = RwSignal::new(None::<DateTimeRange>);
80/// view! {
81///     <PickerPreviewExample data_testid="DRP-02">
82///         <DateRangePicker bind=value />
83///         <div data-testid="DRP-02-VALUE">{move || match value.get() {
84///             Some(range) => [range.start.to_unix_seconds().to_string(), range.end.to_unix_seconds().to_string()].join(","),
85///             None => "none".to_string(),
86///         }}</div>
87///     </PickerPreviewExample>
88/// }
89/// ```
90#[component_doc(
91    category = "Calendar & Time",
92    preview_slug = "date-range-picker",
93    preview_label = "Date Range Picker",
94    preview_icon = icondata::AiCalendarOutlined,
95)]
96#[component]
97pub fn DateRangePicker(
98    /// Value binding for the range field and calendar.
99    #[prop(optional, into)]
100    bind: DateRangePickerBind,
101    /// Field format, panel count, and popover options.
102    #[prop(optional, into)]
103    appearance: DateRangePickerAppearance,
104    /// Optional CSS class on the layout wrapper.
105    #[prop(optional, into)]
106    class: MaybeProp<String>,
107    /// Optional custom range field slot replacing the default [`DateRangeField`].
108    #[prop(optional)]
109    picker_field_slot: Option<PickerFieldSlot>,
110    /// Optional custom open button slot replacing the calendar icon.
111    #[prop(optional)]
112    open_trigger_slot: Option<OpenTriggerSlot>,
113) -> impl IntoView {
114    let DateRangePickerBind { value, id, name } = bind;
115    let DateRangePickerAppearance {
116        format,
117        timezone,
118        disabled,
119        calendars,
120        close_on_select,
121        placement,
122    } = appearance;
123
124    let theme_options = use_theme_options();
125    let value_stored = StoredValue::new(value);
126
127    let slots = DateRangePickerSlots::from_slot_props(picker_field_slot, open_trigger_slot);
128    let field_children = StoredValue::new(slots.field.map(|slot| slot.children));
129    let open_trigger_children = StoredValue::new(slots.open_trigger.map(|slot| slot.children));
130
131    let field_context = Signal::derive(move || PickerFieldContext {
132        value: Signal::derive(move || value_stored.with_value(|v| v.get())),
133        disabled,
134        format,
135        timezone,
136    });
137    let calendar_appearance = DateRangeCalendarAppearance {
138        timezone,
139        min_date: Signal::from(None),
140        max_date: Signal::from(None),
141        disabled,
142        calendars,
143        day: None,
144    };
145
146    let root_class = move || {
147        let mut parts = vec![layout_root_classes(theme_options.get().density)];
148        if let Some(extra) = class.get() {
149            if !extra.is_empty() {
150                parts.push(extra);
151            }
152        }
153        parts.join(" ")
154    };
155
156    view! {
157        <style>{date_range_picker_styles()}</style>
158        <style>{picker_style_sheet()}</style>
159        <div class=root_class data-orbital-picker="">
160            <Popover
161                trigger_type=PopoverTriggerType::Click
162                position=placement_to_popover_position(placement.get_untracked())
163                size=Signal::from(PopoverSize::Large)
164            >
165                <PopoverTrigger slot>
166                    <div class="orb-picker-range-picker__trigger">
167                        {move || {
168                            if let Some(children) = field_children.get_value() {
169                                let children = children.clone();
170                                view! {
171                                    <PickerFieldSlotHost context=field_context children=children />
172                                }
173                                .into_any()
174                            } else {
175                                view! {
176                                    <DateRangeField
177                                        bind=DateRangeFieldBind {
178                                            value: value_stored.with_value(|v| v.clone()),
179                                            id,
180                                            name,
181                                        }
182                                        appearance=DateRangeFieldAppearance { format, timezone, disabled }
183                                    />
184                                }
185                                .into_any()
186                            }
187                        }}
188                        {move || {
189                            if let Some(children) = open_trigger_children.get_value() {
190                                let children = children.clone();
191                                children().into_any()
192                            } else {
193                                view! {
194                                    <button
195                                        type="button"
196                                        class="orb-picker-range-picker__open-btn"
197                                        aria-label="Open calendar"
198                                        disabled=move || disabled.get()
199                                    >
200                                        <Icon icon=icondata::AiCalendarOutlined />
201                                    </button>
202                                }
203                                .into_any()
204                            }
205                        }}
206                    </div>
207                </PopoverTrigger>
208                <DateRangePickerPanel
209                    value=value_stored
210                    appearance=calendar_appearance
211                    close_on_select=close_on_select
212                />
213            </Popover>
214        </div>
215    }
216}
217
218#[component]
219fn DateRangePickerPanel(
220    value: StoredValue<orbital_base_components::OptionBind<crate::DateTimeRange>>,
221    appearance: DateRangeCalendarAppearance,
222    close_on_select: Signal<bool>,
223) -> impl IntoView {
224    let dismiss = use_context::<OverlayDismiss>();
225    let prev_complete = RwSignal::new(None::<bool>);
226
227    Effect::new(move |_| {
228        if !close_on_select.get() {
229            return;
230        }
231        let complete = value.with_value(|v| v.get()).is_some();
232        if complete && prev_complete.get_untracked() == Some(false) {
233            if let Some(dismiss) = dismiss {
234                dismiss.close.run(());
235            }
236        }
237        prev_complete.set(Some(complete));
238    });
239
240    view! {
241        <div class="orb-picker-range-picker__panel">
242            <DateRangeCalendar
243                bind=DateRangeCalendarBind {
244                    value: value.with_value(|v| v.clone()),
245                }
246                appearance=appearance
247            />
248        </div>
249    }
250}
251
252fn placement_to_popover_position(placement: orbital_base_components::Placement) -> PopoverPosition {
253    use orbital_base_components::Placement;
254    match placement {
255        Placement::Top => PopoverPosition::Top,
256        Placement::Bottom => PopoverPosition::Bottom,
257        Placement::Left => PopoverPosition::Left,
258        Placement::Right => PopoverPosition::Right,
259        Placement::TopStart => PopoverPosition::TopStart,
260        Placement::TopEnd => PopoverPosition::TopEnd,
261        Placement::LeftStart => PopoverPosition::LeftStart,
262        Placement::LeftEnd => PopoverPosition::LeftEnd,
263        Placement::RightStart => PopoverPosition::RightStart,
264        Placement::RightEnd => PopoverPosition::RightEnd,
265        Placement::BottomStart => PopoverPosition::BottomStart,
266        Placement::BottomEnd => PopoverPosition::BottomEnd,
267    }
268}