Mobile Sheet Primitives
Loquent uses Apple HIG-style bottom sheets on mobile viewports (<768px) for filter UIs, sort menus, and other controllers that replace desktop popovers. The sheet system lives in src/ui/ and consists of four components and two hooks.
Components
Section titled “Components”MobileSheet
Section titled “MobileSheet”The base primitive. Renders a floating glass card anchored to the bottom of the viewport with inset-x-2 bottom-2, fully rounded corners, and a frosted-glass backdrop. Supports drag-to-resize between detent snap points and swipe-to-dismiss.
File: src/ui/mobile_sheet_ui.rs
MobileSheet { is_open: sheet_open, // Signal<bool> — caller owns open/close title: "Sort by".to_string(), snap_points: vec![SheetDetent::Medium, SheetDetent::Large], primary_label: Some("Done".to_string()), on_primary: move |_| { /* commit */ }, is_dirty: Some(dirty_signal), // triggers discard confirm on dismiss // children: sheet body}Props:
| Prop | Type | Default | Purpose |
|---|---|---|---|
is_open | Signal<bool> | required | Controls visibility |
title | String | required | Centered header title |
snap_points | Vec<SheetDetent> | [Medium, Large] | Detent heights |
cancel_label | String | "Cancel" | Left button aria label |
primary_label | Option<String> | None | Right action button (shows ✓ icon) |
primary_suffix | Option<String> | None | Badge text on primary button, e.g. "(42)" |
primary_disabled | bool | false | Disables the primary action |
on_primary | Option<EventHandler<()>> | None | Primary action callback |
is_dirty | Option<Signal<bool>> | None | Enables discard confirmation on dismiss |
on_dismiss | Option<EventHandler<()>> | None | Called on any dismissal path |
Detents (SheetDetent):
| Variant | Height | Use case |
|---|---|---|
Compact | 40 dvh | Single-section filter, sort menu |
Medium | 70 dvh | 2–4 section filters |
Large | 95 dvh | Dense multi-section filters |
The sheet portals itself to #loquent-portal-root (or document.body) via the use_body_portal hook to escape iOS WebKit stacking-context traps created by CSS transforms on ancestor elements.
MobileFilterSheet
Section titled “MobileFilterSheet”A thin wrapper around MobileSheet that pre-wires the Apply/Reset commit model. Defaults to a single [Large] detent.
File: src/ui/mobile_filter_sheet_ui.rs
MobileFilterSheet { is_open: filter_open, title: "Filters".to_string(), is_dirty: draft_dirty, result_count: Some(42), // shows "Apply (42)" badge on_apply: move |_| { draft.apply(); }, on_reset: Some(move |_| { draft.reset(); }), // children: filter sections}The sheet closes automatically after on_apply fires. When on_reset is provided, a “Reset all” link renders at the top of the body.
MobileFilterButton
Section titled “MobileFilterButton”A 44pt circular trigger button, hidden on desktop (md:hidden). Shows an active-filter count badge when active_count > 0.
File: src/ui/mobile_filter_button_ui.rs
MobileFilterButton { active_count: 3, on_click: move |_| filter_open.set(true),}SheetField
Section titled “SheetField”A labelled-row primitive for sheet bodies. Renders an uppercase tracking label above the control slot, with an optional hint line.
File: src/ui/sheet_field_ui.rs
SheetField { label: "Direction".to_string(), hint: Some("Filter by inbound or outbound".to_string()), // children: Select, MultiSelect, or custom control}use_dismiss_gesture
Section titled “use_dismiss_gesture”Mounts a JS pointer-event handler on the sheet element for swipe-to-dismiss and detent snapping. Drag regions are marked with data-sheet-drag="true" — buttons and form controls inside drag regions short-circuit the gesture so they remain tappable.
File: src/ui/hooks/use_dismiss_gesture.rs
use_dismiss_gesture( "loquent-mobile-sheet", // DOM element id vec![40, 70, 95], // detent heights in dvh 0, // initial detent index Callback::new(move |()| { /* dismiss */ }), None, // optional on_detent_change);Pass an empty snaps_dvh vec for dismiss-only sheets (like MoreSheet) that have no detent snapping.
use_body_portal
Section titled “use_body_portal”Re-parents DOM elements to #loquent-portal-root on each open cycle. Solves the iOS WebKit bug where position: fixed elements trapped inside a CSS transform ancestor get positioned relative to that ancestor instead of the viewport.
File: src/ui/hooks/use_body_portal.rs
use_body_portal( vec!["loquent-mobile-sheet-backdrop", "loquent-mobile-sheet"], is_open,);Migrating a Filter Bar
Section titled “Migrating a Filter Bar”To add a mobile filter sheet to an existing list view:
-
Create a filter sheet component (e.g.
CallFilterSheet) that wrapsMobileFilterSheetand places each filter dimension inside aSheetField. -
Add
MobileFilterButtonto your filter bar, hidden on desktop. Wire it to toggle the sheet’sis_opensignal. -
Gate the desktop popover with
class: "hidden md:flex"so both surfaces don’t render simultaneously. -
Manage draft state — stage filter changes in a draft signal and only commit to the live filter on Apply. Thread
is_dirtythrough to get the discard confirmation.
Desktop Behavior
Section titled “Desktop Behavior”All mobile sheet components are gated behind md:hidden. At ≥768px viewports, the existing desktop popovers and inline pickers render unchanged. The sheet system is mobile-only — no desktop fallback modal is rendered.