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().size(24.0, 24.0).clickable().on_click({
773            let cb = on_expanded_change.clone();
774            move || {
775                if let Some(ref cb) = cb {
776                    cb(false);
777                }
778            }
779        }))
780        .child(Text("✕").size(16.0).color(colors.placeholder_color))
781    } else {
782        Box(Modifier::new())
783    };
784
785    let mut bar_m = modifier
786        .z_index(1.0)
787        .min_width(SearchBarDefaults::MIN_WIDTH)
788        .height(config.height)
789        .state_elevation(StateElevation {
790            default: if active {
791                th.elevation.level3
792            } else {
793                config.tonal_elevation
794            },
795            hovered: th.elevation.level2,
796            focused: th.elevation.level2,
797            pressed: th.elevation.level3,
798            dragged: th.elevation.level3,
799            disabled: 0.0,
800        })
801        .shadow(config.shadow_elevation, 0.0)
802        .padding_values(config.content_padding)
803        .on_key_event({
804            let cb = on_expanded_change.clone();
805            move |ev| {
806                if ev.key == Key::Escape {
807                    if let Some(ref cb) = cb {
808                        cb(false);
809                    }
810                    true
811                } else {
812                    false
813                }
814            }
815        })
816        .background(bar_bg)
817        .clip_rounded(config.shape_radius);
818
819    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
820
821    let bar = Box(bar_m).child(
822        Row(Modifier::new()
823            .fill_max_size()
824            .align_items(AlignItems::CENTER))
825        .child((
826            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
827            Box(Modifier::new().width(12.0).fill_max_height()),
828            input_field,
829            clear_btn,
830        )),
831    );
832
833    let show_content = expanded || content_height > 1.0;
834    if show_content {
835        Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
836            bar,
837            Box(Modifier::new()
838                .min_width(SearchBarDefaults::MIN_WIDTH)
839                .height(content_height)
840                .alpha(content_alpha)
841                .clip_rounded(th.shapes.small)
842                .background(colors.container_color)
843                .state_elevation(StateElevation {
844                    default: th.elevation.level3,
845                    hovered: th.elevation.level3,
846                    focused: th.elevation.level3,
847                    pressed: th.elevation.level3,
848                    dragged: th.elevation.level3,
849                    disabled: 0.0,
850                }))
851            .child(
852                Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
853                    Box(Modifier::new()
854                        .min_width(SearchBarDefaults::MIN_WIDTH)
855                        .height(1.0)
856                        .background(colors.divider_color)),
857                    content,
858                )),
859            ),
860        ))
861    } else {
862        bar
863    }
864}
865
866/// Platform-agnostic window container height. On Skiko this would read
867/// `LocalWindowInfo`, on Android `LocalConfiguration`. Defaults to 800 dp.
868/// The `LayoutEngine` keeps this current from the physical viewport + density.
869pub fn set_window_container_height(h: f32) {
870    repose_core::locals::set_window_container_height(h);
871}
872
873fn get_window_container_height() -> f32 {
874    repose_core::locals::get_window_container_height()
875}
876
877/// Set the window container width (in dp) used for dropdown constraints.
878pub fn set_window_container_width(w: f32) {
879    repose_core::locals::set_window_container_width(w);
880}
881
882fn get_window_container_width() -> f32 {
883    repose_core::locals::get_window_container_width()
884}
885
886/// M3 Expanded Full‑Screen Search Bar -> rendered in an overlay covering the
887/// entire window. Uses the state's own `progress()` for animation.
888/// Equivalent to CK's `ExpandedFullScreenSearchBar(state, inputField, ...)`.
889pub fn ExpandedFullScreenSearchBar(
890    state: Rc<SearchBarState>,
891    overlay: OverlayHandle,
892    input_field: View,
893    modifier: Modifier,
894    config: ExpandedFullScreenSearchBarConfig,
895    content: View,
896) -> View {
897    // Mark as full-screen so AppBarWithSearch can hide the collapsed bar
898    state.expands_to_full_screen.set(true);
899
900    let overlay_id = remember_with_key("efs_oid", || signal(0u64));
901    let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
902    *current_content.borrow_mut() = content;
903
904    let progress = state.progress();
905    let _content_alpha = state.content_progress();
906
907    let expanded = state.is_expanded();
908    let visible = expanded || progress > 0.01;
909
910    if visible {
911        if overlay_id.get() == 0 {
912            let input_fr = FocusRequester::new();
913            let focus_requested = Rc::new(Cell::new(false));
914            let builder: Rc<dyn Fn() -> View> = Rc::new({
915                let state = state.clone();
916                let modifier = modifier.clone();
917                let input_field = input_field.clone();
918                let current_content = current_content.clone();
919                let config = config.clone();
920                let input_fr = input_fr.clone();
921                let focus_requested = focus_requested.clone();
922                move || {
923                    let progress = state.progress();
924                    let content_alpha = state.content_progress();
925                    let alpha = progress.clamp(0.0, 1.0);
926                    let c_alpha = content_alpha.clamp(0.0, 1.0);
927                    let th = theme();
928                    let content = current_content.borrow().clone();
929
930                    // Wrap input with focus requester and request focus.
931                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
932                        .child(input_field.clone());
933                    if !focus_requested.get() {
934                        focus_requested.set(true);
935                        input_fr.request_focus();
936                    }
937
938                    let header = Box(modifier
939                        .clone()
940                        .fill_max_width()
941                        .height(SearchBarDefaults::HEIGHT)
942                        .padding_values(PaddingValues {
943                            left: 16.0,
944                            right: 16.0,
945                            top: 0.0,
946                            bottom: 0.0,
947                        })
948                        .background(config.colors.container_color)
949                        .alpha(alpha))
950                    .child(inp);
951
952                    let body = Box(Modifier::new()
953                        .fill_max_width()
954                        .flex_grow(1.0)
955                        .alpha(c_alpha)
956                        .background(th.surface))
957                    .child(content);
958
959                    let insets = config.window_insets;
960                    let full = Column(Modifier::new().fill_max_size().padding_values(
961                        PaddingValues {
962                            left: insets.left,
963                            right: insets.right,
964                            top: insets.top,
965                            bottom: insets.bottom,
966                        },
967                    ))
968                    .child((header, body));
969
970                    let scrim = Box(Modifier::new()
971                        .fill_max_size()
972                        .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
973                        .on_click({
974                            let s = state.clone();
975                            move || s.collapse()
976                        }));
977
978                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
979                }
980            });
981
982            let id = overlay.show_entry(builder, 900.0, false);
983            overlay_id.set(id);
984        }
985    } else {
986        let prev = overlay_id.get();
987        if prev != 0 {
988            let _ = overlay.dismiss(prev);
989            overlay_id.set(0);
990        }
991    }
992
993    Box(Modifier::new())
994}
995
996/// M3 Expanded Docked Search Bar -> rendered as an overlay popup anchored below
997/// the collapsed search bar using `collapsed_layout_rect`.
998/// Equivalent to CK's `ExpandedDockedSearchBar(state, inputField, ...)`.
999pub fn ExpandedDockedSearchBar(
1000    state: Rc<SearchBarState>,
1001    overlay: OverlayHandle,
1002    input_field: View,
1003    modifier: Modifier,
1004    config: ExpandedDockedSearchBarConfig,
1005    content: View,
1006) -> View {
1007    // Docked search bar does NOT expand to full-screen
1008    state.expands_to_full_screen.set(false);
1009
1010    let overlay_id = remember_with_key("eds_oid", || signal(0u64));
1011    let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
1012    *current_content.borrow_mut() = content;
1013
1014    let progress = state.progress();
1015    let _content_alpha = state.content_progress();
1016    let expanded = state.is_expanded();
1017    let visible = expanded || progress > 0.01;
1018
1019    if visible {
1020        if overlay_id.get() == 0 {
1021            let input_fr = FocusRequester::new();
1022            let focus_requested = Rc::new(Cell::new(false));
1023            let builder: Rc<dyn Fn() -> View> = Rc::new({
1024                let state = state.clone();
1025                let modifier = modifier.clone();
1026                let input_field = input_field.clone();
1027                let current_content = current_content.clone();
1028                let config = config.clone();
1029                let input_fr = input_fr.clone();
1030                let focus_requested = focus_requested.clone();
1031                move || {
1032                    let progress = state.progress();
1033                    let content_alpha = state.content_progress();
1034                    let alpha = progress.clamp(0.0, 1.0);
1035                    let c_alpha = content_alpha.clamp(0.0, 1.0);
1036                    let th = theme();
1037                    let content = current_content.borrow().clone();
1038                    let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
1039
1040                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
1041                        .child(input_field.clone());
1042                    if !focus_requested.get() {
1043                        focus_requested.set(true);
1044                        input_fr.request_focus();
1045                    }
1046
1047                    let header = Box(modifier
1048                        .clone()
1049                        .fill_max_width()
1050                        .height(SearchBarDefaults::HEIGHT)
1051                        .alpha(alpha)
1052                        .background(config.colors.container_color)
1053                        .clip_rounded(config.shape_radius)
1054                        .state_elevation(StateElevation {
1055                            default: th.elevation.level3,
1056                            hovered: th.elevation.level2,
1057                            focused: th.elevation.level2,
1058                            pressed: th.elevation.level3,
1059                            dragged: th.elevation.level3,
1060                            disabled: 0.0,
1061                        }))
1062                    .child(inp);
1063
1064                    let dropdown = Box(Modifier::new()
1065                        .fill_max_width()
1066                        .max_height(get_window_container_height() * 2.0 / 3.0)
1067                        .alpha(c_alpha)
1068                        .clip_rounded(config.dropdown_shape_radius)
1069                        .background(config.colors.container_color)
1070                        .state_elevation(StateElevation {
1071                            default: th.elevation.level3,
1072                            hovered: th.elevation.level3,
1073                            focused: th.elevation.level3,
1074                            pressed: th.elevation.level3,
1075                            dragged: th.elevation.level3,
1076                            disabled: 0.0,
1077                        }))
1078                    .child(
1079                        Column(Modifier::new().fill_max_width()).child((
1080                            Box(Modifier::new()
1081                                .fill_max_width()
1082                                .height(1.0)
1083                                .background(config.colors.divider_color)),
1084                            content,
1085                        )),
1086                    );
1087
1088                    let docked_width = _cw.max(SearchBarDefaults::MIN_WIDTH);
1089                    let popup_left = _cx;
1090                    let popup_top = _cy + _ch + config.dropdown_gap_size;
1091
1092                    let col = Column(Modifier::new().fill_max_width()).child((header, dropdown));
1093
1094                    let positioned = Box(Modifier::new()
1095                        .absolute()
1096                        .offset(Some(popup_left), Some(popup_top), None, None)
1097                        .width(docked_width))
1098                    .child(col);
1099
1100                    let scrim = Box(Modifier::new()
1101                        .fill_max_size()
1102                        .background(config.dropdown_scrim_color)
1103                        .on_click({
1104                            let s = state.clone();
1105                            move || s.collapse()
1106                        }));
1107
1108                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, positioned))
1109                }
1110            });
1111
1112            let id = overlay.show_entry(builder, 900.0, false);
1113            overlay_id.set(id);
1114        }
1115    } else {
1116        let prev = overlay_id.get();
1117        if prev != 0 {
1118            let _ = overlay.dismiss(prev);
1119            overlay_id.set(0);
1120        }
1121    }
1122
1123    Box(Modifier::new())
1124}
1125
1126/// M3 App Bar With Search -> integrates a search bar into a top app bar layout
1127/// with optional navigation icon, action buttons, scroll behavior, and window insets.
1128/// Wraps the internal `SearchBar` collapsed component.
1129pub fn AppBarWithSearch(
1130    state: Rc<SearchBarState>,
1131    input_field: View,
1132    navigation_icon: Option<View>,
1133    actions: Option<Vec<View>>,
1134    config: AppBarWithSearchConfig,
1135) -> View {
1136    let bg = config.colors.search_bar_container(config.scroll_fraction);
1137    let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
1138
1139    let insets = config.window_insets;
1140
1141    // CK parity: when app bar container is transparent, disable tonal/shadow elevations
1142    let is_container_transparent = app_bar_bg.3 == 0;
1143    let tonal_elevation = if is_container_transparent {
1144        0.0
1145    } else {
1146        config.tonal_elevation
1147    };
1148    let shadow_elevation = if is_container_transparent {
1149        0.0
1150    } else {
1151        config.shadow_elevation
1152    };
1153
1154    // Hide the collapsed bar when full-screen expanded (CK parity via expandsToFullScreen)
1155    let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
1156    let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
1157
1158    let bar_m = Modifier::new()
1159        .fill_max_width()
1160        .height(config.height + insets.top)
1161        .translate(0.0, config.scroll_offset)
1162        .background(app_bar_bg)
1163        .semantics(Semantics::new(Role::Container).with_selectable_group());
1164
1165    let row = Row(Modifier::new()
1166        .fill_max_size()
1167        .align_items(AlignItems::CENTER)
1168        .padding_values(PaddingValues {
1169            left: config.content_padding.left + insets.left,
1170            right: config.content_padding.right + insets.right,
1171            top: insets.top,
1172            bottom: 0.0,
1173        }))
1174    .child({
1175        let mut children: Vec<View> = Vec::new();
1176        if let Some(nav) = navigation_icon {
1177            children.push(nav);
1178            children.push(Box(Modifier::new().width(4.0)));
1179        }
1180        // Wrap input_field in collapsed SearchBar (CK parity)
1181        let sb_colors = &config.colors.search_bar_colors;
1182        let collapsed_bar = SearchBar(
1183            state.clone(),
1184            input_field,
1185            Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
1186            None,
1187            None,
1188            SearchBarConfig {
1189                height: config.height - 8.0,
1190                shape_radius: config.shape_radius,
1191                colors: SearchBarColors {
1192                    container_color: bg,
1193                    active_container_color: bg,
1194                    divider_color: sb_colors.divider_color,
1195                    content_color: sb_colors.content_color,
1196                    placeholder_color: sb_colors.placeholder_color,
1197                    scrim_color: sb_colors.scrim_color,
1198                },
1199                tonal_elevation,
1200                shadow_elevation,
1201                ..Default::default()
1202            },
1203        );
1204        children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
1205        if let Some(acts) = actions {
1206            children.push(Spacer());
1207            for a in acts {
1208                children.push(a);
1209            }
1210        }
1211        children
1212    });
1213
1214    Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
1215}