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.
use_is_mobile
Section titled “use_is_mobile”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.
Calendar
Section titled “Calendar”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| Prop | Type | Default | Purpose |
|---|---|---|---|
selected_date | Option<NaiveDate> | None | Highlighted day cell, if any |
on_select | EventHandler<NaiveDate> | — | Fires when a day is clicked |
display_month | NaiveDate | — | Any date within the month to render |
on_month_change | EventHandler<NaiveDate> | — | Fires when the user navigates to a different month |
hide_prev_nav | bool | false | Hides the previous-month arrow (used in dual-calendar left pane) |
hide_next_nav | bool | false | Hides 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.
DateRangePicker
Section titled “DateRangePicker”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| Prop | Type | Default | Purpose |
|---|---|---|---|
range_start | Option<NaiveDate> | None | Start of selected range |
range_end | Option<NaiveDate> | None | End of selected range |
on_range_select | EventHandler<(NaiveDate, NaiveDate)> | — | Fires with (start, end) when both dates are picked (always start ≤ end) |
max_date | Option<NaiveDate> | None | Maximum selectable date — days after are dimmed |
min_date | Option<NaiveDate> | None | Minimum selectable date — days before are dimmed |
stacked | bool | false | Stack calendars vertically instead of side-by-side |
single_month | bool | false | Render 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-sideDateRangePicker { on_range_select: move |(start, end)| { /* ... */ }, max_date: Some(Local::now().date_naive()),}
// Mobile — single paged monthDateRangePicker { 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.
Collapsible
Section titled “Collapsible”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.
TimeRangeSelector
Section titled “TimeRangeSelector”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| Prop | Type | Default | Purpose |
|---|---|---|---|
value | TimeRange | — | Current selection |
on_change | EventHandler<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 |
mode | TimeRangeSelectorMode | PastOnly | Controls which presets appear and calendar date bounds |
TimeRangeSelectorMode
Section titled “TimeRangeSelectorMode”#[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}Responsive behavior
Section titled “Responsive behavior”| Viewport | Surface | Calendar | Touch targets |
|---|---|---|---|
| Mobile (≤767px) | Inline panel below trigger | Single-month, full-width | 44pt (min-h-11) |
| Desktop (≥768px) | Fixed popover with backdrop | Dual side-by-side months | 36pt (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 filtersrsx! { TimeRangeSelector { value: range(), on_change: move |r| range.set(r), mode: TimeRangeSelectorMode::FutureOnly, }}Behavior:
- Clicking a preset calls
on_changeimmediately and closes. - “Custom Range…” switches to the calendar view — pick start then end to commit.
- The trigger button shows the current label (e.g., “Last 7 Days” or “Jan 15 – Feb 23”).
- On desktop, an outside-click backdrop closes the popover without changing the value.
TimeRange Type
Section titled “TimeRange Type”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 },}Variants
Section titled “Variants”| Variant | Serde value | Label | Date 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) |
Custom | object | ”Jan 15 – Feb 23” | parsed from start/end ISO strings |
Key Methods
Section titled “Key Methods”| Method | Returns | Description |
|---|---|---|
label() | String | Human-readable label: “Last 7 Days”, “Jan 15 – Feb 23” |
subtitle() | String | KPI 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() | String | Serde string form for presets, "custom" for Custom |
from_value(s) | Option<Self> | Deserialize from serde string form |
is_custom() | bool | true for the Custom variant |
from_str_or(s, default) | Self | Parse with fallback for AI tool parameters |
Server-Side Conversion
Section titled “Server-Side Conversion”On the server, time_range_start() and time_range_end() convert a TimeRange to NaiveDateTime bounds for database queries:
// Lower bound — None for AllTimepub fn time_range_start(range: &TimeRange) -> Option<NaiveDateTime>
// Upper bound — None unless the range has a definite endpub fn time_range_end(range: &TimeRange) -> Option<NaiveDateTime>For future variants, both functions return appropriate bounds:
Tomorrow: start = tomorrow midnight, end = day-after-tomorrow midnightNext7Days: start = today midnight, end = 7 days from now midnightNext30Days: 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).
Where These Are Used
Section titled “Where These Are Used”| Component | Used In | Mode |
|---|---|---|
Calendar | DateRangePicker, DateTimePicker | — |
DateRangePicker | TimeRangeSelector (custom range view) | — |
Collapsible | Contact filter popover, settings panels | — |
TimeRangeSelector | Dashboard, calls view, insights | PastOnly (default) |
TimeRangeSelector | Custom date field filters (contacts) | PastAndFuture |
TimeRangeSelector | Plan list filter bar | FutureOnly |
TimeRange | Contact filters, dashboard KPIs, call listing, report queries | — |
use_is_mobile | TimeRangeSelector, any component needing structural responsive branching | — |
Custom Date Field Filters
Section titled “Custom Date Field Filters”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,}Portal Architecture
Section titled “Portal Architecture”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.