Skip to content

Filter & Date Primitives

Loquent ships UI primitives that power date filtering and collapsible filter sections across the app: Calendar, DateRangePicker, Collapsible, and TimeRangeSelector. These work together with the TimeRange enum and the use_is_mobile hook to provide consistent, responsive date-range filtering in contacts, calls, dashboard, and task views.

A reactive hook that mirrors the (max-width: 767px) media query as a Signal<bool>. Use it when you need to branch the DOM structure (not just styling) between mobile and desktop.

File: src/ui/hooks/use_is_mobile.rs

use crate::ui::use_is_mobile;
#[component]
pub fn ResponsiveWidget() -> Element {
let is_mobile = use_is_mobile();
rsx! {
if is_mobile() {
div { "Inline mobile layout" }
} else {
div { "Desktop popover layout" }
}
}
}

Returns false initially, then seeds from the live media query on client mount. Updates reactively when the viewport crosses the 767px breakpoint.

A standalone month grid with day-of-week headers, prev/next navigation, selected-date highlighting, and today indicator. The grid uses grid-cols-7 w-full to fill its container — day cells resize proportionally instead of using fixed widths.

File: src/ui/calendar_ui.rs

#[component]
pub fn Calendar(
selected_date: Option<NaiveDate>,
on_select: EventHandler<NaiveDate>,
display_month: NaiveDate,
on_month_change: EventHandler<NaiveDate>,
#[props(default = false)] hide_prev_nav: bool,
#[props(default = false)] hide_next_nav: bool,
) -> Element
PropTypeDefaultPurpose
selected_dateOption<NaiveDate>NoneHighlighted day cell, if any
on_selectEventHandler<NaiveDate>Fires when a day is clicked
display_monthNaiveDateAny date within the month to render
on_month_changeEventHandler<NaiveDate>Fires when the user navigates to a different month
hide_prev_navboolfalseHides the previous-month arrow (used in dual-calendar left pane)
hide_next_navboolfalseHides the next-month arrow (used in dual-calendar right pane)

The grid renders 42 cells (6 weeks × 7 days). Days outside the displayed month render with muted opacity. The selected date gets bg-primary styling, and today gets a subtle border.

Usage:

use crate::ui::Calendar;
let mut month = use_signal(|| Local::now().date_naive());
let mut selected = use_signal(|| Option::<NaiveDate>::None);
rsx! {
Calendar {
selected_date: selected(),
on_select: move |d| selected.set(Some(d)),
display_month: month(),
on_month_change: move |m| month.set(m),
}
}

Calendar is also used internally by DateRangePicker and DateTimePicker.

Renders one or two Calendar instances for selecting a start–end date range. Supports dual side-by-side months (desktop) or a single paged month (mobile).

File: src/ui/date_range_picker_ui.rs

#[component]
pub fn DateRangePicker(
range_start: Option<NaiveDate>,
range_end: Option<NaiveDate>,
on_range_select: EventHandler<(NaiveDate, NaiveDate)>,
max_date: Option<NaiveDate>,
min_date: Option<NaiveDate>,
#[props(default = false)] stacked: bool,
#[props(default = false)] single_month: bool,
) -> Element
PropTypeDefaultPurpose
range_startOption<NaiveDate>NoneStart of selected range
range_endOption<NaiveDate>NoneEnd of selected range
on_range_selectEventHandler<(NaiveDate, NaiveDate)>Fires with (start, end) when both dates are picked (always start ≤ end)
max_dateOption<NaiveDate>NoneMaximum selectable date — days after are dimmed
min_dateOption<NaiveDate>NoneMinimum selectable date — days before are dimmed
stackedboolfalseStack calendars vertically instead of side-by-side
single_monthboolfalseRender one month with prev/next paging instead of dual calendars

Selection state machine: First click sets the start date. Second click sets the end date and fires on_range_select. The callback always provides (lo, hi) with dates normalized so lo ≤ hi.

Dual vs. single month:

