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::prelude::*;
10use yew_hooks::use_event_with_window;
11
12#[derive(Debug, Clone, PartialEq)]
15pub enum ResultsCount {
16 Absolute(usize),
17 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#[derive(Debug, Clone, PartialEq, Properties)]
60pub struct SearchInputProperties {
61 #[prop_or_default]
63 pub id: Option<AttrValue>,
64 #[prop_or_default]
66 pub aria_label: AttrValue,
67 #[prop_or_default]
69 pub class: Classes,
70 #[prop_or_default]
72 pub expandable: Option<SearchInputExpandableProperties>,
73 #[prop_or_default]
75 pub hint: Option<AttrValue>,
76 #[prop_or_default]
78 pub inner_ref: Option<NodeRef>,
79 #[prop_or_default]
81 pub disabled: bool,
82 #[prop_or_default]
84 pub placeholder: Option<AttrValue>,
85 #[prop_or(AttrValue::from("Reset"))]
87 pub reset_button_label: AttrValue,
88 #[prop_or(AttrValue::from("Search"))]
90 pub submit_search_button_label: AttrValue,
91 #[prop_or_default]
94 pub utilities_displayed: bool,
95 #[prop_or_default]
97 pub value: String,
98 #[prop_or_default]
99 pub autofocus: bool,
100
101 #[prop_or_default]
104 pub results_count: Option<ResultsCount>,
105 #[prop_or(AttrValue::from("Previous"))]
107 pub previous_navigation_button_aria_label: AttrValue,
108 #[prop_or_default]
110 pub previous_navigation_button_disabled: bool,
111 #[prop_or(AttrValue::from("Next"))]
113 pub next_navigation_button_aria_label: AttrValue,
114 #[prop_or_default]
116 pub next_navigation_button_disabled: bool,
117
118 #[prop_or_default]
121 pub onchange: Option<Callback<String>>,
122 #[prop_or_default]
124 pub onclear: Option<Callback<MouseEvent>>,
125 #[prop_or_default]
127 pub onnextclick: Option<Callback<MouseEvent>>,
128 #[prop_or_default]
130 pub onpreviousclick: Option<Callback<MouseEvent>>,
131 #[prop_or_default]
133 pub onsearch: Option<Callback<(OnSearchEvent, String)>>,
134}
135
136#[derive(Debug, Clone, PartialEq, Properties)]
140pub struct SearchInputExpandableProperties {
141 #[prop_or_default]
143 pub expanded: bool,
144 #[prop_or_default]
146 pub ontoggleexpand: Callback<(MouseEvent, bool)>,
147 #[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}