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            selectable_group: false,
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            enabled: true,
623            selectable_group: false,
624        })
625        .background(colors.container_color)
626        .clip_rounded(config.shape_radius)
627        .then(track_collapsed_layout(&state));
628
629    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
630
631    Box(bar_m).child(
632        Row(Modifier::new()
633            .fill_max_size()
634            .align_items(AlignItems::CENTER))
635        .child((
636            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
637            Box(Modifier::new().width(8.0).fill_max_height()),
638            input_field,
639            trailing_icon.unwrap_or(Box(Modifier::new())),
640        )),
641    )
642}
643
644/// M3 Search Bar that manages expanded content with animated width and
645/// suggestions dropdown. Equivalent to CK's
646/// `SearchBar(inputField, expanded, onExpandedChange, ..., content)` overload.
647///
648/// The bar itself is a passive surface (no click handling) -> expansion is
649/// driven by the `InputField`'s focus tracking inside `input_field`.
650pub fn SearchBarWithContent(
651    input_field: View,
652    expanded: bool,
653    on_expanded_change: Rc<dyn Fn(bool)>,
654    modifier: Modifier,
655    leading_icon: Option<View>,
656    trailing_icon: Option<View>,
657    config: SearchBarConfig,
658    content: View,
659) -> View {
660    let th = theme();
661    let width = animate_f32(
662        "sbwc_w",
663        if expanded {
664            config.expanded_width
665        } else {
666            config.collapsed_width
667        },
668        theme().motion.expand,
669    );
670
671    let bar_bg = if expanded {
672        config.colors.active_container_color
673    } else {
674        config.colors.container_color
675    };
676    let shape = if expanded {
677        config.active_shape_radius
678    } else {
679        config.shape_radius
680    };
681
682    let mut bar_m = modifier
683        .clone()
684        .width(width)
685        .min_width(config.min_width)
686        .max_width(config.max_width)
687        .height(config.height)
688        .shadow(config.shadow_elevation, 0.0)
689        .padding_values(config.content_padding)
690        .on_key_event({
691            let cb = on_expanded_change.clone();
692            move |ev| {
693                if ev.key == Key::Escape {
694                    cb(false);
695                    true
696                } else {
697                    false
698                }
699            }
700        })
701        .background(bar_bg)
702        .clip_rounded(shape);
703
704    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
705
706    // Content fades with separate alpha so content can fade before collapse
707    let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
708
709    let bar = Box(bar_m).child(
710        Row(Modifier::new()
711            .fill_max_size()
712            .align_items(AlignItems::CENTER))
713        .child((
714            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
715            Box(Modifier::new().width(8.0).fill_max_height()),
716            input_field,
717            trailing_icon.unwrap_or(Box(Modifier::new())),
718        )),
719    );
720
721    let show_content = expanded || content_alpha > 0.01;
722    if show_content || expanded {
723        Column(modifier).child((
724            bar,
725            Box(Modifier::new()
726                .width(width)
727                .max_height(SearchBarDefaults::DOCKED_HEIGHT)
728                .alpha(content_alpha)
729                .background(config.colors.container_color)
730                .clip_rounded(th.shapes.extra_small))
731            .child(content),
732        ))
733    } else {
734        bar
735    }
736}
737
738/// M3 Docked Search Bar -> bounded-width variant with animated suggestions
739/// dropdown (height + alpha).  Equivalent to CK's
740/// `DockedSearchBar(inputField, expanded, onExpandedChange, ..., content)`.
741/// The bar itself is a passive Surface -> expansion is driven by `InputField`.
742pub fn DockedSearchBar(
743    input_field: View,
744    expanded: bool,
745    on_expanded_change: Option<Rc<dyn Fn(bool)>>,
746    modifier: Modifier,
747    leading_icon: Option<View>,
748    config: SearchBarConfig,
749    content: View,
750) -> View {
751    let th = theme();
752    let active = expanded;
753    let colors = config.colors;
754
755    let content_target = if expanded {
756        get_window_container_height() * 2.0 / 3.0
757    } else {
758        0.0
759    };
760    let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
761    let content_alpha = animate_f32(
762        "docked_sa",
763        if expanded { 1.0 } else { 0.0 },
764        theme().motion.color,
765    );
766    let bar_bg = if active {
767        colors.active_container_color
768    } else {
769        colors.container_color
770    };
771
772    let clear_source: Rc<MutableInteractionSource> = remember(MutableInteractionSource::new);
773    let clear_btn = if active {
774        Box(apply_m3_clickable(
775            Modifier::new().size(24.0, 24.0).clip_rounded(12.0),
776            &clear_source,
777            colors.placeholder_color,
778            true,
779            {
780                let cb = on_expanded_change.clone();
781                move || {
782                    if let Some(ref cb) = cb {
783                        cb(false);
784                    }
785                }
786            },
787        ))
788        .child(Text("✕").size(16.0).color(colors.placeholder_color))
789    } else {
790        Box(Modifier::new())
791    };
792
793    let mut bar_m = modifier
794        .z_index(1.0)
795        .min_width(SearchBarDefaults::MIN_WIDTH)
796        .height(config.height)
797        .state_elevation(StateElevation {
798            default: if active {
799                th.elevation.level3
800            } else {
801                config.tonal_elevation
802            },
803            hovered: th.elevation.level2,
804            focused: th.elevation.level2,
805            pressed: th.elevation.level3,
806            dragged: th.elevation.level3,
807            disabled: 0.0,
808        })
809        .shadow(config.shadow_elevation, 0.0)
810        .padding_values(config.content_padding)
811        .on_key_event({
812            let cb = on_expanded_change.clone();
813            move |ev| {
814                if ev.key == Key::Escape {
815                    if let Some(ref cb) = cb {
816                        cb(false);
817                    }
818                    true
819                } else {
820                    false
821                }
822            }
823        })
824        .background(bar_bg)
825        .clip_rounded(config.shape_radius);
826
827    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
828
829    let bar = Box(bar_m).child(
830        Row(Modifier::new()
831            .fill_max_size()
832            .align_items(AlignItems::CENTER))
833        .child((
834            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
835            Box(Modifier::new().width(12.0).fill_max_height()),
836            input_field,
837            clear_btn,
838        )),
839    );
840
841    let show_content = expanded || content_height > 1.0;
842    if show_content {
843        Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
844            bar,
845            Box(Modifier::new()
846                .min_width(SearchBarDefaults::MIN_WIDTH)
847                .height(content_height)
848                .alpha(content_alpha)
849                .clip_rounded(th.shapes.small)
850                .background(colors.container_color)
851                .state_elevation(StateElevation {
852                    default: th.elevation.level3,
853                    hovered: th.elevation.level3,
854                    focused: th.elevation.level3,
855                    pressed: th.elevation.level3,
856                    dragged: th.elevation.level3,
857                    disabled: 0.0,
858                }))
859            .child(
860                Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
861                    Box(Modifier::new()
862                        .min_width(SearchBarDefaults::MIN_WIDTH)
863                        .height(1.0)
864                        .background(colors.divider_color)),
865                    content,
866                )),
867            ),
868        ))
869    } else {
870        bar
871    }
872}
873
874/// Platform-agnostic window container height. On Skiko this would read
875/// `LocalWindowInfo`, on Android `LocalConfiguration`. Defaults to 800 dp.
876/// The `LayoutEngine` keeps this current from the physical viewport + density.
877pub fn set_window_container_height(h: f32) {
878    repose_core::locals::set_window_container_height(h);
879}
880
881fn get_window_container_height() -> f32 {
882    repose_core::locals::get_window_container_height()
883}
884
885/// Set the window container width (in dp) used for dropdown constraints.
886pub fn set_window_container_width(w: f32) {
887    repose_core::locals::set_window_container_width(w);
888}
889
890/// M3 Expanded Full‑Screen Search Bar -> rendered in an overlay covering the
891/// entire window. Uses the state's own `progress()` for animation.
892/// Equivalent to CK's `ExpandedFullScreenSearchBar(state, inputField, ...)`.
893pub fn ExpandedFullScreenSearchBar(
894    state: Rc<SearchBarState>,
895    overlay: OverlayHandle,
896    input_field: View,
897    modifier: Modifier,
898    config: ExpandedFullScreenSearchBarConfig,
899    content: View,
900) -> View {
901    // Mark as full-screen so AppBarWithSearch can hide the collapsed bar
902    state.expands_to_full_screen.set(true);
903
904    let overlay_id = remember_with_key("efs_oid", || signal(0u64));
905    let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
906    *current_content.borrow_mut() = content;
907
908    let progress = state.progress();
909    let _content_alpha = state.content_progress();
910
911    let expanded = state.is_expanded();
912    let visible = expanded || progress > 0.01;
913
914    if visible {
915        if overlay_id.get() == 0 {
916            let input_fr = FocusRequester::new();
917            let focus_requested = Rc::new(Cell::new(false));
918            let builder: Rc<dyn Fn() -> View> = Rc::new({
919                let state = state.clone();
920                let modifier = modifier.clone();
921                let input_field = input_field.clone();
922                let current_content = current_content.clone();
923                let config = config.clone();
924                let input_fr = input_fr.clone();
925                let focus_requested = focus_requested.clone();
926                move || {
927                    let progress = state.progress();
928                    let content_alpha = state.content_progress();
929                    let alpha = progress.clamp(0.0, 1.0);
930                    let c_alpha = content_alpha.clamp(0.0, 1.0);
931                    let th = theme();
932                    let content = current_content.borrow().clone();
933
934                    // Wrap input with focus requester and request focus.
935                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
936                        .child(input_field.clone());
937                    if !focus_requested.get() {
938                        focus_requested.set(true);
939                        input_fr.request_focus();
940                    }
941
942                    let header = Box(modifier
943                        .clone()
944                        .fill_max_width()
945                        .height(SearchBarDefaults::HEIGHT)
946                        .padding_values(PaddingValues {
947                            left: 16.0,
948                            right: 16.0,
949                            top: 0.0,
950                            bottom: 0.0,
951                        })
952                        .background(config.colors.container_color)
953                        .alpha(alpha))
954                    .child(inp);
955
956                    let body = Box(Modifier::new()
957                        .fill_max_width()
958                        .flex_grow(1.0)
959                        .alpha(c_alpha)
960                        .background(th.surface))
961                    .child(content);
962
963                    let insets = config.window_insets;
964                    let full = Column(Modifier::new().fill_max_size().padding_values(
965                        PaddingValues {
966                            left: insets.left,
967                            right: insets.right,
968                            top: insets.top,
969                            bottom: insets.bottom,
970                        },
971                    ))
972                    .child((header, body));
973
974                    let scrim = Box(Modifier::new()
975                        .fill_max_size()
976                        .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
977                        .on_click({
978                            let s = state.clone();
979                            move || s.collapse()
980                        }));
981
982                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
983                }
984            });
985
986            let id = overlay.show_entry(builder, 900.0, false);
987            overlay_id.set(id);
988        }
989    } else {
990        let prev = overlay_id.get();
991        if prev != 0 {
992            let _ = overlay.dismiss(prev);
993            overlay_id.set(0);
994        }
995    }
996
997    Box(Modifier::new())
998}
999
1000/// M3 Expanded Docked Search Bar -> rendered as an overlay popup anchored below
1001/// the collapsed search bar using `collapsed_layout_rect`.
1002/// Equivalent to CK's `ExpandedDockedSearchBar(state, inputField, ...)`.
1003pub fn ExpandedDockedSearchBar(
1004    state: Rc<SearchBarState>,
1005    overlay: OverlayHandle,
1006    input_field: View,
1007    modifier: Modifier,
1008    config: ExpandedDockedSearchBarConfig,
1009    content: View,
1010) -> View {
1011    // Docked search bar does NOT expand to full-screen
1012    state.expands_to_full_screen.set(false);
1013
1014    let overlay_id = remember_with_key("eds_oid", || signal(0u64));
1015    let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
1016    *current_content.borrow_mut() = content;
1017
1018    let progress = state.progress();
1019    let _content_alpha = state.content_progress();
1020    let expanded = state.is_expanded();
1021    let visible = expanded || progress > 0.01;
1022
1023    if visible {
1024        if overlay_id.get() == 0 {
1025            let input_fr = FocusRequester::new();
1026            let focus_requested = Rc::new(Cell::new(false));
1027            let builder: Rc<dyn Fn() -> View> = Rc::new({
1028                let state = state.clone();
1029                let modifier = modifier.clone();
1030                let input_field = input_field.clone();
1031                let current_content = current_content.clone();
1032                let config = config.clone();
1033                let input_fr = input_fr.clone();
1034                let focus_requested = focus_requested.clone();
1035                move || {
1036                    let progress = state.progress();
1037                    let content_alpha = state.content_progress();
1038                    let alpha = progress.clamp(0.0, 1.0);
1039                    let c_alpha = content_alpha.clamp(0.0, 1.0);
1040                    let th = theme();
1041                    let content = current_content.borrow().clone();
1042                    let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
1043
1044                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
1045                        .child(input_field.clone());
1046                    if !focus_requested.get() {
1047                        focus_requested.set(true);
1048                        input_fr.request_focus();
1049                    }
1050
1051                    let header = Box(modifier
1052                        .clone()
1053                        .fill_max_width()
1054                        .height(SearchBarDefaults::HEIGHT)
1055                        .alpha(alpha)
1056                        .background(config.colors.container_color)
1057                        .clip_rounded(config.shape_radius)
1058                        .state_elevation(StateElevation {
1059                            default: th.elevation.level3,
1060                            hovered: th.elevation.level2,
1061                            focused: th.elevation.level2,
1062                            pressed: th.elevation.level3,
1063                            dragged: th.elevation.level3,
1064                            disabled: 0.0,
1065                        }))
1066                    .child(inp);
1067
1068                    let dropdown = Box(Modifier::new()
1069                        .fill_max_width()
1070                        .max_height(get_window_container_height() * 2.0 / 3.0)
1071                        .alpha(c_alpha)
1072                        .clip_rounded(config.dropdown_shape_radius)
1073                        .background(config.colors.container_color)
1074                        .state_elevation(StateElevation {
1075                            default: th.elevation.level3,
1076                            hovered: th.elevation.level3,
1077                            focused: th.elevation.level3,
1078                            pressed: th.elevation.level3,
1079                            dragged: th.elevation.level3,
1080                            disabled: 0.0,
1081                        }))
1082                    .child(
1083                        Column(Modifier::new().fill_max_width()).child((
1084                            Box(Modifier::new()
1085                                .fill_max_width()
1086                                .height(1.0)
1087                                .background(config.colors.divider_color)),
1088                            content,
1089                        )),
1090                    );
1091
1092                    let docked_width = _cw.max(SearchBarDefaults::MIN_WIDTH);
1093                    let popup_left = _cx;
1094                    let popup_top = _cy + _ch + config.dropdown_gap_size;
1095
1096                    let col = Column(Modifier::new().fill_max_width()).child((header, dropdown));
1097
1098                    let positioned = Box(Modifier::new()
1099                        .absolute()
1100                        .offset(Some(popup_left), Some(popup_top), None, None)
1101                        .width(docked_width))
1102                    .child(col);
1103
1104                    let scrim = Box(Modifier::new()
1105                        .fill_max_size()
1106                        .background(config.dropdown_scrim_color)
1107                        .on_click({
1108                            let s = state.clone();
1109                            move || s.collapse()
1110                        }));
1111
1112                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, positioned))
1113                }
1114            });
1115
1116            let id = overlay.show_entry(builder, 900.0, false);
1117            overlay_id.set(id);
1118        }
1119    } else {
1120        let prev = overlay_id.get();
1121        if prev != 0 {
1122            let _ = overlay.dismiss(prev);
1123            overlay_id.set(0);
1124        }
1125    }
1126
1127    Box(Modifier::new())
1128}
1129
1130/// M3 App Bar With Search -> integrates a search bar into a top app bar layout
1131/// with optional navigation icon, action buttons, scroll behavior, and window insets.
1132/// Wraps the internal `SearchBar` collapsed component.
1133pub fn AppBarWithSearch(
1134    state: Rc<SearchBarState>,
1135    input_field: View,
1136    navigation_icon: Option<View>,
1137    actions: Option<Vec<View>>,
1138    config: AppBarWithSearchConfig,
1139) -> View {
1140    let bg = config.colors.search_bar_container(config.scroll_fraction);
1141    let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
1142
1143    let insets = config.window_insets;
1144
1145    // CK parity: when app bar container is transparent, disable tonal/shadow elevations
1146    let is_container_transparent = app_bar_bg.3 == 0;
1147    let tonal_elevation = if is_container_transparent {
1148        0.0
1149    } else {
1150        config.tonal_elevation
1151    };
1152    let shadow_elevation = if is_container_transparent {
1153        0.0
1154    } else {
1155        config.shadow_elevation
1156    };
1157
1158    // Hide the collapsed bar when full-screen expanded (CK parity via expandsToFullScreen)
1159    let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
1160    let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
1161
1162    let bar_m = Modifier::new()
1163        .fill_max_width()
1164        .height(config.height + insets.top)
1165        .translate(0.0, config.scroll_offset)
1166        .background(app_bar_bg)
1167        .semantics(Semantics::new(Role::Container).with_selectable_group());
1168
1169    let row = Row(Modifier::new()
1170        .fill_max_size()
1171        .align_items(AlignItems::CENTER)
1172        .padding_values(PaddingValues {
1173            left: config.content_padding.left + insets.left,
1174            right: config.content_padding.right + insets.right,
1175            top: insets.top,
1176            bottom: 0.0,
1177        }))
1178    .child({
1179        let mut children: Vec<View> = Vec::new();
1180        if let Some(nav) = navigation_icon {
1181            children.push(nav);
1182            children.push(Box(Modifier::new().width(4.0)));
1183        }
1184        // Wrap input_field in collapsed SearchBar (CK parity)
1185        let sb_colors = &config.colors.search_bar_colors;
1186        let collapsed_bar = SearchBar(
1187            state.clone(),
1188            input_field,
1189            Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
1190            None,
1191            None,
1192            SearchBarConfig {
1193                height: config.height - 8.0,
1194                shape_radius: config.shape_radius,
1195                colors: SearchBarColors {
1196                    container_color: bg,
1197                    active_container_color: bg,
1198                    divider_color: sb_colors.divider_color,
1199                    content_color: sb_colors.content_color,
1200                    placeholder_color: sb_colors.placeholder_color,
1201                    scrim_color: sb_colors.scrim_color,
1202                },
1203                tonal_elevation,
1204                shadow_elevation,
1205                ..Default::default()
1206            },
1207        );
1208        children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
1209        if let Some(acts) = actions {
1210            children.push(Spacer());
1211            for a in acts {
1212                children.push(a);
1213            }
1214        }
1215        children
1216    });
1217
1218    Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
1219}