Skip to main content

repose_material/material3/
search_bar.rs

1#![allow(non_snake_case)]
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5
6use repose_core::NestedScrollConnection;
7use repose_core::animation::AnimationSpec;
8use repose_core::text::ImeAction;
9use repose_core::*;
10use repose_ui::{
11    BasicTextField, Box, Column, Row, Spacer, Text, TextFieldState, TextStyle, ViewExt, ZStack,
12    anim::animate_f32, overlay::OverlayHandle,
13};
14
15use super::app_bar::WindowInsets;
16use super::util::apply_m3_clickable;
17use super::util::apply_tonal_elevation;
18use super::*;
19
20use super::util::lerp_color;
21/// Color slots for [`SearchBar`]. Matches Compose Material3 `SearchBarColors`.
22#[derive(Clone, Copy, Debug)]
23pub struct SearchBarColors {
24    pub container_color: Color,
25    pub active_container_color: Color,
26    pub divider_color: Color,
27    pub content_color: Color,
28    pub placeholder_color: Color,
29    pub scrim_color: Color,
30}
31
32impl SearchBarColors {
33    pub fn container(&self, active: bool) -> Color {
34        if active {
35            self.active_container_color
36        } else {
37            self.container_color
38        }
39    }
40}
41
42impl Default for SearchBarColors {
43    fn default() -> Self {
44        Self {
45            container_color: SearchBarDefaults::container_color(),
46            active_container_color: SearchBarDefaults::active_container_color(),
47            divider_color: SearchBarDefaults::divider_color(),
48            content_color: SearchBarDefaults::content_color(),
49            placeholder_color: SearchBarDefaults::placeholder_color(),
50            scrim_color: SearchBarDefaults::scrim_color(),
51        }
52    }
53}
54
55/// Color slots for [`AppBarWithSearch`]. Scrolled/not-scrolled pairs.
56#[derive(Clone, Copy, Debug)]
57pub struct AppBarWithSearchColors {
58    pub search_bar_colors: SearchBarColors,
59    pub scrolled_search_bar_container_color: Color,
60    pub app_bar_container_color: Color,
61    pub scrolled_app_bar_container_color: Color,
62    pub navigation_icon_content_color: Color,
63    pub action_icon_content_color: Color,
64}
65
66impl AppBarWithSearchColors {
67    pub fn search_bar_container(&self, scroll_fraction: f32) -> Color {
68        lerp_color(
69            self.search_bar_colors.container_color,
70            self.scrolled_search_bar_container_color,
71            scroll_fraction.clamp(0.0, 1.0),
72        )
73    }
74    pub fn app_bar_container(&self, scroll_fraction: f32) -> Color {
75        lerp_color(
76            self.app_bar_container_color,
77            self.scrolled_app_bar_container_color,
78            scroll_fraction.clamp(0.0, 1.0),
79        )
80    }
81}
82
83impl Default for AppBarWithSearchColors {
84    fn default() -> Self {
85        Self {
86            search_bar_colors: SearchBarColors::default(),
87            scrolled_search_bar_container_color: SearchBarDefaults::scrolled_container_color(),
88            app_bar_container_color: SearchBarDefaults::app_bar_container_color(),
89            scrolled_app_bar_container_color: SearchBarDefaults::scrolled_app_bar_container_color(),
90            navigation_icon_content_color: SearchBarDefaults::navigation_icon_content_color(),
91            action_icon_content_color: SearchBarDefaults::action_icon_content_color(),
92        }
93    }
94}
95
96/// Configuration for [`SearchBar`].
97#[derive(Clone, Debug)]
98pub struct SearchBarConfig {
99    pub modifier: Modifier,
100    pub colors: SearchBarColors,
101    pub height: f32,
102    pub shape_radius: f32,
103    pub active_shape_radius: f32,
104    pub expanded_width: f32,
105    pub collapsed_width: f32,
106    pub tonal_elevation: f32,
107    pub shadow_elevation: f32,
108    pub window_insets: WindowInsets,
109    pub content_padding: PaddingValues,
110    pub min_width: f32,
111    pub max_width: f32,
112}
113
114impl Default for SearchBarConfig {
115    fn default() -> Self {
116        Self {
117            modifier: Modifier::new(),
118            colors: SearchBarColors::default(),
119            height: SearchBarDefaults::HEIGHT,
120            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
121            active_shape_radius: SearchBarDefaults::ACTIVE_SHAPE_RADIUS,
122            expanded_width: SearchBarDefaults::EXPANDED_WIDTH,
123            collapsed_width: SearchBarDefaults::COLLAPSED_WIDTH,
124            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
125            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
126            window_insets: WindowInsets::default(),
127            content_padding: SearchBarDefaults::CONTENT_PADDING,
128            min_width: SearchBarDefaults::MIN_WIDTH,
129            max_width: SearchBarDefaults::MAX_WIDTH,
130        }
131    }
132}
133
134/// Configuration for [`ExpandedFullScreenSearchBar`].
135#[derive(Clone, Debug)]
136pub struct ExpandedFullScreenSearchBarConfig {
137    pub modifier: Modifier,
138    pub colors: SearchBarColors,
139    pub collapsed_shape_radius: f32,
140    pub tonal_elevation: f32,
141    pub shadow_elevation: f32,
142    pub window_insets: WindowInsets,
143    pub scrim_color: Color,
144}
145
146impl Default for ExpandedFullScreenSearchBarConfig {
147    fn default() -> Self {
148        Self {
149            modifier: Modifier::new(),
150            colors: SearchBarColors::default(),
151            collapsed_shape_radius: SearchBarDefaults::SHAPE_RADIUS,
152            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
153            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
154            window_insets: WindowInsets::default(),
155            scrim_color: SearchBarDefaults::scrim_color(),
156        }
157    }
158}
159
160/// Configuration for [`ExpandedDockedSearchBar`].
161#[derive(Clone, Debug)]
162pub struct ExpandedDockedSearchBarConfig {
163    pub modifier: Modifier,
164    pub colors: SearchBarColors,
165    pub shape_radius: f32,
166    pub dropdown_shape_radius: f32,
167    pub dropdown_gap_size: f32,
168    pub dropdown_scrim_color: Color,
169    pub tonal_elevation: f32,
170    pub shadow_elevation: f32,
171}
172
173impl Default for ExpandedDockedSearchBarConfig {
174    fn default() -> Self {
175        Self {
176            modifier: Modifier::new(),
177            colors: SearchBarColors::default(),
178            shape_radius: SearchBarDefaults::DOCKED_SHAPE_RADIUS,
179            dropdown_shape_radius: SearchBarDefaults::DROPDOWN_SHAPE_RADIUS,
180            dropdown_gap_size: SearchBarDefaults::DROPDOWN_GAP_SIZE,
181            dropdown_scrim_color: SearchBarDefaults::dropdown_scrim_color(),
182            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
183            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
184        }
185    }
186}
187
188/// Configuration for [`AppBarWithSearch`].
189#[derive(Clone, Debug)]
190pub struct AppBarWithSearchConfig {
191    pub modifier: Modifier,
192    pub colors: AppBarWithSearchColors,
193    pub height: f32,
194    pub shape_radius: f32,
195    pub tonal_elevation: f32,
196    pub shadow_elevation: f32,
197    pub content_padding: PaddingValues,
198    pub window_insets: WindowInsets,
199    pub scroll_fraction: f32,
200    pub scroll_offset: f32,
201}
202
203impl Default for AppBarWithSearchConfig {
204    fn default() -> Self {
205        Self {
206            modifier: Modifier::new(),
207            colors: AppBarWithSearchColors::default(),
208            height: SearchBarDefaults::HEIGHT,
209            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
210            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
211            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
212            content_padding: SearchBarDefaults::CONTENT_PADDING,
213            window_insets: WindowInsets::default(),
214            scroll_fraction: 0.0,
215            scroll_offset: 0.0,
216        }
217    }
218}
219
220/// Scroll behavior for [`AppBarWithSearch`] -> collapses/expands on scroll.
221pub struct SearchBarScrollBehavior {
222    pub collapsed_offset: Signal<f32>,
223    pub height: f32,
224    pub collapsed_height: f32,
225    _pending: Rc<Cell<f32>>,
226}
227
228impl SearchBarScrollBehavior {
229    pub fn new(height: f32, collapsed_height: f32) -> Self {
230        Self {
231            collapsed_offset: signal(0.0),
232            height,
233            collapsed_height,
234            _pending: Rc::new(Cell::new(0.0)),
235        }
236    }
237
238    pub fn offset(&self) -> f32 {
239        self.collapsed_offset.get()
240    }
241
242    pub fn nested_scroll_connection(&self) -> NestedScrollConnection {
243        let offset = self.collapsed_offset.clone();
244        let max_offset = self.height - self.collapsed_height;
245        NestedScrollConnection::new().on_pre_scroll(move |delta: Vec2, _source| {
246            let cur = offset.get();
247            let new = (cur - delta.y).clamp(-max_offset, 0.0);
248            let consumed = cur - new;
249            offset.set(new);
250            request_frame();
251            Vec2 {
252                x: 0.0,
253                y: consumed,
254            }
255        })
256    }
257}
258
259/// Possible values of [`SearchBarState`].
260#[derive(Clone, Copy, Debug, PartialEq)]
261pub enum SearchBarValue {
262    Collapsed,
263    Expanded,
264}
265
266/// State for `SearchBar` -> manages expanded/collapsed progress, query text,
267/// active state, and collapsed layout coordinates for popup anchoring.
268pub struct SearchBarState {
269    pub query: Signal<String>,
270    pub expanded: Signal<bool>,
271    pub active: Signal<bool>,
272    /// Whether this search bar expands to full-screen (vs docked).
273    /// Used by AppBarWithSearch to hide the collapsed bar when expanded.
274    pub expands_to_full_screen: Signal<bool>,
275    /// Container animation (shape, size, position)
276    anim: Rc<RefCell<AnimatedValue<f32>>>,
277    /// Content fade animation -> fades FIRST on collapse before container shrinks
278    content_anim: Rc<RefCell<AnimatedValue<f32>>>,
279    /// Tracked via `on_globally_positioned` on the collapsed bar.
280    /// Used by expanded docked variants for popup placement.
281    pub collapsed_layout_rect: Signal<(f32, f32, f32, f32)>,
282}
283
284impl Default for SearchBarState {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290impl SearchBarState {
291    pub fn new() -> Self {
292        Self {
293            query: signal(String::new()),
294            expanded: signal(false),
295            active: signal(false),
296            expands_to_full_screen: signal(false),
297            anim: Rc::new(RefCell::new(AnimatedValue::new(
298                0.0,
299                AnimationSpec::spring_gentle(),
300            ))),
301            content_anim: Rc::new(RefCell::new(AnimatedValue::new(
302                0.0,
303                AnimationSpec::spring_gentle(),
304            ))),
305            collapsed_layout_rect: signal((0.0, 0.0, 0.0, 0.0)),
306        }
307    }
308
309    pub fn query(&self) -> String {
310        self.query.get()
311    }
312
313    pub fn set_query(&self, q: impl Into<String>) {
314        self.query.set(q.into());
315    }
316
317    pub fn is_expanded(&self) -> bool {
318        self.expanded.get()
319    }
320
321    pub fn expand(&self) {
322        self.expanded.set(true);
323        self.anim.borrow_mut().set_target(1.0);
324        self.content_anim.borrow_mut().set_target(1.0);
325        request_frame();
326    }
327
328    pub fn collapse(&self) {
329        self.expanded.set(false);
330        self.active.set(false);
331        // Content fades first; container follows in progress()
332        self.content_anim.borrow_mut().set_target(0.0);
333        self.anim.borrow_mut().set_target(0.0);
334        request_frame();
335    }
336
337    pub fn is_active(&self) -> bool {
338        self.active.get()
339    }
340
341    pub fn activate(&self) {
342        self.active.set(true);
343        self.expanded.set(true);
344        self.anim.borrow_mut().set_target(1.0);
345        self.content_anim.borrow_mut().set_target(1.0);
346        request_frame();
347    }
348
349    pub fn deactivate(&self) {
350        if self.expanded.get() {
351            self.expanded.set(false);
352            self.content_anim.borrow_mut().set_target(0.0);
353            self.anim.borrow_mut().set_target(0.0);
354        }
355        self.active.set(false);
356        FocusManager::new(vec![], None).clear_focus(false);
357        request_frame();
358    }
359
360    /// Container animation progress: 0.0 = collapsed, 1.0 = expanded.
361    /// Ticks the underlying AnimatedValue and requests frames while animating.
362    pub fn progress(&self) -> f32 {
363        let mut a = self.anim.borrow_mut();
364        let still = a.update();
365        if still {
366            request_frame();
367        }
368        a.get().clamp(0.0, 1.0)
369    }
370
371    /// Content fade progress -> fades ahead of container on collapse.
372    pub fn content_progress(&self) -> f32 {
373        let mut a = self.content_anim.borrow_mut();
374        let still = a.update();
375        if still {
376            request_frame();
377        }
378        a.get().clamp(0.0, 1.0)
379    }
380
381    /// Whether the animation is currently running.
382    pub fn is_animating(&self) -> bool {
383        self.anim.borrow().is_animating() || self.content_anim.borrow().is_animating()
384    }
385
386    /// Whether the search bar is currently expanded (with tolerance for spring overshoot).
387    pub fn current_value(&self) -> SearchBarValue {
388        if *self.anim.borrow().get() <= 0.02 {
389            SearchBarValue::Collapsed
390        } else {
391            SearchBarValue::Expanded
392        }
393    }
394
395    /// Snap the container progress to a specific fraction (0.0 = collapsed, 1.0 = expanded).
396    pub fn snap_to(&self, fraction: f32) {
397        self.anim.borrow_mut().snap_to(fraction.clamp(0.0, 1.0));
398        request_frame();
399    }
400}
401
402#[derive(Clone)]
403pub struct SearchBarInputFieldConfig {
404    pub state: Option<Rc<SearchBarState>>,
405    pub on_search: Option<Rc<dyn Fn(String)>>,
406    pub enabled: bool,
407    pub text_color: Color,
408    pub placeholder_color: Color,
409    pub leading_icon: Option<View>,
410    pub trailing_icon: Option<View>,
411    pub interaction_source: Option<MutableInteractionSource>,
412}
413
414impl Default for SearchBarInputFieldConfig {
415    fn default() -> Self {
416        let th = theme();
417        Self {
418            state: None,
419            on_search: None,
420            enabled: true,
421            text_color: th.on_surface,
422            placeholder_color: th.on_surface_variant,
423            leading_icon: None,
424            trailing_icon: None,
425            interaction_source: None,
426        }
427    }
428}
429
430/// Build a search bar input field with proper M3 SearchBar styling.
431/// Equivalent to Compose Material3's `SearchBarDefaults.InputField`.
432/// When `state` is provided, focus gain triggers expand and Escape triggers collapse.
433/// Always renders a `UiTextField` (focusable even in collapsed state, matching CK).
434pub fn SearchBarInputField(
435    placeholder: String,
436    query: String,
437    on_query_change: Rc<dyn Fn(String)>,
438    expanded: bool,
439    config: SearchBarInputFieldConfig,
440) -> View {
441    let source: Rc<MutableInteractionSource> = config
442        .interaction_source
443        .clone()
444        .map(Rc::new)
445        .unwrap_or_else(|| Rc::new(MutableInteractionSource::new()));
446    let focused = source.source().collect_is_focused();
447    let state = config.state;
448    let enabled = config.enabled;
449
450    let mut input_m = Modifier::new()
451        .flex_grow(1.0)
452        .padding(4.0)
453        .required_width_in(SearchBarDefaults::MIN_WIDTH, SearchBarDefaults::MAX_WIDTH)
454        .required_height_in(SearchBarDefaults::HEIGHT, SearchBarDefaults::HEIGHT)
455        .interaction_source(&source)
456        .semantics(Semantics {
457            role: Role::TextField,
458            label: Some("Search".into()),
459            focused: expanded || focused,
460            enabled,
461            ..Default::default()
462        })
463        .on_key_event({
464            let s = state.clone();
465            move |ev| {
466                if ev.key == Key::Escape {
467                    if let Some(ref s) = s
468                        && s.is_active()
469                    {
470                        s.deactivate();
471                    }
472                    true
473                } else if ev.key == Key::ArrowDown || ev.key == Key::ArrowUp {
474                    if let Some(ref s) = s
475                        && !s.is_expanded()
476                    {
477                        s.activate();
478                    }
479                    true
480                } else {
481                    false
482                }
483            }
484        });
485    if let Some(ref s) = state {
486        let s2 = s.clone();
487        input_m = input_m.on_focus_changed(move |focused| {
488            if focused {
489                s2.activate();
490            }
491        });
492    }
493
494    let on_qc = on_query_change.clone();
495    let on_s = config.on_search.clone();
496
497    // Always render the text field (focusable even when collapsed, matching CK).
498    let read_only = !expanded;
499
500    let display_color = if query.is_empty() {
501        config.placeholder_color
502    } else {
503        config.text_color
504    };
505
506    let tf_state = remember_with_key("SearchBarInputField_tf_state", || {
507        RefCell::new(TextFieldState::new())
508    });
509    if tf_state.borrow().text != query {
510        tf_state.borrow_mut().text = query.clone();
511    }
512
513    // Build the row: [leading_icon] + text_field + [trailing_icon]
514    let mut row_children: Vec<View> = Vec::new();
515    if let Some(icon) = config.leading_icon {
516        row_children.push(icon);
517    }
518    let on_qc2 = on_qc.clone();
519    row_children.push(
520        BasicTextField(
521            tf_state.clone(),
522            input_m,
523            placeholder,
524            repose_ui::TextFieldConfig {
525                on_change: Some(Rc::new(move |text| on_qc2(text))),
526                on_submit: on_s.clone(),
527                enabled,
528                read_only,
529                line_limits: TextFieldLineLimits::SingleLine,
530                keyboard_options: KeyboardOptions {
531                    ime_action: ImeAction::Search,
532                    ..KeyboardOptions::DEFAULT
533                },
534                ..Default::default()
535            },
536        )
537        .color(display_color)
538        .size(repose_core::locals::theme().typography.body_large),
539    );
540    if let Some(icon) = config.trailing_icon {
541        row_children.push(icon);
542    }
543
544    if row_children.len() == 1 {
545        row_children.into_iter().next().unwrap()
546    } else {
547        Row(Modifier::new()
548            .fill_max_width()
549            .align_items(AlignItems::CENTER))
550        .child(row_children)
551    }
552}
553
554/// Record the collapsed bar's layout rect on the state. Returns a modifier
555/// that should be applied to the collapsed bar.
556fn track_collapsed_layout(state: &Rc<SearchBarState>) -> Modifier {
557    let s = state.clone();
558    Modifier::new().on_globally_positioned(move |rect| {
559        s.collapsed_layout_rect
560            .set((rect.x, rect.y, rect.w, rect.h));
561    })
562}
563
564/// M3 Collapsed Search Bar -> renders ONLY the collapsed bar surface wrapping
565/// the provided `input_field`. Does NOT manage expanded content.
566///
567/// Equivalent to CK's `SearchBar(state, inputField)` overload -> a passive
568/// Surface that does NOT handle clicks or ripple. The click/focus->expand
569/// behavior is managed by the `InputField` (via `SearchBarInputField`).
570///
571/// Pressing <kbd>Escape</kbd> deactivates the search bar (cross-platform back).
572///
573/// Use [`ExpandedFullScreenSearchBar`] / [`ExpandedDockedSearchBar`] for the
574/// expanded state, or [`SearchBarWithContent`] for an all-in-one variant.
575pub fn SearchBar(
576    state: Rc<SearchBarState>,
577    input_field: View,
578    modifier: Modifier,
579    leading_icon: Option<View>,
580    trailing_icon: Option<View>,
581    config: SearchBarConfig,
582) -> View {
583    let th = theme();
584    let colors = config.colors;
585
586    let mut bar_m = modifier
587        .fill_max_width()
588        .height(config.height)
589        .state_elevation(StateElevation {
590            default: config.tonal_elevation,
591            hovered: th.elevation.level2,
592            focused: th.elevation.level2,
593            pressed: th.elevation.level3,
594            dragged: th.elevation.level3,
595            disabled: 0.0,
596        })
597        .shadow(config.shadow_elevation, 0.0)
598        .padding_values(config.content_padding)
599        .on_key_event({
600            let s = state.clone();
601            move |ev| {
602                if ev.key == Key::Escape && s.is_active() {
603                    s.deactivate();
604                    true
605                } else {
606                    false
607                }
608            }
609        })
610        .on_focus_changed({
611            let s = state.clone();
612            move |focused| {
613                if focused {
614                    s.activate();
615                }
616            }
617        })
618        .semantics(Semantics {
619            role: Role::TextField,
620            label: Some("Search".into()),
621            focused: state.is_active(),
622            ..Default::default()
623        })
624        .background(colors.container_color)
625        .clip_rounded(config.shape_radius)
626        .then(track_collapsed_layout(&state));
627
628    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
629
630    Box(bar_m).child(
631        Row(Modifier::new()
632            .fill_max_size()
633            .align_items(AlignItems::CENTER))
634        .child((
635            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
636            Box(Modifier::new().width(8.0).fill_max_height()),
637            input_field,
638            trailing_icon.unwrap_or(Box(Modifier::new())),
639        )),
640    )
641}
642
643/// M3 Search Bar that manages expanded content with animated width and
644/// suggestions dropdown. Equivalent to CK's
645/// `SearchBar(inputField, expanded, onExpandedChange, ..., content)` overload.
646///
647/// The bar itself is a passive surface (no click handling) -> expansion is
648/// driven by the `InputField`'s focus tracking inside `input_field`.
649pub fn SearchBarWithContent(
650    input_field: View,
651    expanded: bool,
652    on_expanded_change: Rc<dyn Fn(bool)>,
653    modifier: Modifier,
654    leading_icon: Option<View>,
655    trailing_icon: Option<View>,
656    config: SearchBarConfig,
657    content: View,
658) -> View {
659    let th = theme();
660    let width = animate_f32(
661        "sbwc_w",
662        if expanded {
663            config.expanded_width
664        } else {
665            config.collapsed_width
666        },
667        theme().motion.expand,
668    );
669
670    let bar_bg = if expanded {
671        config.colors.active_container_color
672    } else {
673        config.colors.container_color
674    };
675    let shape = if expanded {
676        config.active_shape_radius
677    } else {
678        config.shape_radius
679    };
680
681    let mut bar_m = modifier
682        .clone()
683        .width(width)
684        .min_width(config.min_width)
685        .max_width(config.max_width)
686        .height(config.height)
687        .shadow(config.shadow_elevation, 0.0)
688        .padding_values(config.content_padding)
689        .on_key_event({
690            let cb = on_expanded_change.clone();
691            move |ev| {
692                if ev.key == Key::Escape {
693                    cb(false);
694                    true
695                } else {
696                    false
697                }
698            }
699        })
700        .background(bar_bg)
701        .clip_rounded(shape);
702
703    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
704
705    // Content fades with separate alpha so content can fade before collapse
706    let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
707
708    let bar = Box(bar_m).child(
709        Row(Modifier::new()
710            .fill_max_size()
711            .align_items(AlignItems::CENTER))
712        .child((
713            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
714            Box(Modifier::new().width(8.0).fill_max_height()),
715            input_field,
716            trailing_icon.unwrap_or(Box(Modifier::new())),
717        )),
718    );
719
720    let show_content = expanded || content_alpha > 0.01;
721    if show_content || expanded {
722        Column(modifier).child((
723            bar,
724            Box(Modifier::new()
725                .width(width)
726                .max_height(SearchBarDefaults::DOCKED_HEIGHT)
727                .alpha(content_alpha)
728                .background(config.colors.container_color)
729                .clip_rounded(th.shapes.extra_small))
730            .child(content),
731        ))
732    } else {
733        bar
734    }
735}
736
737/// M3 Docked Search Bar -> bounded-width variant with animated suggestions
738/// dropdown (height + alpha).  Equivalent to CK's
739/// `DockedSearchBar(inputField, expanded, onExpandedChange, ..., content)`.
740/// The bar itself is a passive Surface -> expansion is driven by `InputField`.
741pub fn DockedSearchBar(
742    input_field: View,
743    expanded: bool,
744    on_expanded_change: Option<Rc<dyn Fn(bool)>>,
745    modifier: Modifier,
746    leading_icon: Option<View>,
747    config: SearchBarConfig,
748    content: View,
749) -> View {
750    let th = theme();
751    let active = expanded;
752    let colors = config.colors;
753
754    let content_target = if expanded {
755        get_window_container_height() * 2.0 / 3.0
756    } else {
757        0.0
758    };
759    let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
760    let content_alpha = animate_f32(
761        "docked_sa",
762        if expanded { 1.0 } else { 0.0 },
763        theme().motion.color,
764    );
765    let bar_bg = if active {
766        colors.active_container_color
767    } else {
768        colors.container_color
769    };
770
771    let clear_source: Rc<MutableInteractionSource> = remember(MutableInteractionSource::new);
772    let clear_btn = if active {
773        Box(apply_m3_clickable(
774            Modifier::new().size(24.0, 24.0).clip_rounded(12.0),
775            &clear_source,
776            colors.placeholder_color,
777            true,
778            {
779                let cb = on_expanded_change.clone();
780                move || {
781                    if let Some(ref cb) = cb {
782                        cb(false);
783                    }
784                }
785            },
786        ))
787        .child(Text("✕").size(16.0).color(colors.placeholder_color))
788    } else {
789        Box(Modifier::new())
790    };
791
792    let mut bar_m = modifier
793        .z_index(1.0)
794        .min_width(SearchBarDefaults::MIN_WIDTH)
795        .height(config.height)
796        .state_elevation(StateElevation {
797            default: if active {
798                th.elevation.level3
799            } else {
800                config.tonal_elevation
801            },
802            hovered: th.elevation.level2,
803            focused: th.elevation.level2,
804            pressed: th.elevation.level3,
805            dragged: th.elevation.level3,
806            disabled: 0.0,
807        })
808        .shadow(config.shadow_elevation, 0.0)
809        .padding_values(config.content_padding)
810        .on_key_event({
811            let cb = on_expanded_change.clone();
812            move |ev| {
813                if ev.key == Key::Escape {
814                    if let Some(ref cb) = cb {
815                        cb(false);
816                    }
817                    true
818                } else {
819                    false
820                }
821            }
822        })
823        .background(bar_bg)
824        .clip_rounded(config.shape_radius);
825
826    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
827
828    let bar = Box(bar_m).child(
829        Row(Modifier::new()
830            .fill_max_size()
831            .align_items(AlignItems::CENTER))
832        .child((
833            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
834            Box(Modifier::new().width(12.0).fill_max_height()),
835            input_field,
836            clear_btn,
837        )),
838    );
839
840    let show_content = expanded || content_height > 1.0;
841    if show_content {
842        Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
843            bar,
844            Box(Modifier::new()
845                .min_width(SearchBarDefaults::MIN_WIDTH)
846                .height(content_height)
847                .alpha(content_alpha)
848                .clip_rounded(th.shapes.small)
849                .background(colors.container_color)
850                .state_elevation(StateElevation {
851                    default: th.elevation.level3,
852                    hovered: th.elevation.level3,
853                    focused: th.elevation.level3,
854                    pressed: th.elevation.level3,
855                    dragged: th.elevation.level3,
856                    disabled: 0.0,
857                }))
858            .child(
859                Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
860                    Box(Modifier::new()
861                        .min_width(SearchBarDefaults::MIN_WIDTH)
862                        .height(1.0)
863                        .background(colors.divider_color)),
864                    content,
865                )),
866            ),
867        ))
868    } else {
869        bar
870    }
871}
872
873/// Platform-agnostic window container height. On Skiko this would read
874/// `LocalWindowInfo`, on Android `LocalConfiguration`. Defaults to 800 dp.
875/// The `LayoutEngine` keeps this current from the physical viewport + density.
876pub fn set_window_container_height(h: f32) {
877    repose_core::locals::set_window_container_height(h);
878}
879
880fn get_window_container_height() -> f32 {
881    repose_core::locals::get_window_container_height()
882}
883
884/// Set the window container width (in dp) used for dropdown constraints.
885pub fn set_window_container_width(w: f32) {
886    repose_core::locals::set_window_container_width(w);
887}
888
889/// M3 Expanded Full‑Screen Search Bar -> rendered in an overlay covering the
890/// entire window. Uses the state's own `progress()` for animation.
891/// Equivalent to CK's `ExpandedFullScreenSearchBar(state, inputField, ...)`.
892pub fn ExpandedFullScreenSearchBar(
893    state: Rc<SearchBarState>,
894    overlay: OverlayHandle,
895    input_field: View,
896    modifier: Modifier,
897    config: ExpandedFullScreenSearchBarConfig,
898    content: View,
899) -> View {
900    // Mark as full-screen so AppBarWithSearch can hide the collapsed bar
901    state.expands_to_full_screen.set(true);
902
903    let overlay_id = remember_with_key("efs_oid", || signal(0u64));
904    let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
905    *current_content.borrow_mut() = content;
906
907    let progress = state.progress();
908    let _content_alpha = state.content_progress();
909
910    let expanded = state.is_expanded();
911    let visible = expanded || progress > 0.01;
912
913    if visible {
914        if overlay_id.get() == 0 {
915            let input_fr = FocusRequester::new();
916            let focus_requested = Rc::new(Cell::new(false));
917            let builder: Rc<dyn Fn() -> View> = Rc::new({
918                let state = state.clone();
919                let modifier = modifier.clone();
920                let input_field = input_field.clone();
921                let current_content = current_content.clone();
922                let config = config.clone();
923                let input_fr = input_fr.clone();
924                let focus_requested = focus_requested.clone();
925                move || {
926                    let progress = state.progress();
927                    let content_alpha = state.content_progress();
928                    let alpha = progress.clamp(0.0, 1.0);
929                    let c_alpha = content_alpha.clamp(0.0, 1.0);
930                    let th = theme();
931                    let content = current_content.borrow().clone();
932
933                    // Wrap input with focus requester and request focus.
934                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
935                        .child(input_field.clone());
936                    if !focus_requested.get() {
937                        focus_requested.set(true);
938                        input_fr.request_focus();
939                    }
940
941                    let header = Box(modifier
942                        .clone()
943                        .fill_max_width()
944                        .height(SearchBarDefaults::HEIGHT)
945                        .padding_values(PaddingValues {
946                            left: 16.0,
947                            right: 16.0,
948                            top: 0.0,
949                            bottom: 0.0,
950                        })
951                        .background(config.colors.container_color)
952                        .alpha(alpha))
953                    .child(inp);
954
955                    let body = Box(Modifier::new()
956                        .fill_max_width()
957                        .flex_grow(1.0)
958                        .alpha(c_alpha)
959                        .background(th.surface))
960                    .child(content);
961
962                    let insets = config.window_insets;
963                    let full = Column(Modifier::new().fill_max_size().padding_values(
964                        PaddingValues {
965                            left: insets.left,
966                            right: insets.right,
967                            top: insets.top,
968                            bottom: insets.bottom,
969                        },
970                    ))
971                    .child((header, body));
972
973                    let scrim = Box(Modifier::new()
974                        .fill_max_size()
975                        .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
976                        .on_click({
977                            let s = state.clone();
978                            move || s.collapse()
979                        }));
980
981                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
982                }
983            });
984
985            let id = overlay.show_entry(builder, 900.0, false);
986            overlay_id.set(id);
987        }
988    } else {
989        let prev = overlay_id.get();
990        if prev != 0 {
991            let _ = overlay.dismiss(prev);
992            overlay_id.set(0);
993        }
994    }
995
996    Box(Modifier::new())
997}
998
999/// M3 Expanded Docked Search Bar -> rendered as an overlay popup anchored below
1000/// the collapsed search bar using `collapsed_layout_rect`.
1001/// Equivalent to CK's `ExpandedDockedSearchBar(state, inputField, ...)`.
1002pub fn ExpandedDockedSearchBar(
1003    state: Rc<SearchBarState>,
1004    overlay: OverlayHandle,
1005    input_field: View,
1006    modifier: Modifier,
1007    config: ExpandedDockedSearchBarConfig,
1008    content: View,
1009) -> View {
1010    // Docked search bar does NOT expand to full-screen
1011    state.expands_to_full_screen.set(false);
1012
1013    let overlay_id = remember_with_key("eds_oid", || signal(0u64));
1014    let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
1015    *current_content.borrow_mut() = content;
1016
1017    let progress = state.progress();
1018    let _content_alpha = state.content_progress();
1019    let expanded = state.is_expanded();
1020    let visible = expanded || progress > 0.01;
1021
1022    if visible {
1023        if overlay_id.get() == 0 {
1024            let input_fr = FocusRequester::new();
1025            let focus_requested = Rc::new(Cell::new(false));
1026            let builder: Rc<dyn Fn() -> View> = Rc::new({
1027                let state = state.clone();
1028                let modifier = modifier.clone();
1029                let input_field = input_field.clone();
1030                let current_content = current_content.clone();
1031                let config = config.clone();
1032                let input_fr = input_fr.clone();
1033                let focus_requested = focus_requested.clone();
1034                move || {
1035                    let progress = state.progress();
1036                    let content_alpha = state.content_progress();
1037                    let alpha = progress.clamp(0.0, 1.0);
1038                    let c_alpha = content_alpha.clamp(0.0, 1.0);
1039                    let th = theme();
1040                    let content = current_content.borrow().clone();
1041                    let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
1042
1043                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
1044                        .child(input_field.clone());
1045                    if !focus_requested.get() {
1046                        focus_requested.set(true);
1047                        input_fr.request_focus();
1048                    }
1049
1050                    let header = Box(modifier
1051                        .clone()
1052                        .fill_max_width()
1053                        .height(SearchBarDefaults::HEIGHT)
1054                        .alpha(alpha)
1055                        .background(config.colors.container_color)
1056                        .clip_rounded(config.shape_radius)
1057                        .state_elevation(StateElevation {
1058                            default: th.elevation.level3,
1059                            hovered: th.elevation.level2,
1060                            focused: th.elevation.level2,
1061                            pressed: th.elevation.level3,
1062                            dragged: th.elevation.level3,
1063                            disabled: 0.0,
1064                        }))
1065                    .child(inp);
1066
1067                    let dropdown = Box(Modifier::new()
1068                        .fill_max_width()
1069                        .max_height(get_window_container_height() * 2.0 / 3.0)
1070                        .alpha(c_alpha)
1071                        .clip_rounded(config.dropdown_shape_radius)
1072                        .background(config.colors.container_color)
1073                        .state_elevation(StateElevation {
1074                            default: th.elevation.level3,
1075                            hovered: th.elevation.level3,
1076                            focused: th.elevation.level3,
1077                            pressed: th.elevation.level3,
1078                            dragged: th.elevation.level3,
1079                            disabled: 0.0,
1080                        }))
1081                    .child(
1082                        Column(Modifier::new().fill_max_width()).child((
1083                            Box(Modifier::new()
1084                                .fill_max_width()
1085                                .height(1.0)
1086                                .background(config.colors.divider_color)),
1087                            content,
1088                        )),
1089                    );
1090
1091                    let docked_width = _cw.max(SearchBarDefaults::MIN_WIDTH);
1092                    let popup_left = _cx;
1093                    let popup_top = _cy + _ch + config.dropdown_gap_size;
1094
1095                    let col = Column(Modifier::new().fill_max_width()).child((header, dropdown));
1096
1097                    let positioned = Box(Modifier::new()
1098                        .absolute()
1099                        .offset(Some(popup_left), Some(popup_top), None, None)
1100                        .width(docked_width))
1101                    .child(col);
1102
1103                    let scrim = Box(Modifier::new()
1104                        .fill_max_size()
1105                        .background(config.dropdown_scrim_color)
1106                        .on_click({
1107                            let s = state.clone();
1108                            move || s.collapse()
1109                        }));
1110
1111                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, positioned))
1112                }
1113            });
1114
1115            let id = overlay.show_entry(builder, 900.0, false);
1116            overlay_id.set(id);
1117        }
1118    } else {
1119        let prev = overlay_id.get();
1120        if prev != 0 {
1121            let _ = overlay.dismiss(prev);
1122            overlay_id.set(0);
1123        }
1124    }
1125
1126    Box(Modifier::new())
1127}
1128
1129/// M3 App Bar With Search -> integrates a search bar into a top app bar layout
1130/// with optional navigation icon, action buttons, scroll behavior, and window insets.
1131/// Wraps the internal `SearchBar` collapsed component.
1132pub fn AppBarWithSearch(
1133    state: Rc<SearchBarState>,
1134    input_field: View,
1135    navigation_icon: Option<View>,
1136    actions: Option<Vec<View>>,
1137    config: AppBarWithSearchConfig,
1138) -> View {
1139    let bg = config.colors.search_bar_container(config.scroll_fraction);
1140    let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
1141
1142    let insets = config.window_insets;
1143
1144    // CK parity: when app bar container is transparent, disable tonal/shadow elevations
1145    let is_container_transparent = app_bar_bg.3 == 0;
1146    let tonal_elevation = if is_container_transparent {
1147        0.0
1148    } else {
1149        config.tonal_elevation
1150    };
1151    let shadow_elevation = if is_container_transparent {
1152        0.0
1153    } else {
1154        config.shadow_elevation
1155    };
1156
1157    // Hide the collapsed bar when full-screen expanded (CK parity via expandsToFullScreen)
1158    let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
1159    let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
1160
1161    let bar_m = Modifier::new()
1162        .fill_max_width()
1163        .height(config.height + insets.top)
1164        .translate(0.0, config.scroll_offset)
1165        .background(app_bar_bg)
1166        .semantics(Semantics::new(Role::Container).with_selectable_group());
1167
1168    let row = Row(Modifier::new()
1169        .fill_max_size()
1170        .align_items(AlignItems::CENTER)
1171        .padding_values(PaddingValues {
1172            left: config.content_padding.left + insets.left,
1173            right: config.content_padding.right + insets.right,
1174            top: insets.top,
1175            bottom: 0.0,
1176        }))
1177    .child({
1178        let mut children: Vec<View> = Vec::new();
1179        if let Some(nav) = navigation_icon {
1180            children.push(nav);
1181            children.push(Box(Modifier::new().width(4.0)));
1182        }
1183        // Wrap input_field in collapsed SearchBar (CK parity)
1184        let sb_colors = &config.colors.search_bar_colors;
1185        let collapsed_bar = SearchBar(
1186            state.clone(),
1187            input_field,
1188            Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
1189            None,
1190            None,
1191            SearchBarConfig {
1192                height: config.height - 8.0,
1193                shape_radius: config.shape_radius,
1194                colors: SearchBarColors {
1195                    container_color: bg,
1196                    active_container_color: bg,
1197                    divider_color: sb_colors.divider_color,
1198                    content_color: sb_colors.content_color,
1199                    placeholder_color: sb_colors.placeholder_color,
1200                    scrim_color: sb_colors.scrim_color,
1201                },
1202                tonal_elevation,
1203                shadow_elevation,
1204                ..Default::default()
1205            },
1206        );
1207        children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
1208        if let Some(acts) = actions {
1209            children.push(Spacer());
1210            for a in acts {
1211                children.push(a);
1212            }
1213        }
1214        children
1215    });
1216
1217    Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
1218}