dioxus_nox_shell/breakpoint.rs
1use dioxus::prelude::*;
2
3/// Viewport size category detected via `matchMedia` listeners.
4///
5/// The default value (before any JS event fires) is [`Medium`][ShellBreakpoint::Medium].
6#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
7pub enum ShellBreakpoint {
8 /// Viewport width < `compact_below` (default 640 px). Typical phones.
9 Compact,
10 /// Viewport width between `compact_below` and `expanded_above`. Typical tablets.
11 #[default]
12 Medium,
13 /// Viewport width >= `expanded_above` (default 1024 px). Typical desktops.
14 Expanded,
15}
16
17impl ShellBreakpoint {
18 /// Returns the lowercase string used for the `data-shell-breakpoint` attribute.
19 pub fn as_str(&self) -> &'static str {
20 match self {
21 Self::Compact => "compact",
22 Self::Medium => "medium",
23 Self::Expanded => "expanded",
24 }
25 }
26
27 /// `true` when the viewport is in the compact (phone) range.
28 pub fn is_compact(&self) -> bool {
29 *self == Self::Compact
30 }
31
32 /// Alias for [`is_compact`][Self::is_compact] — reflects mobile-first terminology.
33 pub fn is_mobile(&self) -> bool {
34 self.is_compact()
35 }
36}
37
38/// How the sidebar behaves on compact (mobile) viewports.
39#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
40pub enum MobileSidebar {
41 /// Fixed-position overlay drawer (shadcn Sheet pattern). Default.
42 #[default]
43 Drawer,
44 /// Icon-only narrow strip alongside main content (Flutter NavigationRail style).
45 Rail,
46 /// Sidebar is removed entirely; consumers provide alternative navigation.
47 Hidden,
48}
49
50/// How the sidebar behaves on desktop (non-compact) viewports.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52pub enum DesktopSidebar {
53 /// Full-width sidebar. `toggle_sidebar` collapses it to zero width. Default.
54 #[default]
55 Full,
56 /// Permanent narrow icon-only rail (~56 px). Width never changes; `toggle_sidebar` is a no-op.
57 Rail,
58 /// Rail that expands into a full sidebar on toggle.
59 ///
60 /// `sidebar_visible = true` → full width; `false` → rail width.
61 /// `toggle_sidebar` switches between the two states.
62 Expandable,
63}
64
65/// Snap state for a persistent bottom sheet.
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67pub enum SheetSnap {
68 /// Sheet is hidden (0 height). Default.
69 #[default]
70 Hidden,
71 /// Sheet peeks at the bottom (~15–25% height).
72 Peek,
73 /// Sheet is half-height (~50%).
74 Half,
75 /// Sheet is fully expanded (~90%).
76 Full,
77}
78
79impl SheetSnap {
80 /// Returns the lowercase string used for the `data-shell-sheet-state` attribute.
81 pub fn as_str(&self) -> &'static str {
82 match self {
83 Self::Hidden => "hidden",
84 Self::Peek => "peek",
85 Self::Half => "half",
86 Self::Full => "full",
87 }
88 }
89
90 /// `true` when the sheet is visible at any snap point.
91 pub fn is_visible(&self) -> bool {
92 *self != Self::Hidden
93 }
94}
95
96/// Grouped breakpoint thresholds for [`AppShell`][crate::AppShell] and
97/// [`use_shell_breakpoint`].
98///
99/// Pass a custom value to override the defaults:
100/// ```rust,ignore
101/// AppShell {
102/// breakpoints: BreakpointConfig { compact_below: 480.0, expanded_above: 1280.0 },
103/// }
104/// ```
105#[derive(Clone, Copy, Debug, PartialEq)]
106pub struct BreakpointConfig {
107 /// Viewport width (px) below which the layout is considered compact. Default: 640.
108 pub compact_below: f64,
109 /// Viewport width (px) at or above which the layout is considered expanded. Default: 1024.
110 pub expanded_above: f64,
111}
112
113impl Default for BreakpointConfig {
114 fn default() -> Self {
115 Self {
116 compact_below: 640.0,
117 expanded_above: 1024.0,
118 }
119 }
120}
121
122/// Detects the viewport breakpoint via `matchMedia` listeners fed through
123/// [`document::eval`].
124///
125/// Returns a [`ReadSignal`] that updates reactively on resize events.
126/// The initial value is [`ShellBreakpoint::Medium`] until the first JS event
127/// fires on the next microtask after mount. When the eval channel closes
128/// (unsupported backends), the value falls back to `Medium`.
129///
130/// **Requires a JavaScript engine.** Supported on all WebView targets:
131/// web WASM, Wry desktop, iOS WKWebView, and Android WebView.
132pub fn use_shell_breakpoint(
133 compact_below: f64,
134 expanded_above: f64,
135) -> ReadSignal<ShellBreakpoint> {
136 use_shell_breakpoint_runtime(compact_below, expanded_above)
137}
138
139/// Private composite hook — encapsulates eval-channel logic so
140/// `use_shell_breakpoint` can remain a thin, stable public entry point.
141fn use_shell_breakpoint_runtime(
142 compact_below: f64,
143 expanded_above: f64,
144) -> ReadSignal<ShellBreakpoint> {
145 let mut bp = use_signal(|| ShellBreakpoint::Medium);
146 use_effect(move || {
147 spawn(async move {
148 let compact_max = compact_below - 1.0;
149 let js = format!(
150 r#"
151 function getBp() {{
152 const w = window.innerWidth;
153 if (w < {compact_below}) return "compact";
154 if (w >= {expanded_above}) return "expanded";
155 return "medium";
156 }}
157 dioxus.send(getBp());
158 const mqlC = window.matchMedia("(max-width: {compact_max}px)");
159 const mqlE = window.matchMedia("(min-width: {expanded_above}px)");
160 function onChange() {{ dioxus.send(getBp()); }}
161 mqlC.addEventListener("change", onChange);
162 mqlE.addEventListener("change", onChange);
163 "#
164 );
165 let mut eval = document::eval(&js);
166 while let Ok(v) = eval.recv::<String>().await {
167 bp.set(match v.as_str() {
168 "compact" => ShellBreakpoint::Compact,
169 "medium" => ShellBreakpoint::Medium,
170 _ => ShellBreakpoint::Expanded,
171 });
172 }
173 // Eval channel closed — deterministic fallback for unsupported backends.
174 bp.set(ShellBreakpoint::Medium);
175 });
176 });
177 bp.into()
178}