Files
radzen-blazor/Radzen.Blazor/RadzenMenu.razor.cs

361 lines
14 KiB
C#

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using Radzen.Blazor.Rendering;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Radzen.Blazor
{
/// <summary>
/// A horizontal menu component with support for nested submenus, icons, and responsive behavior.
/// RadzenMenu provides a classic menu bar for navigation, typically used in application headers or toolbars.
/// Displays menu items horizontally with dropdown submenus.
/// Supports multi-level nested menus via RadzenMenuItem child items, automatic navigation via Path property or custom Click handlers,
/// icons displayed alongside menu item text, responsive design that automatically collapses to a hamburger menu on small screens (configurable),
/// click-to-open or hover-to-open interaction modes, keyboard navigation (Arrow keys, Enter, Escape) for accessibility, and visual separators between menu items.
/// Use for application navigation bars, command menus, or toolbar-style interfaces. Menu items are defined using RadzenMenuItem components as child content.
/// </summary>
/// <example>
/// Basic menu with navigation:
/// <code>
/// &lt;RadzenMenu&gt;
/// &lt;RadzenMenuItem Text="Home" Path="/" Icon="home" /&gt;
/// &lt;RadzenMenuItem Text="Data"&gt;
/// &lt;RadzenMenuItem Text="Orders" Path="/orders" /&gt;
/// &lt;RadzenMenuItem Text="Customers" Path="/customers" /&gt;
/// &lt;/RadzenMenuItem&gt;
/// &lt;RadzenMenuItem Text="Reports" Path="/reports" /&gt;
/// &lt;/RadzenMenu&gt;
/// </code>
/// Menu with click handlers:
/// <code>
/// &lt;RadzenMenu Click=@OnMenuClick&gt;
/// &lt;RadzenMenuItem Text="File"&gt;
/// &lt;RadzenMenuItem Text="New" Value="new" Icon="add" /&gt;
/// &lt;RadzenMenuItem Text="Open" Value="open" Icon="folder_open" /&gt;
/// &lt;RadzenMenuItem Text="Save" Value="save" Icon="save" /&gt;
/// &lt;/RadzenMenuItem&gt;
/// &lt;/RadzenMenu&gt;
/// </code>
/// </example>
public partial class RadzenMenu : RadzenComponentWithChildren
{
/// <summary>
/// Gets or sets whether the menu should automatically collapse to a hamburger menu on small screens.
/// When enabled, displays a toggle button that expands/collapses the menu on mobile devices.
/// </summary>
/// <value><c>true</c> to enable responsive behavior with hamburger menu; <c>false</c> for always-horizontal menu. Default is <c>true</c>.</value>
[Parameter]
public bool Responsive { get; set; } = true;
/// <summary>
/// Gets or sets the interaction mode for opening submenus.
/// When true, submenus open on click. When false, submenus open on hover (desktop) and click (touch devices).
/// </summary>
/// <value><c>true</c> to open on click; <c>false</c> to open on hover. Default is <c>true</c>.</value>
[Parameter]
public bool ClickToOpen { get; set; } = true;
/// <summary>
/// Gets or sets whether nested submenus should fly out horizontally to the side instead of expanding vertically inline.
/// When enabled, 2nd level and deeper submenus appear as cascading flyout menus positioned to the right of their parent item.
/// </summary>
/// <value><c>true</c> to enable flyout submenus; <c>false</c> for default accordion-style nesting. Default is <c>false</c>.</value>
[Parameter]
public bool Flyout { get; set; }
private bool IsOpen { get; set; }
/// <inheritdoc />
protected override string GetComponentCssClass() => ClassList.Create("rz-menu")
.Add("rz-menu-open", Responsive && IsOpen)
.Add("rz-menu-closed", Responsive && !IsOpen)
.Add("rz-menu-flyout", Flyout)
.ToString();
IJSObjectReference? _jsRef;
bool _clickToOpenChanged;
/// <inheritdoc />
public override async Task SetParametersAsync(ParameterView parameters)
{
if (parameters.DidParameterChange(nameof(ClickToOpen), ClickToOpen))
{
_clickToOpenChanged = true;
}
await base.SetParametersAsync(parameters);
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if ((firstRender || _clickToOpenChanged) && Visible && JSRuntime != null)
{
_clickToOpenChanged = false;
if (_jsRef != null)
{
await _jsRef.InvokeVoidAsync("dispose");
await _jsRef.DisposeAsync();
}
_jsRef = await JSRuntime.InvokeAsync<IJSObjectReference>(
"Radzen.createMenu", Element, ClickToOpen);
}
}
void OnToggle()
{
IsOpen = !IsOpen;
}
/// <summary>
/// Gets or sets the click callback.
/// </summary>
/// <value>The click callback.</value>
[Parameter]
public EventCallback<MenuItemEventArgs> Click { get; set; }
private string? ariaLabel;
/// <summary>
/// Gets or sets the menu aria label text.
/// </summary>
/// <value>The menu aria label text.</value>
[Parameter]
public string AriaLabel { get => ariaLabel ?? Localize(nameof(RadzenStrings.Menu_AriaLabel)); set => ariaLabel = value; }
[Inject]
NavigationManager? NavigationManager { get; set; }
bool subMenuOpen;
internal int focusedIndex = -1;
bool preventKeyPress = true;
bool stopKeydownPropagation;
async Task OnKeyPress(KeyboardEventArgs args)
{
var key = args.Code != null ? args.Code : args.Key;
if (currentItems.Count == 0)
{
currentItems = items.Where(i => i.Visible && !i.Disabled).ToList();
}
if (key == "ArrowUp" || key == "ArrowDown")
{
preventKeyPress = true;
stopKeydownPropagation = true;
if (subMenuOpen)
{
focusedIndex = Math.Clamp(focusedIndex + (key == "ArrowUp" ? -1 : 1), 0, currentItems.Count - 1);
}
else
{
if (key == "ArrowDown" && currentItems.Count > 0)
{
focusedIndex = Math.Clamp(focusedIndex, 0, currentItems.Count - 1);
var item = currentItems[focusedIndex];
if (item.items.Count > 0)
{
currentItems = item.items.Where(i => i.Visible && !i.Disabled).ToList();
focusedIndex = -1;
subMenuOpen = true;
await item.Open();
}
}
}
}
else if (key == "ArrowLeft" || key == "ArrowRight")
{
preventKeyPress = true;
stopKeydownPropagation = true;
// Flyout mode: ArrowRight opens nested submenu, ArrowLeft closes it
if (Flyout && subMenuOpen)
{
if (key == "ArrowRight" && focusedIndex >= 0 && focusedIndex < currentItems.Count)
{
var item = currentItems[focusedIndex];
if (item.items.Count > 0)
{
currentItems = item.items.Where(i => i.Visible && !i.Disabled).ToList();
focusedIndex = 0;
subMenuOpen = true;
await item.Open();
return;
}
}
else if (key == "ArrowLeft")
{
var firstItem = currentItems.FirstOrDefault();
var parentItem = firstItem?.ParentItem;
if (parentItem?.ParentItem != null)
{
currentItems = parentItem.ParentItem.items.Where(i => i.Visible && !i.Disabled).ToList();
focusedIndex = currentItems.IndexOf(parentItem);
subMenuOpen = true;
await parentItem.Close();
return;
}
}
}
bool shouldOpenNextMenu = false;
if (subMenuOpen)
{
var firstItem = currentItems.FirstOrDefault();
var parentItem = firstItem?.ParentItem;
if (parentItem != null && parentItem.Parent != null)
{
currentItems = parentItem.Parent.items.Where(i => i.Visible && !i.Disabled).ToList();
focusedIndex = currentItems.IndexOf(parentItem);
subMenuOpen = false;
await parentItem.Close();
shouldOpenNextMenu = true;
}
}
focusedIndex = Math.Clamp(focusedIndex + (key == "ArrowLeft" ? -1 : 1), 0, currentItems.Count - 1);
if (shouldOpenNextMenu)
{
shouldOpenNextMenu = false;
var item = currentItems[focusedIndex];
if (item.items.Count > 0)
{
currentItems = item.items.Where(i => i.Visible && !i.Disabled).ToList();
focusedIndex = -1;
subMenuOpen = true;
await item.Toggle();
}
}
}
else if (key == "Space" || key == "Enter")
{
preventKeyPress = true;
stopKeydownPropagation = true;
if (focusedIndex >= 0 && focusedIndex < currentItems.Count)
{
var item = currentItems[focusedIndex];
if (item.items.Count > 0)
{
currentItems = item.items.Where(i => i.Visible && !i.Disabled).ToList();
focusedIndex = -1;
subMenuOpen = true;
await item.Toggle();
}
else
{
if (item.Path != null)
{
NavigationManager?.NavigateTo(item.Path);
}
else
{
await item.OnClick(new MouseEventArgs());
}
}
}
}
else if (key == "Escape")
{
preventKeyPress = true;
stopKeydownPropagation = true;
if (currentItems.Any(i => i.ParentItem != null))
{
var firstItem = currentItems.FirstOrDefault();
var parentItem = firstItem?.ParentItem;
if (parentItem != null)
{
currentItems = (parentItem.ParentItem != null ? parentItem.ParentItem.items : parentItem.Parent?.items ?? new List<RadzenMenuItem>()).Where(i => i.Visible && !i.Disabled).ToList();
focusedIndex = currentItems.IndexOf(parentItem);
subMenuOpen = false;
await parentItem.Close();
}
}
}
else
{
preventKeyPress = false;
stopKeydownPropagation = false;
}
}
internal bool IsFocused(RadzenMenuItem item)
{
return focusedIndex != -1 && currentItems.IndexOf(item) == focusedIndex;
}
List<RadzenMenuItem> currentItems = new();
internal List<RadzenMenuItem> items = new List<RadzenMenuItem>();
/// <summary>
/// Adds the item.
/// </summary>
/// <param name="item">The item.</param>
public void AddItem(RadzenMenuItem item)
{
if (items.IndexOf(item) == -1)
{
items.Add(item);
StateHasChanged();
}
}
private string? toggleAriaLabel;
/// <summary>
/// Gets or sets the add button aria-label attribute.
/// </summary>
[Parameter]
public string ToggleAriaLabel { get => toggleAriaLabel ?? Localize(nameof(RadzenStrings.Menu_ToggleAriaLabel)); set => toggleAriaLabel = value; }
/// <inheritdoc />
protected override void OnInitialized()
{
if (NavigationManager != null)
{
NavigationManager.LocationChanged += OnLocationChanged;
}
}
private void OnLocationChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e)
{
IsOpen = false;
StateHasChanged();
}
/// <inheritdoc />
public override void Dispose()
{
base.Dispose();
if (NavigationManager != null)
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
_jsRef?.InvokeVoidAsync("dispose");
_jsRef?.DisposeAsync();
GC.SuppressFinalize(this);
}
void OnFocus()
{
focusedIndex = focusedIndex == -1 ? 0 : focusedIndex;
}
}
}