1use leptos::*;
2use leptos::prelude::*;
3
4#[derive(Clone, Debug, PartialEq)]
6pub struct PaginationPage {
7 pub number: usize,
8 pub label: Option<String>,
9 pub disabled: bool,
10 pub current: bool,
11}
12
13impl PaginationPage {
14 pub fn new(number: usize) -> Self {
15 Self {
16 number,
17 label: None,
18 disabled: false,
19 current: false,
20 }
21 }
22
23 pub fn with_label(mut self, label: String) -> Self {
24 self.label = Some(label);
25 self
26 }
27
28 pub fn with_disabled(mut self, disabled: bool) -> Self {
29 self.disabled = disabled;
30 self
31 }
32
33 pub fn with_current(mut self, current: bool) -> Self {
34 self.current = current;
35 self
36 }
37}
38
39#[derive(Clone, Debug, PartialEq)]
41pub enum PaginationSize {
42 Small,
43 Medium,
44 Large,
45}
46
47impl PaginationSize {
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 PaginationSize::Small => "small",
51 PaginationSize::Medium => "medium",
52 PaginationSize::Large => "large",
53 }
54 }
55}
56
57#[derive(Clone, Debug, PartialEq)]
59pub enum PaginationVariant {
60 Default,
61 Compact,
62 Detailed,
63}
64
65impl PaginationVariant {
66 pub fn as_str(&self) -> &'static str {
67 match self {
68 PaginationVariant::Default => "default",
69 PaginationVariant::Compact => "compact",
70 PaginationVariant::Detailed => "detailed",
71 }
72 }
73}
74
75#[derive(Clone)]
77pub struct PaginationContext {
78 pub current_page: Signal<usize>,
79 pub total_pages: usize,
80 pub page_size: usize,
81 pub total_items: usize,
82 pub size: PaginationSize,
83 pub variant: PaginationVariant,
84 pub show_first_last: bool,
85 pub show_prev_next: bool,
86 pub show_page_numbers: bool,
87 pub pagination_id: String,
88 pub on_page_change: Option<Callback<usize>>,
89}
90
91fn generate_id(prefix: &str) -> String {
93 static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
94 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
95 format!("{}-{}", prefix, id)
96}
97
98fn merge_classes(existing: Option<&str>, additional: Option<&str>) -> Option<String> {
100 match (existing, additional) {
101 (Some(a), Some(b)) => Some(format!("{} {}", a, b)),
102 (Some(a), None) => Some(a.to_string()),
103 (None, Some(b)) => Some(b.to_string()),
104 (None, None) => None,
105 }
106}
107
108fn calculate_page_range(current_page: usize, total_pages: usize, max_visible: usize) -> (usize, usize) {
110 if total_pages <= max_visible {
111 return (1, total_pages);
112 }
113
114 let half_visible = max_visible / 2;
115 let mut start = current_page.saturating_sub(half_visible);
116 let mut end = start + max_visible - 1;
117
118 if end > total_pages {
119 end = total_pages;
120 start = end.saturating_sub(max_visible - 1);
121 }
122
123 (start, end)
124}
125
126#[component]
128pub fn Pagination(
129 #[prop(optional, default = 1)]
131 current_page: usize,
132 #[prop(optional, default = 1)]
134 total_pages: usize,
135 #[prop(optional, default = 10)]
137 page_size: usize,
138 #[prop(optional)]
140 total_items: Option<usize>,
141 #[prop(optional, default = PaginationSize::Medium)]
143 size: PaginationSize,
144 #[prop(optional, default = PaginationVariant::Default)]
146 variant: PaginationVariant,
147 #[prop(optional, default = true)]
149 show_first_last: bool,
150 #[prop(optional, default = true)]
152 show_prev_next: bool,
153 #[prop(optional, default = true)]
155 show_page_numbers: bool,
156 #[prop(optional)]
158 on_page_change: Option<Callback<usize>>,
159 #[prop(optional)]
161 class: Option<String>,
162 children: Children,
164) -> impl IntoView {
165 let pagination_id = generate_id("pagination");
166
167 let (current_page_signal, _set_current_page_signal) = signal(current_page);
169 let total_items_calculated = total_items.unwrap_or_else(|| total_pages * page_size);
170
171 let context = PaginationContext {
173 current_page: current_page_signal.into(),
174 total_pages,
175 page_size,
176 total_items: total_items_calculated,
177 size: size.clone(),
178 variant: variant.clone(),
179 show_first_last,
180 show_prev_next,
181 show_page_numbers,
182 pagination_id: pagination_id.clone(),
183 on_page_change,
184 };
185
186 let base_classes = "radix-pagination";
188 let combined_class = merge_classes(Some(base_classes), class.as_deref())
189 .unwrap_or_else(|| base_classes.to_string());
190
191 provide_context(context);
193
194 view! {
195 <nav
196 id=pagination_id
197 class=combined_class
198 data-current-page=current_page_signal.get()
199 data-total-pages=total_pages
200 data-page-size=page_size
201 data-total-items=total_items_calculated
202 data-size=size.as_str()
203 data-variant=variant.as_str()
204 data-show-first-last=show_first_last
205 data-show-prev-next=show_prev_next
206 data-show-page-numbers=show_page_numbers
207 role="navigation"
208 aria-label="Pagination"
209 >
210 {children()}
211 </nav>
212 }
213}
214
215#[component]
217pub fn PaginationList(
218 #[prop(optional)]
220 class: Option<String>,
221 #[prop(optional)]
223 style: Option<String>,
224 children: Children,
226) -> impl IntoView {
227 let _context = use_context::<PaginationContext>().expect("PaginationList must be used within Pagination");
228 let list_id = generate_id("pagination-list");
229
230 let base_classes = "radix-pagination-list";
232 let combined_class = merge_classes(Some(base_classes), class.as_deref())
233 .unwrap_or_else(|| base_classes.to_string());
234
235 view! {
236 <ul
237 id=list_id
238 class=combined_class
239 style=style.unwrap_or_default()
240 role="list"
241 >
242 {children()}
243 </ul>
244 }
245}
246
247#[component]
249pub fn PaginationItem(
250 #[prop(optional)]
252 page: Option<PaginationPage>,
253 #[prop(optional)]
255 current: Option<bool>,
256 #[prop(optional)]
258 disabled: Option<bool>,
259 #[prop(optional)]
261 class: Option<String>,
262 #[prop(optional)]
264 style: Option<String>,
265 children: Children,
267) -> impl IntoView {
268 let context = use_context::<PaginationContext>().expect("PaginationItem must be used within Pagination");
269 let item_id = generate_id("pagination-item");
270
271 let page_clone = page.clone();
272 let handle_click = move |event: web_sys::MouseEvent| {
273 event.prevent_default();
274
275 if let Some(page) = page_clone.clone() {
276 if !page.disabled {
277 if let Some(callback) = context.on_page_change.clone() {
279 callback.run(page.number);
280 }
281 }
282 }
283 };
284
285 let page_for_current = page.clone();
286 let page_for_disabled = page.clone();
287
288 let is_current = Memo::new(move |_| {
290 if let Some(current) = current {
291 current
292 } else if let Some(page) = page_for_current.as_ref() {
293 page.current
294 } else {
295 false
296 }
297 });
298
299 let is_disabled = Memo::new(move |_| {
301 if let Some(disabled) = disabled {
302 disabled
303 } else if let Some(page) = page_for_disabled.as_ref() {
304 page.disabled
305 } else {
306 false
307 }
308 });
309
310 let base_classes = "radix-pagination-item";
312 let combined_class = merge_classes(Some(base_classes), class.as_deref())
313 .unwrap_or_else(|| base_classes.to_string());
314
315 view! {
316 <li
317 id=item_id
318 class=combined_class
319 style=style.unwrap_or_default()
320 data-current=is_current.get()
321 data-disabled=is_disabled.get()
322 role="listitem"
323 >
324 <button
325 class="radix-pagination-button"
326 data-current=is_current.get()
327 data-disabled=is_disabled.get()
328 type="button"
329 role="button"
330 tabindex=if is_disabled.get() { "-1" } else { "0" }
331 aria-disabled=is_disabled.get()
332 aria-current=if is_current.get() { "page" } else { "false" }
333 on:click=handle_click
334 >
335 {children()}
336 </button>
337 </li>
338 }
339}
340
341#[component]
343pub fn PaginationFirst(
344 #[prop(optional)]
346 text: Option<String>,
347 #[prop(optional)]
349 icon: Option<String>,
350 #[prop(optional)]
352 class: Option<String>,
353 #[prop(optional)]
355 style: Option<String>,
356 children: Children,
358) -> impl IntoView {
359 let context = use_context::<PaginationContext>().expect("PaginationFirst must be used within Pagination");
360 let first_id = generate_id("pagination-first");
361
362 let handle_click = move |event: web_sys::MouseEvent| {
363 event.prevent_default();
364
365 if context.current_page.get() > 1 {
366 if let Some(callback) = context.on_page_change.clone() {
368 callback.run(1);
369 }
370 }
371 };
372
373 let is_disabled = Memo::new(move |_| context.current_page.get() <= 1);
374
375 let base_classes = "radix-pagination-first";
377 let combined_class = merge_classes(Some(base_classes), class.as_deref())
378 .unwrap_or_else(|| base_classes.to_string());
379
380 view! {
381 <li
382 id=first_id
383 class=combined_class
384 style=style.unwrap_or_default()
385 data-disabled=is_disabled.get()
386 role="listitem"
387 >
388 <button
389 class="radix-pagination-button"
390 data-disabled=is_disabled.get()
391 type="button"
392 role="button"
393 tabindex=if is_disabled.get() { "-1" } else { "0" }
394 aria-disabled=is_disabled.get()
395 aria-label="Go to first page"
396 on:click=handle_click
397 >
398 {icon.map(|icon_text| view! {
399 <span class="radix-pagination-icon">{icon_text}</span>
400 })}
401 {text.map(|button_text| view! {
402 <span class="radix-pagination-text">{button_text}</span>
403 })}
404 {children()}
405 </button>
406 </li>
407 }
408}
409
410#[component]
412pub fn PaginationPrevious(
413 #[prop(optional)]
415 text: Option<String>,
416 #[prop(optional)]
418 icon: Option<String>,
419 #[prop(optional)]
421 class: Option<String>,
422 #[prop(optional)]
424 style: Option<String>,
425 children: Children,
427) -> impl IntoView {
428 let context = use_context::<PaginationContext>().expect("PaginationPrevious must be used within Pagination");
429 let prev_id = generate_id("pagination-previous");
430
431 let handle_click = move |event: web_sys::MouseEvent| {
432 event.prevent_default();
433
434 let current = context.current_page.get();
435 if current > 1 {
436 if let Some(callback) = context.on_page_change.clone() {
438 callback.run(current - 1);
439 }
440 }
441 };
442
443 let is_disabled = Memo::new(move |_| context.current_page.get() <= 1);
444
445 let base_classes = "radix-pagination-previous";
447 let combined_class = merge_classes(Some(base_classes), class.as_deref())
448 .unwrap_or_else(|| base_classes.to_string());
449
450 view! {
451 <li
452 id=prev_id
453 class=combined_class
454 style=style.unwrap_or_default()
455 data-disabled=is_disabled.get()
456 role="listitem"
457 >
458 <button
459 class="radix-pagination-button"
460 data-disabled=is_disabled.get()
461 type="button"
462 role="button"
463 tabindex=if is_disabled.get() { "-1" } else { "0" }
464 aria-disabled=is_disabled.get()
465 aria-label="Go to previous page"
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 = use_context::<PaginationContext>().expect("PaginationNext must be used within Pagination");
499 let next_id = generate_id("pagination-next");
500
501 let handle_click = move |event: web_sys::MouseEvent| {
502 event.prevent_default();
503
504 let current = context.current_page.get();
505 if current < context.total_pages {
506 if let Some(callback) = context.on_page_change.clone() {
508 callback.run(current + 1);
509 }
510 }
511 };
512
513 let is_disabled = Memo::new(move |_| context.current_page.get() >= context.total_pages);
514
515 let base_classes = "radix-pagination-next";
517 let combined_class = merge_classes(Some(base_classes), class.as_deref())
518 .unwrap_or_else(|| base_classes.to_string());
519
520 view! {
521 <li
522 id=next_id
523 class=combined_class
524 style=style.unwrap_or_default()
525 data-disabled=is_disabled.get()
526 role="listitem"
527 >
528 <button
529 class="radix-pagination-button"
530 data-disabled=is_disabled.get()
531 type="button"
532 role="button"
533 tabindex=if is_disabled.get() { "-1" } else { "0" }
534 aria-disabled=is_disabled.get()
535 aria-label="Go to next page"
536 on:click=handle_click
537 >
538 {icon.map(|icon_text| view! {
539 <span class="radix-pagination-icon">{icon_text}</span>
540 })}
541 {text.map(|button_text| view! {
542 <span class="radix-pagination-text">{button_text}</span>
543 })}
544 {children()}
545 </button>
546 </li>
547 }
548}
549
550#[component]
552pub fn PaginationLast(
553 #[prop(optional)]
555 text: Option<String>,
556 #[prop(optional)]
558 icon: Option<String>,
559 #[prop(optional)]
561 class: Option<String>,
562 #[prop(optional)]
564 style: Option<String>,
565 children: Children,
567) -> impl IntoView {
568 let context = use_context::<PaginationContext>().expect("PaginationLast must be used within Pagination");
569 let last_id = generate_id("pagination-last");
570
571 let handle_click = move |event: web_sys::MouseEvent| {
572 event.prevent_default();
573
574 if context.current_page.get() < context.total_pages {
575 if let Some(callback) = context.on_page_change.clone() {
577 callback.run(context.total_pages);
578 }
579 }
580 };
581
582 let is_disabled = Memo::new(move |_| context.current_page.get() >= context.total_pages);
583
584 let base_classes = "radix-pagination-last";
586 let combined_class = merge_classes(Some(base_classes), class.as_deref())
587 .unwrap_or_else(|| base_classes.to_string());
588
589 view! {
590 <li
591 id=last_id
592 class=combined_class
593 style=style.unwrap_or_default()
594 data-disabled=is_disabled.get()
595 role="listitem"
596 >
597 <button
598 class="radix-pagination-button"
599 data-disabled=is_disabled.get()
600 type="button"
601 role="button"
602 tabindex=if is_disabled.get() { "-1" } else { "0" }
603 aria-disabled=is_disabled.get()
604 aria-label="Go to last page"
605 on:click=handle_click
606 >
607 {icon.map(|icon_text| view! {
608 <span class="radix-pagination-icon">{icon_text}</span>
609 })}
610 {text.map(|button_text| view! {
611 <span class="radix-pagination-text">{button_text}</span>
612 })}
613 {children()}
614 </button>
615 </li>
616 }
617}
618
619#[component]
621pub fn PaginationEllipsis(
622 #[prop(optional)]
624 text: Option<String>,
625 #[prop(optional)]
627 class: Option<String>,
628 #[prop(optional)]
630 style: Option<String>,
631 children: Children,
633) -> impl IntoView {
634 let ellipsis_id = generate_id("pagination-ellipsis");
635
636 let base_classes = "radix-pagination-ellipsis";
638 let combined_class = merge_classes(Some(base_classes), class.as_deref())
639 .unwrap_or_else(|| base_classes.to_string());
640
641 view! {
642 <li
643 id=ellipsis_id
644 class=combined_class
645 style=style.unwrap_or_default()
646 role="separator"
647 aria-hidden="true"
648 >
649 <span class="radix-pagination-ellipsis-text">
650 {text.unwrap_or_else(|| "…".to_string())}
651 </span>
652 {children()}
653 </li>
654 }
655}
656
657#[component]
659pub fn PaginationInfo(
660 #[prop(optional)]
662 format: Option<String>,
663 #[prop(optional)]
665 class: Option<String>,
666 #[prop(optional)]
668 style: Option<String>,
669 children: Children,
671) -> impl IntoView {
672 let context = use_context::<PaginationContext>().expect("PaginationInfo must be used within Pagination");
673 let info_id = generate_id("pagination-info");
674
675 let start_item = Memo::new(move |_| {
677 let current = context.current_page.get();
678 let page_size = context.page_size;
679 ((current - 1) * page_size) + 1
680 });
681
682 let end_item = Memo::new(move |_| {
683 let current = context.current_page.get();
684 let page_size = context.page_size;
685 let total_items = context.total_items;
686 std::cmp::min(current * page_size, total_items)
687 });
688
689 let total_items = context.total_items;
690
691 let base_classes = "radix-pagination-info";
693 let combined_class = merge_classes(Some(base_classes), class.as_deref())
694 .unwrap_or_else(|| base_classes.to_string());
695
696 view! {
697 <div
698 id=info_id
699 class=combined_class
700 style=style.unwrap_or_default()
701 role="status"
702 aria-live="polite"
703 >
704 {if let Some(format_str) = format {
705 let start = start_item.get();
706 let end = end_item.get();
707 let total = total_items;
708 let current = context.current_page.get();
709 let total_pages = context.total_pages;
710
711 let info_text = format_str
712 .replace("{start}", &start.to_string())
713 .replace("{end}", &end.to_string())
714 .replace("{total}", &total.to_string())
715 .replace("{current}", ¤t.to_string())
716 .replace("{total_pages}", &total_pages.to_string());
717
718 view! {
719 <span class="radix-pagination-info-text">{info_text}</span>
720 }
721 } else {
722 let start = start_item.get();
723 let end = end_item.get();
724 let total = total_items;
725 view! {
726 <span class="radix-pagination-info-text">
727 {format!("Showing {} to {} of {} results", start, end, total)}
728 </span>
729 }
730 }}
731 {children()}
732 </div>
733 }
734}
735
736#[component]
738pub fn PaginationContent(
739 #[prop(optional)]
741 class: Option<String>,
742 #[prop(optional)]
744 style: Option<String>,
745 children: Children,
747) -> impl IntoView {
748 let content_id = generate_id("pagination-content");
749
750 let base_classes = "radix-pagination-content";
752 let combined_class = merge_classes(Some(base_classes), class.as_deref())
753 .unwrap_or_else(|| base_classes.to_string());
754
755 view! {
756 <div
757 id=content_id
758 class=combined_class
759 style=style.unwrap_or_default()
760 >
761 {children()}
762 </div>
763 }
764}
765
766pub fn generate_page_numbers(current_page: usize, total_pages: usize, max_visible: usize) -> Vec<PaginationPage> {
768 if total_pages <= max_visible {
769 return (1..=total_pages)
770 .map(|page| {
771 PaginationPage::new(page)
772 .with_current(page == current_page)
773 })
774 .collect();
775 }
776
777 let (start, end) = calculate_page_range(current_page, total_pages, max_visible);
778 let mut pages = Vec::new();
779
780 if start > 1 {
782 pages.push(PaginationPage::new(1));
783 if start > 2 {
784 pages.push(PaginationPage::new(0).with_disabled(true)); }
786 }
787
788 for page in start..=end {
790 pages.push(
791 PaginationPage::new(page)
792 .with_current(page == current_page)
793 );
794 }
795
796 if end < total_pages {
798 if end < total_pages - 1 {
799 pages.push(PaginationPage::new(0).with_disabled(true)); }
801 pages.push(PaginationPage::new(total_pages));
802 }
803
804 pages
805}
806
807pub fn get_visible_page_numbers(current_page: usize, total_pages: usize, max_visible: usize) -> Vec<usize> {
811 if total_pages <= max_visible {
812 return (1..=total_pages).collect();
813 }
814
815 let (start, end) = calculate_page_range(current_page, total_pages, max_visible);
816 let mut pages = Vec::new();
817
818 if start > 1 {
820 pages.push(1);
821 if start > 2 {
822 pages.push(0); }
824 }
825
826 for page in start..=end {
828 pages.push(page);
829 }
830
831 if end < total_pages {
833 if end < total_pages - 1 {
834 pages.push(0); }
836 pages.push(total_pages);
837 }
838
839 pages
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845 use wasm_bindgen_test::*;
846 use proptest::prelude::*;
847
848 wasm_bindgen_test_configure!(run_in_browser);
849
850 #[test]
852 fn test_pagination_sizes() {
853 run_test(|| {
854 let sizes = vec![
855 PaginationSize::Small,
856 PaginationSize::Medium,
857 PaginationSize::Large,
858 ];
859
860 for size in sizes {
861 assert!(!size.as_str().is_empty());
863 }
864 });
865 }
866
867 #[test]
869 fn test_pagination_variants() {
870 run_test(|| {
871 let variants = vec![
872 PaginationVariant::Default,
873 PaginationVariant::Compact,
874 PaginationVariant::Detailed,
875 ];
876
877 for variant in variants {
878 assert!(!variant.as_str().is_empty());
880 }
881 });
882 }
883
884 #[test]
886 fn test_pagination_page_change() {
887 run_test(|| {
888 let mut current_page = 1;
890 let total_pages = 10;
891
892 assert_eq!(current_page, 1);
894
895 current_page = 2;
897 assert_eq!(current_page, 2);
898
899 assert!(current_page <= total_pages);
901 });
902 }
903
904 proptest! {
906 #[test]
907 fn test_pagination_properties(
908 current_page in 1..100usize,
909 total_pages in 1..100usize,
910 page_size in 1..50usize
911 ) {
912 prop_assume!(current_page <= total_pages);
914
915 let total_items = total_pages * page_size;
917
918 let max_possible_items = total_pages * page_size;
921 prop_assert!(total_items <= max_possible_items);
922
923 prop_assert!(current_page <= total_pages);
925
926 prop_assert!(page_size > 0);
928 }
929 }
930
931 fn run_test<F>(f: F) where F: FnOnce() {
933 f();
935 }
936}