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