Skip to main content

patternfly_yew/components/pagination/
mod.rs

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