radix_leptos_primitives/components/
pagination.rs

1use leptos::*;
2use leptos::prelude::*;
3
4/// Pagination page information
5#[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/// Pagination size
40#[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/// Pagination variant
58#[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/// Pagination context for state management
76#[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
91/// Generate a simple unique ID for components
92fn 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
98/// Merge CSS classes
99fn 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
108/// Calculate visible page range
109fn 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/// Main Pagination component
127#[component]
128pub fn Pagination(
129    /// Current page number (1-based)
130    #[prop(optional, default = 1)]
131    current_page: usize,
132    /// Total number of pages
133    #[prop(optional, default = 1)]
134    total_pages: usize,
135    /// Number of items per page
136    #[prop(optional, default = 10)]
137    page_size: usize,
138    /// Total number of items
139    #[prop(optional)]
140    total_items: Option<usize>,
141    /// Pagination size
142    #[prop(optional, default = PaginationSize::Medium)]
143    size: PaginationSize,
144    /// Pagination variant
145    #[prop(optional, default = PaginationVariant::Default)]
146    variant: PaginationVariant,
147    /// Whether to show first/last page buttons
148    #[prop(optional, default = true)]
149    show_first_last: bool,
150    /// Whether to show previous/next buttons
151    #[prop(optional, default = true)]
152    show_prev_next: bool,
153    /// Whether to show page numbers
154    #[prop(optional, default = true)]
155    show_page_numbers: bool,
156    /// Page change event handler
157    #[prop(optional)]
158    on_page_change: Option<Callback<usize>>,
159    /// CSS classes
160    #[prop(optional)]
161    class: Option<String>,
162    /// Child content (pagination items, etc.)
163    children: Children,
164) -> impl IntoView {
165    let pagination_id = generate_id("pagination");
166    
167    // Reactive state
168    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    // Create context
172    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    // Build base classes
187    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 the context
192    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/// PaginationList component for the pagination items container
216#[component]
217pub fn PaginationList(
218    /// CSS classes
219    #[prop(optional)]
220    class: Option<String>,
221    /// CSS styles
222    #[prop(optional)]
223    style: Option<String>,
224    /// Child content (pagination items)
225    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    // Build base classes
231    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/// PaginationItem component for individual pagination items
248#[component]
249pub fn PaginationItem(
250    /// The pagination item this component represents
251    #[prop(optional)]
252    page: Option<PaginationPage>,
253    /// Whether this item is current
254    #[prop(optional)]
255    current: Option<bool>,
256    /// Whether this item is disabled
257    #[prop(optional)]
258    disabled: Option<bool>,
259    /// CSS classes
260    #[prop(optional)]
261    class: Option<String>,
262    /// CSS styles
263    #[prop(optional)]
264    style: Option<String>,
265    /// Child content
266    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                // Call the page change handler
278                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    // Determine if this item is current
289    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    // Determine if this item is disabled
300    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    // Build base classes
311    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/// PaginationFirst component for first page button
342#[component]
343pub fn PaginationFirst(
344    /// Button text
345    #[prop(optional)]
346    text: Option<String>,
347    /// Button icon
348    #[prop(optional)]
349    icon: Option<String>,
350    /// CSS classes
351    #[prop(optional)]
352    class: Option<String>,
353    /// CSS styles
354    #[prop(optional)]
355    style: Option<String>,
356    /// Child content
357    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            // Call the page change handler
367            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    // Build base classes
376    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/// PaginationPrevious component for previous page button
411#[component]
412pub fn PaginationPrevious(
413    /// Button text
414    #[prop(optional)]
415    text: Option<String>,
416    /// Button icon
417    #[prop(optional)]
418    icon: Option<String>,
419    /// CSS classes
420    #[prop(optional)]
421    class: Option<String>,
422    /// CSS styles
423    #[prop(optional)]
424    style: Option<String>,
425    /// Child content
426    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            // Call the page change handler
437            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    // Build base classes
446    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/// PaginationNext component for next page button
481#[component]
482pub fn PaginationNext(
483    /// Button text
484    #[prop(optional)]
485    text: Option<String>,
486    /// Button icon
487    #[prop(optional)]
488    icon: Option<String>,
489    /// CSS classes
490    #[prop(optional)]
491    class: Option<String>,
492    /// CSS styles
493    #[prop(optional)]
494    style: Option<String>,
495    /// Child content
496    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            // Call the page change handler
507            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    // Build base classes
516    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/// PaginationLast component for last page button
551#[component]
552pub fn PaginationLast(
553    /// Button text
554    #[prop(optional)]
555    text: Option<String>,
556    /// Button icon
557    #[prop(optional)]
558    icon: Option<String>,
559    /// CSS classes
560    #[prop(optional)]
561    class: Option<String>,
562    /// CSS styles
563    #[prop(optional)]
564    style: Option<String>,
565    /// Child content
566    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            // Call the page change handler
576            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    // Build base classes
585    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/// PaginationEllipsis component for truncated page ranges
620#[component]
621pub fn PaginationEllipsis(
622    /// Ellipsis text
623    #[prop(optional)]
624    text: Option<String>,
625    /// CSS classes
626    #[prop(optional)]
627    class: Option<String>,
628    /// CSS styles
629    #[prop(optional)]
630    style: Option<String>,
631    /// Child content
632    children: Children,
633) -> impl IntoView {
634    let ellipsis_id = generate_id("pagination-ellipsis");
635    
636    // Build base classes
637    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/// PaginationInfo component for displaying pagination information
658#[component]
659pub fn PaginationInfo(
660    /// Information format
661    #[prop(optional)]
662    format: Option<String>,
663    /// CSS classes
664    #[prop(optional)]
665    class: Option<String>,
666    /// CSS styles
667    #[prop(optional)]
668    style: Option<String>,
669    /// Child content
670    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    // Calculate pagination information
676    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    // Build base classes
692    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}", &current.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/// PaginationContent component for wrapping pagination content
737#[component]
738pub fn PaginationContent(
739    /// CSS classes
740    #[prop(optional)]
741    class: Option<String>,
742    /// CSS styles
743    #[prop(optional)]
744    style: Option<String>,
745    /// Child content
746    children: Children,
747) -> impl IntoView {
748    let content_id = generate_id("pagination-content");
749    
750    // Build base classes
751    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
766/// Helper function to generate page numbers for pagination
767pub 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    // Add first page if not in range
781    if start > 1 {
782        pages.push(PaginationPage::new(1));
783        if start > 2 {
784            pages.push(PaginationPage::new(0).with_disabled(true)); // Placeholder for ellipsis
785        }
786    }
787    
788    // Add visible pages
789    for page in start..=end {
790        pages.push(
791            PaginationPage::new(page)
792                .with_current(page == current_page)
793        );
794    }
795    
796    // Add last page if not in range
797    if end < total_pages {
798        if end < total_pages - 1 {
799            pages.push(PaginationPage::new(0).with_disabled(true)); // Placeholder for ellipsis
800        }
801        pages.push(PaginationPage::new(total_pages));
802    }
803    
804    pages
805}
806
807/// Helper function to generate page numbers for pagination
808/// This function returns a vector of page numbers that should be displayed
809/// It handles ellipsis for large page counts
810pub 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    // Add first page if not in range
819    if start > 1 {
820        pages.push(1);
821        if start > 2 {
822            pages.push(0); // Placeholder for ellipsis
823        }
824    }
825    
826    // Add visible pages
827    for page in start..=end {
828        pages.push(page);
829    }
830    
831    // Add last page if not in range
832    if end < total_pages {
833        if end < total_pages - 1 {
834            pages.push(0); // Placeholder for ellipsis
835        }
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    // 1. Basic Rendering Tests
851    #[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                // Each size should have a valid string representation
862                assert!(!size.as_str().is_empty());
863            }
864        });
865    }
866    
867    // 2. Props Validation Tests
868    #[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                // Each variant should have a valid string representation
879                assert!(!variant.as_str().is_empty());
880            }
881        });
882    }
883    
884    // 3. State Management Tests
885    #[test]
886    fn test_pagination_page_change() {
887        run_test(|| {
888            // Test pagination state logic
889            let mut current_page = 1;
890            let total_pages = 10;
891            
892            // Initial page should be 1
893            assert_eq!(current_page, 1);
894            
895            // Simulate page change
896            current_page = 2;
897            assert_eq!(current_page, 2);
898            
899            // Should not exceed total pages
900            assert!(current_page <= total_pages);
901        });
902    }
903    
904    // 4. Property-Based Tests
905    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            // Property: current_page should never exceed total_pages
913            prop_assume!(current_page <= total_pages);
914            
915            // Calculate total_items based on realistic pagination scenario
916            let total_items = total_pages * page_size;
917            
918            // Property: Pagination should always render without panicking
919            // Property: Calculated values should be consistent
920            let max_possible_items = total_pages * page_size;
921            prop_assert!(total_items <= max_possible_items);
922            
923            // Property: Current page should never exceed total pages
924            prop_assert!(current_page <= total_pages);
925            
926            // Property: Page size should be positive
927            prop_assert!(page_size > 0);
928        }
929    }
930    
931    // Helper function for running tests
932    fn run_test<F>(f: F) where F: FnOnce() {
933        // Simplified test runner for Leptos 0.8
934        f();
935    }
936}