// Desktop — two months side-by-side
DateRangePicker {
on_range_select: move |(start, end)| { /* ... */ },
max_date: Some(Local::now().date_naive()),
}
// Mobile — single paged month
DateRangePicker {
on_range_select: move |(start, end)| { /* ... */ },
max_date: Some(Local::now().date_naive()),
single_month: true,
}

TimeRangeSelector passes single_month: is_mobile() automatically — you only need single_month when using DateRangePicker directly.

A generic expandable section primitive with a clickable header and animated body. Used by the contact filter popover to organize filter groups (tags, status, date range, custom fields).

File: src/ui/collapsible_ui.rs

#[derive(Props, PartialEq, Clone)]
pub struct CollapsibleProps {
pub open: bool,
pub on_toggle: EventHandler<()>,
pub header: Element,
pub children: Element,
pub class: String, // optional, outer wrapper
pub header_class: String, // optional, button element
pub content_class: String, // optional, body container
}

The body uses CSS grid-rows animation: grid-rows-[1fr] when open, grid-rows-[0fr] when collapsed. This gives a smooth height transition without JavaScript measurement.

Usage:

use crate::ui::Collapsible;
let mut open = use_signal(|| true);
rsx! {
Collapsible {
open: open(),
on_toggle: move |_| open.toggle(),
header: rsx! {
div { class: "flex items-center justify-between px-2 py-1.5",
span { class: "text-sm font-medium", "Status & Tags" }
span { class: "text-xs text-muted-foreground", "(2 active)" }
}
},
// Filter controls go here
p { "Tag checkboxes, status toggles, etc." }
}
}

The contact filter popover uses one Collapsible per filter section. Each header shows the section name plus an active-filter count badge, and a per-section clear button.

A responsive date-range picker that renders as an inline panel on mobile (≤767px) or a positioned popover on desktop (≥768px). Combines preset time ranges with a custom calendar for picking arbitrary date spans.

File: src/shared/components/time_range_selector_component.rs

#[component]
pub fn TimeRangeSelector(
value: TimeRange,
on_change: EventHandler<TimeRange>,
#[props(default = "right")]
align: &'static str,
#[props(default)]
mode: TimeRangeSelectorMode,
) -> Element
PropTypeDefaultPurpose
valueTimeRangeCurrent selection
on_changeEventHandler<TimeRange>Fires when the user picks a preset or completes a custom range
align&'static str"right"Popover anchor edge on desktop ("left" or "right"). Ignored on mobile
modeTimeRangeSelectorModePastOnlyControls which presets appear and calendar date bounds
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TimeRangeSelectorMode {
#[default]
PastOnly, // Past presets + All Time, calendar capped at today
FutureOnly, // Future presets only, calendar refuses dates before today
PastAndFuture, // Both groups with separator, unbounded calendar
}
ViewportSurfaceCalendarTouch targets
Mobile (≤767px)Inline panel below triggerSingle-month, full-width44pt (min-h-11)
Desktop (≥768px)Fixed popover with backdropDual side-by-side months36pt (md:min-h-9)

On desktop, the popover auto-flips above the trigger when there isn’t enough viewport space below. The panel uses z-[1000] to escape overflow containers.

On mobile, the inline panel avoids nesting a second MobileSheet inside parent filter sheets — it flows with the parent sheet’s scroll container.

use crate::shared::{TimeRangeSelector, TimeRange, TimeRangeSelectorMode};
let mut range = use_signal(|| TimeRange::Last30Days);
// Past-only (default)
rsx! {
TimeRangeSelector {
value: range(),
on_change: move |r| range.set(r),
}
}
// Future dates for due-date filters
rsx! {
TimeRangeSelector {
value: range(),
on_change: move |r| range.set(r),
mode: TimeRangeSelectorMode::FutureOnly,
}
}

Behavior:

  1. Clicking a preset calls on_change immediately and closes.
  2. “Custom Range…” switches to the calendar view — pick start then end to commit.
  3. The trigger button shows the current label (e.g., “Last 7 Days” or “Jan 15 – Feb 23”).
  4. On desktop, an outside-click backdrop closes the popover without changing the value.

