orbital-core-components 0.1.1

Themed Orbital UI components for Leptos applications
Documentation
use leptos::prelude::*;
use orbital_base_components::{
    format_datetime, parse_datetime, BaseDatePicker, DatePickerRuleTrigger, FieldInjection,
    Handler, InputType, MonthButtonRenderProps, MonthButtonRenderer, OrbitalDateTime, Rule,
};
use orbital_macros::component_doc;
use orbital_style::inject_style;

use super::styles::date_picker_styles;
use super::types::{DatePickerAppearance, DatePickerBind};
use crate::forms::calendar::CalendarMonthButtonProps;
use crate::forms::datetime_bridge::use_unix_bridge;
use crate::{Icon, Input, InputAppearance, InputBind, InputEvents, InputSuffix};

/// Labeled date field with an anchored calendar panel, bound to [`OrbitalDateTime`].
///
/// DatePicker is a labeled-ready date field: users type a locale-formatted date or open the calendar panel to pick a day. The bound value is [`OrbitalDateTime`] at start-of-day in your chosen [`DatetimeTimezone`](orbital_base_components::DatetimeTimezone). Convert at API boundaries via `ToUnixSeconds` or `ToIso8601`. Display format is controlled via [`DatetimeFormat`](orbital_base_components::DatetimeFormat). Pair with [`TimePicker`](crate::TimePicker) when you need time-of-day. For an inline month grid without a text field, use [`Calendar`](crate::Calendar) instead.
///
/// # When to use
///
/// - Single-date form fields (start date, birth date, due date)
/// - Filter bars where a calendar affordance beats free-text parsing alone
/// - Flows that need locale-specific display format while storing a canonical timestamp
///
/// # DatePicker vs Calendar
///
/// | Need | Component |
/// |------|-----------|
/// | Text field + anchored calendar panel | `DatePicker` |
/// | Inline month grid on the page | `Calendar` |
///
/// # Usage
///
/// 1. Create an `Option<OrbitalDateTime>` signal and pass it via [`DatePickerBind`].
/// 2. Set `appearance.format` and `appearance.timezone` when display differs from defaults.
/// 3. Wrap in [`Field`](crate::Field) with `DatePickerRule::required` when a date is mandatory.
/// 4. Wrap preview examples in a native element with `data-testid` for E2E selectors.
///
/// # Lifecycle
///
/// - **Open:** the calendar panel opens when the trigger is clicked; it closes after a day or shortcut is selected.
/// - **Value (panel):** selecting a day or shortcut commits immediately and closes the panel.
/// - **Value (typed):** the text input commits on blur after parsing.
/// - **Validation:** `Blur` runs after typed input; `Change` runs after panel or shortcut selection.
///
/// # Timezone
///
/// `appearance.timezone` controls how typed input is parsed and how the panel maps days to
/// [`OrbitalDateTime`] start-of-day. The bound value retains its own `OrbitalDateTime::timezone()`.
///
/// # Best Practices
///
/// ## Do's
///
/// * Bind `value` with [`DatePickerBind`] for two-way sync
/// * Use [`Field`](crate::Field) + `DatePickerRule::required` when a date is mandatory
/// * Pick `DatetimeFormat` and `DatetimeTimezone` to match user locale expectations
///
/// ## Don'ts
///
/// * Do not use for time-only selection — prefer [`TimePicker`](crate::TimePicker)
/// * Do not use for inline grids without a text field — prefer [`Calendar`](crate::Calendar)
/// * Do not put `data-testid` on the component — wrap with a native element
///
/// # Examples
///
/// ## Default
/// Empty date field with calendar suffix button.
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::OrbitalDateTime;
/// let value = RwSignal::new(None::<OrbitalDateTime>);
/// view! { <div data-testid="date-picker-preview"><DatePicker bind=value /></div> }
/// ```
///
/// ## Preselected unix value
/// Existing unix seconds populate the text input on mount.
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::{DatetimeTimezone, OrbitalDateTime, TryFromUnixSeconds};
/// let value = RwSignal::new(Some(
///     OrbitalDateTime::try_from_unix_seconds(1735689600_i64, DatetimeTimezone::Utc)
///         .expect("valid date"),
/// ));
/// view! { <div data-testid="DP-02"><DatePicker bind=value /></div> }
/// ```
///
/// ## Bind readout
/// Panel selection updates the bound unix signal and readout text.
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::{OrbitalDateTime, ToUnixSeconds};
/// let value = RwSignal::new(None::<OrbitalDateTime>);
/// view! {
///     <div data-testid="DP-03">
///         <DatePicker bind=value />
///         <div data-testid="DP-03-VALUE">{move || value.get().map(|v| v.to_unix_seconds().to_string()).unwrap_or_else(|| "none".to_string())}</div>
///     </div>
/// }
/// ```
///
/// ## ISO format
/// Year-month-day display for locales that prefer ISO ordering.
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::{DatetimeFormat, DatetimeTimezone, OrbitalDateTime, TryFromUnixSeconds};
/// let value = RwSignal::new(Some(
///     OrbitalDateTime::try_from_unix_seconds(1735689600_i64, DatetimeTimezone::Utc)
///         .expect("valid date"),
/// ));
/// view! {
///     <div data-testid="DP-04">
///         <DatePicker bind=value appearance=DatePickerAppearance { format: Signal::from(DatetimeFormat::IsoDate), ..Default::default() } />
///     </div>
/// }
/// ```
///
/// ## UTC timezone
/// Formatting uses UTC regardless of browser local offset.
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::{DatetimeTimezone, OrbitalDateTime, TryFromUnixSeconds};
/// let value = RwSignal::new(Some(
///     OrbitalDateTime::try_from_unix_seconds(1735689600_i64, DatetimeTimezone::Utc)
///         .expect("valid date"),
/// ));
/// view! {
///     <div data-testid="DP-05">
///         <DatePicker bind=value appearance=DatePickerAppearance { timezone: Signal::from(DatetimeTimezone::Utc), ..Default::default() } />
///     </div>
/// }
/// ```
///
/// ## Fixed offset timezone
/// Display anchored to a fixed offset from UTC (e.g. EST).
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::{DatetimeTimezone, OrbitalDateTime, TryFromUnixSeconds};
/// let value = RwSignal::new(Some(
///     OrbitalDateTime::try_from_unix_seconds(1735689600_i64, DatetimeTimezone::Utc)
///         .expect("valid date"),
/// ));
/// view! {
///     <div data-testid="DP-06">
///         <DatePicker bind=value appearance=DatePickerAppearance { timezone: Signal::from(DatetimeTimezone::FixedOffset(-5 * 3600)), ..Default::default() } />
///     </div>
/// }
/// ```
///
/// ## Required in Field
/// Required rule shows validation messaging on blur when empty.
/// <!-- preview -->
/// ```rust
/// use crate::Field;
/// use orbital_base_components::DatePickerRule;
/// let value = RwSignal::new(None::<OrbitalDateTime>);
/// let required = Signal::from(true);
/// view! {
///     <div data-testid="DP-07">
///         <Field label="Start date" name="start_date" required=true>
///             <DatePicker bind=DatePickerBind { value: value.into(), rules: vec![DatePickerRule::required(required)], ..Default::default() } />
///         </Field>
///     </div>
/// }
/// ```
///
/// ## Disabled
/// Input and calendar button cannot be interacted with.
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::{DatetimeTimezone, OrbitalDateTime, TryFromUnixSeconds};
/// let value = RwSignal::new(Some(
///     OrbitalDateTime::try_from_unix_seconds(1735689600_i64, DatetimeTimezone::Utc)
///         .expect("valid date"),
/// ));
/// view! {
///     <div data-testid="DP-08">
///         <DatePicker bind=value appearance=DatePickerAppearance { disabled: Signal::from(true), ..Default::default() } />
///     </div>
/// }
/// ```
///
/// ## Format and timezone readout
/// Default US format with UTC timezone reflected in the bound readout.
/// <!-- preview -->
/// ```rust
/// use orbital_base_components::{DatetimeFormat, DatetimeTimezone, format_datetime, ToUnixSeconds, TryFromUnixSeconds};
/// let value = RwSignal::new(Some(
///     OrbitalDateTime::try_from_unix_seconds(1735689600_i64, DatetimeTimezone::Utc)
///         .expect("valid date"),
/// ));
/// view! {
///     <div data-testid="DP-09">
///         <DatePicker bind=value appearance=DatePickerAppearance { timezone: Signal::from(DatetimeTimezone::Utc), ..Default::default() } />
///         <div data-testid="DP-09-TEXT">{move || value.get().map(|v| format_datetime(v, DatetimeFormat::default())).unwrap_or_else(|| "none".to_string())}</div>
///     </div>
/// }
/// ```
///
/// ## Min/max validation
/// Field shows an error when the typed date falls outside the allowed range.
/// <!-- preview -->
/// ```rust
/// use crate::Field;
/// use orbital_base_components::{DatePickerRule, DatetimeTimezone, OrbitalDateTime, TryFromUnixSeconds};
/// let value = RwSignal::new(None::<OrbitalDateTime>);
/// let min = Signal::from(Some(
///     OrbitalDateTime::try_from_unix_seconds(1735689600_i64, DatetimeTimezone::Utc)
///         .expect("valid min"),
/// ));
/// let max = Signal::from(Some(
///     OrbitalDateTime::try_from_unix_seconds(1767225600_i64, DatetimeTimezone::Utc)
///         .expect("valid max"),
/// ));
/// view! {
///     <div data-testid="DP-10">
///         <Field label="Event date" name="event_date">
///             <DatePicker
///                 bind=DatePickerBind { value: value.into(), rules: vec![DatePickerRule::min_date(min), DatePickerRule::max_date(max)], ..Default::default() }
///                 appearance=DatePickerAppearance { min_date: min, max_date: max, ..Default::default() }
///             />
///         </Field>
///     </div>
/// }
/// ```
#[component_doc(
    category = "Calendar & Time",
    preview_slug = "date-picker",
    preview_label = "Date Picker",
    preview_icon = icondata::AiCalendarOutlined,
)]
#[component]
pub fn DatePicker(
    /// Value binding, field identity, and validation rules.
    #[prop(optional, into)]
    bind: DatePickerBind,
    /// Display format, timezone, placeholder, disabled/readonly, and panel placement.
    #[prop(optional, into)]
    appearance: DatePickerAppearance,
    /// Extra CSS class names merged onto the root wrapper.
    #[prop(optional, into)]
    class: MaybeProp<String>,
) -> impl IntoView {
    inject_style("orbital-date-picker", date_picker_styles());

    let DatePickerBind {
        value,
        id,
        name,
        rules,
    } = bind;
    let DatePickerAppearance {
        format,
        timezone,
        placeholder,
        disabled,
        readonly,
        placement,
        min_date,
        max_date,
        shortcuts,
        month_button,
    } = appearance;

    let (resolved_id, resolved_name) = FieldInjection::use_id_and_name(id, name);
    let value = StoredValue::new(value);
    let validate = Rule::validate(rules, value, resolved_name);
    let unix_value = use_unix_bridge(value.get_value(), timezone);

    let text_value = RwSignal::new(String::new());
    let sync_display = Callback::new(move |_| {
        let display = value
            .get_value()
            .get_untracked()
            .map(|dt| format_datetime(dt, format.get_untracked()))
            .unwrap_or_default();
        text_value.set(display);
    });

    let sync_display_effect = sync_display;
    Effect::new(move |_| {
        let _ = value.get_value().get();
        let _ = format.get();
        let _ = timezone.get();
        sync_display_effect.run(());
    });

    let validate_blur = validate;
    let on_blur = move |_| {
        if disabled.get_untracked() || readonly.get_untracked() {
            validate_blur.run(Some(DatePickerRuleTrigger::Blur));
            return;
        }

        let trimmed = text_value.get_untracked().trim().to_string();
        if trimmed.is_empty() {
            value.with_value(|v| v.set(None));
            validate_blur.run(Some(DatePickerRuleTrigger::Blur));
            return;
        }

        if let Some(parsed) =
            parse_datetime(&trimmed, format.get_untracked(), timezone.get_untracked())
        {
            let normalized = parsed.start_of_day();
            value.with_value(|v| v.set(Some(normalized)));
            text_value.set(format_datetime(normalized, format.get_untracked()));
        } else {
            sync_display.run(());
        }

        validate_blur.run(Some(DatePickerRuleTrigger::Blur));
    };

    let validate_change = validate;
    let on_select = Callback::new(move |_: i64| {
        validate_change.run(Some(DatePickerRuleTrigger::Change));
    });

    let validate_shortcut = validate;
    let sync_display_shortcut = sync_display;
    let on_shortcut = Callback::new(move |_dt: OrbitalDateTime| {
        sync_display_shortcut.run(());
        validate_shortcut.run(Some(DatePickerRuleTrigger::Change));
    });

    let input_bind = InputBind {
        value: text_value.into(),
        id: resolved_id.into(),
        name: resolved_name.into(),
        ..Default::default()
    };
    let input_appearance = InputAppearance {
        input_type: Signal::from(InputType::Text),
        placeholder,
        disabled,
        readonly,
        ..Default::default()
    };
    let input_events = InputEvents {
        on_blur: Some(Handler::with(on_blur)),
        ..Default::default()
    };
    let panel_disabled = Signal::derive(move || disabled.get() || readonly.get());
    let suffix_disabled = panel_disabled;
    let wrapper_class = Memo::new(move |_| {
        let mut parts = vec!["orbital-date-picker".to_string()];
        if let Some(extra) = class.get() {
            if !extra.is_empty() {
                parts.push(extra);
            }
        }
        parts.join(" ")
    });

    let base_month_button = month_button.map(|renderer| {
        std::sync::Arc::new(move |props: MonthButtonRenderProps| {
            renderer(CalendarMonthButtonProps {
                month: props.month,
                label: props.label,
                selected: props.selected,
                on_select: props.on_select,
            })
        }) as MonthButtonRenderer
    });

    view! {
        <BaseDatePicker
            class=wrapper_class
            panel_class="orbital-date-picker__panel"
            value=unix_value
            timezone=timezone
            min_date=min_date
            max_date=max_date
            shortcuts=shortcuts
            disabled=panel_disabled
            placement=placement
            on_select=on_select
            on_shortcut=on_shortcut
            month_button=base_month_button
        >
            <div class="orbital-date-picker__trigger">
                <Input bind=input_bind appearance=input_appearance events=input_events>
                    <InputSuffix slot>
                        <button
                            type="button"
                            class="orbital-date-picker__suffix-button"
                            aria-label="Open calendar"
                            tabindex="-1"
                            disabled=move || suffix_disabled.get()
                            on:mousedown=|e| e.prevent_default()
                        >
                            <Icon icon=icondata::AiCalendarOutlined />
                        </button>
                    </InputSuffix>
                </Input>
            </div>
        </BaseDatePicker>
    }
}