1use leptos::children::Children;
2use leptos::context::use_context;
3use leptos::prelude::*;
4
5#[derive(Clone, Debug, PartialEq)]
7pub struct PaginationPage {
8 pub number: usize,
9 pub label: Option<String>,
10 pub _disabled: bool,
11 pub _current: bool,
12}
13
14impl PaginationPage {
15 pub fn new(number: usize) -> Self {
16 Self {
17 number,
18 label: None,
19 _disabled: false,
20 _current: false,
21 }
22 }
23
24 pub fn with_label(mut self, label: String) -> Self {
25 self.label = Some(label);
26 self
27 }
28
29 pub fn withdisabled(mut self, disabled: bool) -> Self {
30 self._disabled = disabled;
31 self
32 }
33
34 pub fn withcurrent(mut self, current: bool) -> Self {
35 self._current = current;
36 self
37 }
38}
39
40#[derive(Clone, Debug, PartialEq)]
42pub enum PaginationSize {
43 Small,
44 Medium,
45 Large,
46}
47
48impl PaginationSize {
49 pub fn as_str(&self) -> &'static str {
50 match self {
51 PaginationSize::Small => "small",
52 PaginationSize::Medium => "medium",
53 PaginationSize::Large => "large",
54 }
55 }
56}
57
58#[derive(Clone, Debug, PartialEq)]
60pub enum PaginationVariant {
61 Default,
62 Compact,
63 Detailed,
64}
65
66impl PaginationVariant {
67 pub fn as_str(&self) -> &'static str {
68 match self {
69 PaginationVariant::Default => "default",
70 PaginationVariant::Compact => "compact",
71 PaginationVariant::Detailed => "detailed",
72 }
73 }
74}
75
76#[derive(Clone)]
78pub struct PaginationContext {
79 pub current_page: Signal<usize>,
80 pub total_pages: usize,
81 pub page_size: usize,
82 pub total_items: usize,
83 pub size: PaginationSize,
84 pub variant: PaginationVariant,
85 pub _show_first_last: bool,
86 pub _show_prev_next: bool,
87 pub _show_page_numbers: bool,
88 pub pagination_id: String,
89 pub on_page_change: Option<Callback<usize>>,
90}
91
92fn generate_id(prefix: &str) -> String {
94 static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
95 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
96 format!("{}-{}", prefix, id)
97}
98
99fn merge_classes(existing: Option<&str>, additional: Option<&str>) -> Option<String> {
101 match (existing, additional) {
102 (Some(a), Some(b)) => Some(format!("{} {}", a, b)),
103 (Some(a), None) => Some(a.to_string()),
104 (None, Some(b)) => Some(b.to_string()),
105 (None, None) => None,
106 }
107}
108
109fn calculate_page_range(
111 current_page: usize,
112 total_pages: usize,
113 maxvisible: usize,
114) -> (usize, usize) {
115 if total_pages <= maxvisible {
116 return (1, total_pages);
117 }
118
119 let halfvisible = maxvisible / 2;
120 let mut start = current_page.saturating_sub(halfvisible);
121 let mut end = start + maxvisible - 1;
122
123 if end > total_pages {
124 end = total_pages;
125 start = end.saturating_sub(maxvisible - 1);
126 }
127
128 (start, end)
129}
130
131#[component]
133pub fn Pagination(
134 #[prop(optional, default = 1)]
136 current_page: usize,
137 #[prop(optional, default = 1)]
139 total_pages: usize,
140 #[prop(optional, default = 10)]
142 page_size: usize,
143 #[prop(optional)]
145 total_items: Option<usize>,
146 #[prop(optional, default = PaginationSize::Medium)]
148 size: PaginationSize,
149 #[prop(optional, default = PaginationVariant::Default)]
151 variant: PaginationVariant,
152 #[prop(optional, default = true)]
154 _show_first_last: bool,
155 #[prop(optional, default = true)]
157 _show_prev_next: bool,
158 #[prop(optional, default = true)]
160 _show_page_numbers: bool,
161 #[prop(optional)]
163 on_page_change: Option<Callback<usize>>,
164 #[prop(optional)]
166 class: Option<String>,
167 children: Children,
169) -> impl IntoView {
170 let pagination_id = generate_id("pagination");
171
172 let (current_page_signal, _setcurrent_page_signal) = signal(current_page);
174 let total_items_calculated = total_items.unwrap_or_else(|| total_pages * page_size);
175
176 let context = PaginationContext {
178 current_page: current_page_signal.into(),
179 total_pages,
180 page_size,
181 total_items: total_items_calculated,
182 size: size.clone(),
183 variant: variant.clone(),
184 _show_first_last,
185 _show_prev_next,
186 _show_page_numbers,
187 pagination_id: pagination_id.clone(),
188 on_page_change,
189 };
190
191 let base_classes = "radix-pagination";
193 let combined_class = merge_classes(Some(base_classes), class.as_deref())
194 .unwrap_or_else(|| base_classes.to_string());
195
196 provide_context(context);
198
199 view! {
200 <nav
201 id=pagination_id
202 class=combined_class
203 data-current-page=current_page_signal.get()
204 data-total-pages=total_pages
205 data-page-size=page_size
206 data-total-items=total_items_calculated
207 data-size=size.as_str()
208 data-variant=variant.as_str()
209 data-show-first-last=_show_first_last
210 data-show-prev-next=_show_prev_next
211 data-show-page-numbers=_show_page_numbers
212 role="navigation"
213 aria-label="Pagination"
214 >
215 {children()}
216 </nav>
217 }
218}
219
220#[component]
222pub fn PaginationList(
223 #[prop(optional)]
225 class: Option<String>,
226 #[prop(optional)]
228 style: Option<String>,
229 children: Children,
231) -> impl IntoView {
232 let _context =
233 use_context::<PaginationContext>().expect("PaginationList must be used within Pagination");
234 let list_id = generate_id("pagination-list");
235
236 let base_classes = "radix-pagination-list";
238 let combined_class = merge_classes(Some(base_classes), class.as_deref())
239 .unwrap_or_else(|| base_classes.to_string());
240
241 view! {
242 <ul
243 id=list_id
244 class=combined_class
245 style=style.unwrap_or_default()
246 role="list"
247 >
248 {children()}
249 </ul>
250 }
251}
252
253#[component]
255pub fn PaginationItem(
256 #[prop(optional)]
258 page: Option<PaginationPage>,
259 #[prop(optional)]
261 current: Option<bool>,
262 #[prop(optional)]
264 disabled: Option<bool>,
265 #[prop(optional)]
267 class: Option<String>,
268 #[prop(optional)]
270 style: Option<String>,
271 children: Children,
273) -> impl IntoView {
274 let context =
275 use_context::<PaginationContext>().expect("PaginationItem must be used within Pagination");
276 let item_id = generate_id("pagination-item");
277
278 let page_clone = page.clone();
279 let handle_click = move |event: web_sys::MouseEvent| {
280 event.prevent_default();
281
282 if let Some(page) = page_clone.clone() {
283 if !page._disabled {
284 if let Some(callback) = context.on_page_change.clone() {
286 callback.run(page.number);
287 }
288 }
289 }
290 };
291
292 let page_forcurrent = page.clone();
293 let page_fordisabled = page.clone();
294
295 let iscurrent = Memo::new(move |_| {
297 if let Some(current) = current {
298 current
299 } else if let Some(page) = page_forcurrent.as_ref() {
300 page._current
301 } else {
302 false
303 }
304 });
305
306 let isdisabled = Memo::new(move |_| {
308 if let Some(disabled) = disabled {
309 disabled
310 } else if let Some(page) = page_fordisabled.as_ref() {
311 page._disabled
312 } else {
313 false
314 }
315 });
316
317 let base_classes = "radix-pagination-item";
319 let combined_class = merge_classes(Some(base_classes), class.as_deref())
320 .unwrap_or_else(|| base_classes.to_string());
321
322 view! {
323 <li
324 id=item_id
325 class=combined_class
326 style=style.unwrap_or_default()
327 data-current=iscurrent.get()
328 data-disabled=isdisabled.get()
329 role="listitem"
330 >
331 <button
332 class="radix-pagination-button"
333 data-current=iscurrent.get()
334 data-disabled=isdisabled.get()
335 type="button"
336 role="button"
337 on:click=handle_click
338 >
339 {children()}
340 </button>
341 </li>
342 }
343}
344
345#[component]
347pub fn PaginationFirst(
348 #[prop(optional)]
350 text: Option<String>,
351 #[prop(optional)]
353 icon: Option<String>,
354 #[prop(optional)]
356 class: Option<String>,
357 #[prop(optional)]
359 style: Option<String>,
360 children: Children,
362) -> impl IntoView {
363 let context =
364 use_context::<PaginationContext>().expect("PaginationFirst must be used within Pagination");
365 let first_id = generate_id("pagination-first");
366
367 let handle_click = move |event: web_sys::MouseEvent| {
368 event.prevent_default();
369
370 if context.current_page.get() > 1 {
371 if let Some(callback) = context.on_page_change.clone() {
373 callback.run(1);
374 }
375 }
376 };
377
378 let isdisabled = Memo::new(move |_| context.current_page.get() <= 1);
379
380 let base_classes = "radix-pagination-first";
382 let combined_class = merge_classes(Some(base_classes), class.as_deref())
383 .unwrap_or_else(|| base_classes.to_string());
384
385 view! {
386 <li
387 id=first_id
388 class=combined_class
389 style=style.unwrap_or_default()
390 data-disabled=isdisabled.get()
391 role="listitem"
392 >
393 <button
394 class="radix-pagination-button"
395 data-disabled=isdisabled.get()
396 type="button"
397 role="button"
398 on:click=handle_click
399 >
400 {icon.map(|icon_text| view! {
401 <span class="radix-pagination-icon">{icon_text}</span>
402 })}
403 {text.map(|button_text| view! {
404 <span class="radix-pagination-text">{button_text}</span>
405 })}
406 {children()}
407 </button>
408 </li>
409 }
410}
411
412#[component]
414pub fn PaginationPrevious(
415 #[prop(optional)]
417 text: Option<String>,
418 #[prop(optional)]
420 icon: Option<String>,
421 #[prop(optional)]
423 class: Option<String>,
424 #[prop(optional)]
426 style: Option<String>,
427 children: Children,
429) -> impl IntoView {
430 let context = use_context::<PaginationContext>()
431 .expect("PaginationPrevious must be used within Pagination");
432 let prev_id = generate_id("pagination-previous");
433
434 let handle_click = move |event: web_sys::MouseEvent| {
435 event.prevent_default();
436
437 let current = context.current_page.get();
438 if current > 1 {
439 if let Some(callback) = context.on_page_change.clone() {
441 callback.run(current - 1);
442 }
443 }
444 };
445
446 let isdisabled = Memo::new(move |_| context.current_page.get() <= 1);
447
448 let base_classes = "radix-pagination-previous";
450 let combined_class = merge_classes(Some(base_classes), class.as_deref())
451 .unwrap_or_else(|| base_classes.to_string());
452
453 view! {
454 <li
455 id=prev_id
456 class=combined_class
457 style=style.unwrap_or_default()
458 data-disabled=isdisabled.get()
459 role="listitem"
460 >
461 <button
462 class="radix-pagination-button"
463 data-disabled=isdisabled.get()
464 type="button"
465 role="button"
466 on:click=handle_click
467 >
468 {icon.map(|icon_text| view! {
469 <span class="radix-pagination-icon">{icon_text}</span>
470 })}
471 {text.map(|button_text| view! {
472 <span class="radix-pagination-text">{button_text}</span>
473 })}
474 {children()}
475 </button>
476 </li>
477 }
478}
479
480#[component]
482pub fn PaginationNext(
483 #[prop(optional)]
485 text: Option<String>,
486 #[prop(optional)]
488 icon: Option<String>,
489 #[prop(optional)]
491 class: Option<String>,
492 #[prop(optional)]
494 style: Option<String>,
495 children: Children,
497) -> impl IntoView {
498 let context =
499 use_context::<PaginationContext>().expect("PaginationNext must be used within Pagination");
500 let next_id = generate_id("pagination-next");
501
502 let handle_click = move |event: web_sys::MouseEvent| {
503 event.prevent_default();
504
505 let current = context.current_page.get();
506 if current < context.total_pages {
507 if let Some(callback) = context.on_page_change.clone() {
509 callback.run(current + 1);
510 }
511 }
512 };
513
514 let isdisabled = Memo::new(move |_| context.current_page.get() >= context.total_pages);
515
516 let base_classes = "radix-pagination-next";
518 let combined_class = merge_classes(Some(base_classes), class.as_deref())
519 .unwrap_or_else(|| base_classes.to_string());
520
521 view! {
522 <li
523 id=next_id
524 class=combined_class
525 style=style.unwrap_or_default()
526 data-disabled=isdisabled.get()
527 role="listitem"
528 >
529 <button
530 class="radix-pagination-button"
531 data-disabled=isdisabled.get()
532 type="button"
533 role="button"
534 on:click=handle_click
535 >
536 {icon.map(|icon_text| view! {
537 <span class="radix-pagination-icon">{icon_text}</span>
538 })}
539 {text.map(|button_text| view! {
540 <span class="radix-pagination-text">{button_text}</span>
541 })}
542 {children()}
543 </button>
544 </li>
545 }
546}
547
548#[component]
550pub fn PaginationLast(
551 #[prop(optional)]
553 text: Option<String>,
554 #[prop(optional)]
556 icon: Option<String>,
557 #[prop(optional)]
559 class: Option<String>,
560 #[prop(optional)]
562 style: Option<String>,
563 children: Children,
565) -> impl IntoView {
566 let context =
567 use_context::<PaginationContext>().expect("PaginationLast must be used within Pagination");
568 let last_id = generate_id("pagination-last");
569
570 let handle_click = move |event: web_sys::MouseEvent| {
571 event.prevent_default();
572
573 if context.current_page.get() < context.total_pages {
574 if let Some(callback) = context.on_page_change.clone() {
576 callback.run(context.total_pages);
577 }
578 }
579 };
580
581 let isdisabled = Memo::new(move |_| context.current_page.get() >= context.total_pages);
582
583 let base_classes = "radix-pagination-last";
585 let combined_class = merge_classes(Some(base_classes), class.as_deref())
586 .unwrap_or_else(|| base_classes.to_string());
587
588 view! {
589 <li
590 id=last_id
591 class=combined_class
592 style=style.unwrap_or_default()
593 data-disabled=isdisabled.get()
594 role="listitem"
595 >
596 <button
597 class="radix-pagination-button"
598 data-disabled=isdisabled.get()
599 type="button"
600 role="button"
601 on:click=handle_click
602 >
603 {icon.map(|icon_text| view! {
604 <span class="radix-pagination-icon">{icon_text}</span>
605 })}
606 {text.map(|button_text| view! {
607 <span class="radix-pagination-text">{button_text}</span>
608 })}
609 {children()}
610 </button>
611 </li>
612 }
613}
614
615#[component]
617pub fn PaginationEllipsis(
618 #[prop(optional)]
620 text: Option<String>,
621 #[prop(optional)]
623 class: Option<String>,
624 #[prop(optional)]
626 style: Option<String>,
627 children: Children,
629) -> impl IntoView {
630 let ellipsis_id = generate_id("pagination-ellipsis");
631
632 let base_classes = "radix-pagination-ellipsis";
634 let combined_class = merge_classes(Some(base_classes), class.as_deref())
635 .unwrap_or_else(|| base_classes.to_string());
636
637 view! {
638 <li
639 id=ellipsis_id
640 class=combined_class
641 style=style.unwrap_or_default()
642 role="separator"
643 aria-hidden="true"
644 >
645 <span class="radix-pagination-ellipsis-text">
646 {text.unwrap_or_else(|| "…".to_string())}
647 </span>
648 {children()}
649 </li>
650 }
651}
652
653#[component]
655pub fn PaginationInfo(
656 #[prop(optional)]
658 format: Option<String>,
659 #[prop(optional)]
661 class: Option<String>,
662 #[prop(optional)]
664 style: Option<String>,
665 children: Children,
667) -> impl IntoView {
668 let context =
669 use_context::<PaginationContext>().expect("PaginationInfo must be used within Pagination");
670 let info_id = generate_id("pagination-info");
671
672 let start_item = Memo::new(move |_| {
674 let current = context.current_page.get();
675 let page_size = context.page_size;
676 ((current - 1) * page_size) + 1
677 });
678
679 let end_item = Memo::new(move |_| {
680 let current = context.current_page.get();
681 let page_size = context.page_size;
682 let total_items = context.total_items;
683 std::cmp::min(current * page_size, total_items)
684 });
685
686 let total_items = context.total_items;
687
688 let base_classes = "radix-pagination-info";
690 let combined_class = merge_classes(Some(base_classes), class.as_deref())
691 .unwrap_or_else(|| base_classes.to_string());
692
693 view! {
694 <div
695 id=info_id
696 class=combined_class
697 style=style.unwrap_or_default()
698 role="status"
699 aria-live="polite"
700 >
701 {if let Some(format_str) = format {
702 let start = start_item.get();
703 let end = end_item.get();
704 let total = total_items;
705 let current = context.current_page.get();
706 let total_pages = context.total_pages;
707
708 let info_text = format_str
709 .replace("{start}", &start.to_string())
710 .replace("{end}", &end.to_string())
711 .replace("{total}", &total.to_string())
712 .replace("{current}", ¤t.to_string())
713 .replace("{total_pages}", &total_pages.to_string());
714
715 view! {
716 <span class="radix-pagination-info-text">{info_text}</span>
717 }
718 } else {
719 view! { <span class="radix-pagination-info-text">{String::new()}</span> }
720 }}
721 {children()}
722 </div>
723 }
724}
725
726#[component]
728pub fn PaginationContent(
729 #[prop(optional)]
731 class: Option<String>,
732 #[prop(optional)]
734 style: Option<String>,
735 children: Children,
737) -> impl IntoView {
738 let content_id = generate_id("pagination-content");
739
740 let base_classes = "radix-pagination-content";
742 let combined_class = merge_classes(Some(base_classes), class.as_deref())
743 .unwrap_or_else(|| base_classes.to_string());
744
745 view! {
746 <div
747 id=content_id
748 class=combined_class
749 style=style.unwrap_or_default()
750 >
751 {children()}
752 </div>
753 }
754}
755
756pub fn generate_page_numbers(
758 current_page: usize,
759 total_pages: usize,
760 maxvisible: usize,
761) -> Vec<PaginationPage> {
762 if total_pages <= maxvisible {
763 return (1..=total_pages)
764 .map(|page| PaginationPage::new(page).withcurrent(page == current_page))
765 .collect();
766 }
767
768 let (start, end) = calculate_page_range(current_page, total_pages, maxvisible);
769 let mut pages = Vec::new();
770
771 if start > 1 {
773 pages.push(PaginationPage::new(1));
774 if start > 2 {
775 pages.push(PaginationPage::new(0).withdisabled(true)); }
777 }
778
779 for page in start..=end {
781 pages.push(PaginationPage::new(page).withcurrent(page == current_page));
782 }
783
784 if end < total_pages {
786 if end < total_pages - 1 {
787 pages.push(PaginationPage::new(0).withdisabled(true)); }
789 pages.push(PaginationPage::new(total_pages));
790 }
791
792 pages
793}
794
795pub fn getvisible_page_numbers(
799 current_page: usize,
800 total_pages: usize,
801 maxvisible: usize,
802) -> Vec<usize> {
803 if total_pages <= maxvisible {
804 return (1..=total_pages).collect();
805 }
806
807 let (start, end) = calculate_page_range(current_page, total_pages, maxvisible);
808 let mut pages = Vec::new();
809
810 if start > 1 {
812 pages.push(1);
813 if start > 2 {
814 pages.push(0); }
816 }
817
818 for page in start..=end {
820 pages.push(page);
821 }
822
823 if end < total_pages {
825 if end < total_pages - 1 {
826 pages.push(0); }
828 pages.push(total_pages);
829 }
830
831 pages
832}
833
834#[cfg(test)]
835mod tests {
836 use crate::{Pagination, PaginationProps, PaginationSize, PaginationVariant};
837 use leptos::callback::Callback;
838 use proptest::prelude::*;
839 use wasm_bindgen_test::*;
840
841 wasm_bindgen_test_configure!(run_in_browser);
842
843 #[test]
845 fn test_pagination_sizes() {
846 run_test(|| {
847 let sizes = [
848 PaginationSize::Small,
849 PaginationSize::Medium,
850 PaginationSize::Large,
851 ];
852
853 for size in sizes {
854 assert!(!size.as_str().is_empty());
856 }
857 });
858 }
859
860 #[test]
862 fn test_pagination_variants() {
863 run_test(|| {
864 let variants = [
865 PaginationVariant::Default,
866 PaginationVariant::Compact,
867 PaginationVariant::Detailed,
868 ];
869
870 for variant in variants {
871 assert!(!variant.as_str().is_empty());
873 }
874 });
875 }
876
877 #[test]
879 fn test_pagination_page_change() {
880 run_test(|| {
881 let mut current_page = 1;
883 let total_pages = 10;
884
885 assert_eq!(current_page, 1);
887
888 current_page = 2;
890 assert_eq!(current_page, 2);
891
892 assert!(current_page <= total_pages);
894 });
895 }
896
897 proptest! {
899 #[test]
900 fn test_pagination_properties(
901 current_page in 1..100usize,
902 total_pages in 1..100usize,
903 page_size in 1..50usize
904 ) {
905 prop_assume!(current_page <= total_pages);
907
908 let total_items = total_pages * page_size;
910
911 let max_possible_items = total_pages * page_size;
914 prop_assert!(total_items <= max_possible_items);
915
916 prop_assert!(current_page <= total_pages);
918
919 prop_assert!(page_size > 0);
921 }
922 }
923
924 fn run_test<F>(f: F)
926 where
927 F: FnOnce(),
928 {
929 f();
931 }
932}