leptos_shadcn_pagination/
default.rs1use leptos::prelude::*;
2use tailwind_fuse::tw_merge;
3
4const PAGINATION_CLASS: &str = "mx-auto flex w-full justify-center";
5const PAGINATION_CONTENT_CLASS: &str = "flex flex-row items-center gap-1";
6const PAGINATION_ITEM_CLASS: &str = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2";
7const PAGINATION_LINK_CLASS: &str = "gap-1 pl-2.5";
8const PAGINATION_PREVIOUS_CLASS: &str = "gap-1 pr-2.5";
9const PAGINATION_ELLIPSIS_CLASS: &str = "flex h-9 w-9 items-center justify-center";
10
11#[component]
12pub fn Pagination(
13    #[prop(optional)] current_page: MaybeProp<usize>,
14    #[prop(default = 1)] total_pages: usize,
15    #[prop(optional)] on_page_change: Option<Callback<usize>>,
16    #[prop(optional)] show_previous_next: MaybeProp<bool>,
17    #[prop(optional)] show_first_last: MaybeProp<bool>,
18    #[prop(optional)] class: MaybeProp<String>,
19) -> impl IntoView {
20    let current = current_page.get().unwrap_or(1);
21    let show_prev_next = show_previous_next.get().unwrap_or(true);
22    let show_first_last_pages = show_first_last.get().unwrap_or(false);
23    
24    let handle_page_change = move |page: usize| {
25        if let Some(on_page_change) = on_page_change {
26            on_page_change.run(page);
27        }
28    };
29    
30    let get_visible_pages = move || -> Vec<Option<usize>> {
31        let mut pages = Vec::new();
32        
33        if total_pages <= 7 {
34            for i in 1..=total_pages {
36                pages.push(Some(i));
37            }
38        } else {
39            if show_first_last_pages {
41                pages.push(Some(1));
42            }
43            
44            let start = if current <= 3 {
46                1
47            } else if current >= total_pages - 2 {
48                total_pages - 4
49            } else {
50                current - 2
51            };
52            
53            let end = if current <= 3 {
54                5
55            } else if current >= total_pages - 2 {
56                total_pages
57            } else {
58                current + 2
59            };
60            
61            if show_first_last_pages && start > 2 {
63                pages.push(None); }
65            
66            let actual_start = if show_first_last_pages && start > 1 { start.max(2) } else { start };
68            let actual_end = if show_first_last_pages && end < total_pages { end.min(total_pages - 1) } else { end };
69            
70            for i in actual_start..=actual_end {
71                pages.push(Some(i));
72            }
73            
74            if show_first_last_pages && end < total_pages - 1 {
76                pages.push(None); }
78            
79            if show_first_last_pages && !pages.contains(&Some(total_pages)) {
81                pages.push(Some(total_pages));
82            }
83        }
84        
85        pages
86    };
87    
88    let merged_class = tw_merge!(&format!("{} {}", 
89        PAGINATION_CLASS,
90        class.get().unwrap_or_default()
91    ));
92    
93    view! {
94        <nav 
95            class={merged_class}
96            role="navigation"
97            aria-label="pagination"
98        >
99            <div class=PAGINATION_CONTENT_CLASS>
100                {if show_prev_next {
102                    view! {
103                        <PaginationItem>
104                            <PaginationPrevious 
105                                disabled={(current <= 1).into()}
106                                on_click=Callback::new(move |_| if current > 1 { handle_page_change(current - 1) })
107                            />
108                        </PaginationItem>
109                    }.into_any()
110                } else {
111                    view! {}.into_any()
112                }}
113                
114                {get_visible_pages().into_iter().map(|page_opt| {
116                    match page_opt {
117                        Some(page) => view! {
118                            <PaginationItem>
119                                <PaginationLink 
120                                    _page=page.into()
121                                    is_active={(page == current).into()}
122                                    on_click=Callback::new(move |_| handle_page_change(page))
123                                >
124                                    {page.to_string()}
125                                </PaginationLink>
126                            </PaginationItem>
127                        }.into_any(),
128                        None => view! {
129                            <PaginationItem>
130                                <PaginationEllipsis />
131                            </PaginationItem>
132                        }.into_any(),
133                    }
134                }).collect::<Vec<_>>()}
135                
136                {if show_prev_next {
138                    view! {
139                        <PaginationItem>
140                            <PaginationNext 
141                                disabled={(current >= total_pages).into()}
142                                on_click=Callback::new(move |_| if current < total_pages { handle_page_change(current + 1) })
143                            />
144                        </PaginationItem>
145                    }.into_any()
146                } else {
147                    view! {}.into_any()
148                }}
149            </div>
150        </nav>
151    }
152}
153
154#[component]
155pub fn PaginationContent(
156    #[prop(optional)] class: MaybeProp<String>,
157    children: Children,
158) -> impl IntoView {
159    let merged_class = tw_merge!(&format!("{} {}", 
160        PAGINATION_CONTENT_CLASS,
161        class.get().unwrap_or_default()
162    ));
163    
164    view! {
165        <div class={merged_class}>
166            {children()}
167        </div>
168    }
169}
170
171#[component]
172pub fn PaginationItem(
173    #[prop(optional)] class: MaybeProp<String>,
174    children: Children,
175) -> impl IntoView {
176    view! {
177        <div class={class.get().unwrap_or_default()}>
178            {children()}
179        </div>
180    }
181}
182
183#[component]
184pub fn PaginationLink(
185    #[prop(optional)] _page: MaybeProp<usize>,
186    #[prop(optional)] is_active: MaybeProp<bool>,
187    #[prop(optional)] disabled: MaybeProp<bool>,
188    #[prop(optional)] on_click: Option<Callback<()>>,
189    #[prop(optional)] class: MaybeProp<String>,
190    children: Children,
191) -> impl IntoView {
192    let is_active_val = is_active.get().unwrap_or(false);
193    let is_disabled = disabled.get().unwrap_or(false);
194    
195    let button_class = if is_active_val {
196        tw_merge!(&format!("{} bg-primary text-primary-foreground hover:bg-primary/80", PAGINATION_ITEM_CLASS))
197    } else {
198        PAGINATION_ITEM_CLASS.to_string()
199    };
200    
201    let merged_class = tw_merge!(&format!("{} {}", 
202        button_class,
203        class.get().unwrap_or_default()
204    ));
205    
206    view! {
207        <button
208            class={merged_class}
209            disabled={is_disabled}
210            on:click=move |_| {
211                if !is_disabled {
212                    if let Some(on_click) = on_click {
213                        on_click.run(());
214                    }
215                }
216            }
217            aria-current={if is_active_val { "page" } else { "false" }}
218        >
219            {children()}
220        </button>
221    }
222}
223
224#[component]
225pub fn PaginationPrevious(
226    #[prop(optional)] disabled: MaybeProp<bool>,
227    #[prop(optional)] on_click: Option<Callback<()>>,
228    #[prop(optional)] class: MaybeProp<String>,
229    #[prop(optional)] children: Option<Children>,
230) -> impl IntoView {
231    let is_disabled = disabled.get().unwrap_or(false);
232    
233    let merged_class = tw_merge!(&format!("{} {} {}", 
234        PAGINATION_ITEM_CLASS,
235        PAGINATION_PREVIOUS_CLASS,
236        class.get().unwrap_or_default()
237    ));
238    
239    view! {
240        <button
241            class={merged_class}
242            disabled={is_disabled}
243            on:click=move |_| {
244                if !is_disabled {
245                    if let Some(on_click) = on_click {
246                        on_click.run(());
247                    }
248                }
249            }
250            aria-label="Go to previous page"
251        >
252            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
253                <path d="m15 18-6-6 6-6"/>
254            </svg>
255            {if let Some(children) = children {
256                children().into_any()
257            } else {
258                view! { <span>"Previous"</span> }.into_any()
259            }}
260        </button>
261    }
262}
263
264#[component]
265pub fn PaginationNext(
266    #[prop(optional)] disabled: MaybeProp<bool>,
267    #[prop(optional)] on_click: Option<Callback<()>>,
268    #[prop(optional)] class: MaybeProp<String>,
269    #[prop(optional)] children: Option<Children>,
270) -> impl IntoView {
271    let is_disabled = disabled.get().unwrap_or(false);
272    
273    let merged_class = tw_merge!(&format!("{} {} {}", 
274        PAGINATION_ITEM_CLASS,
275        PAGINATION_LINK_CLASS,
276        class.get().unwrap_or_default()
277    ));
278    
279    view! {
280        <button
281            class={merged_class}
282            disabled={is_disabled}
283            on:click=move |_| {
284                if !is_disabled {
285                    if let Some(on_click) = on_click {
286                        on_click.run(());
287                    }
288                }
289            }
290            aria-label="Go to next page"
291        >
292            {if let Some(children) = children {
293                children().into_any()
294            } else {
295                view! { <span>"Next"</span> }.into_any()
296            }}
297            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
298                <path d="m9 18 6-6-6-6"/>
299            </svg>
300        </button>
301    }
302}
303
304#[component]
305pub fn PaginationEllipsis(
306    #[prop(optional)] class: MaybeProp<String>,
307) -> impl IntoView {
308    let merged_class = tw_merge!(&format!("{} {}", 
309        PAGINATION_ELLIPSIS_CLASS,
310        class.get().unwrap_or_default()
311    ));
312    
313    view! {
314        <span 
315            class={merged_class}
316            aria-hidden="true"
317        >
318            <span class="h-4 w-4">...</span>
319            <span class="sr-only">"More pages"</span>
320        </span>
321    }
322}