Skip to main content

tessera_components/
search.rs

1//! Search bar component - capture queries with optional suggestion content.
2//!
3//! ## Usage
4//!
5//! Use to collect search queries and show suggestions or results as the user
6//! types.
7use tessera_ui::{
8    CallbackWith, Color, CursorEventContent, Dp, KeyboardInput, KeyboardInputModifierNode,
9    Modifier, PointerInput, PointerInputModifierNode, PressKeyEventType, RenderSlot, State,
10    layout::layout, modifier::ModifierCapabilityExt as _, remember, tessera, use_context, winit,
11};
12
13use crate::{
14    alignment::{Alignment, CrossAxisAlignment},
15    boxed::boxed,
16    column::column,
17    divider::horizontal_divider,
18    modifier::ModifierExt as _,
19    pos_misc::is_position_inside_bounds,
20    row::row,
21    shape_def::Shape,
22    spacer::spacer,
23    surface::surface,
24    text_field::{TextFieldDefaults, TextFieldLineLimit, text_field},
25    text_input::TextInputController,
26    theme::MaterialTheme,
27};
28
29const DEFAULT_RESULTS_PADDING: Dp = Dp(16.0);
30
31/// Color values used by search bars.
32#[derive(Clone, PartialEq, Copy, Debug)]
33pub struct SearchBarColors {
34    /// Container color for the search bar and results surface.
35    pub container_color: Color,
36    /// Divider color between the input field and results content.
37    pub divider_color: Color,
38}
39
40impl Default for SearchBarColors {
41    fn default() -> Self {
42        SearchBarColors {
43            container_color: Color::new(0.9, 0.9, 0.9, 1.0),
44            divider_color: Color::new(0.5, 0.5, 0.5, 1.0),
45        }
46    }
47}
48
49/// Defaults for search bars.
50pub struct SearchBarDefaults;
51
52impl SearchBarDefaults {
53    /// Default height for the search input field.
54    pub const INPUT_HEIGHT: Dp = Dp(56.0);
55    /// Default tonal elevation for search bars.
56    pub const TONAL_ELEVATION: Dp = Dp(0.0);
57    /// Default shadow elevation for search bars.
58    pub const SHADOW_ELEVATION: Dp = Dp(0.0);
59    /// Default gap between a docked input field and its results surface.
60    pub const DOCKED_DROPDOWN_GAP: Dp = Dp(2.0);
61    /// Default rounded corner radius for a docked results surface.
62    pub const DOCKED_DROPDOWN_RADIUS: Dp = Dp(12.0);
63
64    /// Default shape for a collapsed search input field.
65    pub fn input_shape() -> Shape {
66        Shape::CAPSULE
67    }
68
69    /// Default shape for a docked search bar container.
70    pub fn docked_shape() -> Shape {
71        Shape::rounded_rectangle(Dp(28.0))
72    }
73
74    /// Default shape for a docked results surface.
75    pub fn docked_dropdown_shape() -> Shape {
76        Shape::rounded_rectangle(Self::DOCKED_DROPDOWN_RADIUS)
77    }
78
79    /// Default colors for search bars.
80    pub fn colors() -> SearchBarColors {
81        SearchBarColors::default()
82    }
83}
84
85#[derive(Clone, PartialEq)]
86struct SearchBarProps {
87    modifier: Modifier,
88    enabled: bool,
89    read_only: bool,
90    is_active: Option<bool>,
91    placeholder: Option<String>,
92    leading_icon: Option<RenderSlot>,
93    trailing_icon: Option<RenderSlot>,
94    on_query_change: Option<CallbackWith<String, String>>,
95    on_search: Option<CallbackWith<String, ()>>,
96    on_active_change: Option<CallbackWith<bool, ()>>,
97    shape: Shape,
98    colors: SearchBarColors,
99    results_container_color: Option<Color>,
100    tonal_elevation: Dp,
101    shadow_elevation: Dp,
102    dropdown_shape: Shape,
103    dropdown_gap: Dp,
104    show_divider: bool,
105    content_padding: Dp,
106    controller: Option<State<SearchBarController>>,
107    content: Option<RenderSlot>,
108}
109
110impl Default for SearchBarProps {
111    fn default() -> Self {
112        Self {
113            modifier: Modifier::new(),
114            enabled: true,
115            read_only: false,
116            is_active: None,
117            placeholder: None,
118            leading_icon: None,
119            trailing_icon: None,
120            on_query_change: None,
121            on_search: None,
122            on_active_change: None,
123            shape: SearchBarDefaults::input_shape(),
124            colors: SearchBarDefaults::colors(),
125            results_container_color: None,
126            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
127            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
128            dropdown_shape: SearchBarDefaults::docked_dropdown_shape(),
129            dropdown_gap: SearchBarDefaults::DOCKED_DROPDOWN_GAP,
130            show_divider: true,
131            content_padding: DEFAULT_RESULTS_PADDING,
132            controller: None,
133            content: None,
134        }
135    }
136}
137
138/// Controller for search bars, managing active state and the current query.
139#[derive(Clone, PartialEq)]
140pub struct SearchBarController {
141    is_active: bool,
142    query: String,
143}
144
145impl SearchBarController {
146    /// Creates a new controller.
147    pub fn new(initial_active: bool) -> Self {
148        Self {
149            is_active: initial_active,
150            query: String::new(),
151        }
152    }
153
154    /// Opens the search bar.
155    pub fn open(&mut self) {
156        self.is_active = true;
157    }
158
159    /// Closes the search bar.
160    pub fn close(&mut self) {
161        self.is_active = false;
162    }
163
164    /// Toggles the active state.
165    pub fn toggle(&mut self) {
166        self.is_active = !self.is_active;
167    }
168
169    /// Returns whether the search bar is active.
170    pub fn is_active(&self) -> bool {
171        self.is_active
172    }
173
174    /// Updates the stored query.
175    pub fn set_query(&mut self, query: impl Into<String>) {
176        self.query = query.into();
177    }
178
179    /// Returns the current query.
180    pub fn query(&self) -> &str {
181        &self.query
182    }
183}
184
185impl Default for SearchBarController {
186    fn default() -> Self {
187        Self::new(false)
188    }
189}
190
191#[derive(Clone, PartialEq, Copy, Default)]
192enum SearchBarLayoutKind {
193    #[default]
194    FullScreen,
195    Docked,
196}
197
198/// # search_bar
199///
200/// Show a full-screen search bar that expands to display results.
201///
202/// ## Usage
203///
204/// Use for immersive search flows where results take over the screen.
205///
206/// ## Parameters
207///
208/// - `modifier` - modifier chain applied to the search bar container.
209/// - `enabled` - whether the input is enabled.
210/// - `read_only` - whether the input is read-only.
211/// - `is_active` - whether the search bar is currently expanded.
212/// - `placeholder` - input hint and accessible name for the search field.
213/// - `leading_icon` - optional leading icon slot.
214/// - `trailing_icon` - optional trailing icon slot.
215/// - `on_query_change` - optional callback for query changes.
216/// - `on_search` - optional callback invoked when the query is submitted.
217/// - `on_active_change` - optional callback invoked when active state changes.
218/// - `shape` - shape used for the search input.
219/// - `colors` - search bar color configuration.
220/// - `results_container_color` - results surface color, matching the input by
221///   default.
222/// - `tonal_elevation` - tonal elevation for the search container.
223/// - `shadow_elevation` - shadow elevation for the search container.
224/// - `dropdown_shape` - shape used for docked result surfaces.
225/// - `dropdown_gap` - gap between a docked field and its results.
226/// - `show_divider` - show the results divider; disable for separated contained
227///   surfaces.
228/// - `content_padding` - padding inside the result surface.
229/// - `controller` - optional external controller for active/query state.
230/// - `content` - optional results content slot.
231///
232/// ## Examples
233///
234/// ```
235/// # use tessera_ui::tessera;
236/// # use tessera_components::theme::{MaterialTheme, material_theme};
237/// # #[tessera]
238/// # fn component() {
239/// use tessera_components::search::search_bar;
240/// material_theme()
241///     .theme(|| MaterialTheme::default())
242///     .child(|| {
243///         search_bar().is_active(true).content(|| {});
244///     });
245/// # }
246/// # tessera_ui::testing::layout_test(component).run();
247/// ```
248#[tessera]
249pub fn search_bar(
250    modifier: Option<Modifier>,
251    enabled: Option<bool>,
252    read_only: Option<bool>,
253    is_active: Option<bool>,
254    #[prop(into)] placeholder: Option<String>,
255    leading_icon: Option<RenderSlot>,
256    trailing_icon: Option<RenderSlot>,
257    on_query_change: Option<CallbackWith<String, String>>,
258    on_search: Option<CallbackWith<String, ()>>,
259    on_active_change: Option<CallbackWith<bool, ()>>,
260    shape: Option<Shape>,
261    colors: Option<SearchBarColors>,
262    results_container_color: Option<Color>,
263    tonal_elevation: Option<Dp>,
264    shadow_elevation: Option<Dp>,
265    dropdown_shape: Option<Shape>,
266    dropdown_gap: Option<Dp>,
267    show_divider: Option<bool>,
268    content_padding: Option<Dp>,
269    controller: Option<State<SearchBarController>>,
270    content: Option<RenderSlot>,
271) {
272    let scheme = use_context::<MaterialTheme>()
273        .expect("MaterialTheme must be provided")
274        .get()
275        .color_scheme;
276    let args = resolve_search_bar(
277        modifier,
278        enabled,
279        read_only,
280        is_active,
281        placeholder,
282        leading_icon,
283        trailing_icon,
284        on_query_change,
285        on_search,
286        on_active_change,
287        shape,
288        colors,
289        results_container_color,
290        tonal_elevation,
291        shadow_elevation,
292        dropdown_shape,
293        dropdown_gap,
294        show_divider,
295        content_padding,
296        controller,
297        content,
298        scheme,
299    );
300    mount_search_bar(args, SearchBarLayoutKind::FullScreen);
301}
302
303fn resolve_search_bar(
304    modifier: Option<Modifier>,
305    enabled: Option<bool>,
306    read_only: Option<bool>,
307    is_active: Option<bool>,
308    placeholder: Option<String>,
309    leading_icon: Option<RenderSlot>,
310    trailing_icon: Option<RenderSlot>,
311    on_query_change: Option<CallbackWith<String, String>>,
312    on_search: Option<CallbackWith<String, ()>>,
313    on_active_change: Option<CallbackWith<bool, ()>>,
314    shape: Option<Shape>,
315    colors: Option<SearchBarColors>,
316    results_container_color: Option<Color>,
317    tonal_elevation: Option<Dp>,
318    shadow_elevation: Option<Dp>,
319    dropdown_shape: Option<Shape>,
320    dropdown_gap: Option<Dp>,
321    show_divider: Option<bool>,
322    content_padding: Option<Dp>,
323    controller: Option<State<SearchBarController>>,
324    content: Option<RenderSlot>,
325    scheme: crate::theme::MaterialColorScheme,
326) -> SearchBarProps {
327    let modifier = modifier.unwrap_or_default();
328    let enabled = enabled.unwrap_or(true);
329    let read_only = read_only.unwrap_or(false);
330    let shape = shape.unwrap_or(SearchBarDefaults::docked_shape());
331    let colors = colors.unwrap_or(SearchBarColors {
332        container_color: scheme.surface_container_high,
333        divider_color: scheme.outline,
334    });
335    let defaults = SearchBarProps::default();
336    let tonal_elevation = tonal_elevation.unwrap_or(defaults.tonal_elevation);
337    let shadow_elevation = shadow_elevation.unwrap_or(defaults.shadow_elevation);
338    let dropdown_shape = dropdown_shape.unwrap_or(defaults.dropdown_shape);
339    let dropdown_gap = dropdown_gap.unwrap_or(defaults.dropdown_gap);
340    let show_divider = show_divider.unwrap_or(defaults.show_divider);
341    let content_padding = content_padding.unwrap_or(defaults.content_padding);
342    SearchBarProps {
343        modifier,
344        enabled,
345        read_only,
346        is_active,
347        placeholder,
348        leading_icon,
349        trailing_icon,
350        on_query_change,
351        on_search,
352        on_active_change,
353        shape,
354        colors,
355        results_container_color,
356        tonal_elevation,
357        shadow_elevation,
358        dropdown_shape,
359        dropdown_gap,
360        show_divider,
361        content_padding,
362        controller,
363        content,
364    }
365}
366
367/// # docked_search_bar
368///
369/// Show a docked search bar with a results surface below the input.
370///
371/// ## Usage
372///
373/// Use for search experiences embedded within existing layouts.
374///
375/// ## Parameters
376///
377/// - Parameters are the same as [`search_bar`], but docked results render below
378///   the field instead of taking over the screen.
379///
380/// ## Examples
381///
382/// ```
383/// # use tessera_ui::tessera;
384/// # use tessera_components::theme::{MaterialTheme, material_theme};
385/// # #[tessera]
386/// # fn component() {
387/// use tessera_components::search::docked_search_bar;
388/// material_theme()
389///     .theme(|| MaterialTheme::default())
390///     .child(|| {
391///         docked_search_bar().is_active(true).content(|| {});
392///     });
393/// # }
394/// # tessera_ui::testing::layout_test(component).run();
395/// ```
396#[tessera]
397pub fn docked_search_bar(
398    modifier: Option<Modifier>,
399    enabled: Option<bool>,
400    read_only: Option<bool>,
401    is_active: Option<bool>,
402    #[prop(into)] placeholder: Option<String>,
403    leading_icon: Option<RenderSlot>,
404    trailing_icon: Option<RenderSlot>,
405    on_query_change: Option<CallbackWith<String, String>>,
406    on_search: Option<CallbackWith<String, ()>>,
407    on_active_change: Option<CallbackWith<bool, ()>>,
408    shape: Option<Shape>,
409    colors: Option<SearchBarColors>,
410    results_container_color: Option<Color>,
411    tonal_elevation: Option<Dp>,
412    shadow_elevation: Option<Dp>,
413    dropdown_shape: Option<Shape>,
414    dropdown_gap: Option<Dp>,
415    show_divider: Option<bool>,
416    content_padding: Option<Dp>,
417    controller: Option<State<SearchBarController>>,
418    content: Option<RenderSlot>,
419) {
420    let scheme = use_context::<MaterialTheme>()
421        .expect("MaterialTheme must be provided")
422        .get()
423        .color_scheme;
424    let args = resolve_search_bar(
425        modifier,
426        enabled,
427        read_only,
428        is_active,
429        placeholder,
430        leading_icon,
431        trailing_icon,
432        on_query_change,
433        on_search,
434        on_active_change,
435        shape,
436        colors,
437        results_container_color,
438        tonal_elevation,
439        shadow_elevation,
440        dropdown_shape,
441        dropdown_gap,
442        show_divider,
443        content_padding,
444        controller,
445        content,
446        scheme,
447    );
448    mount_search_bar(args, SearchBarLayoutKind::Docked);
449}
450
451#[tessera]
452fn mount_search_bar(args: SearchBarProps, kind: SearchBarLayoutKind) {
453    let mut args = args;
454    let controller = if let Some(controller) = args.controller {
455        controller
456    } else {
457        remember(|| SearchBarController::new(args.is_active.unwrap_or(false)))
458    };
459    args.controller = Some(controller);
460    let render_args = build_search_bar_props(args, kind);
461    let modifier = render_args.modifier.clone();
462    layout().modifier(modifier).child(move || {
463        search_bar_inner()
464            .kind(render_args.kind)
465            .enabled(render_args.enabled)
466            .read_only(render_args.read_only)
467            .shape(render_args.shape)
468            .colors(render_args.colors)
469            .results_container_color_optional(render_args.results_container_color)
470            .tonal_elevation(render_args.tonal_elevation)
471            .shadow_elevation(render_args.shadow_elevation)
472            .dropdown_shape(render_args.dropdown_shape)
473            .dropdown_gap(render_args.dropdown_gap)
474            .show_divider(render_args.show_divider)
475            .content_padding(render_args.content_padding)
476            .controller(render_args.controller)
477            .content_shared(render_args.content)
478            .on_query_change_shared(render_args.on_query_change)
479            .on_search_shared(render_args.on_search)
480            .on_active_change_shared(render_args.on_active_change)
481            .placeholder_optional(render_args.placeholder.clone())
482            .leading_icon_optional(render_args.leading_icon)
483            .trailing_icon_optional(render_args.trailing_icon);
484    });
485}
486
487fn build_search_bar_props(args: SearchBarProps, kind: SearchBarLayoutKind) -> SearchBarRenderArgs {
488    let content = args.content.unwrap_or_else(RenderSlot::empty);
489    let controller = args
490        .controller
491        .expect("build_search_bar_props requires controller to be set");
492    if let Some(is_active) = args.is_active
493        && is_active != controller.with(|c| c.is_active())
494    {
495        if is_active {
496            controller.with_mut(|c| c.open());
497        } else {
498            controller.with_mut(|c| c.close());
499        }
500    }
501
502    SearchBarRenderArgs {
503        kind,
504        modifier: args.modifier,
505        enabled: args.enabled,
506        read_only: args.read_only,
507        placeholder: args.placeholder,
508        leading_icon: args.leading_icon,
509        trailing_icon: args.trailing_icon,
510        on_query_change: args.on_query_change.unwrap_or_else(CallbackWith::identity),
511        on_search: args.on_search.unwrap_or_else(CallbackWith::default_value),
512        on_active_change: args
513            .on_active_change
514            .unwrap_or_else(CallbackWith::default_value),
515        shape: args.shape,
516        colors: args.colors,
517        results_container_color: args.results_container_color,
518        tonal_elevation: args.tonal_elevation,
519        shadow_elevation: args.shadow_elevation,
520        dropdown_shape: args.dropdown_shape,
521        dropdown_gap: args.dropdown_gap,
522        show_divider: args.show_divider,
523        content_padding: args.content_padding,
524        controller,
525        content,
526    }
527}
528
529#[derive(Clone)]
530struct SearchBarRenderArgs {
531    kind: SearchBarLayoutKind,
532    modifier: Modifier,
533    enabled: bool,
534    read_only: bool,
535    placeholder: Option<String>,
536    leading_icon: Option<RenderSlot>,
537    trailing_icon: Option<RenderSlot>,
538    on_query_change: CallbackWith<String, String>,
539    on_search: CallbackWith<String, ()>,
540    on_active_change: CallbackWith<bool, ()>,
541    shape: Shape,
542    colors: SearchBarColors,
543    results_container_color: Option<Color>,
544    tonal_elevation: Dp,
545    shadow_elevation: Dp,
546    dropdown_shape: Shape,
547    dropdown_gap: Dp,
548    show_divider: bool,
549    content_padding: Dp,
550    controller: State<SearchBarController>,
551    content: RenderSlot,
552}
553
554#[derive(Clone)]
555struct SearchResultsSurfaceArgs {
556    kind: SearchBarLayoutKind,
557    container_color: Color,
558    divider_color: Color,
559    show_divider: bool,
560    dropdown_shape: Shape,
561    tonal_elevation: Dp,
562    shadow_elevation: Dp,
563    content_padding: Dp,
564    content: RenderSlot,
565}
566
567#[derive(Clone, Copy)]
568struct SearchInteractionModifier {
569    enabled: bool,
570    controller: State<SearchBarController>,
571    input_controller: State<TextInputController>,
572    on_active_change: CallbackWith<bool>,
573    on_search: CallbackWith<String>,
574}
575
576impl PointerInputModifierNode for SearchInteractionModifier {
577    fn on_pointer_input(&self, input: PointerInput<'_>) {
578        let within_bounds = input
579            .cursor_position_rel
580            .map(|pos| is_position_inside_bounds(input.computed_data, pos))
581            .unwrap_or(false);
582        if self.enabled
583            && within_bounds
584            && input.pointer_changes.iter().any(|change| {
585                !change.is_consumed()
586                    && matches!(
587                        change.content,
588                        CursorEventContent::Pressed(PressKeyEventType::Left)
589                    )
590            })
591            && !self.controller.with(SearchBarController::is_active)
592        {
593            self.controller.with_mut(SearchBarController::open);
594            self.on_active_change.call(true);
595        }
596    }
597}
598
599impl KeyboardInputModifierNode for SearchInteractionModifier {
600    fn on_keyboard_input(&self, mut input: KeyboardInput<'_>) {
601        if !self.enabled || !self.controller.with(SearchBarController::is_active) {
602            return;
603        }
604        let mut handled = false;
605        for event in input.keyboard_events.iter() {
606            if event.state != winit::event::ElementState::Pressed {
607                continue;
608            }
609            match event.physical_key {
610                winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) => {
611                    self.controller.with_mut(SearchBarController::close);
612                    self.on_active_change.call(false);
613                    handled = true;
614                }
615                winit::keyboard::PhysicalKey::Code(
616                    winit::keyboard::KeyCode::Enter | winit::keyboard::KeyCode::NumpadEnter,
617                ) if self
618                    .input_controller
619                    .with(|input| input.focus_handler().is_focused()) =>
620                {
621                    self.on_search
622                        .call(self.controller.with(|c| c.query().to_owned()));
623                    handled = true;
624                }
625                _ => {}
626            }
627        }
628        if handled {
629            input.block_keyboard();
630        }
631    }
632}
633
634#[tessera]
635fn search_bar_inner(
636    kind: Option<SearchBarLayoutKind>,
637    enabled: Option<bool>,
638    read_only: Option<bool>,
639    #[prop(into)] placeholder: Option<String>,
640    leading_icon: Option<RenderSlot>,
641    trailing_icon: Option<RenderSlot>,
642    on_query_change: Option<CallbackWith<String, String>>,
643    on_search: Option<CallbackWith<String, ()>>,
644    on_active_change: Option<CallbackWith<bool, ()>>,
645    shape: Option<Shape>,
646    colors: Option<SearchBarColors>,
647    results_container_color: Option<Color>,
648    tonal_elevation: Option<Dp>,
649    shadow_elevation: Option<Dp>,
650    dropdown_shape: Option<Shape>,
651    dropdown_gap: Option<Dp>,
652    show_divider: Option<bool>,
653    content_padding: Option<Dp>,
654    controller: Option<State<SearchBarController>>,
655    content: Option<RenderSlot>,
656) {
657    let kind = kind.expect("search_bar_inner requires kind");
658    let enabled = enabled.expect("search_bar_inner requires enabled");
659    let read_only = read_only.expect("search_bar_inner requires read_only");
660    let shape = shape.expect("search_bar_inner requires shape");
661    let colors = colors.expect("search_bar_inner requires colors");
662    let tonal_elevation = tonal_elevation.expect("search_bar_inner requires tonal_elevation");
663    let shadow_elevation = shadow_elevation.expect("search_bar_inner requires shadow_elevation");
664    let dropdown_shape = dropdown_shape.expect("search_bar_inner requires dropdown_shape");
665    let dropdown_gap = dropdown_gap.expect("search_bar_inner requires dropdown_gap");
666    let show_divider = show_divider.expect("search_bar_inner requires show_divider");
667    let content_padding = content_padding.expect("search_bar_inner requires content_padding");
668    let controller = controller.expect("search_bar_inner requires controller");
669    let content = content.expect("search_bar_inner requires content");
670    let on_query_change = on_query_change.expect("search_bar_inner requires on_query_change");
671    let on_search = on_search.expect("search_bar_inner requires on_search");
672    let on_active_change = on_active_change.expect("search_bar_inner requires on_active_change");
673    let theme = use_context::<MaterialTheme>()
674        .expect("MaterialTheme must be provided")
675        .get();
676
677    let font_size = theme.typography.body_large.font_size;
678    let line_height = theme.typography.body_large.line_height;
679    let input_controller = remember(|| TextInputController::new(font_size, line_height));
680    let synced_query = remember(String::new);
681
682    sync_query(&controller, &input_controller, &synced_query);
683
684    let interaction = SearchInteractionModifier {
685        enabled,
686        controller,
687        input_controller,
688        on_active_change,
689        on_search,
690    };
691    let modifier = Modifier::new()
692        .push_pointer_preview_input(interaction)
693        .push_keyboard_preview_input(interaction);
694
695    let results_surface_args = SearchResultsSurfaceArgs {
696        kind,
697        container_color: results_container_color.unwrap_or(colors.container_color),
698        divider_color: colors.divider_color,
699        show_divider,
700        dropdown_shape,
701        tonal_elevation,
702        shadow_elevation,
703        content_padding,
704        content,
705    };
706
707    layout().modifier(modifier).child(move || {
708        let placeholder = placeholder.clone();
709        let leading_icon = leading_icon;
710        let trailing_icon = trailing_icon;
711        let results_surface_args = results_surface_args.clone();
712        column().children(move || {
713            search_field().args(SearchFieldArgs {
714                enabled,
715                read_only,
716                placeholder: placeholder.clone(),
717                leading_icon,
718                trailing_icon,
719                shape,
720                colors,
721                controller,
722                input_controller,
723                synced_query,
724                on_query_change,
725            });
726
727            if controller.with(|c| c.is_active()) {
728                if matches!(kind, SearchBarLayoutKind::Docked) {
729                    spacer().modifier(Modifier::new().height(dropdown_gap));
730                }
731
732                let shape = match results_surface_args.kind {
733                    SearchBarLayoutKind::FullScreen => Shape::RECTANGLE,
734                    SearchBarLayoutKind::Docked => results_surface_args.dropdown_shape,
735                };
736
737                let modifier = match results_surface_args.kind {
738                    SearchBarLayoutKind::FullScreen => {
739                        Modifier::new().fill_max_width().fill_max_height()
740                    }
741                    SearchBarLayoutKind::Docked => Modifier::new().fill_max_width(),
742                };
743
744                let content = results_surface_args.content;
745                let divider_color = results_surface_args.divider_color;
746                let show_divider = results_surface_args.show_divider;
747                let content_padding = results_surface_args.content_padding;
748
749                surface()
750                    .style(results_surface_args.container_color.into())
751                    .shape(shape)
752                    .modifier(modifier)
753                    .tonal_elevation(results_surface_args.tonal_elevation)
754                    .block_input(true)
755                    .elevation(results_surface_args.shadow_elevation)
756                    .child(move || {
757                        let content = content;
758                        column().children(move || {
759                            if show_divider {
760                                horizontal_divider().color(divider_color);
761                            }
762                            let content = content;
763                            layout()
764                                .modifier(Modifier::new().padding_all(content_padding))
765                                .child(move || {
766                                    content.render();
767                                });
768                        });
769                    });
770            }
771        });
772    });
773}
774
775#[derive(Clone, PartialEq)]
776struct SearchFieldArgs {
777    enabled: bool,
778    read_only: bool,
779    placeholder: Option<String>,
780    leading_icon: Option<RenderSlot>,
781    trailing_icon: Option<RenderSlot>,
782    shape: Shape,
783    colors: SearchBarColors,
784    controller: State<SearchBarController>,
785    input_controller: State<TextInputController>,
786    synced_query: State<String>,
787    on_query_change: CallbackWith<String, String>,
788}
789
790#[tessera]
791fn search_field(args: Option<SearchFieldArgs>) {
792    let args = args.expect("search_field requires args");
793    let has_icons = args.leading_icon.is_some() || args.trailing_icon.is_some();
794    let scheme = use_context::<MaterialTheme>().unwrap().get().color_scheme;
795    let color = if args.enabled {
796        scheme.on_surface_variant
797    } else {
798        scheme.on_surface.with_alpha(0.38)
799    };
800    surface()
801        .modifier(
802            Modifier::new()
803                .fill_max_width()
804                .height(SearchBarDefaults::INPUT_HEIGHT),
805        )
806        .style(args.colors.container_color.into())
807        .shape(args.shape)
808        .content_color(color)
809        .block_input(!args.enabled)
810        .child(move || {
811            let args = args.clone();
812            row()
813                .modifier(
814                    Modifier::new()
815                        .fill_max_size()
816                        .padding_symmetric(Dp(4.0), Dp::ZERO),
817                )
818                .cross_axis_alignment(CrossAxisAlignment::Center)
819                .children(move || {
820                    let trailing_icon = args.trailing_icon;
821                    if let Some(leading) = args.leading_icon {
822                        boxed()
823                            .modifier(
824                                Modifier::new()
825                                    .default_min_size(Dp(48.0), Dp::ZERO)
826                                    .fill_max_height(),
827                            )
828                            .alignment(Alignment::Center)
829                            .children(move || leading.render());
830                    } else if has_icons {
831                        spacer().modifier(Modifier::new().width(Dp(8.0)));
832                    }
833                    let args = args.clone();
834                    boxed()
835                        .modifier(Modifier::new().weight(1.0).fill_max_height())
836                        .children(move || {
837                            text_field()
838                                .filled()
839                                .enabled(args.enabled)
840                                .read_only(args.read_only)
841                                .modifier(
842                                    Modifier::new()
843                                        .fill_max_width()
844                                        .height(SearchBarDefaults::INPUT_HEIGHT),
845                                )
846                                .min_width(Dp::ZERO)
847                                .min_height(SearchBarDefaults::INPUT_HEIGHT)
848                                .shape(args.shape)
849                                .background_color(Color::TRANSPARENT)
850                                .focus_background_color(Color::TRANSPARENT)
851                                .border_width(Dp(0.0))
852                                .border_color(Color::TRANSPARENT)
853                                .focus_border_width(Dp(0.0))
854                                .focus_border_color(Color::TRANSPARENT)
855                                .show_indicator(false)
856                                .padding(TextFieldDefaults::CONTENT_PADDING)
857                                .horizontal_padding(if has_icons { Dp(4.0) } else { Dp(12.0) })
858                                .line_limit(TextFieldLineLimit::SingleLine)
859                                .on_change(move |text| {
860                                    let next = args.on_query_change.call(text);
861                                    args.controller.with_mut(|c| c.set_query(next.clone()));
862                                    args.synced_query.set(next.clone());
863                                    next
864                                })
865                                .controller(args.input_controller)
866                                .accessibility_label_optional(args.placeholder.clone())
867                                .placeholder_optional(args.placeholder.clone());
868                        });
869
870                    if let Some(trailing) = trailing_icon {
871                        boxed()
872                            .modifier(
873                                Modifier::new()
874                                    .default_min_size(Dp(48.0), Dp::ZERO)
875                                    .fill_max_height(),
876                            )
877                            .alignment(Alignment::Center)
878                            .children(move || trailing.render());
879                    } else if has_icons {
880                        spacer().modifier(Modifier::new().width(Dp(8.0)));
881                    }
882                });
883        });
884}
885
886fn sync_query(
887    controller: &State<SearchBarController>,
888    input: &State<TextInputController>,
889    synced_query: &State<String>,
890) {
891    let needs_sync =
892        controller.with(|c| synced_query.with(|current| current.as_str() != c.query()));
893    if needs_sync {
894        let query = controller.with(|c| c.query().to_owned());
895        input.with_mut(|c| c.set_text(&query));
896        synced_query.set(query);
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use tessera_ui::{
903        CursorEventContent, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
904        PxPosition, State, remember, tessera,
905        testing::{
906            TestProbe, layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
907            start_layout_test_session,
908        },
909    };
910
911    use crate::{modifier::ModifierExt as _, spacer::spacer, theme::material_theme};
912
913    use super::{SearchBarController, docked_search_bar};
914
915    #[tessera]
916    fn search_fixture(
917        active: Option<bool>,
918        show_divider: Option<bool>,
919        probe: Option<TestProbe<Option<State<SearchBarController>>>>,
920    ) {
921        let controller = remember(SearchBarController::default);
922        if let Some(probe) = probe {
923            probe.set(Some(controller));
924        }
925        material_theme().child(move || {
926            docked_search_bar()
927                .controller(controller)
928                .is_active_optional(active)
929                .show_divider_optional(show_divider)
930                .content(|| {
931                    spacer().modifier(
932                        Modifier::new()
933                            .fill_max_width()
934                            .height(tessera_ui::Dp(40.0))
935                            .semantics(crate::modifier::SemanticsArgs {
936                                test_tag: Some("search-results".into()),
937                                ..Default::default()
938                            }),
939                    );
940                });
941        });
942    }
943
944    #[tessera]
945    fn multiple_search_actions_fixture() {
946        material_theme().child(|| {
947            docked_search_bar()
948                .placeholder("Search")
949                .trailing_icon(|| {
950                    crate::row::row().children(|| {
951                        for tag in ["first-search-action", "second-search-action"] {
952                            spacer().modifier(
953                                Modifier::new()
954                                    .size(tessera_ui::Dp(48.0), tessera_ui::Dp(48.0))
955                                    .semantics(crate::modifier::SemanticsArgs {
956                                        test_tag: Some(tag.into()),
957                                        ..Default::default()
958                                    }),
959                            );
960                        }
961                    });
962                })
963                .content(|| {});
964        });
965    }
966
967    #[test]
968    fn search_measures_multiple_trailing_actions_without_clipping_their_layout() {
969        let snapshot = layout_test(|| multiple_search_actions_fixture())
970            .viewport_px(400, 100)
971            .run();
972        snapshot
973            .node("first-search-action")
974            .position(300, 4)
975            .size(48, 48);
976        snapshot
977            .node("second-search-action")
978            .position(348, 4)
979            .size(48, 48);
980    }
981
982    #[test]
983    fn contained_search_removes_divider_geometry_while_divided_search_preserves_it() {
984        for (show_divider, content_y) in [(None, 75), (Some(true), 75), (Some(false), 74)] {
985            let snapshot = layout_test(|| {
986                search_fixture()
987                    .active(true)
988                    .show_divider_optional(show_divider);
989            })
990            .viewport_px(400, 300)
991            .run();
992            assert_eq!(snapshot.node("search-results").bounds().y, Px(content_y));
993            snapshot.node("search-results").size(368, 40);
994        }
995    }
996
997    #[test]
998    fn clicking_search_field_opens_uncontrolled_results_and_preserves_controlled_state() {
999        for (active, expected) in [(None, true), (Some(false), false), (Some(true), true)] {
1000            let probe = TestProbe::default();
1001            let mut session = start_layout_test_session(
1002                layout_test(|| {
1003                    search_fixture()
1004                        .active_optional(active)
1005                        .probe(probe.clone());
1006                })
1007                .viewport_px(400, 300),
1008            );
1009            let _ = pump_layout_test_session(&mut session, 0);
1010            let now = tessera_ui::time::Instant::now();
1011            let cursor = Some(PxPosition::new(Px(100), Px(28)));
1012            for (index, content) in [
1013                CursorEventContent::Pressed(PressKeyEventType::Left),
1014                CursorEventContent::Released(PressKeyEventType::Left),
1015            ]
1016            .into_iter()
1017            .enumerate()
1018            {
1019                let _ = pump_layout_test_session_with_input(
1020                    &mut session,
1021                    16_666_667 * (index as u64 + 1),
1022                    vec![PointerChange::new(
1023                        now,
1024                        1,
1025                        content,
1026                        GestureState::TapCandidate,
1027                    )],
1028                    cursor,
1029                );
1030            }
1031            let _ = pump_layout_test_session(&mut session, 50_000_001);
1032            let controller = probe.get().unwrap();
1033            assert_eq!(
1034                controller.with(SearchBarController::is_active),
1035                expected,
1036                "active={active:?}"
1037            );
1038        }
1039    }
1040}