yew-duskmoon 0.8.2

DuskMoon, a Yew component libaray.
Documentation
use web_sys::{Event, HtmlInputElement, HtmlSelectElement, MouseEvent};
use yew::prelude::*;
use yew::virtual_dom::AttrValue;
use yew::TargetCast;

#[derive(Properties, Clone, PartialEq)]
pub struct PaginationProps {
    #[prop_or_default]
    pub class: Classes,
    #[prop_or_default]
    pub children: Children,
    #[prop_or_default]
    pub variant: Option<String>,
    #[prop_or_default]
    pub total: Option<usize>,
    #[prop_or(10)]
    pub page_size: usize,
    #[prop_or(1)]
    pub current: usize,
    #[prop_or_else(default_page_size_options)]
    pub page_size_options: Vec<usize>,
    #[prop_or_default]
    pub on_change: Callback<usize>,
    #[prop_or_default]
    pub on_page_size_change: Callback<usize>,
    #[prop_or_default]
    pub on_refresh: Callback<MouseEvent>,
    #[prop_or(true)]
    pub show_refresh: bool,
    #[prop_or_else(|| AttrValue::from("items/page"))]
    pub page_size_label: AttrValue,
    #[prop_or_else(|| AttrValue::from("Refresh"))]
    pub refresh_label: AttrValue,
    #[prop_or_else(|| AttrValue::from("Pagination"))]
    pub aria_label: AttrValue,
}

#[function_component(Pagination)]
pub fn pagination(props: &PaginationProps) -> Html {
    let mut classes = classes!("pagination");
    if let Some(variant) = &props.variant {
        classes.push(format!("pagination-{}", variant));
    }
    classes.push(props.class.clone());

    if let Some(total) = props.total {
        classes.push("pagination-auto");
        return render_automatic_pagination(props, classes, total);
    }

    html! {
        <div class={classes}>
            { for props.children.iter() }
        </div>
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct PaginationState {
    current: usize,
    page_size: usize,
    total_pages: usize,
    range_start: usize,
    range_end: usize,
}

fn default_page_size_options() -> Vec<usize> {
    vec![10, 20, 30, 50, 100]
}

fn calculate_state(total: usize, page_size: usize, current: usize) -> PaginationState {
    let page_size = page_size.max(1);
    let total_pages = total.div_ceil(page_size).max(1);
    let current = current.clamp(1, total_pages);
    let range_start = if total == 0 {
        0
    } else {
        ((current - 1) * page_size) + 1
    };
    let range_end = (current * page_size).min(total);

    PaginationState {
        current,
        page_size,
        total_pages,
        range_start,
        range_end,
    }
}

fn render_automatic_pagination(props: &PaginationProps, classes: Classes, total: usize) -> Html {
    let state = calculate_state(total, props.page_size, props.current);
    let is_first = state.current <= 1;
    let is_last = state.current >= state.total_pages;

    let previous = {
        let on_change = props.on_change.clone();
        let current = state.current;
        Callback::from(move |_| {
            if current > 1 {
                on_change.emit(current - 1);
            }
        })
    };

    let next = {
        let on_change = props.on_change.clone();
        let current = state.current;
        let total_pages = state.total_pages;
        Callback::from(move |_| {
            if current < total_pages {
                on_change.emit(current + 1);
            }
        })
    };

    let jump_to_page = {
        let on_change = props.on_change.clone();
        let total_pages = state.total_pages;
        Callback::from(move |event: Event| {
            let input = event.target_unchecked_into::<HtmlInputElement>();
            if let Ok(page) = input.value().parse::<usize>() {
                on_change.emit(page.clamp(1, total_pages));
            }
        })
    };

    let change_page_size = {
        let on_page_size_change = props.on_page_size_change.clone();
        Callback::from(move |event: Event| {
            let select = event.target_unchecked_into::<HtmlSelectElement>();
            if let Ok(page_size) = select.value().parse::<usize>() {
                on_page_size_change.emit(page_size.max(1));
            }
        })
    };

    let mut page_size_options = props.page_size_options.clone();
    if !page_size_options.contains(&state.page_size) {
        page_size_options.push(state.page_size);
    }
    page_size_options.sort_unstable();
    page_size_options.dedup();

    html! {
        <div class={classes} role="navigation" aria-label={props.aria_label.clone()}>
            <span class="pagination-total">
                { format!("{}-{} / {}", state.range_start, state.range_end, total) }
            </span>

            <button
                class="pagination-page-button pagination-prev"
                type="button"
                disabled={is_first}
                aria-label="Previous page"
                onclick={previous}
            >
                { "<" }
            </button>

            <input
                class="pagination-current-input"
                type="number"
                name="pagination-current"
                min="1"
                max={state.total_pages.to_string()}
                value={state.current.to_string()}
                aria-label="Current page"
                onchange={jump_to_page}
            />

            <span class="pagination-slash">{ "/" }</span>
            <span class="pagination-page-total">{ state.total_pages }</span>

            <button
                class="pagination-page-button pagination-next"
                type="button"
                disabled={is_last}
                aria-label="Next page"
                onclick={next}
            >
                { ">" }
            </button>

            <label class="pagination-page-size">
                <select
                    class="pagination-page-size-select"
                    name="pagination-page-size"
                    value={state.page_size.to_string()}
                    aria-label="Page size"
                    onchange={change_page_size}
                >
                    {
                        for page_size_options.into_iter().map(|page_size| html! {
                            <option value={page_size.to_string()} selected={page_size == state.page_size}>
                                { page_size }
                            </option>
                        })
                    }
                </select>
                <span class="pagination-page-size-label">{ props.page_size_label.clone() }</span>
                <span class="pagination-page-size-caret" aria-hidden="true"></span>
            </label>

            {
                if props.show_refresh {
                    html! {
                        <button
                            class="pagination-refresh"
                            type="button"
                            aria-label={props.refresh_label.clone()}
                            onclick={props.on_refresh.clone()}
                        >
                            <span class="pagination-refresh-icon" aria-hidden="true"></span>
                            <span>{ props.refresh_label.clone() }</span>
                        </button>
                    }
                } else {
                    Html::default()
                }
            }
        </div>
    }
}

#[cfg(test)]
mod tests {
    use super::calculate_state;

    #[test]
    fn calculates_page_count_and_visible_range() {
        let state = calculate_state(8038, 30, 1);

        assert_eq!(state.total_pages, 268);
        assert_eq!(state.range_start, 1);
        assert_eq!(state.range_end, 30);
    }

    #[test]
    fn clamps_current_page_to_available_range() {
        let state = calculate_state(95, 30, 99);

        assert_eq!(state.current, 4);
        assert_eq!(state.range_start, 91);
        assert_eq!(state.range_end, 95);
    }

    #[test]
    fn handles_empty_totals_and_zero_page_size() {
        let state = calculate_state(0, 0, 4);

        assert_eq!(state.current, 1);
        assert_eq!(state.page_size, 1);
        assert_eq!(state.total_pages, 1);
        assert_eq!(state.range_start, 0);
        assert_eq!(state.range_end, 0);
    }
}