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