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
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}