Files
radzen-blazor/Radzen.Blazor/RadzenTabs.razor.cs
2026-07-03 08:34:20 +03:00

596 lines
19 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>
/// &lt;RadzenTabs&gt;
/// &lt;Tabs&gt;
/// &lt;RadzenTabsItem Text="Orders"&gt;
/// Order list and details...
/// &lt;/RadzenTabsItem&gt;
/// &lt;RadzenTabsItem Text="Customers"&gt;
/// Customer information...
/// &lt;/RadzenTabsItem&gt;
/// &lt;/Tabs&gt;
/// &lt;/RadzenTabs&gt;
/// </code>
/// Tabs with client-side rendering and change event:
/// <code>
/// &lt;RadzenTabs RenderMode="TabRenderMode.Client" @bind-SelectedIndex=@selectedTab Change=@OnTabChange&gt;
/// &lt;Tabs&gt;
/// &lt;RadzenTabsItem Text="Tab 1" Icon="home"&gt;Content 1&lt;/RadzenTabsItem&gt;
/// &lt;RadzenTabsItem Text="Tab 2" Icon="settings" Disabled="true"&gt;Content 2&lt;/RadzenTabsItem&gt;
/// &lt;/Tabs&gt;
/// &lt;/RadzenTabs&gt;
/// @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; }
/// <summary>
/// Gets or sets the accessible name applied to the tab list via <c>aria-label</c>.
/// Use this to give assistive technologies a label describing the group of tabs.
/// </summary>
/// <value>The tab list aria-label. Default is <c>null</c>.</value>
[Parameter]
public string? AriaLabel { get; set; }
/// <summary>
/// Gets or sets the id of the element that labels the tab list via <c>aria-labelledby</c>.
/// Use this when a visible element already provides the accessible name for the group of tabs.
/// </summary>
/// <value>The id of the labelling element. Default is <c>null</c>.</value>
[Parameter]
public string? AriaLabelledBy { get; set; }
internal ElementReference tablistElement;
List<RadzenTabsItem> tabs = new List<RadzenTabsItem>();
internal List<RadzenTabsItem> NavigableTabs()
{
var item = tabs.ElementAtOrDefault(selectedIndex) ?? tabs.FirstOrDefault();
if (item == null)
{
return new List<RadzenTabsItem>();
}
return tabs.Where(t => HasInvisibleBefore(item) ? true : t.Visible).ToList();
}
internal string? GetActiveDescendantId()
{
if (focusedIndex < 0)
{
return null;
}
var focused = NavigableTabs().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 tablistElement.FocusAsync(preventScroll: true);
}
catch (JSDisconnectedException)
{
}
catch (JSException)
{
}
}
}
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;
SetFocusedIndex();
if (focusedIndex == -1)
{
focusedIndex = 0;
}
base.OnInitialized();
}
void SetFocusedIndex()
{
var selected = tabs.ElementAtOrDefault(selectedIndex);
if (selected != null)
{
var navigableIndex = NavigableTabs().IndexOf(selected);
if (navigableIndex != -1)
{
focusedIndex = navigableIndex;
}
}
}
/// <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 tablistElement.FocusAsync(preventScroll: true);
}
catch (JSDisconnectedException)
{
}
catch (JSException)
{
}
}
}
internal RadzenTabsItem? FirstVisibleTab()
{
return tabs?.Where(t => t.Visible).FirstOrDefault();
}
internal bool IsVertical => TabPosition == TabPosition.Left || TabPosition == TabPosition.Right;
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 navigableTabs = NavigableTabs();
if (navigableTabs.Count == 0)
{
return;
}
var previousKey = IsVertical ? "ArrowUp" : "ArrowLeft";
var nextKey = IsVertical ? "ArrowDown" : "ArrowRight";
if (key == previousKey || key == nextKey)
{
preventKeyPress = true;
stopKeydownPropagation = true;
var direction = key == previousKey ? -1 : 1;
var count = navigableTabs.Count;
focusedIndex = ((focusedIndex + direction) % count + count) % count;
var steps = 0;
while (navigableTabs.ElementAtOrDefault(focusedIndex)?.Disabled == true && steps < count)
{
focusedIndex = ((focusedIndex + direction) % count + count) % count;
steps++;
}
}
else if (key == "Home" || key == "End")
{
preventKeyPress = true;
stopKeydownPropagation = true;
var count = navigableTabs.Count;
var direction = key == "Home" ? 1 : -1;
focusedIndex = key == "Home" ? 0 : count - 1;
var steps = 0;
while (navigableTabs.ElementAtOrDefault(focusedIndex)?.Disabled == true && steps < count)
{
focusedIndex = ((focusedIndex + direction) % count + count) % count;
steps++;
}
}
else if (key == "Space" || key == "Enter")
{
preventKeyPress = true;
stopKeydownPropagation = true;
if (focusedIndex >= 0 && focusedIndex < navigableTabs.Count)
{
await navigableTabs[focusedIndex].OnClick();
}
}
else
{
if (!preventKeyPress && !stopKeydownPropagation)
{
suppressNextRender = true;
}
preventKeyPress = false;
stopKeydownPropagation = false;
}
}
internal bool IsFocused(RadzenTabsItem item)
{
return NavigableTabs().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;
}
}
}