Skip to main content

repose_material/material3/
mod.rs

1#![allow(non_snake_case)]
2
3pub mod defaults;
4pub use defaults::*;
5
6mod components;
7pub use components::*;
8
9pub mod dialog;
10pub use dialog::*;
11
12use std::cell::{Cell, RefCell};
13use std::rc::Rc;
14use std::sync::atomic::{AtomicU64, Ordering};
15use web_time::Duration;
16
17use crate::{Icon, Symbol};
18use repose_core::animation::{AnimationSpec, Easing, RepeatableSpec};
19use repose_core::*;
20use repose_ui::lazy::{LazyRow, LazyRowState};
21use repose_ui::{
22    Box, Column, Row, Spacer, Stack, Text, TextField, TextStyle, ViewExt, ZStack,
23    anim::{animate_color, animate_f32, animate_f32_from},
24    overlay::OverlayHandle,
25    overlay::SnackbarAction,
26    overlay::snackbar_is_dismissing,
27};
28
29pub(crate) fn alert_dialog_body(
30    title: View,
31    text: View,
32    confirm_button: View,
33    dismiss_button: Option<View>,
34) -> View {
35    Column(Modifier::new()).child((
36        title,
37        Box(Modifier::new().fill_max_width().height(16.0)),
38        text,
39        Spacer(),
40        Row(Modifier::new()).child((
41            dismiss_button.unwrap_or(Box(Modifier::new())),
42            Spacer(),
43            confirm_button,
44        )),
45    ))
46}
47
48pub fn AlertDialog(
49    visible: bool,
50    on_dismiss: impl Fn() + 'static,
51    title: View,
52    text: View,
53    confirm_button: View,
54    dismiss_button: Option<View>,
55) -> View {
56    if !visible {
57        return Box(Modifier::new());
58    }
59
60    let th = theme();
61    ZStack(Modifier::new().fill_max_size()).child((
62        Box(Modifier::new()
63            .fill_max_size()
64            .background(th.scrim.with_alpha(170))
65            .clickable()
66            .on_pointer_down(move |_| on_dismiss())),
67        Box(Modifier::new()
68            .min_width(280.0)
69            .max_width(560.0)
70            .padding(24.0)
71            .background(th.surface_container_high)
72            .clip_rounded(th.shapes.extra_large))
73        .child(alert_dialog_body(
74            title,
75            text,
76            confirm_button,
77            dismiss_button,
78        )),
79    ))
80}
81
82static BOTTOMSHEET_COUNTER: AtomicU64 = AtomicU64::new(0);
83
84pub fn BottomSheet(
85    visible: bool,
86    on_dismiss: impl Fn() + 'static,
87    modifier: Modifier,
88    content: View,
89    config: BottomSheetConfig, // HACK: use ot
90) -> View {
91    let th = theme();
92    let id = remember(|| BOTTOMSHEET_COUNTER.fetch_add(1, Ordering::Relaxed));
93
94    let opacity = animate_f32_from(
95        format!("bs_opacity_{id}"),
96        if visible { 0.0 } else { 1.0 },
97        if visible { 1.0 } else { 0.0 },
98        th.motion.layout,
99    );
100
101    let keep = visible || opacity > 0.01;
102    if keep {
103        Column(Modifier::new()).child((
104            Box(modifier.alpha(opacity)).child(content),
105            Box(Modifier::new()
106                .width(1.0)
107                .height(0.0)
108                .fill_max_width()
109                .alpha(opacity)
110                .hit_passthrough()
111                .on_pointer_down(move |_| on_dismiss())),
112        ))
113    } else {
114        Box(Modifier::new())
115    }
116}
117
118static NAVBAR_COUNTER: AtomicU64 = AtomicU64::new(0);
119
120/// M3 Navigation Bar - a bottom navigation bar with animated selection.
121/// Colors and indicator background transition with 200ms FastOutSlowIn.
122pub fn NavigationBar(
123    selected_index: usize,
124    items: Vec<NavItem>,
125    config: NavigationBarConfig,
126) -> View {
127    let th = theme();
128    let id = remember(|| NAVBAR_COUNTER.fetch_add(1, Ordering::Relaxed));
129    let spec = th.motion.shape;
130    Row(Modifier::new()
131        .fill_max_size()
132        .min_height(config.height)
133        .background(config.container_color)
134        .padding(8.0)
135        .then(config.modifier))
136    .child(
137        items
138            .into_iter()
139            .enumerate()
140            .map(|(i, item)| {
141                let selected = i == selected_index;
142                let fg = animate_color(
143                    format!("nb_fg_{}_{}", id, i),
144                    if selected {
145                        config.selected_icon_color
146                    } else {
147                        config.unselected_icon_color
148                    },
149                    spec,
150                );
151                let bg_alpha = animate_f32(
152                    format!("nb_bg_{}_{}", id, i),
153                    if selected { 1.0 } else { 0.0 },
154                    spec,
155                );
156                let indicator_bg = config
157                    .indicator_color
158                    .with_alpha_f32(bg_alpha * config.indicator_opacity);
159                let cb = item.on_click.clone();
160
161                Column(
162                    Modifier::new()
163                        .flex_grow(1.0)
164                        .padding_values(PaddingValues {
165                            left: config.item_horizontal_padding,
166                            right: config.item_horizontal_padding,
167                            top: config.item_vertical_padding,
168                            bottom: config.item_vertical_padding,
169                        })
170                        .align_items(AlignItems::Center)
171                        .justify_content(JustifyContent::Center)
172                        .background(indicator_bg)
173                        .clip_rounded(config.indicator_radius)
174                        .state_colors(StateColors {
175                            default: Color::TRANSPARENT,
176                            hovered: th.on_surface.with_alpha_f32(0.08),
177                            pressed: th.on_surface.with_alpha_f32(0.12),
178                            disabled: Color::TRANSPARENT,
179                        })
180                        .clickable()
181                        .on_pointer_down(move |_| cb()),
182                )
183                .child((
184                    item.icon,
185                    Text(item.label)
186                        .color(fg)
187                        .size(th.typography.label_medium)
188                        .single_line(),
189                ))
190            })
191            .collect::<Vec<_>>(),
192    )
193}
194
195pub struct NavItem {
196    pub icon: View,
197    pub label: String,
198    pub on_click: Rc<dyn Fn()>,
199}
200
201pub fn Card(modifier: Modifier, content: View) -> View {
202    let config = CardConfig::default();
203    Box(modifier
204        .background(config.container_color)
205        .clip_rounded(config.shape_radius)
206        .then(config.modifier))
207    .child(Column(Modifier::new().fill_max_size()).child(content))
208}
209
210pub fn ElevatedCard(modifier: Modifier, content: View) -> View {
211    let th = theme();
212    let config = CardConfig {
213        container_color: CardDefaults::elevated_container_color(),
214        ..Default::default()
215    };
216    Box(modifier
217        .state_elevation(StateElevation {
218            default: config.tonal_elevation,
219            hovered: th.elevation.level2,
220            pressed: th.elevation.level3,
221            disabled: 0.0,
222        })
223        .background(config.container_color)
224        .clip_rounded(config.shape_radius))
225    .child(Column(Modifier::new().fill_max_size()).child(content))
226}
227
228pub fn OutlinedCard(modifier: Modifier, content: View) -> View {
229    let config = CardConfig {
230        container_color: CardDefaults::outlined_container_color(),
231        ..Default::default()
232    };
233    Box(modifier
234        .background(config.container_color)
235        .clip_rounded(config.shape_radius)
236        .border(
237            1.0,
238            CardDefaults::outlined_border_color(),
239            config.shape_radius,
240        ))
241    .child(Column(Modifier::new().fill_max_size()).child(content))
242}
243
244fn card_state_colors(bg: Color) -> StateColors {
245    let th = theme();
246    StateColors {
247        default: Color::TRANSPARENT,
248        hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
249        pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
250        disabled: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
251    }
252}
253
254pub fn ClickableCard(on_click: impl Fn() + 'static, modifier: Modifier, content: View) -> View {
255    let th = theme();
256    let bg = th.surface_container_highest;
257    Box(modifier
258        .state_colors(card_state_colors(bg))
259        .clickable()
260        .on_pointer_down(move |_| on_click())
261        .background(bg)
262        .clip_rounded(th.shapes.medium))
263    .child(Column(Modifier::new().fill_max_size()).child(content))
264}
265
266pub fn ClickableElevatedCard(
267    on_click: impl Fn() + 'static,
268    modifier: Modifier,
269    content: View,
270) -> View {
271    let th = theme();
272    let bg = th.surface;
273    Box(modifier
274        .state_colors(card_state_colors(bg))
275        .state_elevation(StateElevation {
276            default: th.elevation.level1,
277            hovered: th.elevation.level2,
278            pressed: th.elevation.level3,
279            disabled: 0.0,
280        })
281        .clickable()
282        .on_pointer_down(move |_| on_click())
283        .background(bg)
284        .clip_rounded(th.shapes.medium))
285    .child(Column(Modifier::new().fill_max_size()).child(content))
286}
287
288pub fn ClickableOutlinedCard(
289    on_click: impl Fn() + 'static,
290    modifier: Modifier,
291    content: View,
292) -> View {
293    let th = theme();
294    let bg = th.surface;
295    Box(modifier
296        .state_colors(card_state_colors(bg))
297        .clickable()
298        .on_pointer_down(move |_| on_click())
299        .background(bg)
300        .clip_rounded(th.shapes.medium)
301        .border(1.0, th.outline_variant, th.shapes.medium))
302    .child(Column(Modifier::new().fill_max_size()).child(content))
303}
304
305pub fn Snackbar(
306    message: impl Into<String>,
307    action: Option<SnackbarAction>,
308    modifier: Modifier,
309    config: SnackbarConfig,
310) -> View {
311    let msg = message.into();
312    let th = theme();
313    let bg = config.container_color;
314    let fg = config.content_color;
315    let action_color = config.action_color;
316
317    let dismissing = snackbar_is_dismissing();
318
319    let slide_target = if dismissing { 80.0 } else { 0.0 };
320    let slide = animate_f32_from("snackbar_slide", 80.0, slide_target, th.motion.overlay);
321
322    let alpha_target = if dismissing { 0.0 } else { 1.0 };
323    let alpha = animate_f32_from("snackbar_alpha", 0.0, alpha_target, th.motion.overlay);
324
325    let snackbar = Box(Modifier::new()
326        .translate(0.0, slide)
327        .alpha(alpha)
328        .min_height(48.0)
329        .min_width(280.0)
330        .max_width(600.0)
331        .background(bg)
332        .clip_rounded(th.shapes.small))
333    .child(
334        Row(Modifier::new()
335            .fill_max_width()
336            .padding_values(PaddingValues {
337                left: 16.0,
338                right: 8.0,
339                top: 0.0,
340                bottom: 0.0,
341            })
342            .align_items(repose_core::AlignItems::Center))
343        .child((
344            Text(msg)
345                .modifier(Modifier::new().padding_values(PaddingValues {
346                    left: 0.0,
347                    right: 0.0,
348                    top: 14.0,
349                    bottom: 14.0,
350                }))
351                .color(fg)
352                .size(th.typography.body_medium)
353                .max_lines(2)
354                .overflow_ellipsize(),
355            Spacer(),
356            action
357                .map(|a| {
358                    let label = a.label.clone();
359                    TextButton(
360                        Modifier::new(),
361                        move || (a.on_click)(),
362                        ButtonConfig::default(),
363                        || {
364                            Text(label)
365                                .color(action_color)
366                                .size(th.typography.label_large)
367                                .single_line()
368                        },
369                    )
370                })
371                .unwrap_or(Box(Modifier::new())),
372        )),
373    );
374
375    Box(Modifier::new()
376        .absolute()
377        .offset_bottom(0.0)
378        .fill_max_width()
379        .justify_content(repose_core::JustifyContent::Center)
380        .then(modifier))
381    .child(snackbar)
382}
383
384pub fn FilterChip(
385    selected: bool,
386    on_click: impl Fn() + 'static,
387    label: View,
388    leading_icon: Option<View>,
389    trailing_icon: Option<View>,
390    config: ChipConfig,
391) -> View {
392    let th = theme();
393    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
394    let spec = th.motion.color;
395
396    let bg = animate_color(
397        format!("fc_bg_{}", id),
398        if selected {
399            config.selected_container_color
400        } else {
401            config.container_color
402        },
403        spec,
404    );
405    let label_color = animate_color(
406        format!("fc_lc_{}", id),
407        if selected {
408            config.selected_content_color
409        } else {
410            config.content_color
411        },
412        spec,
413    );
414    let border_color = if selected {
415        Color::TRANSPARENT
416    } else {
417        config.border_color
418    };
419    let leading_color = animate_color(
420        format!("fc_lic_{}", id),
421        if selected {
422            config.selected_content_color
423        } else {
424            config.content_color
425        },
426        spec,
427    );
428
429    Box(Modifier::new()
430        .state_colors(StateColors {
431            default: Color::TRANSPARENT,
432            hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
433            pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
434            disabled: Color::TRANSPARENT,
435        })
436        .padding_values(PaddingValues {
437            left: config.horizontal_padding,
438            right: config.horizontal_padding,
439            top: 8.0,
440            bottom: 8.0,
441        })
442        .clickable()
443        .on_pointer_down(move |_| on_click())
444        .background(bg)
445        .clip_rounded(config.shape_radius)
446        .border(1.0, border_color, config.shape_radius))
447    .child(
448        Row(Modifier::new().align_items(AlignItems::Center)).child((
449            leading_icon
450                .map(|v| {
451                    Box(Modifier::new().padding_values(PaddingValues {
452                        left: 0.0,
453                        right: 8.0,
454                        top: 0.0,
455                        bottom: 0.0,
456                    }))
457                    .child(with_content_color(leading_color, move || v))
458                })
459                .unwrap_or(Box(Modifier::new())),
460            with_content_color(label_color, move || label),
461            trailing_icon
462                .map(|v| {
463                    Box(Modifier::new().padding_values(PaddingValues {
464                        left: 8.0,
465                        right: 0.0,
466                        top: 0.0,
467                        bottom: 0.0,
468                    }))
469                    .child(with_content_color(config.content_color, move || v))
470                })
471                .unwrap_or(Box(Modifier::new())),
472        )),
473    )
474}
475
476pub fn SuggestionChip(on_click: impl Fn() + 'static, label: View, icon: Option<View>) -> View {
477    let th = theme();
478    Box(Modifier::new()
479        .state_colors(StateColors {
480            default: Color::TRANSPARENT,
481            hovered: th.on_surface.with_alpha_f32(0.08),
482            pressed: th.on_surface.with_alpha_f32(0.12),
483            disabled: Color::TRANSPARENT,
484        })
485        .padding_values(PaddingValues {
486            left: 16.0,
487            right: 16.0,
488            top: 8.0,
489            bottom: 8.0,
490        })
491        .clickable()
492        .on_pointer_down(move |_| on_click())
493        .background(Color::TRANSPARENT)
494        .clip_rounded(8.0)
495        .border(1.0, th.outline_variant, 8.0))
496    .child(
497        Row(Modifier::new().align_items(AlignItems::Center)).child((
498            icon.map(|v| {
499                Box(Modifier::new().padding_values(PaddingValues {
500                    left: 0.0,
501                    right: 8.0,
502                    top: 0.0,
503                    bottom: 0.0,
504                }))
505                .child(with_content_color(th.primary, move || v))
506            })
507            .unwrap_or(Box(Modifier::new())),
508            with_content_color(th.on_surface_variant, move || label),
509        )),
510    )
511}
512
513pub fn InputChip(
514    selected: bool,
515    on_click: impl Fn() + 'static,
516    label: View,
517    leading_icon: Option<View>,
518    avatar: Option<View>,
519    trailing_icon: Option<View>,
520) -> View {
521    let th = theme();
522    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
523    let spec = th.motion.color;
524
525    let bg = animate_color(
526        format!("ic_bg_{}", id),
527        if selected {
528            th.secondary_container
529        } else {
530            th.surface
531        },
532        spec,
533    );
534    let label_color = animate_color(
535        format!("ic_lc_{}", id),
536        if selected {
537            th.on_secondary_container
538        } else {
539            th.on_surface_variant
540        },
541        spec,
542    );
543    let border_color = if selected {
544        Color::TRANSPARENT
545    } else {
546        th.outline_variant
547    };
548    let leading_color = animate_color(
549        format!("ic_lic_{}", id),
550        if selected {
551            th.primary
552        } else {
553            th.on_surface_variant
554        },
555        spec,
556    );
557
558    Box(Modifier::new()
559        .state_colors(StateColors {
560            default: Color::TRANSPARENT,
561            hovered: th.on_surface.with_alpha_f32(0.08).composite_over(bg),
562            pressed: th.on_surface.with_alpha_f32(0.12).composite_over(bg),
563            disabled: Color::TRANSPARENT,
564        })
565        .padding_values(PaddingValues {
566            left: 16.0,
567            right: 16.0,
568            top: 8.0,
569            bottom: 8.0,
570        })
571        .clickable()
572        .on_pointer_down(move |_| on_click())
573        .background(bg)
574        .clip_rounded(8.0)
575        .border(1.0, border_color, 8.0))
576    .child(
577        Row(Modifier::new().align_items(AlignItems::Center)).child((
578            avatar
579                .or(leading_icon)
580                .map(|v| {
581                    Box(Modifier::new().padding_values(PaddingValues {
582                        left: 0.0,
583                        right: 8.0,
584                        top: 0.0,
585                        bottom: 0.0,
586                    }))
587                    .child(with_content_color(leading_color, move || v))
588                })
589                .unwrap_or(Box(Modifier::new())),
590            with_content_color(label_color, move || label),
591            trailing_icon
592                .map(|v| {
593                    Box(Modifier::new().padding_values(PaddingValues {
594                        left: 8.0,
595                        right: 0.0,
596                        top: 0.0,
597                        bottom: 0.0,
598                    }))
599                    .child(with_content_color(th.on_surface_variant, move || v))
600                })
601                .unwrap_or(Box(Modifier::new())),
602        )),
603    )
604}
605
606pub fn Scaffold(
607    top_bar: Option<View>,
608    bottom_bar: Option<View>,
609    floating_action_button: Option<View>,
610    content: impl Fn(PaddingValues) -> View,
611) -> View {
612    let insets = window_insets();
613
614    let content_padding = PaddingValues {
615        top: if top_bar.is_some() { 64.0 } else { insets.top },
616        bottom: if bottom_bar.is_some() {
617            80.0 + insets.bottom + insets.ime_bottom
618        } else {
619            insets.bottom + insets.ime_bottom
620        },
621        left: insets.left,
622        right: insets.right,
623    };
624
625    Stack(Modifier::new().fill_max_size()).child((
626        Box(Modifier::new()
627            .fill_max_size()
628            .padding_values(PaddingValues {
629                top: if top_bar.is_some() {
630                    64.0 + insets.top
631                } else {
632                    0.0
633                },
634                bottom: if bottom_bar.is_some() {
635                    80.0 + insets.bottom + insets.ime_bottom
636                } else {
637                    insets.bottom + insets.ime_bottom
638                },
639                ..Default::default()
640            }))
641        .child(content(content_padding)),
642        if let Some(bar) = top_bar {
643            Box(Modifier::new()
644                .absolute()
645                .offset(Some(0.0), Some(insets.top), Some(0.0), None))
646            .child(bar)
647        } else {
648            Box(Modifier::new())
649        },
650        if let Some(bar) = bottom_bar {
651            Box(Modifier::new().absolute().offset(
652                Some(0.0),
653                None,
654                Some(insets.bottom + insets.ime_bottom),
655                Some(0.0),
656            ))
657            .child(bar)
658        } else {
659            Box(Modifier::new())
660        },
661        if let Some(fab) = floating_action_button {
662            Box(Modifier::new().absolute().offset(
663                None,
664                None,
665                Some(16.0 + insets.bottom + insets.ime_bottom),
666                Some(16.0),
667            ))
668            .child(fab)
669        } else {
670            Box(Modifier::new())
671        },
672    ))
673}
674
675/// State controlling tooltip visibility.
676pub struct TooltipState {
677    visible: Signal<bool>,
678}
679
680impl TooltipState {
681    pub fn new() -> Rc<Self> {
682        Rc::new(Self {
683            visible: signal(false),
684        })
685    }
686
687    pub fn is_visible(&self) -> bool {
688        self.visible.get()
689    }
690
691    pub fn show(&self) {
692        self.visible.set(true);
693    }
694
695    pub fn dismiss(&self) {
696        self.visible.set(false);
697    }
698}
699
700/// Wraps `content` with a tooltip label shown above it when `state` is visible.
701///
702/// Usage:
703/// ```ignore
704/// let tip = TooltipState::new();
705/// TooltipBox("I'm a tooltip", tip.clone(), Modifier::new(), Button("Hover me", {
706///     let tip = tip.clone();
707///     move || tip.show()
708/// }));
709/// ```
710pub fn TooltipBox(
711    text: impl Into<String>,
712    state: Rc<TooltipState>,
713    modifier: Modifier,
714    content: View,
715) -> View {
716    let text: Rc<str> = Rc::from(text.into());
717    let th = theme();
718    let spec = th.motion.overlay;
719
720    let alpha = animate_f32(
721        "tooltip_alpha",
722        if state.is_visible() { 1.0 } else { 0.0 },
723        spec,
724    );
725
726    let tooltip_visible = state.is_visible() || alpha > 0.01;
727    let scale = 0.92 + 0.08 * alpha;
728
729    Stack(modifier).child((
730        Box(Modifier::new().fill_max_size()).child(content),
731        if tooltip_visible {
732            Box(Modifier::new()
733                .background(th.inverse_surface)
734                .clip_rounded(th.shapes.extra_small)
735                .padding_values(PaddingValues {
736                    left: 8.0,
737                    right: 8.0,
738                    top: 4.0,
739                    bottom: 4.0,
740                })
741                .absolute()
742                .offset(None, Some(-28.0), None, None)
743                .align_self(AlignSelf::Center)
744                .render_z_index(10000.0)
745                .alpha(alpha)
746                .scale(scale))
747            .child(
748                Text((*text).to_string())
749                    .color(th.inverse_on_surface)
750                    .size(th.typography.label_medium)
751                    .single_line(),
752            )
753        } else {
754            Box(Modifier::new())
755        },
756    ))
757}
758
759/// State controlling drawer open/close.
760pub struct DrawerState {
761    visible: Signal<bool>,
762}
763
764impl DrawerState {
765    pub fn new() -> Rc<Self> {
766        Rc::new(Self {
767            visible: signal(false),
768        })
769    }
770
771    pub fn is_open(&self) -> bool {
772        self.visible.get()
773    }
774
775    pub fn open(&self) {
776        self.visible.set(true);
777    }
778
779    pub fn dismiss(&self) {
780        self.visible.set(false);
781    }
782}
783
784/// A modal navigation drawer that slides in from the left with a scrim overlay.
785pub fn ModalNavigationDrawer(
786    drawer_state: Rc<DrawerState>,
787    drawer_content: View,
788    content: View,
789) -> View {
790    let th = theme();
791
792    let drawer_offset = animate_f32(
793        "modal_drawer_offset",
794        if drawer_state.is_open() { 0.0 } else { -360.0 },
795        theme().motion.spring,
796    );
797
798    ZStack(Modifier::new().fill_max_size()).child((
799        Box(Modifier::new().fill_max_size()).child(content),
800        if drawer_state.is_open() {
801            Box(Modifier::new()
802                .fill_max_size()
803                .background(th.scrim.with_alpha(82))
804                .clickable()
805                .on_pointer_down({
806                    let ds = drawer_state.clone();
807                    move |_| ds.dismiss()
808                }))
809            .child(Box(Modifier::new()))
810        } else {
811            Box(Modifier::new())
812        },
813        Box(Modifier::new()
814            .absolute()
815            .offset(Some(drawer_offset), Some(0.0), None, Some(0.0))
816            .fill_max_height()
817            .width(300.0)
818            .background(th.surface_container_low)
819            .clip_rounded(th.shapes.large))
820        .child(drawer_content),
821    ))
822}
823
824/// A destination entry inside a NavigationDrawer.
825pub fn NavigationDrawerItem(
826    label: View,
827    selected: bool,
828    on_click: impl Fn() + 'static,
829    icon: Option<View>,
830    badge: Option<View>,
831) -> View {
832    let th = theme();
833    let id = remember(|| FILTERCHIP_COUNTER.fetch_add(1, Ordering::Relaxed));
834    let spec = th.motion.color;
835    let bg = animate_color(
836        format!("ndi_bg_{}", id),
837        if selected {
838            th.secondary_container
839        } else {
840            Color::TRANSPARENT
841        },
842        spec,
843    );
844    let fg = animate_color(
845        format!("ndi_fg_{}", id),
846        if selected {
847            th.on_secondary_container
848        } else {
849            th.on_surface_variant
850        },
851        spec,
852    );
853
854    Box(Modifier::new()
855        .fill_max_width()
856        .padding_values(PaddingValues {
857            left: 12.0,
858            right: 12.0,
859            top: 0.0,
860            bottom: 0.0,
861        })
862        .min_height(56.0)
863        .clickable()
864        .on_pointer_down(move |_| on_click())
865        .background(bg)
866        .clip_rounded(28.0))
867    .child(with_content_color(fg, || {
868        Row(Modifier::new()
869            .align_items(AlignItems::Center)
870            .padding_values(PaddingValues {
871                left: 16.0,
872                right: 24.0,
873                top: 0.0,
874                bottom: 0.0,
875            }))
876        .child((
877            icon.unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
878            Box(Modifier::new().width(12.0).height(1.0)),
879            Box(Modifier::new().flex_grow(1.0)).child(label),
880            badge.unwrap_or(Box(Modifier::new())),
881        ))
882    }))
883}
884
885/// A single item inside a `DropdownMenu`.
886#[derive(Clone)]
887pub struct DropdownMenuItem {
888    pub text: String,
889    pub leading_icon: Option<View>,
890    pub trailing_icon: Option<View>,
891    pub on_click: Rc<dyn Fn()>,
892    pub enabled: bool,
893}
894
895impl DropdownMenuItem {
896    pub fn new(text: impl Into<String>, on_click: impl Fn() + 'static) -> Self {
897        Self {
898            text: text.into(),
899            leading_icon: None,
900            trailing_icon: None,
901            on_click: Rc::new(on_click),
902            enabled: true,
903        }
904    }
905
906    pub fn leading_icon(mut self, icon: View) -> Self {
907        self.leading_icon = Some(icon);
908        self
909    }
910
911    pub fn trailing_icon(mut self, icon: View) -> Self {
912        self.trailing_icon = Some(icon);
913        self
914    }
915
916    pub fn disabled(mut self) -> Self {
917        self.enabled = false;
918        self
919    }
920}
921
922/// A menu divider line.
923pub struct MenuDivider;
924
925/// State for controlling `DropdownMenu` visibility.
926pub struct MenuState {
927    visible: Signal<bool>,
928    anchor: Signal<Option<Vec2>>,
929}
930
931impl Default for MenuState {
932    fn default() -> Self {
933        Self::new()
934    }
935}
936
937impl MenuState {
938    pub fn new() -> Self {
939        Self {
940            visible: signal(false),
941            anchor: signal(None),
942        }
943    }
944
945    pub fn is_open(&self) -> bool {
946        self.visible.get()
947    }
948
949    pub fn open(&self) {
950        self.visible.set(true);
951    }
952
953    pub fn open_at(&self, screen_pos: Vec2) {
954        self.anchor.set(Some(screen_pos));
955        self.visible.set(true);
956    }
957
958    pub fn dismiss(&self) {
959        self.visible.set(false);
960    }
961}
962
963static DROPDOWN_COUNTER: AtomicU64 = AtomicU64::new(0);
964
965/// M3 Dropdown Menu anchored to a trigger element.
966///
967/// Renders a full-screen scrim in the overlay (to dismiss taps outside the card)
968/// while keeping the menu positioned inline below the trigger for correct position.
969/// The menu card fades and scales in/out with a 120ms FastOutSlowIn animation.
970/// Items can be `DropdownMenuItem` or `MenuDivider`.
971pub fn DropdownMenu(
972    state: Rc<MenuState>,
973    overlay: OverlayHandle,
974    modifier: Modifier,
975    trigger: View,
976    items: Vec<DropdownMenuEntry>,
977    config: DropdownMenuConfig,
978) -> View {
979    let th = theme();
980    let ddm_id = remember(|| DROPDOWN_COUNTER.fetch_add(1, Ordering::Relaxed));
981    let overlay_id = remember_with_key(format!("ddm_oid_{}", ddm_id), || signal(0u64));
982
983    // Animated open/close progress
984    let anim = remember_state_with_key(format!("ddm_anim_{}", ddm_id), || {
985        AnimatedValue::new(0.0, theme().motion.overlay)
986    });
987    let last_target = remember_state_with_key(format!("ddm_lt_{}", ddm_id), || f32::NAN);
988    let anim_target = if state.is_open() { 1.0 } else { 0.0 };
989
990    {
991        let mut a = anim.borrow_mut();
992        let mut lt = last_target.borrow_mut();
993        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
994            a.set_target(anim_target);
995            *lt = anim_target;
996        }
997        drop(lt);
998        if a.update() {
999            request_frame();
1000        }
1001    }
1002
1003    let progress = *anim.borrow().get();
1004    let menu_visible = state.is_open() || progress > 0.01;
1005
1006    // Scrim overlay - keep alive during exit animation
1007    if menu_visible {
1008        if overlay_id.get() == 0 {
1009            let scrim = Box(Modifier::new().fill_max_size().absolute().on_pointer_down({
1010                let s = state.clone();
1011                move |_| s.dismiss()
1012            }));
1013            let id = overlay.show_with(scrim, 899.0, true);
1014            overlay_id.set(id);
1015        }
1016    } else {
1017        let prev = overlay_id.get();
1018        if prev != 0 {
1019            let _ = overlay.dismiss(prev);
1020            overlay_id.set(0);
1021        }
1022    }
1023
1024    let scale = 0.92 + 0.08 * progress;
1025    let alpha = progress;
1026
1027    Stack(modifier).child((
1028        trigger,
1029        if menu_visible {
1030            Box(Modifier::new()
1031                .absolute()
1032                .offset(None, Some(40.0), None, None)
1033                .render_z_index(900.0)
1034                .scale(scale)
1035                .alpha(alpha))
1036            .child(render_dropdown_menu_content(
1037                &th,
1038                &items,
1039                state.clone(),
1040                &config,
1041            ))
1042        } else {
1043            Box(Modifier::new())
1044        },
1045    ))
1046}
1047
1048/// Either a menu item or a divider.
1049#[derive(Clone)]
1050pub enum DropdownMenuEntry {
1051    Item(DropdownMenuItem),
1052    Divider,
1053}
1054
1055fn render_dropdown_menu_content(
1056    th: &Theme,
1057    items: &[DropdownMenuEntry],
1058    state: Rc<MenuState>,
1059    config: &DropdownMenuConfig,
1060) -> View {
1061    let children: Vec<View> = items
1062        .iter()
1063        .map(|entry| match entry {
1064            DropdownMenuEntry::Item(item) => {
1065                let text_color = if item.enabled {
1066                    config.item_text_color
1067                } else {
1068                    config.disabled_item_text_color
1069                };
1070                let on_click = item.on_click.clone();
1071                let state = state.clone();
1072                let mut modifier = Modifier::new()
1073                    .fill_max_width()
1074                    .min_height(40.0)
1075                    .padding_values(PaddingValues {
1076                        left: 12.0,
1077                        right: 12.0,
1078                        top: 0.0,
1079                        bottom: 0.0,
1080                    })
1081                    .align_items(AlignItems::Center);
1082
1083                if item.enabled {
1084                    modifier = modifier
1085                        .state_colors(StateColors {
1086                            default: Color::TRANSPARENT,
1087                            hovered: th.on_surface.with_alpha_f32(0.08),
1088                            pressed: th.on_surface.with_alpha_f32(0.12),
1089                            disabled: Color::TRANSPARENT,
1090                        })
1091                        .clickable()
1092                        .on_pointer_down(move |_| {
1093                            on_click();
1094                            state.dismiss();
1095                        });
1096                }
1097
1098                Row(modifier).child((
1099                    item.leading_icon
1100                        .clone()
1101                        .unwrap_or(Box(Modifier::new().width(24.0).height(24.0))),
1102                    Box(Modifier::new().width(12.0).fill_max_height()),
1103                    Box(Modifier::new().flex_grow(1.0)).child(
1104                        Text(item.text.clone())
1105                            .color(text_color)
1106                            .size(th.typography.body_large)
1107                            .single_line(),
1108                    ),
1109                    item.trailing_icon.clone().unwrap_or(Box(Modifier::new())),
1110                ))
1111            }
1112            DropdownMenuEntry::Divider => Box(Modifier::new()
1113                .fill_max_width()
1114                .height(1.0)
1115                .margin(12.0)
1116                .background(config.divider_color)),
1117        })
1118        .collect();
1119
1120    Box(Modifier::new()
1121        .state_elevation(StateElevation {
1122            default: th.elevation.level2,
1123            hovered: th.elevation.level3,
1124            pressed: th.elevation.level3,
1125            disabled: 0.0,
1126        })
1127        .min_width(config.min_width)
1128        .padding(4.0)
1129        .background(config.container_color)
1130        .clip_rounded(th.shapes.small))
1131    .child(Column(Modifier::new()).with_children(children))
1132}
1133
1134/// State for `SearchBar` - manages expanded/collapsed and query text.
1135pub struct SearchBarState {
1136    pub query: Signal<String>,
1137    pub expanded: Signal<bool>,
1138    pub active: Signal<bool>,
1139}
1140
1141impl Default for SearchBarState {
1142    fn default() -> Self {
1143        Self::new()
1144    }
1145}
1146
1147impl SearchBarState {
1148    pub fn new() -> Self {
1149        Self {
1150            query: signal(String::new()),
1151            expanded: signal(false),
1152            active: signal(false),
1153        }
1154    }
1155
1156    pub fn query(&self) -> String {
1157        self.query.get()
1158    }
1159
1160    pub fn set_query(&self, q: String) {
1161        self.query.set(q);
1162    }
1163
1164    pub fn is_expanded(&self) -> bool {
1165        self.expanded.get()
1166    }
1167
1168    pub fn expand(&self) {
1169        self.expanded.set(true);
1170    }
1171
1172    pub fn collapse(&self) {
1173        self.expanded.set(false);
1174        self.active.set(false);
1175    }
1176
1177    pub fn is_active(&self) -> bool {
1178        self.active.get()
1179    }
1180
1181    pub fn activate(&self) {
1182        self.active.set(true);
1183        self.expanded.set(true);
1184    }
1185
1186    pub fn deactivate(&self) {
1187        self.active.set(false);
1188    }
1189}
1190
1191/// M3 Search Bar - an expandable search input with leading icon and optional
1192/// suggestions that expand below the search bar (pushing content down).
1193pub fn SearchBar(
1194    state: Rc<SearchBarState>,
1195    modifier: Modifier,
1196    leading_icon: Option<View>,
1197    trailing_icon: Option<View>,
1198    placeholder: impl Into<String>,
1199    on_query_change: Option<Rc<dyn Fn(String)>>,
1200    content: View,
1201) -> View {
1202    let th = theme();
1203    let placeholder = placeholder.into();
1204    let expanded = state.is_expanded();
1205    let query = state.query();
1206    let active = state.is_active();
1207
1208    let width = animate_f32(
1209        "searchbar_width",
1210        if expanded { 360.0 } else { 240.0 },
1211        theme().motion.expand,
1212    );
1213
1214    let input_field: View = if active {
1215        TextField(
1216            placeholder.clone(),
1217            query.clone(),
1218            Modifier::new().flex_grow(1.0).padding(4.0),
1219            Some({
1220                let s = state.clone();
1221                let cb = on_query_change.clone();
1222                move |text| {
1223                    s.set_query(text);
1224                    if let Some(ref cb) = cb {
1225                        cb(s.query());
1226                    }
1227                }
1228            }),
1229            None::<fn(String)>,
1230        )
1231        .color(th.on_surface)
1232        .size(th.typography.body_large)
1233    } else {
1234        Box(Modifier::new().flex_grow(1.0)).child(
1235            Text(if query.is_empty() {
1236                placeholder.clone()
1237            } else {
1238                query.clone()
1239            })
1240            .color(if query.is_empty() {
1241                th.on_surface_variant
1242            } else {
1243                th.on_surface
1244            })
1245            .size(th.typography.body_large)
1246            .single_line(),
1247        )
1248    };
1249
1250    let bar_modifier = modifier.clone();
1251    let bar_bg = if active {
1252        th.surface_container_high
1253    } else {
1254        th.surface_container
1255    };
1256    let bar = Box(bar_modifier
1257        .width(width)
1258        .height(56.0)
1259        .state_elevation(StateElevation {
1260            default: if active { th.elevation.level3 } else { 0.0 },
1261            hovered: th.elevation.level2,
1262            pressed: th.elevation.level3,
1263            disabled: 0.0,
1264        })
1265        .padding_values(PaddingValues {
1266            left: 16.0,
1267            right: 16.0,
1268            top: 0.0,
1269            bottom: 0.0,
1270        })
1271        .clickable()
1272        .on_pointer_down({
1273            let s = state.clone();
1274            move |_| s.activate()
1275        })
1276        .background(bar_bg)
1277        .clip_rounded(th.shapes.large))
1278    .child(
1279        Row(Modifier::new()
1280            .fill_max_size()
1281            .align_items(AlignItems::Center))
1282        .child((
1283            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1284            Box(Modifier::new().width(8.0).fill_max_height()),
1285            input_field,
1286            trailing_icon.unwrap_or(Box(Modifier::new())),
1287        )),
1288    );
1289
1290    if expanded {
1291        Stack(modifier).child((
1292            bar,
1293            Box(Modifier::new()
1294                .width(width)
1295                .max_height(400.0)
1296                .background(th.surface_container)
1297                .clip_rounded(th.shapes.small))
1298            .child(content),
1299        ))
1300    } else {
1301        bar
1302    }
1303}
1304
1305/// M3 Docked Search Bar - full-width variant anchored to the top of the screen.
1306/// Shows a search bar with animated suggestions dropdown.
1307pub fn DockedSearchBar(
1308    state: Rc<SearchBarState>,
1309    modifier: Modifier,
1310    leading_icon: Option<View>,
1311    placeholder: impl Into<String>,
1312    on_query_change: Option<Rc<dyn Fn(String)>>,
1313    content: View,
1314) -> View {
1315    let th = theme();
1316    let placeholder = placeholder.into();
1317    let expanded = state.is_expanded();
1318    let query = state.query();
1319    let active = state.is_active();
1320
1321    let content_target = if expanded { 400.0 } else { 0.0 };
1322    let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
1323    let content_alpha = animate_f32(
1324        "docked_sa",
1325        if expanded { 1.0 } else { 0.0 },
1326        theme().motion.color,
1327    );
1328
1329    let input_field: View = if active {
1330        TextField(
1331            placeholder.clone(),
1332            query.clone(),
1333            Modifier::new().flex_grow(1.0),
1334            Some({
1335                let s = state.clone();
1336                let cb = on_query_change.clone();
1337                move |text| {
1338                    s.set_query(text);
1339                    if let Some(ref cb) = cb {
1340                        cb(s.query());
1341                    }
1342                }
1343            }),
1344            None::<fn(String)>,
1345        )
1346        .color(th.on_surface)
1347        .size(th.typography.body_large)
1348    } else {
1349        Box(Modifier::new().flex_grow(1.0)).child(if query.is_empty() {
1350            Text(placeholder.clone())
1351                .color(th.on_surface_variant)
1352                .size(th.typography.body_large)
1353                .single_line()
1354        } else {
1355            Text(query.clone())
1356                .color(th.on_surface)
1357                .size(th.typography.body_large)
1358                .single_line()
1359        })
1360    };
1361
1362    let bar_bg = if active {
1363        th.surface_container_high
1364    } else {
1365        th.surface_container
1366    };
1367    let bar = Box(modifier
1368        .fill_max_width()
1369        .height(56.0)
1370        .state_elevation(StateElevation {
1371            default: if active { th.elevation.level3 } else { 0.0 },
1372            hovered: th.elevation.level2,
1373            pressed: th.elevation.level3,
1374            disabled: 0.0,
1375        })
1376        .padding_values(PaddingValues {
1377            left: 16.0,
1378            right: 16.0,
1379            top: 0.0,
1380            bottom: 0.0,
1381        })
1382        .clickable()
1383        .on_pointer_down({
1384            let s = state.clone();
1385            move |_| {
1386                if !s.is_active() {
1387                    s.activate()
1388                }
1389            }
1390        })
1391        .background(bar_bg)
1392        .clip_rounded(th.shapes.large))
1393    .child(
1394        Row(Modifier::new()
1395            .fill_max_size()
1396            .align_items(AlignItems::Center))
1397        .child((
1398            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
1399            Box(Modifier::new().width(12.0).fill_max_height()),
1400            input_field,
1401            if active {
1402                Box(Modifier::new()
1403                    .size(24.0, 24.0)
1404                    .clickable()
1405                    .on_pointer_down({
1406                        let s = state.clone();
1407                        move |_| {
1408                            s.set_query(String::new());
1409                            s.collapse();
1410                        }
1411                    }))
1412                .child(Text("✕").size(16.0).color(th.on_surface_variant))
1413            } else {
1414                Box(Modifier::new())
1415            },
1416        )),
1417    );
1418
1419    let show_content = expanded || content_height > 1.0;
1420    if show_content {
1421        Column(Modifier::new().fill_max_width()).child((
1422            bar,
1423            Box(Modifier::new()
1424                .fill_max_width()
1425                .height(content_height)
1426                .alpha(content_alpha)
1427                .clip_rounded(th.shapes.small)
1428                .background(th.surface_container)
1429                .state_elevation(StateElevation {
1430                    default: th.elevation.level3,
1431                    hovered: th.elevation.level3,
1432                    pressed: th.elevation.level3,
1433                    disabled: 0.0,
1434                }))
1435            .child(
1436                Column(Modifier::new().fill_max_width()).child((
1437                    Box(Modifier::new()
1438                        .fill_max_width()
1439                        .height(1.0)
1440                        .background(th.outline_variant)),
1441                    content,
1442                )),
1443            ),
1444        ))
1445    } else {
1446        bar
1447    }
1448}
1449
1450/// State for `ModalBottomSheet` - manages visibility and drag offset.
1451pub struct SheetState {
1452    visible: Signal<bool>,
1453    drag_offset: Signal<f32>,
1454    peek_height: Signal<f32>,
1455}
1456
1457impl SheetState {
1458    pub fn new(peek_height: f32) -> Self {
1459        Self {
1460            visible: signal(false),
1461            drag_offset: signal(0.0),
1462            peek_height: signal(peek_height),
1463        }
1464    }
1465
1466    pub fn is_visible(&self) -> bool {
1467        self.visible.get()
1468    }
1469
1470    pub fn show(&self) {
1471        self.visible.set(true);
1472    }
1473
1474    pub fn dismiss(&self) {
1475        self.visible.set(false);
1476        self.drag_offset.set(0.0);
1477    }
1478
1479    pub fn set_peek_height(&self, h: f32) {
1480        self.peek_height.set(h);
1481    }
1482}
1483
1484/// M3 Modal Bottom Sheet - slides up from the bottom with a drag handle.
1485///
1486/// Renders as an overlay so it is not clipped by parent containers.
1487/// Shows on `state.show()`, dismisses on `state.dismiss()` or scrim tap.
1488pub fn ModalBottomSheet(
1489    state: Rc<SheetState>,
1490    overlay: OverlayHandle,
1491    modifier: Modifier,
1492    content: View,
1493    config: BottomSheetConfig,
1494) -> View {
1495    let th = theme();
1496    let peek_h = state.peek_height.get().max(config.peek_height);
1497    let anim_distance = peek_h.max(48.0).max(400.0);
1498    let overlay_id = remember_with_key("mbs_oid", || signal(0u64));
1499
1500    // Animated offset: anim_distance px (off-screen) → 0px (visible)
1501    let anim = remember_state_with_key("mbs_anim", || {
1502        AnimatedValue::new(anim_distance, theme().motion.spring)
1503    });
1504    let last_target = remember_state_with_key("mbs_anim_target", || f32::NAN);
1505    let anim_target = if state.is_visible() {
1506        0.0
1507    } else {
1508        anim_distance
1509    };
1510
1511    {
1512        let mut a = anim.borrow_mut();
1513        let mut lt = last_target.borrow_mut();
1514        if lt.is_nan() || (*lt - anim_target).abs() > 1e-6 {
1515            if state.is_visible() {
1516                a.set_spec(th.motion.spring);
1517            } else {
1518                a.set_spec(AnimationSpec::fast());
1519            }
1520            a.set_target(anim_target);
1521            *lt = anim_target;
1522        }
1523        drop(lt);
1524        let still_animating = a.update();
1525        if still_animating {
1526            request_frame();
1527        }
1528    }
1529
1530    let offset = *anim.borrow().get();
1531    let sheet_visible = state.is_visible() || offset < anim_distance - 10.0;
1532
1533    if sheet_visible {
1534        if overlay_id.get() == 0 {
1535            let builder: Rc<dyn Fn() -> View> = Rc::new({
1536                let state = state.clone();
1537                let anim = anim.clone();
1538                let modifier = modifier.clone();
1539                let content = content.clone();
1540                move || {
1541                    let off = *anim.borrow().get();
1542
1543                    let sheet_body = Box(modifier
1544                        .clone()
1545                        .fill_max_width()
1546                        .max_width(dp_to_px(config.max_width))
1547                        .translate(0.0, off)
1548                        .background(config.container_color)
1549                        .clip_rounded(config.shape_radius))
1550                    .child(
1551                        Column(Modifier::new().fill_max_width()).child((
1552                            Row(Modifier::new()
1553                                .fill_max_width()
1554                                .justify_content(JustifyContent::Center))
1555                            .child(Box(Modifier::new()
1556                                .margin_vertical(22.0)
1557                                .width(config.drag_handle_width)
1558                                .height(config.drag_handle_height)
1559                                .background(config.drag_handle_color)
1560                                .clip_rounded(2.0))),
1561                            content.clone(),
1562                        )),
1563                    );
1564
1565                    let sheet = Box(Modifier::new()
1566                        .fill_max_size()
1567                        .justify_content(JustifyContent::Center)
1568                        .align_items(AlignItems::FlexEnd))
1569                    .child(sheet_body);
1570
1571                    let scrim_alpha = if state.is_visible() {
1572                        config.scrim_color.3
1573                    } else {
1574                        let t = (off / anim_distance).clamp(0.0, 1.0);
1575                        (config.scrim_color.3 as f32 * (1.0 - t)) as u8
1576                    };
1577                    let scrim = Box(Modifier::new()
1578                        .fill_max_size()
1579                        .background(config.scrim_color.with_alpha(scrim_alpha))
1580                        .on_pointer_down({
1581                            let s = state.clone();
1582                            move |_| s.dismiss()
1583                        }));
1584
1585                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, sheet))
1586                }
1587            });
1588
1589            let id = overlay.show_entry(builder, 900.0, false);
1590            overlay_id.set(id);
1591        }
1592    } else {
1593        let prev = overlay_id.get();
1594        if prev != 0 {
1595            let _ = overlay.dismiss(prev);
1596            overlay_id.set(0);
1597        }
1598    }
1599
1600    Box(Modifier::new())
1601}
1602
1603/// State for `PullToRefresh` - tracks pull progress and refresh trigger.
1604///
1605/// Connect to a [`ScrollState`](repose_ui::scroll::ScrollState) via
1606/// [`set_scroll_state`](PullToRefreshState::set_scroll_state) so that the
1607/// pull offset is automatically driven by scroll overscroll.
1608pub struct PullToRefreshState {
1609    refreshing: Signal<bool>,
1610    scroll_state: RefCell<Option<Rc<repose_ui::scroll::ScrollState>>>,
1611    threshold: f32,
1612    triggered: Cell<bool>,
1613}
1614
1615impl Default for PullToRefreshState {
1616    fn default() -> Self {
1617        Self::new()
1618    }
1619}
1620
1621impl PullToRefreshState {
1622    pub fn new() -> Self {
1623        Self {
1624            refreshing: signal(false),
1625            scroll_state: RefCell::new(None),
1626            threshold: 64.0,
1627            triggered: Cell::new(false),
1628        }
1629    }
1630
1631    /// Connect this PullToRefresh state to a scroll state.
1632    /// The pull offset is then derived from the scroll state's overscroll.
1633    pub fn set_scroll_state(&self, state: Rc<repose_ui::scroll::ScrollState>) {
1634        *self.scroll_state.borrow_mut() = Some(state);
1635    }
1636
1637    /// Set the overscroll threshold that triggers a refresh (default 64px).
1638    pub fn set_threshold(&mut self, px: f32) {
1639        self.threshold = px;
1640    }
1641
1642    pub fn is_refreshing(&self) -> bool {
1643        self.refreshing.get()
1644    }
1645
1646    pub fn set_refreshing(&self, v: bool) {
1647        self.refreshing.set(v);
1648        if !v && let Some(sc) = self.scroll_state.borrow().as_ref() {
1649            sc.set_overscroll(0.0);
1650        }
1651    }
1652
1653    /// Read the current pull offset from the connected scroll state's overscroll.
1654    pub fn pull_offset(&self) -> f32 {
1655        if let Some(sc) = self.scroll_state.borrow().as_ref() {
1656            let os = sc.overscroll_offset();
1657            if os < 0.0 { -os } else { 0.0 }
1658        } else {
1659            0.0
1660        }
1661    }
1662}
1663
1664/// Wraps scrollable content with a pull-to-refresh indicator.
1665///
1666/// Renders a small spinner at the top when the user pulls down past a threshold,
1667/// or shows the current pull offset as a visual indicator.
1668///
1669/// The `state` must be connected to a [`ScrollState`](repose_ui::scroll::ScrollState)
1670/// via [`set_scroll_state`](PullToRefreshState::set_scroll_state) for the pull
1671/// offset to be derived from the scroll overscroll automatically.
1672pub fn PullToRefresh(
1673    state: Rc<PullToRefreshState>,
1674    modifier: Modifier,
1675    on_refresh: Rc<dyn Fn()>,
1676    content: View,
1677    config: PullToRefreshConfig,
1678) -> View {
1679    let pull = state.pull_offset();
1680    let refreshing = state.is_refreshing();
1681    let threshold = config.threshold;
1682
1683    if state.triggered.get() && !refreshing && pull < threshold {
1684        state.triggered.set(false);
1685    }
1686
1687    if !refreshing && !state.triggered.get() && pull >= threshold {
1688        state.triggered.set(true);
1689        state.refreshing.set(true);
1690        (on_refresh)();
1691    }
1692
1693    let frac_key = format!("ptr_frac_{}", Rc::as_ptr(&state) as u64);
1694    let raw_frac = if refreshing {
1695        1.0
1696    } else if pull > 0.0 {
1697        (pull / threshold).min(1.0)
1698    } else {
1699        0.0
1700    };
1701    let distance_fraction = animate_f32_from(frac_key, 0.0, raw_frac, theme().motion.color);
1702
1703    // Indicator at top (pushed into view by overscroll) + content below.
1704    let indicator_h = distance_fraction * threshold;
1705    let icon_size = if refreshing {
1706        24.0
1707    } else {
1708        (16.0 + distance_fraction * 8.0).min(24.0)
1709    };
1710    let rotation = if refreshing {
1711        animate_f32_from(
1712            "ptr_spin",
1713            0.0,
1714            std::f32::consts::TAU,
1715            AnimationSpec::tween(Duration::from_millis(1000), Easing::Linear)
1716                .repeated(RepeatableSpec::infinite()),
1717        )
1718    } else {
1719        (distance_fraction * 180.0).to_radians()
1720    };
1721    Column(modifier).child((
1722        if distance_fraction > 0.01 {
1723            Box(Modifier::new()
1724                .fill_max_width()
1725                .height(indicator_h)
1726                .align_items(AlignItems::Center)
1727                .justify_content(JustifyContent::Center))
1728            .child(
1729                Box(Modifier::new()
1730                    .size(icon_size, icon_size)
1731                    .translate(icon_size * 0.5, icon_size * 0.5)
1732                    .rotate(rotation)
1733                    .translate(-icon_size * 0.5, -icon_size * 0.5))
1734                .child(if refreshing {
1735                    Icon(Symbol::new("refresh", '\u{E5D5}'))
1736                        .size(24.0)
1737                        .color(config.indicator_color)
1738                } else {
1739                    Icon(Symbol::new("arrow_downward", '\u{E5DB}'))
1740                        .size(icon_size)
1741                        .color(
1742                            config
1743                                .indicator_color
1744                                .with_alpha_f32(distance_fraction.min(1.0)),
1745                        )
1746                }),
1747            )
1748        } else {
1749            Box(Modifier::new())
1750        },
1751        content,
1752    ))
1753}
1754
1755/// State for `DatePicker` - manages selected date.
1756pub struct DatePickerState {
1757    pub year: Signal<i32>,
1758    pub month: Signal<u32>, // 1-12
1759    pub day: Signal<u32>,
1760}
1761
1762impl DatePickerState {
1763    pub fn new(year: i32, month: u32, day: u32) -> Self {
1764        Self {
1765            year: signal(year),
1766            month: signal(month.clamp(1, 12)),
1767            day: signal(day.clamp(1, 31)),
1768        }
1769    }
1770
1771    pub fn selected_date(&self) -> (i32, u32, u32) {
1772        (self.year.get(), self.month.get(), self.day.get())
1773    }
1774}
1775
1776fn days_in_month(year: i32, month: u32) -> u32 {
1777    match month {
1778        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1779        4 | 6 | 9 | 11 => 30,
1780        2 => {
1781            if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
1782                29
1783            } else {
1784                28
1785            }
1786        }
1787        _ => 30,
1788    }
1789}
1790
1791/// Day of week for the first day of the given month/year.
1792/// Returns 0=Mon ... 6=Sun using Zeller-like formula for Gregorian calendar.
1793fn first_day_of_month(year: i32, month: u32) -> u32 {
1794    let m = month as i32;
1795    let (y, adj_m) = if m <= 2 {
1796        (year - 1, m + 12)
1797    } else {
1798        (year, m)
1799    };
1800    let k = y % 100;
1801    let j = y / 100;
1802    let h = (1 + (13 * (adj_m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
1803    // Convert Zeller's Saturday=0 to Monday=0, Sunday=6
1804    ((h + 5) % 7) as u32
1805}
1806
1807/// Simple calendar date for today-highlighting in DatePicker.
1808struct ReposeDate {
1809    year: i32,
1810    month: u32,
1811    day: u32,
1812}
1813
1814impl ReposeDate {
1815    /// Compute today's date from the system clock.
1816    fn now() -> Self {
1817        let duration = web_time::SystemTime::now()
1818            .duration_since(web_time::UNIX_EPOCH)
1819            .unwrap_or_default();
1820        let days = (duration.as_secs() / 86_400) as i64;
1821        // Howard Hinnant's civil_from_days
1822        let z = days + 719468;
1823        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1824        let doe = (z - era * 146_097) as u64;
1825        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1826        let y = (yoe as i64) + era * 400;
1827        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1828        let mp = (5 * doy + 2) / 153;
1829        let d = doy - (153 * mp + 2) / 5 + 1;
1830        let m = if mp < 10 { mp + 3 } else { mp - 9 };
1831        let y = if m <= 2 { y + 1 } else { y };
1832        Self {
1833            year: y as i32,
1834            month: m as u32,
1835            day: d as u32,
1836        }
1837    }
1838}
1839
1840const MONTH_NAMES: [&str; 12] = [
1841    "January",
1842    "February",
1843    "March",
1844    "April",
1845    "May",
1846    "June",
1847    "July",
1848    "August",
1849    "September",
1850    "October",
1851    "November",
1852    "December",
1853];
1854
1855const DOW_HEADERS: [&str; 7] = ["M", "T", "W", "T", "F", "S", "S"];
1856
1857/// M3 Date Picker dialog with month/year navigation, proper calendar grid,
1858/// today indicator, and confirm/cancel actions.
1859pub fn DatePicker(
1860    state: Rc<DatePickerState>,
1861    on_confirm: Rc<dyn Fn(i32, u32, u32)>,
1862    on_dismiss: Rc<dyn Fn()>,
1863) -> View {
1864    let th = theme();
1865    let (year, month, day) = state.selected_date();
1866    let dim = days_in_month(year, month);
1867    let start_dow = first_day_of_month(year, month);
1868
1869    // Year step helpers
1870    let prev_year = {
1871        let s = state.clone();
1872        move || {
1873            s.year.set(s.year.get() - 1);
1874            let d = days_in_month(s.year.get(), s.month.get());
1875            if s.day.get() > d {
1876                s.day.set(d);
1877            }
1878        }
1879    };
1880    let next_year = {
1881        let s = state.clone();
1882        move || {
1883            s.year.set(s.year.get() + 1);
1884            let d = days_in_month(s.year.get(), s.month.get());
1885            if s.day.get() > d {
1886                s.day.set(d);
1887            }
1888        }
1889    };
1890
1891    let prev_month = {
1892        let s = state.clone();
1893        move || {
1894            if s.month.get() == 1 {
1895                s.year.set(s.year.get() - 1);
1896                s.month.set(12);
1897            } else {
1898                s.month.set(s.month.get() - 1);
1899            }
1900            let d = days_in_month(s.year.get(), s.month.get());
1901            if s.day.get() > d {
1902                s.day.set(d);
1903            }
1904        }
1905    };
1906
1907    let next_month = {
1908        let s = state.clone();
1909        move || {
1910            if s.month.get() == 12 {
1911                s.year.set(s.year.get() + 1);
1912                s.month.set(1);
1913            } else {
1914                s.month.set(s.month.get() + 1);
1915            }
1916            let d = days_in_month(s.year.get(), s.month.get());
1917            if s.day.get() > d {
1918                s.day.set(d);
1919            }
1920        }
1921    };
1922
1923    // Determine today for highlight
1924    let now = ReposeDate::now();
1925    let today = (now.year, now.month, now.day);
1926
1927    Column(Modifier::new().padding(16.0)).child((
1928        // Month header
1929        Row(Modifier::new()
1930            .fill_max_width()
1931            .align_items(AlignItems::Center))
1932        .child((
1933            IconButton(
1934                Box(Modifier::new()).child(Text("◀").color(th.on_surface).size(16.0)),
1935                prev_month,
1936                IconButtonConfig::default(),
1937            ),
1938            Spacer(),
1939            Column(Modifier::new().align_items(AlignItems::Center)).child((
1940                Text(MONTH_NAMES[(month - 1) as usize].to_string())
1941                    .size(th.typography.title_medium)
1942                    .color(th.on_surface),
1943                Row(Modifier::new().gap(8.0).align_items(AlignItems::Center)).child((
1944                    IconButton(
1945                        Box(Modifier::new())
1946                            .child(Text("‹").color(th.on_surface_variant).size(14.0)),
1947                        prev_year,
1948                        IconButtonConfig::default(),
1949                    ),
1950                    Text(year.to_string())
1951                        .size(th.typography.body_small)
1952                        .color(th.on_surface_variant),
1953                    IconButton(
1954                        Box(Modifier::new())
1955                            .child(Text("›").color(th.on_surface_variant).size(14.0)),
1956                        next_year,
1957                        IconButtonConfig::default(),
1958                    ),
1959                )),
1960            )),
1961            Spacer(),
1962            IconButton(
1963                Box(Modifier::new()).child(Text("▶").color(th.on_surface).size(16.0)),
1964                next_month,
1965                IconButtonConfig::default(),
1966            ),
1967        )),
1968        Box(Modifier::new().fill_max_width().height(12.0)),
1969        // Day grid
1970        Column(Modifier::new()).child({
1971            let mut rows: Vec<View> = Vec::new();
1972            // Day-of-week headers
1973            let dow_headers: Vec<View> = DOW_HEADERS
1974                .iter()
1975                .map(|d| {
1976                    Box(Modifier::new()
1977                        .width(40.0)
1978                        .height(40.0)
1979                        .align_items(AlignItems::Center)
1980                        .justify_content(JustifyContent::Center))
1981                    .child(
1982                        Text(d.to_string())
1983                            .size(th.typography.label_small)
1984                            .color(th.on_surface_variant),
1985                    )
1986                })
1987                .collect();
1988            rows.push(Row(Modifier::new()).with_children(dow_headers));
1989
1990            // Proper calendar grid: offset by start_dow, 6 rows
1991            let total_cells = start_dow + dim;
1992            let num_rows = total_cells.div_ceil(7).min(6);
1993            for w in 0..num_rows {
1994                let mut week: Vec<View> = Vec::new();
1995                for d in 0..7 {
1996                    let cell_idx = w * 7 + d;
1997                    if cell_idx < start_dow {
1998                        week.push(Box(Modifier::new().width(40.0).height(40.0)));
1999                    } else {
2000                        let day_num = (cell_idx - start_dow + 1) as i32;
2001                        if day_num <= dim as i32 {
2002                            let is_selected = day_num == day as i32;
2003                            let is_today =
2004                                today.0 == year && today.1 == month && today.2 == day_num as u32;
2005                            let s = state.clone();
2006                            let on_confirm = on_confirm.clone();
2007                            let on_dismiss_fn = on_dismiss.clone();
2008                            week.push(
2009                                Box(Modifier::new()
2010                                    .width(40.0)
2011                                    .height(40.0)
2012                                    .background(if is_selected {
2013                                        th.primary
2014                                    } else {
2015                                        Color::TRANSPARENT
2016                                    })
2017                                    .clip_rounded(20.0)
2018                                    .align_items(AlignItems::Center)
2019                                    .justify_content(JustifyContent::Center)
2020                                    .clickable()
2021                                    .on_pointer_down(move |_| {
2022                                        s.day.set(day_num as u32);
2023                                        on_confirm(s.year.get(), s.month.get(), day_num as u32);
2024                                        on_dismiss_fn();
2025                                    }))
2026                                .child({
2027                                    let mut t = Text(day_num.to_string())
2028                                        .size(th.typography.body_medium)
2029                                        .color(if is_selected {
2030                                            th.on_primary
2031                                        } else {
2032                                            th.on_surface
2033                                        });
2034                                    if is_today && !is_selected {
2035                                        t = t.modifier(
2036                                            Modifier::new().border(1.0, th.primary, 10.0),
2037                                        );
2038                                    }
2039                                    t
2040                                }),
2041                            );
2042                        } else {
2043                            week.push(Box(Modifier::new().width(40.0).height(40.0)));
2044                        }
2045                    }
2046                }
2047                rows.push(Row(Modifier::new()).with_children(week));
2048            }
2049            rows
2050        }),
2051        Box(Modifier::new().fill_max_width().height(12.0)),
2052        // Cancel / Confirm
2053        Row(Modifier::new()
2054            .fill_max_width()
2055            .justify_content(JustifyContent::End)
2056            .gap(8.0))
2057        .child((
2058            TextButton(
2059                Modifier::new(),
2060                {
2061                    let on_dismiss = on_dismiss.clone();
2062                    move || (on_dismiss)()
2063                },
2064                ButtonConfig::default(),
2065                || Text("Cancel").size(14.0),
2066            ),
2067            FilledButton(
2068                Modifier::new(),
2069                {
2070                    let on_confirm = on_confirm.clone();
2071                    let s = state.clone();
2072                    move || {
2073                        let (y, m, d) = s.selected_date();
2074                        on_confirm(y, m, d);
2075                    }
2076                },
2077                ButtonConfig::default(),
2078                || Text("OK").size(14.0),
2079            ),
2080        )),
2081    ))
2082}
2083
2084/// State for `TimePicker` - manages selected hour and minute.
2085pub struct TimePickerState {
2086    pub hour: Signal<u32>,
2087    pub minute: Signal<u32>,
2088    pub is_am: Signal<bool>,
2089}
2090
2091impl TimePickerState {
2092    pub fn new(hour: u32, minute: u32) -> Self {
2093        let h = hour % 12;
2094        let am = hour < 12;
2095        Self {
2096            hour: signal(if h == 0 { 12 } else { h }),
2097            minute: signal(minute.min(59)),
2098            is_am: signal(am),
2099        }
2100    }
2101
2102    pub fn selected_time(&self) -> (u32, u32) {
2103        let mut h = self.hour.get();
2104        if !self.is_am.get() {
2105            h = (h % 12) + 12;
2106        } else if h == 12 {
2107            h = 0;
2108        }
2109        (h, self.minute.get())
2110    }
2111}
2112
2113/// M3 Time Picker - a simple time picker with hour/minute fields and AM/PM toggle.
2114pub fn TimePicker(
2115    state: Rc<TimePickerState>,
2116    on_confirm: Rc<dyn Fn(u32, u32)>,
2117    on_dismiss: Rc<dyn Fn()>,
2118) -> View {
2119    let th = theme();
2120    let hour = state.hour.get();
2121    let minute = state.minute.get();
2122    let is_am = state.is_am.get();
2123
2124    let hour_str = format!("{:02}", hour);
2125    let min_str = format!("{:02}", minute);
2126
2127    Column(
2128        Modifier::new()
2129            .width(256.0)
2130            .padding(24.0)
2131            .align_items(AlignItems::Center),
2132    )
2133    .child((
2134        // Time display
2135        Row(Modifier::new().align_items(AlignItems::Center)).child((
2136            Box(Modifier::new()
2137                .clickable()
2138                .on_pointer_down({
2139                    let s = state.clone();
2140                    move |_| s.hour.set((s.hour.get() % 12) + 1)
2141                })
2142                .padding(8.0))
2143            .child(Text(hour_str).size(48.0).color(th.on_surface).single_line()),
2144            Text(":")
2145                .size(48.0)
2146                .color(th.on_surface_variant)
2147                .single_line(),
2148            Box(Modifier::new()
2149                .clickable()
2150                .on_pointer_down({
2151                    let s = state.clone();
2152                    move |_| s.minute.set((s.minute.get() + 1) % 60)
2153                })
2154                .padding(8.0))
2155            .child(Text(min_str).size(48.0).color(th.on_surface).single_line()),
2156        )),
2157        Box(Modifier::new().fill_max_width().height(16.0)),
2158        // AM/PM toggle
2159        Row(Modifier::new().align_items(AlignItems::Center)).child((
2160            Box(Modifier::new()
2161                .padding_values(PaddingValues {
2162                    left: 12.0,
2163                    right: 12.0,
2164                    top: 4.0,
2165                    bottom: 4.0,
2166                })
2167                .background(if is_am {
2168                    th.primary
2169                } else {
2170                    Color::TRANSPARENT
2171                })
2172                .clip_rounded(8.0)
2173                .clickable()
2174                .on_pointer_down({
2175                    let s = state.clone();
2176                    move |_| {
2177                        if !s.is_am.get() {
2178                            s.is_am.set(true);
2179                            let h = s.hour.get();
2180                            s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
2181                            if s.hour.get() == 0 {
2182                                s.hour.set(12);
2183                            }
2184                        }
2185                    }
2186                }))
2187            .child(Text("AM").size(th.typography.label_large).color(if is_am {
2188                th.on_primary
2189            } else {
2190                th.on_surface
2191            })),
2192            Box(Modifier::new().width(8.0).height(1.0)),
2193            Box(Modifier::new()
2194                .padding_values(PaddingValues {
2195                    left: 12.0,
2196                    right: 12.0,
2197                    top: 4.0,
2198                    bottom: 4.0,
2199                })
2200                .background(if !is_am {
2201                    th.primary
2202                } else {
2203                    Color::TRANSPARENT
2204                })
2205                .clip_rounded(8.0)
2206                .clickable()
2207                .on_pointer_down({
2208                    let s = state.clone();
2209                    move |_| {
2210                        if s.is_am.get() {
2211                            s.is_am.set(false);
2212                            let h = s.hour.get();
2213                            s.hour.set(if h == 12 { 12 } else { (h + 12) % 24 });
2214                            if s.hour.get() == 0 {
2215                                s.hour.set(12);
2216                            }
2217                        }
2218                    }
2219                }))
2220            .child(Text("PM").size(th.typography.label_large).color(if !is_am {
2221                th.on_primary
2222            } else {
2223                th.on_surface
2224            })),
2225        )),
2226        Box(Modifier::new().fill_max_width().height(16.0)),
2227        Row(Modifier::new().fill_max_width()).child((
2228            Spacer(),
2229            Box(Modifier::new().padding(8.0).clickable().on_pointer_down({
2230                let on_dismiss = on_dismiss.clone();
2231                move |_| on_dismiss()
2232            }))
2233            .child(
2234                Text("Cancel")
2235                    .color(th.primary)
2236                    .size(th.typography.label_large)
2237                    .single_line(),
2238            ),
2239            Box(Modifier::new().width(8.0).height(1.0)),
2240            Box(Modifier::new()
2241                .padding(8.0)
2242                .clickable()
2243                .on_pointer_down(move |_| {
2244                    let (h, m) = state.selected_time();
2245                    on_confirm(h, m);
2246                    on_dismiss();
2247                }))
2248            .child(
2249                Text("OK")
2250                    .color(th.primary)
2251                    .size(th.typography.label_large)
2252                    .single_line(),
2253            ),
2254        )),
2255    ))
2256}
2257
2258/// A destination entry inside a NavigationRail.
2259pub struct NavRailItem {
2260    pub icon: View,
2261    pub label: String,
2262    pub on_click: Rc<dyn Fn()>,
2263    pub badge: Option<View>,
2264}
2265
2266static NAVRAIL_COUNTER: AtomicU64 = AtomicU64::new(0);
2267static FILTERCHIP_COUNTER: AtomicU64 = AtomicU64::new(0);
2268
2269/// M3 Navigation Rail - a compact vertical navigation sidebar.
2270///
2271/// Typically placed on the left side of the screen. Contains navigation items
2272/// (icon + label) with animated selection indicator.
2273pub fn NavigationRail(
2274    selected_index: usize,
2275    items: Vec<NavRailItem>,
2276    header: Option<View>,
2277    fab: Option<View>,
2278    config: NavigationRailConfig,
2279) -> View {
2280    let th = theme();
2281    let id = remember(|| NAVRAIL_COUNTER.fetch_add(1, Ordering::Relaxed));
2282    let spec = th.motion.shape;
2283
2284    let mut top_children: Vec<View> = Vec::new();
2285    let mut item_views: Vec<View> = Vec::new();
2286
2287    let has_header = header.is_some();
2288    let has_fab = fab.is_some();
2289
2290    if let Some(h) = header {
2291        top_children.push(
2292            Box(Modifier::new()
2293                .padding_values(PaddingValues {
2294                    left: 12.0,
2295                    right: 12.0,
2296                    top: 12.0,
2297                    bottom: 12.0,
2298                })
2299                .align_self(AlignSelf::Center))
2300            .child(h),
2301        );
2302    }
2303
2304    if let Some(f) = fab {
2305        top_children.push(
2306            Box(Modifier::new()
2307                .padding_values(PaddingValues {
2308                    left: 12.0,
2309                    right: 12.0,
2310                    top: 8.0,
2311                    bottom: 8.0,
2312                })
2313                .align_self(AlignSelf::Center))
2314            .child(f),
2315        );
2316    }
2317
2318    if has_header || has_fab {
2319        top_children.push(Box(Modifier::new()
2320            .fill_max_width()
2321            .height(1.0)
2322            .background(th.outline_variant)));
2323    }
2324
2325    for (i, item) in items.into_iter().enumerate() {
2326        let selected = i == selected_index;
2327
2328        let fg = animate_color(
2329            format!("nr_fg_{}_{}", id, i),
2330            if selected {
2331                config.selected_icon_color
2332            } else {
2333                config.unselected_icon_color
2334            },
2335            spec,
2336        );
2337        let bg = animate_color(
2338            format!("nr_bg_{}_{}", id, i),
2339            if selected {
2340                config.selected_container_color
2341            } else {
2342                Color::TRANSPARENT
2343            },
2344            spec,
2345        );
2346
2347        let cb = item.on_click.clone();
2348
2349        item_views.push(
2350            Column(
2351                Modifier::new()
2352                    .fill_max_width()
2353                    .padding_values(PaddingValues {
2354                        left: 4.0,
2355                        right: 4.0,
2356                        top: 4.0,
2357                        bottom: 4.0,
2358                    })
2359                    .align_items(AlignItems::Center)
2360                    .justify_content(JustifyContent::Center)
2361                    .background(bg)
2362                    .state_colors(StateColors {
2363                        default: Color::TRANSPARENT,
2364                        hovered: th.on_surface.with_alpha_f32(0.08),
2365                        pressed: th.on_surface.with_alpha_f32(0.12),
2366                        disabled: Color::TRANSPARENT,
2367                    })
2368                    .clip_rounded(config.item_radius)
2369                    .clickable()
2370                    .on_pointer_down(move |_| cb()),
2371            )
2372            .child((
2373                Stack(Modifier::new()).child((
2374                    Box(Modifier::new().size(24.0, 24.0))
2375                        .child(with_content_color(fg, move || item.icon)),
2376                    item.badge
2377                        .map(|b| {
2378                            Box(Modifier::new()
2379                                .absolute()
2380                                .offset(None, None, None, Some(0.0)))
2381                            .child(b)
2382                        })
2383                        .unwrap_or(Box(Modifier::new())),
2384                )),
2385                Box(Modifier::new().fill_max_width().height(4.0)),
2386                Text(item.label)
2387                    .color(fg)
2388                    .size(th.typography.label_medium)
2389                    .single_line(),
2390            )),
2391        );
2392    }
2393
2394    Column(
2395        Modifier::new()
2396            .width(config.width)
2397            .fill_max_height()
2398            .background(config.container_color)
2399            .align_items(AlignItems::Center)
2400            .then(config.modifier),
2401    )
2402    .child((
2403        Column(Modifier::new()).with_children(top_children),
2404        Box(Modifier::new().flex_grow(1.0)).child(
2405            Column(
2406                Modifier::new()
2407                    .fill_max_size()
2408                    .justify_content(JustifyContent::SpaceBetween)
2409                    .align_items(AlignItems::Center),
2410            )
2411            .with_children(item_views),
2412        ),
2413    ))
2414}
2415
2416/// State for `SwipeToDismiss` - tracks swipe offset with spring animation.
2417pub struct SwipeToDismissState {
2418    anim: Rc<RefCell<AnimatedValue<f32>>>,
2419    /// Set when `dismiss()` starts the spring; checked when the spring settles
2420    /// to fire `on_dismiss` exactly once per dismiss gesture.
2421    dismiss_handled: Rc<RefCell<bool>>,
2422}
2423
2424impl Default for SwipeToDismissState {
2425    fn default() -> Self {
2426        Self::new()
2427    }
2428}
2429
2430impl SwipeToDismissState {
2431    pub fn new() -> Self {
2432        Self {
2433            anim: Rc::new(RefCell::new(AnimatedValue::new(
2434                0.0,
2435                AnimationSpec::spring_gentle(),
2436            ))),
2437            dismiss_handled: Rc::new(RefCell::new(true)),
2438        }
2439    }
2440
2441    /// Read and tick the animated offset.  Call every frame that needs the value.
2442    /// Requests a re-draw while the spring is still running.
2443    pub fn offset(&self) -> f32 {
2444        let mut anim = self.anim.borrow_mut();
2445        if anim.update() {
2446            request_frame();
2447        }
2448        *anim.get()
2449    }
2450
2451    /// Snap instantly to an offset (used during active drag).
2452    pub fn set_offset_instant(&self, off: f32) {
2453        self.anim.borrow_mut().snap_to(off);
2454        request_frame();
2455    }
2456
2457    /// Whether the current position is past the dismiss threshold.
2458    pub fn is_dismissed(&self) -> bool {
2459        *self.anim.borrow().get() < -150.0
2460    }
2461
2462    /// Animate to the dismissed position.
2463    pub fn dismiss(&self) {
2464        *self.dismiss_handled.borrow_mut() = false;
2465        self.anim.borrow_mut().set_target(-300.0);
2466        request_frame();
2467    }
2468
2469    /// Animate to the dismissed position with custom offset.
2470    pub fn dismiss_to(&self, offset: f32) {
2471        *self.dismiss_handled.borrow_mut() = false;
2472        self.anim.borrow_mut().set_target(-offset);
2473        request_frame();
2474    }
2475
2476    /// Animate back to origin.
2477    pub fn reset(&self) {
2478        *self.dismiss_handled.borrow_mut() = true;
2479        self.anim.borrow_mut().set_target(0.0);
2480        request_frame();
2481    }
2482
2483    /// Fire the dismiss callback once when the spring settles past threshold.
2484    fn try_handle_dismiss(&self, on_dismiss: &Option<Rc<dyn Fn()>>) {
2485        let anim = self.anim.borrow();
2486        if !anim.is_animating() && !*self.dismiss_handled.borrow() && *anim.get() < -150.0 {
2487            *self.dismiss_handled.borrow_mut() = true;
2488            if let Some(cb) = on_dismiss {
2489                cb();
2490            }
2491        }
2492    }
2493
2494    /// Fire the dismiss callback once when the spring settles past a given threshold.
2495    fn try_handle_dismiss_with_threshold(&self, on_dismiss: &Option<Rc<dyn Fn()>>, threshold: f32) {
2496        let anim = self.anim.borrow();
2497        if !anim.is_animating() && !*self.dismiss_handled.borrow() && *anim.get() < -threshold {
2498            *self.dismiss_handled.borrow_mut() = true;
2499            if let Some(cb) = on_dismiss {
2500                cb();
2501            }
2502        }
2503    }
2504}
2505
2506/// M3 SwipeToDismiss - wraps content that can be swiped left to reveal
2507/// a `background` action view.  On release past the threshold the content
2508/// springs to the dismissed position and `on_dismiss` fires **once**.
2509pub fn SwipeToDismiss(
2510    state: Rc<SwipeToDismissState>,
2511    on_dismiss: Option<Rc<dyn Fn()>>,
2512    background: View,
2513    content: View,
2514    modifier: Modifier,
2515    config: SwipeToDismissConfig,
2516) -> View {
2517    let offset = state.offset();
2518    state.try_handle_dismiss_with_threshold(&on_dismiss, config.dismiss_threshold);
2519
2520    let drag_start_x = remember_with_key("swipe_drag_start", || RefCell::new(None::<f32>));
2521    let drag_base = remember_with_key("swipe_drag_base", || RefCell::new(0.0f32));
2522
2523    let st = state.clone();
2524    let on_down = {
2525        let d = drag_start_x.clone();
2526        let base = drag_base.clone();
2527        move |e: PointerEvent| {
2528            *d.borrow_mut() = Some(e.position.x);
2529            *base.borrow_mut() = *st.anim.borrow().get();
2530        }
2531    };
2532
2533    let st = state.clone();
2534    let on_move = {
2535        let d = drag_start_x.clone();
2536        let base = drag_base.clone();
2537        move |e: PointerEvent| {
2538            if let Some(start) = *d.borrow() {
2539                let dx = e.position.x - start;
2540                st.set_offset_instant(*base.borrow() + dx);
2541            }
2542        }
2543    };
2544
2545    let st = state.clone();
2546    let on_up = {
2547        let d = drag_start_x.clone();
2548        let dt = config.dismiss_threshold;
2549        move |_e: PointerEvent| {
2550            *d.borrow_mut() = None;
2551            let off = *st.anim.borrow().get();
2552            if off > -dt * 0.333 {
2553                st.reset();
2554            } else {
2555                st.dismiss();
2556            }
2557        }
2558    };
2559
2560    let display_offset = offset.max(-config.dismissed_offset).min(0.0);
2561
2562    Stack(modifier.fill_max_width()).child((
2563        Box(Modifier::new().fill_max_size().absolute()).child(background),
2564        Box(Modifier::new()
2565            .fill_max_width()
2566            .translate(display_offset, 0.0)
2567            .on_pointer_down(on_down)
2568            .on_pointer_move(on_move)
2569            .on_pointer_up(on_up))
2570        .child(content),
2571    ))
2572}
2573
2574/// M3 Carousel - a horizontally scrolling container with peek edges.
2575///
2576/// Uses a `LazyRow` internally. The first and last items are partially visible
2577/// (peek) to indicate there is more scrollable content.
2578pub fn Carousel<T, F>(
2579    items: Vec<T>,
2580    item_width: f32,
2581    peek_amount: f32,
2582    modifier: Modifier,
2583    state: Rc<LazyRowState>,
2584    item_builder: F,
2585) -> View
2586where
2587    T: Clone + 'static,
2588    F: Fn(T, usize) -> View + 'static,
2589{
2590    let padded_modifier = modifier.clone().padding_values(PaddingValues {
2591        left: peek_amount,
2592        right: peek_amount,
2593        top: 0.0,
2594        bottom: 0.0,
2595    });
2596
2597    LazyRow(items, item_width, state, padded_modifier, item_builder)
2598}