Skip to content

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.

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:

PropTypeDefaultPurpose
is_openSignal<bool>requiredControls visibility
titleStringrequiredCentered header title
snap_pointsVec<SheetDetent>[Medium, Large]Detent heights
cancel_labelString"Cancel"Left button aria label
primary_labelOption<String>NoneRight action button (shows ✓ icon)
primary_suffixOption<String>NoneBadge text on primary button, e.g. "(42)"
primary_disabledboolfalseDisables the primary action
on_primaryOption<EventHandler<()>>NonePrimary action callback
is_dirtyOption<Signal<bool>>NoneEnables discard confirmation on dismiss
on_dismissOption<EventHandler<()>>NoneCalled on any dismissal path

Detents (SheetDetent):

VariantHeightUse case
Compact40 dvhSingle-section filter, sort menu
Medium70 dvh2–4 section filters
Large95 dvhDense 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.

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.

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),
}

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
}

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.

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,
);

To add a mobile filter sheet to an existing list view:

  1. Create a filter sheet component (e.g. CallFilterSheet) that wraps MobileFilterSheet and places each filter dimension inside a SheetField.

  2. Add MobileFilterButton to your filter bar, hidden on desktop. Wire it to toggle the sheet’s is_open signal.

  3. Gate the desktop popover with class: "hidden md:flex" so both surfaces don’t render simultaneously.

  4. Manage draft state — stage filter changes in a draft signal and only commit to the live filter on Apply. Thread is_dirty through to get the discard confirmation.

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.