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 id={&props.props.id} class={props.props.class.clone()}>
266            <InputGroupItem fill=true>
267                <InnerTextInputGroup
268                    props={props.props.clone()}
269                    search_value={props.search_value.clone()}
270                    input_ref={props.input_ref.clone()}
271                />
272            </InputGroupItem>
273            <InputGroupItem plain=true>{ props.expand_toggle.clone() }</InputGroupItem>
274        </InputGroup>
275    }
276}
277
278#[derive(Debug, Clone, PartialEq, Properties)]
279struct InnerTextInputGroupProps {
280    search_value: UseStateHandle<String>,
281    input_ref: NodeRef,
282    props: SearchInputProperties,
283}
284
285#[function_component(InnerTextInputGroup)]
286fn inner_text_input_group(props: &InnerTextInputGroupProps) -> Html {
287    let onchange = use_callback(
288        (props.search_value.clone(), props.props.onchange.clone()),
289        |value: String, (search_value, onchange)| {
290            if let Some(f) = onchange.as_ref() {
291                f.emit(value.clone())
292            }
293            search_value.set(value)
294        },
295    );
296
297    let render_utilities = !props.props.value.is_empty()
298        && (props.props.results_count.is_some()
299            || (props.props.onnextclick.is_some() && props.props.onpreviousclick.is_some())
300            || (props.props.onclear.is_some() && props.props.expandable.is_none()));
301    let badge = if let Some(results_count) = &props.props.results_count {
302        html! { <Badge read=true>{ results_count.clone() }</Badge> }
303    } else {
304        html! {}
305    };
306
307    let mut clicknav = html! {};
308    if let Some(onnextclick) = &props.props.onnextclick
309        && let Some(onprevclick) = &props.props.onpreviousclick
310    {
311        clicknav = html! {
312            <div class={classes!["pf-v6-c-text-input-group__group"]}>
313                <Button
314                    variant={ButtonVariant::Plain}
315                    aria_label={props.props.previous_navigation_button_aria_label.clone()}
316                    disabled={props.props.disabled || props.props.previous_navigation_button_disabled}
317                    onclick={onprevclick}
318                >
319                    { Icon::AngleUp }
320                </Button>
321                <Button
322                    variant={ButtonVariant::Plain}
323                    aria_label={props.props.next_navigation_button_aria_label.clone()}
324                    disabled={props.props.disabled || props.props.next_navigation_button_disabled}
325                    onclick={onnextclick.clone()}
326                >
327                    { Icon::AngleDown }
328                </Button>
329            </div>
330        };
331    }
332    let onclearinput = use_callback(
333        (props.props.onclear.clone(), props.input_ref.clone()),
334        |e, (onclear, input_ref)| {
335            if let Some(f) = onclear.as_ref() {
336                f.emit(e)
337            }
338            input_ref.focus();
339        },
340    );
341    let mut clearnav = html! {};
342    if props.props.onclear.is_some() && props.props.expandable.is_none() {
343        clearnav = html! {
344            <Button
345                variant={ButtonVariant::Plain}
346                disabled={props.props.disabled}
347                aria_label={props.props.reset_button_label.clone()}
348                onclick={onclearinput}
349            >
350                { Icon::Times }
351            </Button>
352        };
353    };
354    html! {
355        <TextInputGroup
356            id={&props.props.id}
357            class={props.props.class.clone()}
358            disabled={props.props.disabled}
359        >
360            <TextInputGroupMain
361                hint={props.props.hint.clone()}
362                icon={Icon::Search}
363                value={(*props.search_value).clone()}
364                placeholder={props.props.placeholder.clone()}
365                aria_label={props.props.aria_label.clone()}
366                {onchange}
367                inner_ref={props.input_ref.clone()}
368                autofocus={props.props.autofocus}
369            />
370            if render_utilities || props.props.utilities_displayed {
371                <TextInputGroupUtilities>{ badge }{ clicknav }{ clearnav }</TextInputGroupUtilities>
372            }
373        </TextInputGroup>
374    }
375}
376
377#[derive(Debug, Clone, PartialEq, Properties)]
378struct TextInputGroupWithExtraButtonsProps {
379    search_value: UseStateHandle<String>,
380    focus_after_expand_change: UseStateHandle<bool>,
381    is_search_menu_open: UseStateHandle<bool>,
382    input_ref: NodeRef,
383    ontoggle: Callback<MouseEvent>,
384    expand_toggle: Html,
385    props: SearchInputProperties,
386}
387
388#[function_component(TextInputGroupWithExtraButtons)]
389fn text_input_group_with_extra_buttons(props: &TextInputGroupWithExtraButtonsProps) -> Html {
390    let onsearchhandler = use_callback(
391        (
392            props.props.onsearch.clone(),
393            props.props.value.clone(),
394            props.is_search_menu_open.clone(),
395        ),
396        |e: OnSearchEvent, (onsearch, value, is_search_menu_open)| {
397            e.prevent_default();
398            if let Some(f) = onsearch.as_ref() {
399                f.emit((e, value.clone()))
400            }
401            is_search_menu_open.set(false);
402        },
403    );
404    {
405        let onsearchhandler = onsearchhandler.clone();
406        use_event_with_window("keydown", move |e: KeyboardEvent| {
407            if e.key() == "Enter" {
408                onsearchhandler.emit(e.into());
409            }
410        });
411    }
412
413    let submit_button = if props.props.onsearch.is_some() {
414        let onsearchhandler = onsearchhandler.clone();
415        let onclick = Callback::from(move |e: MouseEvent| onsearchhandler.emit(e.into()));
416        html! {
417            <InputGroupItem>
418                <Button
419                    r#type={ButtonType::Submit}
420                    variant={ButtonVariant::Control}
421                    aria_label={props.props.submit_search_button_label.clone()}
422                    {onclick}
423                    disabled={props.props.disabled}
424                >
425                    { Icon::ArrowRight }
426                </Button>
427            </InputGroupItem>
428        }
429    } else {
430        html! {}
431    };
432
433    html! (
434        <InputGroup id={&props.props.id} class={props.props.class.clone()}>
435            <InputGroupItem fill=true>
436                <InnerTextInputGroup
437                    props={props.props.clone()}
438                    search_value={props.search_value.clone()}
439                    input_ref={props.input_ref.clone()}
440                />
441                { submit_button }
442            </InputGroupItem>
443            if props.props.expandable.is_some() {
444                { props.expand_toggle.clone() }
445            }
446        </InputGroup>
447    )
448}