patternfly_yew/components/search_input/
mod.rs1use 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#[derive(Debug, Clone, PartialEq)]
16pub enum ResultsCount {
17 Absolute(usize),
18 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#[derive(Debug, Clone, PartialEq, Properties)]
61pub struct SearchInputProperties {
62 #[prop_or_default]
64 pub id: Option<AttrValue>,
65 #[prop_or_default]
67 pub aria_label: AttrValue,
68 #[prop_or_default]
70 pub class: Classes,
71 #[prop_or_default]
73 pub expandable: Option<SearchInputExpandableProperties>,
74 #[prop_or_default]
76 pub hint: Option<AttrValue>,
77 #[prop_or_default]
79 pub inner_ref: Option<NodeRef>,
80 #[prop_or_default]
82 pub disabled: bool,
83 #[prop_or_default]
85 pub placeholder: Option<AttrValue>,
86 #[prop_or(AttrValue::from("Reset"))]
88 pub reset_button_label: AttrValue,
89 #[prop_or(AttrValue::from("Search"))]
91 pub submit_search_button_label: AttrValue,
92 #[prop_or_default]
95 pub utilities_displayed: bool,
96 #[prop_or_default]
98 pub value: String,
99 #[prop_or_default]
100 pub autofocus: bool,
101
102 #[prop_or_default]
105 pub results_count: Option<ResultsCount>,
106 #[prop_or(AttrValue::from("Previous"))]
108 pub previous_navigation_button_aria_label: AttrValue,
109 #[prop_or_default]
111 pub previous_navigation_button_disabled: bool,
112 #[prop_or(AttrValue::from("Next"))]
114 pub next_navigation_button_aria_label: AttrValue,
115 #[prop_or_default]
117 pub next_navigation_button_disabled: bool,
118
119 #[prop_or_default]
122 pub onchange: Option<Callback<String>>,
123 #[prop_or_default]
125 pub onclear: Option<Callback<MouseEvent>>,
126 #[prop_or_default]
128 pub onnextclick: Option<Callback<MouseEvent>>,
129 #[prop_or_default]
131 pub onpreviousclick: Option<Callback<MouseEvent>>,
132 #[prop_or_default]
134 pub onsearch: Option<Callback<(OnSearchEvent, String)>>,
135}
136
137#[derive(Debug, Clone, PartialEq, Properties)]
141pub struct SearchInputExpandableProperties {
142 #[prop_or_default]
144 pub expanded: bool,
145 #[prop_or_default]
147 pub ontoggleexpand: Callback<(MouseEvent, bool)>,
148 #[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}