The TimeRange enum represents all selectable date-range options.

File: src/shared/types/time_range_type.rs

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TimeRange {
Today,
Yesterday,
Tomorrow,
Last7Days,
Last30Days,
Last90Days,
Next7Days,
Next30Days,
AllTime,
Custom { start: String, end: String },
}
VariantSerde valueLabelDate range
Today"today"”Today”[today, today]
Yesterday"yesterday"”Yesterday”[today-1, today-1]
Tomorrow"tomorrow"”Tomorrow”[today+1, today+1]
Last7Days"last_7_days"”Last 7 Days”[today-6, today]
Last30Days"last_30_days"”Last 30 Days”[today-29, today]
Last90Days"last_90_days"”Last 90 Days”[today-89, today]
Next7Days"next_7_days"”Next 7 Days”[today, today+6]
Next30Days"next_30_days"”Next 30 Days”[today, today+29]
AllTime"all_time"”All Time”None (no bounds)
Customobject”Jan 15 – Feb 23”parsed from start/end ISO strings
MethodReturnsDescription
label()StringHuman-readable label: “Last 7 Days”, “Jan 15 – Feb 23”
subtitle()StringKPI card subtitle: “last 7 days”, “next 30 days”
all()&[TimeRange]Past-facing presets in display order (excludes Custom and future variants)
future_presets()&[TimeRange]Future-facing presets: [Tomorrow, Next7Days, Next30Days]
to_date_range()Option<(NaiveDate, NaiveDate)>Resolves to concrete start/end dates (inclusive). Returns None for AllTime
to_value()StringSerde string form for presets, "custom" for Custom
from_value(s)Option<Self>Deserialize from serde string form
is_custom()booltrue for the Custom variant
from_str_or(s, default)SelfParse with fallback for AI tool parameters

On the server, time_range_start() and time_range_end() convert a TimeRange to NaiveDateTime bounds for database queries:

// Lower bound — None for AllTime
pub fn time_range_start(range: &TimeRange) -> Option<NaiveDateTime>
// Upper bound — None unless the range has a definite end
pub fn time_range_end(range: &TimeRange) -> Option<NaiveDateTime>

For future variants, both functions return appropriate bounds:

  • Tomorrow: start = tomorrow midnight, end = day-after-tomorrow midnight
  • Next7Days: start = today midnight, end = 7 days from now midnight
  • Next30Days: start = today midnight, end = 30 days from now midnight

For Custom ranges, start/end parse from YYYY-MM-DD strings. The end bound is exclusive (start of the next day).

ComponentUsed InMode
CalendarDateRangePicker, DateTimePicker
DateRangePickerTimeRangeSelector (custom range view)
CollapsibleContact filter popover, settings panels
TimeRangeSelectorDashboard, calls view, insightsPastOnly (default)
TimeRangeSelectorCustom date field filters (contacts)PastAndFuture
TimeRangeSelectorPlan list filter barFutureOnly
TimeRangeContact filters, dashboard KPIs, call listing, report queries
use_is_mobileTimeRangeSelector, any component needing structural responsive branching

Contact custom fields of type “date” can store future dates (e.g., renewal dates, appointment dates). The custom field filter component uses PastAndFuture mode, which enables both past and future presets and removes calendar date restrictions.

TimeRangeSelector {
value: current_range,
on_change: move |r| { /* update filter state */ },
mode: TimeRangeSelectorMode::PastAndFuture,
}

TimeRangeSelector uses the use_body_portal hook (via MobileSheet) to escape stacking contexts on iOS. Portaled elements move to #loquent-portal-root — a display: contents div inside the Dioxus mount root (#main) in AppLayout. This ensures event delegation works while avoiding iOS transform traps.

The portal re-runs on every open cycle via use_effect (not one-shot use_future), so sheets always render above page transitions.