Skip to main content

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