mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
Follow-up to the #2558 fix. In SelectTab the StateHasChanged call was skipped
if Change/SelectedIndexChanged threw, leaving the component stale (multiple
tabs visually active); in SelectTabOnClient a throw left shouldRender stuck at
false, blocking all further renders. Wrap both in finally so the render state
is always restored. Addresses review feedback on commit df1dfa713.
Fixes #2558
513 lines
17 KiB
C#
513 lines
17 KiB
C#
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Web;
|
|
using Microsoft.JSInterop;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Radzen.Blazor
|
|
{
|
|
/// <summary>
|
|
/// A tabbed interface component that organizes content into multiple panels with clickable tabs for navigation.
|
|
/// RadzenTabs allows users to switch between different views or sections without navigating away from the page.
|
|
/// Provides a container for RadzenTabsItem components, each representing one tab and its associated content panel.
|
|
/// Supports tab positioning at Top, Bottom, Left, Right, TopRight, or BottomRight, server-side rendering (default) or client-side rendering for improved interactivity,
|
|
/// programmatic selection via SelectedIndex with two-way binding, Change event when tabs are switched, dynamic tab addition/removal using AddTab() and RemoveItem(),
|
|
/// keyboard navigation (Arrow keys, Home, End, Space, Enter) for accessibility, and disabled tabs to prevent selection.
|
|
/// Use Server render mode for standard Blazor rendering, or Client mode for faster tab switching with JavaScript.
|
|
/// </summary>
|
|
/// <example>
|
|
/// Basic tabs with server-side rendering:
|
|
/// <code>
|
|
/// <RadzenTabs>
|
|
/// <Tabs>
|
|
/// <RadzenTabsItem Text="Orders">
|
|
/// Order list and details...
|
|
/// </RadzenTabsItem>
|
|
/// <RadzenTabsItem Text="Customers">
|
|
/// Customer information...
|
|
/// </RadzenTabsItem>
|
|
/// </Tabs>
|
|
/// </RadzenTabs>
|
|
/// </code>
|
|
/// Tabs with client-side rendering and change event:
|
|
/// <code>
|
|
/// <RadzenTabs RenderMode="TabRenderMode.Client" @bind-SelectedIndex=@selectedTab Change=@OnTabChange>
|
|
/// <Tabs>
|
|
/// <RadzenTabsItem Text="Tab 1" Icon="home">Content 1</RadzenTabsItem>
|
|
/// <RadzenTabsItem Text="Tab 2" Icon="settings" Disabled="true">Content 2</RadzenTabsItem>
|
|
/// </Tabs>
|
|
/// </RadzenTabs>
|
|
/// @code {
|
|
/// int selectedTab = 0;
|
|
/// void OnTabChange(int index) => Console.WriteLine($"Selected tab: {index}");
|
|
/// }
|
|
/// </code>
|
|
/// </example>
|
|
public partial class RadzenTabs : RadzenComponent
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets the rendering mode that determines how tab content is rendered and switched.
|
|
/// Server mode re-renders on the server when tabs change, while Client mode uses JavaScript for instant switching.
|
|
/// </summary>
|
|
/// <value>The tab render mode. Default is <see cref="TabRenderMode.Server"/>.</value>
|
|
[Parameter]
|
|
public TabRenderMode RenderMode { get; set; } = TabRenderMode.Server;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the visual position of the tab headers relative to the content panels.
|
|
/// Controls the layout direction and can position tabs at Top, Bottom, Left, Right, TopRight, or BottomRight of the content.
|
|
/// </summary>
|
|
/// <value>The tab position. Default is <see cref="TabPosition.Top"/>.</value>
|
|
[Parameter]
|
|
public TabPosition TabPosition { get; set; } = TabPosition.Top;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the zero-based index of the currently selected tab.
|
|
/// Use with @bind-SelectedIndex for two-way binding to track and control the active tab.
|
|
/// Set to -1 for no selection (though typically the first tab is selected automatically).
|
|
/// </summary>
|
|
/// <value>The selected tab index. Default is -1 (auto-select first tab).</value>
|
|
[Parameter]
|
|
public int SelectedIndex { get; set; } = -1;
|
|
|
|
private int selectedIndex = -1;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the callback invoked when the selected tab index changes.
|
|
/// Used for two-way binding with @bind-SelectedIndex.
|
|
/// </summary>
|
|
/// <value>The event callback receiving the new selected index.</value>
|
|
[Parameter]
|
|
public EventCallback<int> SelectedIndexChanged { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the callback invoked when the user switches to a different tab.
|
|
/// Provides the index of the newly selected tab. Use this for side effects or logging.
|
|
/// </summary>
|
|
/// <value>The change event callback receiving the selected tab index.</value>
|
|
[Parameter]
|
|
public EventCallback<int> Change { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether the user can reorder tabs by dragging and dropping.
|
|
/// When enabled, tab headers become draggable and can be rearranged by the user.
|
|
/// </summary>
|
|
/// <value><c>true</c> if tab reordering is allowed; otherwise, <c>false</c>. Default is <c>false</c>.</value>
|
|
[Parameter]
|
|
public bool AllowReorder { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the callback invoked when tabs are reordered via drag and drop.
|
|
/// Provides a <see cref="TabsReorderEventArgs"/> with the old and new index of the moved tab.
|
|
/// </summary>
|
|
[Parameter]
|
|
public EventCallback<TabsReorderEventArgs> Reorder { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the render fragment containing RadzenTabsItem components that define the tabs.
|
|
/// Each RadzenTabsItem represents one tab with its header and content.
|
|
/// </summary>
|
|
/// <value>The tabs render fragment containing tab definitions.</value>
|
|
[Parameter]
|
|
public RenderFragment? Tabs { get; set; }
|
|
|
|
List<RadzenTabsItem> tabs = new List<RadzenTabsItem>();
|
|
|
|
internal string? GetActiveDescendantId()
|
|
{
|
|
if (focusedIndex < 0) return null;
|
|
var visibleTabs = tabs.Where(t => t.Visible).ToList();
|
|
var focused = visibleTabs.ElementAtOrDefault(focusedIndex);
|
|
if (focused == null) return null;
|
|
return $"{GetId()}-tabpanel-{IndexOf(focused)}-label";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the tab.
|
|
/// </summary>
|
|
/// <param name="tab">The tab.</param>
|
|
public async Task AddTab(RadzenTabsItem tab)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(tab);
|
|
if (!tabs.Contains(tab))
|
|
{
|
|
tabs.Add(tab);
|
|
|
|
if (tab.Selected)
|
|
{
|
|
selectedIndex = IndexOf(tab);
|
|
}
|
|
|
|
if (IsSelected(tab))
|
|
{
|
|
await SelectTab(tab);
|
|
}
|
|
else if (selectedIndex < 0)
|
|
{
|
|
await SelectTab(tab); // Select the first tab by default
|
|
}
|
|
}
|
|
}
|
|
|
|
internal string? Id
|
|
{
|
|
get
|
|
{
|
|
return GetId();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the currently selected RadzenTabsItem based on the selectedIndex.
|
|
/// </summary>
|
|
|
|
public RadzenTabsItem? SelectedTab
|
|
{
|
|
get
|
|
{
|
|
return tabs.ElementAtOrDefault(selectedIndex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the item.
|
|
/// </summary>
|
|
/// <param name="item">The item.</param>
|
|
public void RemoveItem(RadzenTabsItem item)
|
|
{
|
|
if (tabs.Remove(item))
|
|
{
|
|
if (!disposed)
|
|
{
|
|
try { InvokeAsync(StateHasChanged); } catch { }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reloads this instance.
|
|
/// </summary>
|
|
public void Reload()
|
|
{
|
|
StateHasChanged();
|
|
}
|
|
|
|
internal bool IsSelected(RadzenTabsItem tab)
|
|
{
|
|
return IndexOf(tab) == selectedIndex;
|
|
}
|
|
|
|
internal int IndexOf(RadzenTabsItem tab)
|
|
{
|
|
return tabs.IndexOf(tab);
|
|
}
|
|
|
|
internal async Task SelectTab(RadzenTabsItem tab, bool raiseChange = false)
|
|
{
|
|
selectedIndex = IndexOf(tab);
|
|
|
|
SetFocusedIndex();
|
|
|
|
try
|
|
{
|
|
if (raiseChange)
|
|
{
|
|
await Change.InvokeAsync(selectedIndex);
|
|
|
|
await SelectedIndexChanged.InvokeAsync(selectedIndex);
|
|
|
|
try
|
|
{
|
|
await Element.FocusAsync(preventScroll: true);
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override string GetComponentCssClass()
|
|
{
|
|
var positionCSS = "rz-tabview-top";
|
|
|
|
if (TabPosition == TabPosition.Bottom)
|
|
{
|
|
positionCSS = "rz-tabview-bottom";
|
|
}
|
|
else if (TabPosition == TabPosition.Right)
|
|
{
|
|
positionCSS = "rz-tabview-right";
|
|
}
|
|
else if (TabPosition == TabPosition.Left)
|
|
{
|
|
positionCSS = "rz-tabview-left";
|
|
}
|
|
else if(TabPosition == TabPosition.TopRight)
|
|
{
|
|
positionCSS = "rz-tabview-top rz-tabview-top-right";
|
|
}
|
|
else if (TabPosition == TabPosition.BottomRight)
|
|
{
|
|
positionCSS = "rz-tabview-bottom rz-tabview-bottom-right";
|
|
}
|
|
|
|
return $"rz-tabview {positionCSS}";
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void OnInitialized()
|
|
{
|
|
selectedIndex = SelectedIndex;
|
|
|
|
focusedIndex = focusedIndex == -1 ? 0 : focusedIndex;
|
|
|
|
base.OnInitialized();
|
|
}
|
|
|
|
void SetFocusedIndex()
|
|
{
|
|
if (focusedIndex != selectedIndex)
|
|
{
|
|
focusedIndex = selectedIndex;
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task SetParametersAsync(ParameterView parameters)
|
|
{
|
|
if (parameters.DidParameterChange(nameof(SelectedIndex), SelectedIndex))
|
|
{
|
|
selectedIndex = parameters.GetValueOrDefault<int>(nameof(SelectedIndex));
|
|
}
|
|
|
|
SetFocusedIndex();
|
|
|
|
await base.SetParametersAsync(parameters);
|
|
}
|
|
|
|
|
|
int previousSelectedIndex;
|
|
/// <inheritdoc />
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (JSRuntime != null && RenderMode == TabRenderMode.Client && previousSelectedIndex != selectedIndex)
|
|
{
|
|
previousSelectedIndex = selectedIndex;
|
|
try
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("Radzen.selectTab", $"{GetId()}-tabpanel-{selectedIndex}", selectedIndex);
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
}
|
|
|
|
await base.OnAfterRenderAsync(firstRender);
|
|
}
|
|
|
|
bool shouldRender = true;
|
|
bool suppressNextRender;
|
|
/// <summary>
|
|
/// Should render.
|
|
/// </summary>
|
|
protected override bool ShouldRender()
|
|
{
|
|
if (suppressNextRender)
|
|
{
|
|
suppressNextRender = false;
|
|
return false;
|
|
}
|
|
|
|
return shouldRender;
|
|
}
|
|
|
|
internal async System.Threading.Tasks.Task SelectTabOnClient(RadzenTabsItem tab)
|
|
{
|
|
var index = IndexOf(tab);
|
|
if (index != selectedIndex && JSRuntime != null)
|
|
{
|
|
selectedIndex = index;
|
|
previousSelectedIndex = selectedIndex;
|
|
SetFocusedIndex();
|
|
|
|
try
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("Radzen.selectTab", $"{GetId()}-tabpanel-{selectedIndex}", selectedIndex);
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
|
|
shouldRender = false;
|
|
try
|
|
{
|
|
await Change.InvokeAsync(selectedIndex);
|
|
await SelectedIndexChanged.InvokeAsync(selectedIndex);
|
|
}
|
|
finally
|
|
{
|
|
shouldRender = true;
|
|
}
|
|
|
|
try
|
|
{
|
|
await Element.FocusAsync(preventScroll: true);
|
|
}
|
|
catch (JSDisconnectedException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
internal RadzenTabsItem? FirstVisibleTab()
|
|
{
|
|
return tabs?.Where(t => t.Visible).FirstOrDefault();
|
|
}
|
|
|
|
internal int focusedIndex = -1;
|
|
bool preventKeyPress = true;
|
|
bool stopKeydownPropagation;
|
|
|
|
bool stopGuardKeydownPropagation = true;
|
|
void OnGuardKeyDown(KeyboardEventArgs args)
|
|
{
|
|
var key = args.Code ?? args.Key;
|
|
var stop = key != "Escape";
|
|
|
|
if (stop == stopGuardKeydownPropagation)
|
|
{
|
|
suppressNextRender = true;
|
|
}
|
|
|
|
stopGuardKeydownPropagation = stop;
|
|
}
|
|
|
|
async Task OnKeyPress(KeyboardEventArgs args)
|
|
{
|
|
var key = args.Code != null ? args.Code : args.Key;
|
|
|
|
var item = tabs.ElementAtOrDefault(focusedIndex) ?? tabs.FirstOrDefault();
|
|
|
|
if (item == null) return;
|
|
|
|
if (key == "ArrowLeft" || key == "ArrowRight")
|
|
{
|
|
preventKeyPress = true;
|
|
stopKeydownPropagation = true;
|
|
|
|
focusedIndex = Math.Clamp(focusedIndex + (key == "ArrowLeft" ? -1 : 1), 0, tabs.Where(t => HasInvisibleBefore(item) ? true : t.Visible).Count() - 1);
|
|
}
|
|
else if (key == "Home" || key == "End")
|
|
{
|
|
preventKeyPress = true;
|
|
stopKeydownPropagation = true;
|
|
|
|
focusedIndex = key == "Home" ? 0 : tabs.Where(t => HasInvisibleBefore(item) ? true : t.Visible).Count() - 1;
|
|
}
|
|
else if (key == "Space" || key == "Enter")
|
|
{
|
|
preventKeyPress = true;
|
|
stopKeydownPropagation = true;
|
|
|
|
if (focusedIndex >= 0 && focusedIndex < tabs.Where(t => HasInvisibleBefore(item) ? true : t.Visible).Count())
|
|
{
|
|
await tabs.Where(t => HasInvisibleBefore(item) ? true : t.Visible).ToList()[focusedIndex].OnClick();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!preventKeyPress && !stopKeydownPropagation)
|
|
{
|
|
suppressNextRender = true;
|
|
}
|
|
preventKeyPress = false;
|
|
stopKeydownPropagation = false;
|
|
}
|
|
}
|
|
internal bool IsFocused(RadzenTabsItem item)
|
|
{
|
|
return tabs.Where(t => HasInvisibleBefore(item) ? true : t.Visible).ToList().IndexOf(item) == focusedIndex && focusedIndex != -1;
|
|
}
|
|
|
|
internal bool HasInvisibleBefore(RadzenTabsItem item)
|
|
{
|
|
return tabs.Take(tabs.IndexOf(item)).Any(t => !t.Visible);
|
|
}
|
|
|
|
internal RadzenTabsItem? draggedTab;
|
|
internal RadzenTabsItem? dragOverTab;
|
|
|
|
internal void OnTabDragStart(RadzenTabsItem tab)
|
|
{
|
|
draggedTab = tab;
|
|
}
|
|
|
|
internal void OnTabDragOver(RadzenTabsItem tab)
|
|
{
|
|
dragOverTab = tab;
|
|
}
|
|
|
|
internal void OnTabDragEnd()
|
|
{
|
|
draggedTab = null;
|
|
dragOverTab = null;
|
|
}
|
|
|
|
internal async Task OnTabDrop(RadzenTabsItem tab)
|
|
{
|
|
if (draggedTab == null || draggedTab == tab)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var oldIndex = tabs.IndexOf(draggedTab);
|
|
var newIndex = tabs.IndexOf(tab);
|
|
|
|
if (oldIndex < 0 || newIndex < 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
tabs.RemoveAt(oldIndex);
|
|
tabs.Insert(newIndex, draggedTab);
|
|
|
|
// Adjust selectedIndex to follow the selected tab
|
|
if (selectedIndex == oldIndex)
|
|
{
|
|
selectedIndex = newIndex;
|
|
}
|
|
else if (oldIndex < selectedIndex && newIndex >= selectedIndex)
|
|
{
|
|
selectedIndex--;
|
|
}
|
|
else if (oldIndex > selectedIndex && newIndex <= selectedIndex)
|
|
{
|
|
selectedIndex++;
|
|
}
|
|
|
|
SetFocusedIndex();
|
|
|
|
await Reorder.InvokeAsync(new TabsReorderEventArgs { OldIndex = oldIndex, NewIndex = newIndex });
|
|
await SelectedIndexChanged.InvokeAsync(selectedIndex);
|
|
|
|
draggedTab = null;
|
|
dragOverTab = null;
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
internal bool IsDragOver(RadzenTabsItem tab)
|
|
{
|
|
return dragOverTab == tab && draggedTab != tab;
|
|
}
|
|
}
|
|
}
|