Skip to content
2 changes: 2 additions & 0 deletions docs/components/dropdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ BitBlazor.Components

A custom `ActivatorTemplate` can replace the built-in toggle button with any element while retaining the complete keyboard interaction model.

> **Requires interactive rendering.** Because the open/closed state is managed purely in C# (`@onclick` / `@onkeydown`) with no JavaScript fallback, `BitDropdown` only works under an interactive render mode (Server, WebAssembly, or Auto). Under static SSR the activator button cannot be toggled, so the menu can never open — even though `BitDropdownItem.Href` values still render as valid links. This also applies to any component that renders a `BitDropdown` internally, such as `BitPagination`'s `ShowChanger` feature (see [pagination.md](./pagination.md)).

## Components

| Component | Description |
Expand Down
200 changes: 185 additions & 15 deletions docs/components/pagination.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ else
</div>

<BitPagination NumberOfPages="@totalPages"
Page="@currentPage"
PageLinkGenerator="@(p => $"/news/{p}")"
@bind-Page="currentPage"
@bind-Page:after="LoadPageAsync"
PageLinkGenerator="@(state => $"/news/{state.CurrentPage}")"
Description="Navigazione notizie"
Alignment="PaginationAlignment.Center"
PageRangeSize="2" />
Expand All @@ -63,6 +64,7 @@ else
[Parameter]
public int Page { get; set; } = 1;

private const int DefaultPageSize = 10;
private int currentPage;
private NewsResult? newsResult;
private int totalPages;
Expand All @@ -76,8 +78,13 @@ else
protected override async Task OnInitializedAsync()
{
currentPage = Page < 1 ? 1 : Page;
newsResult = await NewsService.GetNewsAsync(currentPage);
totalPages = (int)Math.Ceiling((double)newsResult.TotalCount / 10);
await LoadPageAsync();
}

private async Task LoadPageAsync()
{
newsResult = await NewsService.GetNewsAsync(currentPage, DefaultPageSize);
totalPages = (int)Math.Ceiling((double)newsResult.TotalCount / DefaultPageSize);
}

private static Color GetCategoryColor(string category) => category switch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,17 +116,22 @@ else
Description="Navigazione pratiche"
Alignment="PaginationAlignment.Center"
PageRangeSize="2"
ShowJumpToPage="true">
ShowJumpToPage="true"
ShowChanger="true"
PageSizeOptions="@(new[] { 8, 16, 24, 32 })"
@bind-PageSize="pageSize"
@bind-PageSize:after="HandlePageSizeChanged">
<TotalItemsTemplate>
Pratiche @((currentPage - 1) * PageSize + 1)–@(Math.Min(currentPage * PageSize, result.TotalCount)) di @result.TotalCount
Pratiche @((currentPage - 1) * pageSize + 1)–@(Math.Min(currentPage * pageSize, result.TotalCount)) di @result.TotalCount
</TotalItemsTemplate>
</BitPagination>
}

@code {
private const int PageSize = 8;
private const int DefaultPageSize = 8;

private int currentPage = 1;
private int pageSize = DefaultPageSize;
private string? statoFiltroAttivo;
private string? _activeToolbarItem;
private PraticheResult? result;
Expand Down Expand Up @@ -156,10 +161,16 @@ else
await LoadPageAsync();
}

private async Task HandlePageSizeChanged()
{
currentPage = 1;
await LoadPageAsync();
}

private async Task LoadPageAsync()
{
result = await PraticheService.GetPraticheAsync(currentPage, statoFiltroAttivo);
totalPages = (int)Math.Ceiling((double)result.TotalCount / PageSize);
result = await PraticheService.GetPraticheAsync(currentPage, statoFiltroAttivo, pageSize);
totalPages = (int)Math.Ceiling((double)result.TotalCount / pageSize);
}

private static Color GetStatoColor(string stato) => stato switch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ namespace BitBlazor.Sample.Services;

