From 77259639abf588e4571e191a14dcd684e46cbfea Mon Sep 17 00:00:00 2001 From: Vladimir Enchev Date: Mon, 30 Mar 2026 16:36:55 +0300 Subject: [PATCH] Allow displaying multiple items on Carousel component Fix #1979 --- Radzen.Blazor.Tests/CarouselTests.cs | 219 ++++++++++++++++++ Radzen.Blazor/RadzenCarousel.razor | 14 +- Radzen.Blazor/RadzenCarousel.razor.cs | 31 ++- Radzen.Blazor/RadzenCarouselItem.razor | 2 +- Radzen.Blazor/RadzenCarouselItem.razor.cs | 4 + RadzenBlazorDemos/Pages/CarouselConfig.razor | 7 +- .../Pages/CarouselMultipleItems.razor | 49 ++++ RadzenBlazorDemos/Pages/CarouselPage.razor | 10 + 8 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 RadzenBlazorDemos/Pages/CarouselMultipleItems.razor diff --git a/Radzen.Blazor.Tests/CarouselTests.cs b/Radzen.Blazor.Tests/CarouselTests.cs index b1e02e001..46951adc3 100644 --- a/Radzen.Blazor.Tests/CarouselTests.cs +++ b/Radzen.Blazor.Tests/CarouselTests.cs @@ -1,5 +1,6 @@ using Bunit; using Microsoft.AspNetCore.Components; +using System.Linq; using Xunit; namespace Radzen.Blazor.Tests @@ -173,6 +174,224 @@ namespace Radzen.Blazor.Tests Assert.DoesNotContain("rz-carousel-pager-overlay", component.Markup); } + + [Fact] + public void Carousel_ItemsPerPage_DefaultIsOne() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(); + + Assert.Equal(1, component.Instance.ItemsPerPage); + } + + [Fact] + public void Carousel_ItemsPerPage_SetsFlexBasisOnItems() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 3); + parameters.Add(p => p.Items, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "ChildContent", (RenderFragment)(b => b.AddContent(0, "Slide 1"))); + builder.CloseComponent(); + + builder.OpenComponent(2); + builder.AddAttribute(3, "ChildContent", (RenderFragment)(b => b.AddContent(0, "Slide 2"))); + builder.CloseComponent(); + + builder.OpenComponent(4); + builder.AddAttribute(5, "ChildContent", (RenderFragment)(b => b.AddContent(0, "Slide 3"))); + builder.CloseComponent(); + }); + }); + + Assert.Contains("calc(100% / 3)", component.Markup); + } + + [Fact] + public void Carousel_ItemsPerPage_PagerShowsPageCount() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 2); + parameters.Add(p => p.AllowPaging, true); + parameters.Add(p => p.Items, builder => + { + for (int i = 0; i < 5; i++) + { + var seq = i * 2; + builder.OpenComponent(seq); + builder.AddAttribute(seq + 1, "ChildContent", (RenderFragment)(b => b.AddContent(0, $"Slide {i + 1}"))); + builder.CloseComponent(); + } + }); + }); + + // 5 items / 2 per page = 3 pages (ceil), so 3 pager buttons + var pagerButtons = component.FindAll(".rz-carousel-pager-button"); + Assert.Equal(3, pagerButtons.Count); + } + + [Fact] + public void Carousel_ItemsPerPage_One_NoInlineStyle() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 1); + parameters.Add(p => p.Items, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "ChildContent", (RenderFragment)(b => b.AddContent(0, "Slide 1"))); + builder.CloseComponent(); + }); + }); + + // With ItemsPerPage=1 (default), no flex override style should be applied + Assert.DoesNotContain("calc(100%", component.Markup); + } + + [Fact] + public void Carousel_ItemsPerPage_PagerShowsCorrectActiveButton() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 3); + parameters.Add(p => p.AllowPaging, true); + parameters.Add(p => p.Items, builder => + { + for (int i = 0; i < 9; i++) + { + var seq = i * 2; + builder.OpenComponent(seq); + builder.AddAttribute(seq + 1, "ChildContent", (RenderFragment)(b => b.AddContent(0, $"Slide {i + 1}"))); + builder.CloseComponent(); + } + }); + }); + + // 9 items / 3 per page = 3 pages + var pagerButtons = component.FindAll(".rz-carousel-pager-button"); + Assert.Equal(3, pagerButtons.Count); + + // First page button should be active by default (selectedIndex=0) + Assert.Contains("rz-state-active", pagerButtons[0].GetAttribute("class")); + Assert.DoesNotContain("rz-state-active", pagerButtons[1].GetAttribute("class")); + Assert.DoesNotContain("rz-state-active", pagerButtons[2].GetAttribute("class")); + } + + [Fact] + public void Carousel_ItemsPerPage_PagerWithUnevenItems() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 3); + parameters.Add(p => p.AllowPaging, true); + parameters.Add(p => p.Items, builder => + { + for (int i = 0; i < 7; i++) + { + var seq = i * 2; + builder.OpenComponent(seq); + builder.AddAttribute(seq + 1, "ChildContent", (RenderFragment)(b => b.AddContent(0, $"Slide {i + 1}"))); + builder.CloseComponent(); + } + }); + }); + + // 7 items / 3 per page = 3 pages (ceil) + var pagerButtons = component.FindAll(".rz-carousel-pager-button"); + Assert.Equal(3, pagerButtons.Count); + } + + [Fact] + public void Carousel_ItemsPerPage_AllItemsSetFlexBasis() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 4); + parameters.Add(p => p.Items, builder => + { + for (int i = 0; i < 6; i++) + { + var seq = i * 2; + builder.OpenComponent(seq); + builder.AddAttribute(seq + 1, "ChildContent", (RenderFragment)(b => b.AddContent(0, $"Slide {i + 1}"))); + builder.CloseComponent(); + } + }); + }); + + // All 6 items should have the flex style + var items = component.FindAll(".rz-carousel-item"); + Assert.Equal(6, items.Count); + foreach (var item in items) + { + Assert.Contains("calc(100% / 4)", item.GetAttribute("style")); + } + } + + [Fact] + public void Carousel_ItemsPerPage_SingleItem_OnePage() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 3); + parameters.Add(p => p.AllowPaging, true); + parameters.Add(p => p.Items, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, "ChildContent", (RenderFragment)(b => b.AddContent(0, "Only slide"))); + builder.CloseComponent(); + }); + }); + + // 1 item / 3 per page = 1 page + var pagerButtons = component.FindAll(".rz-carousel-pager-button"); + Assert.Single(pagerButtons); + } + + [Fact] + public void Carousel_ItemsPerPage_PagerButtonCount_WithTopAndBottom() + { + using var ctx = new TestContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + var component = ctx.RenderComponent(parameters => + { + parameters.Add(p => p.ItemsPerPage, 2); + parameters.Add(p => p.AllowPaging, true); + parameters.Add(p => p.PagerPosition, PagerPosition.TopAndBottom); + parameters.Add(p => p.Items, builder => + { + for (int i = 0; i < 6; i++) + { + var seq = i * 2; + builder.OpenComponent(seq); + builder.AddAttribute(seq + 1, "ChildContent", (RenderFragment)(b => b.AddContent(0, $"Slide {i + 1}"))); + builder.CloseComponent(); + } + }); + }); + + // 6 items / 2 per page = 3 pages, rendered in both top and bottom = 6 buttons total + var pagerButtons = component.FindAll(".rz-carousel-pager-button"); + Assert.Equal(6, pagerButtons.Count); + } } } diff --git a/Radzen.Blazor/RadzenCarousel.razor b/Radzen.Blazor/RadzenCarousel.razor index bb59e7d99..d6e150934 100644 --- a/Radzen.Blazor/RadzenCarousel.razor +++ b/Radzen.Blazor/RadzenCarousel.razor @@ -8,10 +8,11 @@ @if(AllowPaging && (PagerPosition == PagerPosition.Top || PagerPosition == PagerPosition.TopAndBottom)) { - @foreach (var item in items) + @for (var p = 0; p < PageCount; p++) { - var index = items.IndexOf(item); - + var pageIndex = p; + var itemIndex = pageIndex * ItemsPerPage; + } } @@ -31,10 +32,11 @@ @if(AllowPaging && (PagerPosition == PagerPosition.Bottom || PagerPosition == PagerPosition.TopAndBottom)) { - @foreach (var item in items) + @for (var p = 0; p < PageCount; p++) { - var index = items.IndexOf(item); - + var pageIndex = p; + var itemIndex = pageIndex * ItemsPerPage; + } } diff --git a/Radzen.Blazor/RadzenCarousel.razor.cs b/Radzen.Blazor/RadzenCarousel.razor.cs index 3c15c3a62..028919275 100644 --- a/Radzen.Blazor/RadzenCarousel.razor.cs +++ b/Radzen.Blazor/RadzenCarousel.razor.cs @@ -104,14 +104,34 @@ namespace Radzen.Blazor await GoTo(index); } + int PageCount => ItemsPerPage > 1 ? (int)Math.Ceiling((double)items.Count / ItemsPerPage) : items.Count; + + int ActivePage => ItemsPerPage > 1 ? selectedIndex / ItemsPerPage : selectedIndex; + async Task Prev() { - await Navigate(selectedIndex == 0 ? items.Count - 1 : selectedIndex - 1); + if (ItemsPerPage > 1) + { + var prevIndex = selectedIndex - ItemsPerPage; + await Navigate(prevIndex < 0 ? (PageCount - 1) * ItemsPerPage : prevIndex); + } + else + { + await Navigate(selectedIndex == 0 ? items.Count - 1 : selectedIndex - 1); + } } async Task Next() { - await Navigate(selectedIndex == items.Count - 1 ? 0 : selectedIndex + 1); + if (ItemsPerPage > 1) + { + var nextIndex = selectedIndex + ItemsPerPage; + await Navigate(nextIndex >= items.Count ? 0 : nextIndex); + } + else + { + await Navigate(selectedIndex == items.Count - 1 ? 0 : selectedIndex + 1); + } } async Task GoTo(int index) @@ -270,6 +290,13 @@ namespace Radzen.Blazor [Parameter] public bool AllowNavigation { get; set; } = true; + /// + /// Gets or sets the number of items visible at the same time. Set to 1 by default. + /// + /// The number of items per page. + [Parameter] + public int ItemsPerPage { get; set; } = 1; + /// /// Gets or sets a value indicating whether the user can scroll or swipe through carousel items. Set to true by default. /// diff --git a/Radzen.Blazor/RadzenCarouselItem.razor b/Radzen.Blazor/RadzenCarouselItem.razor index 2a4032f8e..de0920296 100644 --- a/Radzen.Blazor/RadzenCarouselItem.razor +++ b/Radzen.Blazor/RadzenCarouselItem.razor @@ -2,7 +2,7 @@ @implements IDisposable @inject IJSRuntime JSRuntime -
  • +
  • @ChildContent
  • \ No newline at end of file diff --git a/Radzen.Blazor/RadzenCarouselItem.razor.cs b/Radzen.Blazor/RadzenCarouselItem.razor.cs index 4c1fab50e..27c5820f2 100644 --- a/Radzen.Blazor/RadzenCarouselItem.razor.cs +++ b/Radzen.Blazor/RadzenCarouselItem.razor.cs @@ -59,6 +59,10 @@ namespace Radzen.Blazor GC.SuppressFinalize(this); } + string? ItemStyle => Carousel != null && Carousel.ItemsPerPage > 1 + ? $"flex: 0 0 calc(100% / {Carousel.ItemsPerPage}); width: calc(100% / {Carousel.ItemsPerPage})" + : null; + int itemIndex; internal ElementReference element; } diff --git a/RadzenBlazorDemos/Pages/CarouselConfig.razor b/RadzenBlazorDemos/Pages/CarouselConfig.razor index 3bf8a38f1..854a4f921 100644 --- a/RadzenBlazorDemos/Pages/CarouselConfig.razor +++ b/RadzenBlazorDemos/Pages/CarouselConfig.razor @@ -19,6 +19,10 @@ Animation duration + + Items per page + + Auto-cycle @@ -34,7 +38,7 @@ - @foreach (var orderID in orderIDs) @@ -58,6 +62,7 @@ bool auto = true; double interval = 4000; double? animationDuration; + int itemsPerPage = 1; string toggleText = "Stop"; bool started = true; diff --git a/RadzenBlazorDemos/Pages/CarouselMultipleItems.razor b/RadzenBlazorDemos/Pages/CarouselMultipleItems.razor new file mode 100644 index 000000000..65791e70b --- /dev/null +++ b/RadzenBlazorDemos/Pages/CarouselMultipleItems.razor @@ -0,0 +1,49 @@ + + + + + Items per page + + + + Auto-cycle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +@code { + int itemsPerPage = 3; + bool auto = false; + int selectedIndex; +} diff --git a/RadzenBlazorDemos/Pages/CarouselPage.razor b/RadzenBlazorDemos/Pages/CarouselPage.razor index ba3862410..5a8b2e697 100644 --- a/RadzenBlazorDemos/Pages/CarouselPage.razor +++ b/RadzenBlazorDemos/Pages/CarouselPage.razor @@ -11,6 +11,16 @@ + + Multiple items per page + + + Use ItemsPerPage to display multiple items at the same time. + + + + + Navigation button styles