patternfly_yew/components/search_input/
mod.rs

1use std::ops::Deref;
2
3use crate::components::badge::Badge;
4use crate::components::button::*;
5use crate::components::input_group::*;
6use crate::components::text_input_group::*;
7use crate::icon::Icon;
8use crate::utils::HtmlElementSupport;
9use yew::prelude::*;
10use yew_hooks::use_event_with_window;
11
12/// The number of search results returned. Either a total number of results,
13/// or an index of the current result in relation to total results.
14#[derive(Debug, Clone, PartialEq)]
15pub enum ResultsCount {
16    Absolute(usize),
17    /// For an index out of total such as "1/5"
18    Fraction(usize, usize),
19}
20
21impl ToHtml for ResultsCount {
22    fn to_html(&self) -> Html {
23        match self {
24            Self::Absolute(i) => html!(i),
25            Self::Fraction(i, j) => html!(format!("{i}/{j}")),
26        }
27    }
28}
29
30pub enum OnSearchEvent {
31    Mouse(MouseEvent),
32    Keyboard(KeyboardEvent),
33}
34
35impl Deref for OnSearchEvent {
36    type Target = Event;
37
38    fn deref(&self) -> &Self::Target {
39        match self {
40            Self::Mouse(e) => e.deref(),
41            Self::Keyboard(e) => e.deref(),
42        }
43    }
44}
45
46impl From<MouseEvent> for OnSearchEvent {
47    fn from(value: MouseEvent) -> Self {
48        Self::Mouse(value)
49    }
50}
51
52impl From<KeyboardEvent> for OnSearchEvent {
53    fn from(value: KeyboardEvent) -> Self {
54        Self::Keyboard(value)
55    }
56}
57
58/// The main search input component.
59#[derive(Debug, Clone, PartialEq, Properties)]
60pub struct SearchInputProperties {
61    /// Id of the outermost element
62    #[prop_or_default]
63    pub id: Option<AttrValue>,
64    /// An accessible label for the search input.
65    #[prop_or_default]
66    pub aria_label: AttrValue,
67    /// Additional classes added to the search input.
68    #[prop_or_default]
69    pub class: Classes,
70    /// Object that makes the search input expandable/collapsable.
71    #[prop_or_default]
72    pub expandable: Option<SearchInputExpandableProperties>,
73    /// A suggestion for autocompleting
74    #[prop_or_default]
75    pub hint: Option<AttrValue>,
76    /// A reference object to attach to the input box.
77    #[prop_or_default]
78    pub inner_ref: Option<NodeRef>,
79    /// Flag indicating if searchinput is disabled.
80    #[prop_or_default]
81    pub disabled: bool,
82    /// Placeholder text of the search input.
83    #[prop_or_default]
84    pub placeholder: Option<AttrValue>,
85    /// Label for the button which resets the advanced search form and clears the search input.
86    #[prop_or(AttrValue::from("Reset"))]
87    pub reset_button_label: AttrValue,
88    /// Label for the button which calls the onSearch event handler.
89    #[prop_or(AttrValue::from("Search"))]
90    pub submit_search_button_label: AttrValue,
91    /// Flag to indicate utilities should be displayed.
92    /// By default, utilities will only be displayed when the search input has a value.
93    #[prop_or_default]
94    pub utilities_displayed: bool,
95    /// Value of the search input.
96    #[prop_or_default]
97    pub value: String,
98    #[prop_or_default]
99    pub autofocus: bool,
100
101    // Navigable results
102    /// The number of search results returned. View `[ResultsCount]`.
103    #[prop_or_default]
104    pub results_count: Option<ResultsCount>,
105    /// Accessible label for the button to navigate to previous result.
106    #[prop_or(AttrValue::from("Previous"))]
107    pub previous_navigation_button_aria_label: AttrValue,
108    /// Flag indicating if the previous navigation button is disabled.
109    #[prop_or_default]
110    pub previous_navigation_button_disabled: bool,
111    /// Accessible label for the button to navigate to next result.
112    #[prop_or(AttrValue::from("Next"))]
113    pub next_navigation_button_aria_label: AttrValue,
114    /// Flag indicating if the next navigation button is disabled.
115    #[prop_or_default]
116    pub next_navigation_button_disabled: bool,
117
118    // Callbacks
119    /// A callback for when the input value changes.
120    #[prop_or_default]
121    pub onchange: Option<Callback<String>>,
122    /// A callback for when the user clicks the clear button.
123    #[prop_or_default]
124    pub onclear: Option<Callback<MouseEvent>>,
125    /// A callback for when the user clicks to navigate to next result.
126    #[prop_or_default]
127    pub onnextclick: Option<Callback<MouseEvent>>,
128    /// A callback for when the user clicks to navigate to previous result.
129    #[prop_or_default]
130    pub onpreviousclick: Option<Callback<MouseEvent>>,
131    /// A callback for when the search button is clicked.
132    #[prop_or_default]
133    pub onsearch: Option<Callback<(OnSearchEvent, String)>>,
134}
135
136/// Properties for creating an expandable search input. These properties should be passed into
137/// the search input component's expandableInput property.
138///
139#[derive(Debug, Clone, PartialEq, Properties)]
140pub struct SearchInputExpandableProperties {
141    /// Flag to indicate if the search input is expanded.
142    #[prop_or_default]
143    pub expanded: bool,
144    /// Callback function to toggle the expandable search input.
145    #[prop_or_default]
146    pub ontoggleexpand: Callback<(MouseEvent, bool)>,
147    /// An accessible label for the expandable search input toggle.
148    #[prop_or_default]
149    pub toggle_aria_label: AttrValue,
150}
151
152#[function_component(SearchInput)]
153pub fn search_input(props: &SearchInputProperties) -> Html {
154    let search_value = use_state(|| props.value.clone());
155    use_effect_with(
156        (props.value.clone(), search_value.clone()),
157        move |(prop_val, search_value)| search_value.set(prop_val.clone()),
158    );
159    let focus_after_expand_change = use_state(|| false);
160    let is_search_menu_open = use_state(|| false);
161    let node_ref = use_node_ref();
162    let input_ref = props.inner_ref.clone().unwrap_or(node_ref);
163    let expandable_toggle_ref = use_node_ref();
164
165    use_effect_with(
166        (
167            focus_after_expand_change.clone(),
168            props.expandable.clone(),
169            input_ref.clone(),
170            expandable_toggle_ref.clone(),
171        ),
172        |(focus, expandable, input_ref, toggle_ref)| {
173            if !**focus {
174                return;
175            }
176            if expandable.as_ref().is_some_and(|e| e.expanded) {
177                input_ref.focus();
178            } else {
179                toggle_ref.focus();
180            }
181        },
182    );
183
184    let ontoggle = use_callback(is_search_menu_open.clone(), |_, is_search_menu_open| {
185        is_search_menu_open.set(!**is_search_menu_open);
186    });
187    let expand_toggle = if let Some(expandable) = &props.expandable {
188        let onclick = {
189            let value = search_value.clone();
190            let ontoggleexpand = expandable.ontoggleexpand.clone();
191            let focus_after_expand_change = focus_after_expand_change.clone();
192            let expanded = expandable.expanded;
193            Callback::from(move |e| {
194                value.set(String::new());
195                ontoggleexpand.emit((e, expanded));
196                focus_after_expand_change.set(true);
197            })
198        };
199        html! {
200            <Button
201                variant={ButtonVariant::Plain}
202                aria_label={expandable.toggle_aria_label.clone()}
203                aria_expanded={expandable.expanded.to_string()}
204                icon={if expandable.expanded { Icon::Times} else { Icon::Search }}
205                {onclick}
206            />
207        }
208    } else {
209        html! {}
210    };
211
212    if let Some(SearchInputExpandableProperties {
213        expanded: false, ..
214    }) = props.expandable
215    {
216        html! {
217            <InputGroup class={props.class.clone()}>
218                <InputGroupItem>{expand_toggle}</InputGroupItem>
219            </InputGroup>
220        }
221    } else if props.onsearch.is_some() {
222        html! {
223            <TextInputGroupWithExtraButtons
224                search_value={search_value.clone()}
225                focus_after_expand_change={focus_after_expand_change.clone()}
226                is_search_menu_open={is_search_menu_open.clone()}
227                ontoggle={ontoggle.clone()}
228                expand_toggle={expand_toggle.clone()}
229                {input_ref}
230                props={props.clone()}
231            />
232        }
233    } else if props.expandable.is_some() {
234        html! {
235            <ExpandableInputGroup
236                search_value={search_value.clone()}
237                expand_toggle={expand_toggle.clone()}
238                {input_ref}
239                props={props.clone()}
240            />
241        }
242    } else {
243        html! {
244            <InnerTextInputGroup
245                search_value={search_value.clone()}
246                {input_ref}
247                props={props.clone()}
248            />
249        }
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Properties)]
254struct ExpandableInputGroupProps {
255    search_value: UseStateHandle<String>,
256    expand_toggle: Html,
257    input_ref: NodeRef,
258    props: SearchInputProperties,
259}
260
261#[function_component(ExpandableInputGroup)]
262fn expandable_input_group(props: &ExpandableInputGroupProps) -> Html {
263    html! {
264        <InputGroup
265            id={&props.props.id}
266            class={props.props.class.clone()}
267        >
268            <InputGroupItem fill=true>
269                <InnerTextInputGroup
270                    props={props.props.clone()}
271                    search_value={props.search_value.clone()}
272                    input_ref={props.input_ref.clone()}
273                />
274            </InputGroupItem>
275            <InputGroupItem plain=true>{props.expand_toggle.clone()}</InputGroupItem>
276        </InputGroup>
277    }
278}
279
280#[derive(Debug, Clone, PartialEq, Properties)]
281struct InnerTextInputGroupProps {
282    search_value: UseStateHandle<String>,
283    input_ref: NodeRef,
284    props: SearchInputProperties,
285}
286
287#[function_component(InnerTextInputGroup)]
288fn inner_text_input_group(props: &InnerTextInputGroupProps) -> Html {
289    let onchange = use_callback(
290        (props.search_value.clone(), props.props.onchange.clone()),
291        |value: String, (search_value, onchange)| {
292            if let Some(f) = onchange.as_ref() {
293                f.emit(value.clone())
294            }
295            search_value.set(value)
296        },
297    );
298
299    let render_utilities = !props.props.value.is_empty()
300        && (props.props.results_count.is_some()
301            || (props.props.onnextclick.is_some() && props.props.onpreviousclick.is_some())
302            || (props.props.onclear.is_some() && props.props.expandable.is_none()));
303    let badge = if let Some(results_count) = &props.props.results_count {
304        html! { <Badge read=true>{results_count}</Badge> }
305    } else {
306        html! {}
307    };
308
309    let mut clicknav = html! {};
310    if let Some(onnextclick) = &props.props.onnextclick {
311        if let Some(onprevclick) = &props.props.onpreviousclick {
312            clicknav = html! {
313                <div class={classes!["pf-v5-c-text-input-group__group"]}>
314                    <Button
315                        variant={ButtonVariant::Plain}
316                        aria_label={props.props.previous_navigation_button_aria_label.clone()}
317                        disabled={props.props.disabled || props.props.previous_navigation_button_disabled}
318                        onclick={onprevclick}
319                    >
320                        {Icon::AngleUp}
321                    </Button>
322                    <Button
323                        variant={ButtonVariant::Plain}
324                        aria_label={props.props.next_navigation_button_aria_label.clone()}
325                        disabled={props.props.disabled || props.props.next_navigation_button_disabled}
326                        onclick={onnextclick.clone()}
327                    >
328                        {Icon::AngleDown}
329                    </Button>
330                </div>
331            };
332        }
333    }
334    let onclearinput = use_callback(
335        (props.props.onclear.clone(), props.input_ref.clone()),
336        |e, (onclear, input_ref)| {
337            if let Some(f) = onclear.as_ref() {
338                f.emit(e)
339            }
340            input_ref.focus();
341        },
342    );
343    let mut clearnav = html! {};
344    if props.props.onclear.is_some() && props.props.expandable.is_none() {
345        clearnav = html! {
346            <Button
347                variant={ButtonVariant::Plain}
348                disabled={props.props.disabled}
349                aria_label={props.props.reset_button_label.clone()}
350                onclick={onclearinput}
351            >
352                {Icon::Times}
353            </Button>
354        };
355    };
356    html! {
357        <TextInputGroup
358            id={&props.props.id}
359            class={props.props.class.clone()}
360            disabled={props.props.disabled}
361        >
362            <TextInputGroupMain
363                hint={props.props.hint.clone()}
364                icon={Icon::Search}
365                value={(*props.search_value).clone()}
366                placeholder={props.props.placeholder.clone()}
367                aria_label={props.props.aria_label.clone()}
368                {onchange}
369                inner_ref={props.input_ref.clone()}
370                autofocus={props.props.autofocus}
371            />
372            if render_utilities || props.props.utilities_displayed {
373                <TextInputGroupUtilities>
374                    {badge}
375                    {clicknav}
376                    {clearnav}
377                </TextInputGroupUtilities>
378            }
379        </TextInputGroup>
380    }
381}
382
383#[derive(Debug, Clone, PartialEq, Properties)]
384struct TextInputGroupWithExtraButtonsProps {
385    search_value: UseStateHandle<String>,
386    focus_after_expand_change: UseStateHandle<bool>,
387    is_search_menu_open: UseStateHandle<bool>,
388    input_ref: NodeRef,
389    ontoggle: Callback<MouseEvent>,
390    expand_toggle: Html,
391    props: SearchInputProperties,
392}
393
394#[function_component(TextInputGroupWithExtraButtons)]
395fn text_input_group_with_extra_buttons(props: &TextInputGroupWithExtraButtonsProps) -> Html {
396    let onsearchhandler = use_callback(
397        (
398            props.props.onsearch.clone(),
399            props.props.value.clone(),
400            props.is_search_menu_open.clone(),
401        ),
402        |e: OnSearchEvent, (onsearch, value, is_search_menu_open)| {
403            e.prevent_default();
404            if let Some(f) = onsearch.as_ref() {
405                f.emit((e, value.clone()))
406            }
407            is_search_menu_open.set(false);
408        },
409    );
410    {
411        let onsearchhandler = onsearchhandler.clone();
412        use_event_with_window("keydown", move |e: KeyboardEvent| {
413            if e.key() == "Enter" {
414                onsearchhandler.emit(e.into());
415            }
416        });
417    }
418
419    let submit_button = if props.props.onsearch.is_some() {
420        let onsearchhandler = onsearchhandler.clone();
421        let onclick = Callback::from(move |e: MouseEvent| onsearchhandler.emit(e.into()));
422        html! {
423            <InputGroupItem>
424                <Button
425                    r#type={ButtonType::Submit}
426                    variant={ButtonVariant::Control}
427                    aria_label={props.props.submit_search_button_label.clone()}
428                    {onclick}
429                    disabled={props.props.disabled}
430                >
431                    {Icon::ArrowRight}
432                </Button>
433            </InputGroupItem>
434        }
435    } else {
436        html! {}
437    };
438
439    html! (
440        <InputGroup
441            id={&props.props.id}
442            class={props.props.class.clone()}
443        >
444            <InputGroupItem fill=true>
445                <InnerTextInputGroup
446                    props={props.props.clone()}
447                    search_value={props.search_value.clone()}
448                    input_ref={props.input_ref.clone()}
449                />
450                {submit_button}
451            </InputGroupItem>
452            if props.props.expandable.is_some() {
453                {props.expand_toggle.clone()}
454            }
455        </InputGroup>
456    )
457}