patternfly_yew/components/pagination/
mod.rs

1//! Pagination controls
2
3mod simple;
4pub use simple::*;
5
6use crate::prelude::{
7    use_on_enter, AsClasses, Button, ButtonVariant, ExtendClasses, Icon, TextInput, TextInputType,
8};
9use yew::prelude::*;
10use yew_hooks::use_click_away;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum PaginationPosition {
14    Top,
15    Bottom,
16}
17
18impl AsClasses for PaginationPosition {
19    fn extend_classes(&self, classes: &mut Classes) {
20        match self {
21            Self::Top => {}
22            Self::Bottom => classes.push(classes!("pf-m-top")),
23        }
24    }
25}
26
27impl PaginationPosition {
28    fn toggle_icon(&self, expanded: bool) -> Icon {
29        match (self, expanded) {
30            (Self::Bottom, true) => Icon::CaretUp,
31            _ => Icon::CaretDown,
32        }
33    }
34}
35
36/// Properties for [`Pagination`]
37#[derive(Clone, PartialEq, Properties)]
38pub struct PaginationProperties {
39    #[prop_or_default]
40    pub total_entries: Option<usize>,
41    #[prop_or_default]
42    pub offset: usize,
43    #[prop_or(vec![10,25,50])]
44    pub entries_per_page_choices: Vec<usize>,
45    #[prop_or(25)]
46    pub selected_choice: usize,
47
48    /// Callback for navigation
49    #[prop_or_default]
50    pub onnavigation: Callback<Navigation>,
51
52    /// Callback for change in limit (page size, per page)
53    #[prop_or_default]
54    pub onlimit: Callback<usize>,
55
56    /// Element ID
57    #[prop_or_default]
58    pub id: Option<AttrValue>,
59
60    /// Additional styles
61    #[prop_or_default]
62    pub style: AttrValue,
63
64    #[prop_or(PaginationPosition::Top)]
65    pub position: PaginationPosition,
66
67    /// Disable the full control
68    #[prop_or_default]
69    pub disabled: bool,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum Navigation {
74    First,
75    Previous,
76    Next,
77    Last,
78    /// navigate to a specific page (zero based)
79    Page(usize),
80}
81
82/// Pagination component.
83///
84/// > A **pagination** component gives users more navigational capability on pages with content views.
85///
86/// See: <https://www.patternfly.org/components/pagination>
87///
88/// ## Properties
89///
90/// Defined by [`PaginationProperties`].
91///
92/// ## Example
93///
94/// See the [PatternFly Quickstart](https://github.com/ctron/patternfly-yew-quickstart) for a complete example.
95#[function_component(Pagination)]
96pub fn pagination(props: &PaginationProperties) -> Html {
97    let expanded = use_state_eq(|| false);
98
99    // The pagination menu : "1-20 of nnn"
100    let mut menu_classes = classes!("pf-v5-c-options-menu");
101    menu_classes.extend_from(&props.position);
102
103    if *expanded {
104        menu_classes.push("pf-m-expanded");
105    }
106
107    // if the dataset is empty
108    let empty = props
109        .total_entries
110        .map(|total| total == 0)
111        .unwrap_or_default();
112
113    // The default rust div operator does floor(), we need ceil, so we cast to float before doing the operation
114    let max_page = props
115        .total_entries
116        .map(|m| (m as f64 / props.selected_choice as f64).ceil() as usize);
117
118    // the current page
119    let current_page = match empty {
120        true => 0,
121        false => (props.offset as f64 / props.selected_choice as f64).ceil() as usize,
122    };
123
124    // if this is the  last page
125    let is_last_page = if let Some(max) = props.total_entries {
126        props.offset + props.selected_choice >= max
127    } else {
128        false
129    };
130
131    // total entries string
132    let total_entries = props
133        .total_entries
134        .map(|m| format!("{}", m))
135        .unwrap_or_else(|| String::from("many"));
136
137    // first entry number (one based)
138    let start = match empty {
139        true => 0,
140        // +1 because humans don't count from 0 :)
141        false => props.offset + 1,
142    };
143
144    let mut end = props.offset + props.selected_choice;
145    if let Some(total) = props.total_entries {
146        end = end.min(total);
147    }
148    let showing = format!("{start} - {end}",);
149
150    let limit_choices = props.entries_per_page_choices.clone();
151
152    // toggle
153    let ontoggle = use_callback(expanded.clone(), |_, expanded| {
154        expanded.set(!**expanded);
155    });
156
157    let node = use_node_ref();
158    {
159        let expanded = expanded.clone();
160        use_click_away(node.clone(), move |_| {
161            expanded.set(false);
162        });
163    }
164
165    // page input field
166
167    // the parsed input (zero based)
168    let input = use_state_eq(|| 0);
169    // the raw input of the page number field
170    let input_text = use_state_eq(|| Some((current_page + 1).to_string()));
171
172    if input_text.is_none() {
173        input_text.set(Some((current_page + 1).to_string()));
174    }
175
176    let onkeydown = use_on_enter(
177        (input.clone(), props.onnavigation.clone(), max_page),
178        |(input, onnavigation, max_page)| {
179            let mut page: usize = **input;
180            if let Some(max_page) = max_page {
181                if page > *max_page {
182                    page = *max_page;
183                }
184            }
185            // humans start with 1, we use 0.
186            page = page.saturating_sub(1);
187            log::debug!("Emit page change: {page}");
188            onnavigation.emit(Navigation::Page(page));
189        },
190    );
191
192    let onchange = use_callback(
193        (input.clone(), input_text.clone(), max_page, current_page),
194        |text: String, (input, input_text, max_page, current_page)| {
195            input_text.set(Some(text.clone()));
196
197            let value = match text.parse::<usize>() {
198                Ok(value) => {
199                    let max_page = max_page.unwrap_or(usize::MAX);
200                    if value > 0 && value <= max_page {
201                        Some(value)
202                    } else {
203                        None
204                    }
205                }
206                Err(_) => None,
207            };
208
209            if let Some(value) = value {
210                input.set(value);
211            } else {
212                // +1 because humans
213                input.set(current_page.saturating_add(1));
214            }
215
216            log::debug!("New prepared page value: {:?} / {}", **input_text, **input);
217        },
218    );
219
220    let onblur = use_callback(input_text.clone(), |_, input_text| {
221        input_text.set(None);
222    });
223
224    let onnavigation = use_callback(
225        (props.onnavigation.clone(), input_text.clone()),
226        |nav, (onnavigation, input_text)| {
227            input_text.set(None);
228            onnavigation.emit(nav);
229        },
230    );
231
232    // Page number can be changed through props, therefore input_text should watch props
233    {
234        let input_text = input_text.clone();
235        use_effect_with(
236            (props.offset, props.selected_choice, props.total_entries),
237            move |(offset, selected, total)| {
238                let r = (*offset as f64 / *selected as f64).ceil() as usize;
239
240                if *total == Some(0) {
241                    input_text.set(Some("0".to_string()));
242                } else {
243                    input_text.set(Some((r + 1).to_string()));
244                }
245            },
246        );
247    }
248
249    // on limit change
250    let onlimit = use_callback(
251        (props.onlimit.clone(), input_text.clone()),
252        |limit, (onlimit, input_text)| {
253            input_text.set(None);
254            onlimit.emit(limit);
255        },
256    );
257
258    // The main div
259    let pagination_classes = match &props.position {
260        PaginationPosition::Top => classes!("pf-v5-c-pagination"),
261        PaginationPosition::Bottom => classes!("pf-v5-c-pagination", "pf-m-bottom"),
262    };
263
264    let pagination_styles = format!(
265        "--pf-v5-c-pagination__nav-page-select--c-form-control--width-chars: {};",
266        max_page.unwrap_or_default().to_string().len().clamp(2, 10)
267    );
268
269    // render
270
271    let unbound = props.total_entries.is_none();
272
273    html! (
274
275        <div
276            id={&props.id}
277            class={pagination_classes}
278            style={[pagination_styles, props.style.to_string()].join(" ")}
279            ref={node}
280        >
281
282            // the selector of how many entries per page to display
283            <div class="pf-v5-c-pagination__total-items">
284                <b>{ showing.clone() }</b> {"\u{00a0}of\u{00a0}"}
285                <b>{ total_entries.clone() }</b>
286            </div>
287
288            <div class={ menu_classes }>
289                <button
290                    class="pf-v5-c-options-menu__toggle pf-m-text pf-m-plain"
291                    type="button"
292                    aria-haspopup="listbox"
293                    aria-expanded="true"
294                    onclick={ontoggle}
295                    disabled={props.disabled}
296                >
297                    <span class="pf-v5-c-options-menu__toggle-text">
298                        <b>{ showing }</b>{"\u{00a0}of\u{00a0}"}
299                        <b>{ total_entries }</b>
300                    </span>
301                    <div class="pf-v5-c-options-menu__toggle-icon">
302                        { props.position.toggle_icon(*expanded)}
303                    </div>
304                </button>
305
306            if *expanded {
307                <ul class="pf-v5-c-options-menu__menu" >
308                    { for limit_choices.into_iter().map(|limit|  {
309                        let expanded = expanded.clone();
310                        let onlimit = onlimit.clone();
311                        let onclick = Callback::from(move |_|{
312                            onlimit.emit(limit);
313                            expanded.set(false);
314                        });
315                        html!(
316                            <li>
317                                <button
318                                    class="pf-v5-c-options-menu__menu-item"
319                                    type="button"
320                                    {onclick}
321                                >
322                                    {limit} {" per page"}
323                                    if props.selected_choice == limit {
324                                        <div class="pf-v5-c-options-menu__menu-item-icon">
325                                            { Icon::Check }
326                                        </div>
327                                    }
328                                </button>
329                            </li>
330                    )})}
331                </ul>
332            }
333            </div>
334
335            // the navigation buttons
336            <nav class="pf-v5-c-pagination__nav" aria-label="Pagination">
337                <div class="pf-v5-c-pagination__nav-control pf-m-first">
338                    <Button
339                        variant={ButtonVariant::Plain}
340                        onclick={onnavigation.reform(|_|Navigation::First)}
341                        disabled={ props.disabled || props.offset == 0 }
342                        aria_label="Go to first page"
343                    >
344                      { Icon::AngleDoubleLeft }
345                    </Button>
346                </div>
347                <div class="pf-v5-c-pagination__nav-control pf-m-prev">
348                    <Button
349                        aria_label="Go to previous page"
350                        variant={ButtonVariant::Plain}
351                        onclick={onnavigation.reform(|_|Navigation::Previous)}
352                        disabled={ props.disabled || props.offset == 0 }
353                    >
354                       { Icon::AngleLeft }
355                    </Button>
356                </div>
357                <div class="pf-v5-c-pagination__nav-page-select">
358                    <TextInput
359                        r#type={TextInputType::Number}
360                        inputmode="number"
361                        {onchange}
362                        {onkeydown}
363                        {onblur}
364                        value={(*input_text).clone().unwrap_or_else(|| (current_page+1).to_string()) }
365                        disabled={ props.disabled || empty }
366                    />
367                if let Some(max_page) = max_page {
368                    <span aria-hidden="true">{ "of "} { max_page }</span>
369                }
370                </div>
371
372                <div class="pf-v5-c-pagination__nav-control pf-m-next">
373                    <Button
374                        aria_label="Go to next page"
375                        variant={ButtonVariant::Plain}
376                        onclick={onnavigation.reform(|_|Navigation::Next)}
377                        disabled={ props.disabled || is_last_page }
378                    >
379                        { Icon::AngleRight }
380                    </Button>
381                </div>
382                <div class="pf-v5-c-pagination__nav-control pf-m-last">
383                    <Button
384                        aria_label="Go to last page"
385                        variant={ButtonVariant::Plain}
386                        onclick={onnavigation.reform(|_|Navigation::Last)}
387                        disabled={ props.disabled || unbound || is_last_page }
388                    >
389                        { Icon::AngleDoubleRight }
390                    </Button>
391                </div>
392            </nav>
393        </div>
394    )
395}