public interface INewsService
{
Task<NewsResult> GetNewsAsync(int page);
Task<NewsResult> GetNewsAsync(int page, int pageSize = 10);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ namespace BitBlazor.Sample.Services;

public interface IPraticheService
{
Task<PraticheResult> GetPraticheAsync(int page, string? statoFiltro = null);
Task<PraticheResult> GetPraticheAsync(int page, string? statoFiltro = null, int pageSize = 8);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ namespace BitBlazor.Sample.Services;

public class NewsService : INewsService
{
private const int PageSize = 10;
private const int TotalItems = 50;

private static readonly string[] Categories =
Expand All @@ -30,10 +29,10 @@ public class NewsService : INewsService
"Il Comune di Bitopoli aderisce al programma nazionale per la rigenerazione urbana dei quartieri periferici."
];

public Task<NewsResult> GetNewsAsync(int page)
public Task<NewsResult> GetNewsAsync(int page, int pageSize = 10)
{
var skip = (page - 1) * PageSize;
var count = Math.Min(PageSize, TotalItems - skip);
var skip = (page - 1) * pageSize;
var count = Math.Min(pageSize, TotalItems - skip);

var items = Enumerable.Range(skip + 1, count)
.Select(i => new NewsItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ namespace BitBlazor.Sample.Services;

public class PraticheService : IPraticheService
{
private const int PageSize = 8;

private static readonly IReadOnlyList<PraticaItem> AllPratiche =
[
new(1, "Rinnovo Carta d'Identità Elettronica", "Anagrafe", "Completata", "Richiesta di rinnovo della CIE scaduta. Documento ritirato allo sportello.", new DateOnly(2025, 11, 3)),
Expand Down Expand Up @@ -40,15 +38,15 @@ public class PraticheService : IPraticheService
new(30, "Rettifica Atto di Nascita", "Anagrafe", "In Lavorazione", "Istanza di rettifica per errore materiale in trascrizione atto di nascita. Pratiche in corso.", new DateOnly(2026, 4, 9)),
];

public Task<PraticheResult> GetPraticheAsync(int page, string? statoFiltro = null)
public Task<PraticheResult> GetPraticheAsync(int page, string? statoFiltro = null, int pageSize = 8)
{
var filtered = string.IsNullOrEmpty(statoFiltro)
? AllPratiche
: AllPratiche.Where(p => p.Stato == statoFiltro).ToList();

var items = filtered
.Skip((page - 1) * PageSize)
.Take(PageSize)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();

return Task.FromResult(new PraticheResult(items, filtered.Count));
Expand Down
42 changes: 29 additions & 13 deletions src/BitBlazor/Components/Dropdown/BitDropdown.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ namespace BitBlazor.Components;
/// <summary>
/// Represents a dropdown component that can be used to display a list of options or actions in a collapsible menu.
/// </summary>
/// <remarks>
/// Requires an interactive render mode (Server, WebAssembly, or Auto). The open/closed state and keyboard
/// navigation are managed entirely in C# via event handlers, with no JavaScript interop and no href-based
/// fallback for the activator button. Under static SSR (no circuit/WASM runtime attached) the activator
/// cannot be toggled, so the menu can never be opened. Do not use <see cref="BitDropdown"/> — directly or
/// indirectly, e.g. via <see cref="BitPagination.ShowChanger"/> — on a page that stays fully static.
/// </remarks>
public partial class BitDropdown : BitComponentBase
{
/// <summary>
Expand Down Expand Up @@ -107,23 +114,33 @@ internal void Toggle()
isOpen = !isOpen;
if (isOpen)
{
dropdownMenuAttributes["data-popper-placement"] = Position switch
{
DropdownPosition.Up => "top-start",
DropdownPosition.End => "right-start",
DropdownPosition.Start => "left-start",
_ => "bottom-start"
};

activatorContext.Attributes["aria-expanded"] = "true";
AddOpenDropdownMenuAttributes();
}
else
{
dropdownMenuAttributes.Remove("data-popper-placement");
activatorContext.Attributes["aria-expanded"] = "false";
RemoveDropdownMenuAttributes();
}
}

private void AddOpenDropdownMenuAttributes()
{
dropdownMenuAttributes["data-popper-placement"] = Position switch
{
DropdownPosition.Up => "top-start",
DropdownPosition.End => "right-start",
DropdownPosition.Start => "left-start",
_ => "bottom-start"
};

activatorContext.Attributes["aria-expanded"] = "true";
}

private void RemoveDropdownMenuAttributes()
{
dropdownMenuAttributes.Remove("data-popper-placement");
activatorContext.Attributes["aria-expanded"] = "false";
}

private string ComputeDropdownContainerClass()
{
var builder = new CssClassBuilder("dropdown");
Expand Down Expand Up @@ -237,8 +254,7 @@ internal async Task FocusPreviousItemAsync(BitDropdownItem current)
internal async Task CloseAsync()
{
isOpen = false;
activatorContext.Attributes["aria-expanded"] = "false";
dropdownMenuAttributes.Remove("data-popper-placement");
RemoveDropdownMenuAttributes();
StateHasChanged();

if (activatorContext.ActivatorRef.Id is not null)
Expand Down
2 changes: 2 additions & 0 deletions src/BitBlazor/Components/Dropdown/BitDropdownItem.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ private async Task ClickAsync()
{
NavigationManager.NavigateTo(Href);
}

await Parent.CloseAsync();
}

private async Task OnKeyDownAsync(KeyboardEventArgs args)
Expand Down
66 changes: 43 additions & 23 deletions src/BitBlazor/Components/Pagination/BitPagination.razor
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,39 @@
</CascadingValue>
</ul>

@if (ShowChanger)
{
<BitDropdown ActivatorId="@GetChangerComputedId()">
<ActivatorTemplate>
<button class="btn btn-dropdown dropdown-toggle" type="button" id="@context.ActivatorId" aria-label="@ChangerAriaLabel" disabled="@Disabled" @ref="context.ActivatorRef" @attributes="context.Attributes" @onclick="@context.ToggleDropdown" @onkeydown="@context.HandleKeyDownAsync">
@RenderPageSizeLabel(state.PageSize)
<BitIcon IconName="@Icons.ItExpand" Color="IconColor.Primary" Size="IconSize.Small" CssClass="icon-expand" />
</button>
</ActivatorTemplate>
<ChildContent>
@foreach (var pageSize in PageSizeOptions)
{
<BitDropdownItem Active="@(state.PageSize == pageSize)" Href="@GetPageSizeHref(pageSize)" OnClick="@(() => ClickPageSizeItemAsync(pageSize))">
<span>@RenderPageSizeLabel(pageSize)</span>
</BitDropdownItem>
}
</ChildContent>
</BitDropdown>
}

@if (ShowJumpToPage)
{
<div class="form-group">
<InputText type="text"
class="form-control"
id="@jumpToPageId"
inputmode="numeric"
pattern="[0-9]*"
disabled="@Disabled"
@bind-Value="jumpToPageValue"
@bind-Value:after="JumpToPageAsync"
@onfocus="SetJumpToPageLabelActive"
@onblur="UpdateJumpToPageLabelClass"/>
class="form-control"
id="@jumpToPageId"
inputmode="numeric"
pattern="[0-9]*"
disabled="@Disabled"
@bind-Value="jumpToPageValue"
@bind-Value:after="JumpToPageAsync"
@onfocus="SetJumpToPageLabelActive"
@onblur="UpdateJumpToPageLabelClass" />
<label for="@jumpToPageId" class="@jumpToPageLabelClass">
@(JumpToPageLabelTemplate ?? DefaultJumpToPageLabelTemplate)
</label>
Expand All @@ -52,23 +72,23 @@

@code {
private RenderFragment RenderFullPaginationControl() => __builder =>
{
foreach (var pageItem in GetPageSequence())
{
foreach (var pageItem in GetPageSequence())
if (pageItem is null)
{
if (pageItem is null)
{
<li class="page-item">
<span class="page-link">...</span>
</li>
}
else
{
<BitPageItem Page="pageItem" Disabled="Disabled" Href="@GetPageHref(pageItem.Value)" PageItemClicked="@(() => ChangePageAsync(pageItem.Value))">
@pageItem
</BitPageItem>
}
<li class="page-item">
<span class="page-link">...</span>
</li>
}
};
else
{
<BitPageItem Page="pageItem" Disabled="Disabled" Href="@GetPageHref(pageItem.Value)" PageItemClicked="@(() => ChangePageAsync(pageItem.Value))">
@pageItem
</BitPageItem>
}
}
};

private RenderFragment RenderSimpleModePaginationControl() => __builder =>
{
Expand Down
Loading
Loading