diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Nav/Examples/NavDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Nav/Examples/NavDefault.razor new file mode 100644 index 0000000000..b85ce739c6 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Nav/Examples/NavDefault.razor @@ -0,0 +1,108 @@ +@using Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inject IDialogService DialogService + +

NavDefault

+ + + + Dashboard + Announcements + Employee Spotlight + Profile Search + Performance Reviews + + + + + + Openings + Submissions + + + Interviews + + + Health Plans + + + + Plan Information + Fund Performance + + + + + + Training Programs + + + + Openings + Submissions + + + + Workforce Data + Reports + + + + + + + + + + + + + Toggle Navigation + + Programmatically control expanded/collapsed state + + Expand 'Retirement' + Collapse 'Retirement' + + + + + +@code +{ + private FluentNav nav = default!; + NavDensity _density; + bool _useHeader = false; + bool _useIcons = true; + bool _useMultipleExpanded = true; + private readonly string? _link = "https://fluentui-blazor-v5.azurewebsites.net/"; + + private async Task ShowInformationAsync() + { + await DialogService.ShowInfoAsync("This is a message"); + } + + private async Task ToggleNavAsync() + { + if (nav.UseHeader) + { + await nav.ToggleNavAsync(); + } + } + + private async Task ExpandRetirementAsync() + { + await nav.ExpandCategoryAsync("retirement"); + } + + private async Task CollapseRetirementAsync() + { + await nav.CollapseCategoryAsync("retirement"); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Nav/FluentNav.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Nav/FluentNav.md new file mode 100644 index 0000000000..e1b68fbccd --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Nav/FluentNav.md @@ -0,0 +1,61 @@ +--- +title: Nav +route: /Nav +icon: Navigation +--- + +# Nav + +Nav, or navigation, provides a list of links that lets people move through the main sections of an app or site. It’s a high-level wayfinding component that’s always easily accessible but can be minimized to free up space. + +Nav only supports one level of nesting and might not show all available items. + +## Selection +The selection indicator tells people at a glance which page is active. If a nav sub-item is the active page and the category is closed, the selection indicator will display on the nav category. + +## Grouping +Navs can be organized with up to two levels of hierarchy. Simple navs list the spaces in a site or app as links on the same level. For more complex navs, links can be grouped into categories for easier interaction. + +Create a simple nav using nav items. These are first-level links that give people a quick understanding of the main parts of an experience. + +Create a complex nav using nav categories and sub-items. Nav categories expand and collapse so people only see the information they need. Nav sub-items group related links within that category and let people navigate to those sub-pages. Nav categories act as accordions and show or hide information; they’re not links and won’t lead to site or app locations. + +By using the `UseSingleExpanded` parameter, you can ensure that only one nav category is expanded at a time. When a new category is expanded, any previously expanded category will automatically collapse. + +## Divider +Use the `FluentDivider` to separate groups of nav items. This helps people scan and find what they need more quickly. The right styling will automatically be applied when using the divider inside the `FluentNav` + +## Icons +Whenever possible, use icons with nav category labels. They create additional visual emphasis and differentiate nav categories from the nav subitems within them. Use simple and recognizable icons that are easy to understand. + +If icons aren’t technically possible or difficult to pick due to an overwhelming number of nav items, remove them. If nav categories don’t include an icon, subitems are indented to maintain a clear hierarchy. + +## Items +Nav doesn’t support an icon-only layout. + +A `NavItem` can contain either an `Href` or an `OnClick` handler. + +When a `NavItem` is used inside a `NavCategory`, the `Icon` parameter is ignored. No icon will be displayed. + +In the example below, the first (and third and fifth) item has an `Href` that navigates to an external site, while the second (and fourth) item has an `OnClick` handler that triggers a method in the component. + +{{ NavDefault }} + +## API FluentNav + +{{ API Type=FluentNav }} + +## API FluentNavCategory + +{{ API Type=FluentNavCategory }} + +## API FluentNavItem +{{ API Type=FluentNavItem }} + +## API FluentNavSectionHeader +{{ API Type=FluentNavSectionHeader }} + +## Migrating to v5 +There is no direct migration path for the `FluentNavMenu` from v4 +to the `FluentNav` in v5. This is due to the fact that in the v4 component +it was possible to have multiple levels of nesting, while in v5 only one level is supported. diff --git a/src/Core/Components/Icons/CoreIcons.cs b/src/Core/Components/Icons/CoreIcons.cs index 0da8daaf69..20064b7c8a 100644 --- a/src/Core/Components/Icons/CoreIcons.cs +++ b/src/Core/Components/Icons/CoreIcons.cs @@ -49,12 +49,16 @@ public class ChevronLeft : Icon { public ChevronLeft() : base("ChevronLeft", Ico public class ChevronRight : Icon { public ChevronRight() : base("ChevronRight", IconVariant.Regular, IconSize.Size20, "") { } } + public class ChevronUp : Icon { public ChevronUp() : base("ChevronUp", IconVariant.Regular, IconSize.Size20, "") { } } + public class Dismiss : Icon { public Dismiss() : base("Dismiss", IconVariant.Regular, IconSize.Size20, "") { } }; public class Filter : Icon { public Filter() : base("Filter", IconVariant.Regular, IconSize.Size20, "") { } } public class FilterDismiss : Icon { public FilterDismiss() : base("FilterDismiss", IconVariant.Regular, IconSize.Size20, "") { } } + public class Folder : Icon { public Folder() : base("Folder", IconVariant.Regular, IconSize.Size20, "") { } } + public class LineHorizontal3 : Icon { public LineHorizontal3() : base("LineHorizontal3", IconVariant.Regular, IconSize.Size20, "") { } }; public class QuestionCircle : Icon { public QuestionCircle() : base("QuestionCircle", IconVariant.Regular, IconSize.Size20, "") { } }; diff --git a/src/Core/Components/Nav/FluentNav.razor b/src/Core/Components/Nav/FluentNav.razor new file mode 100644 index 0000000000..7b8c61aaca --- /dev/null +++ b/src/Core/Components/Nav/FluentNav.razor @@ -0,0 +1,38 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + + +
+ +
+
diff --git a/src/Core/Components/Nav/FluentNav.razor.cs b/src/Core/Components/Nav/FluentNav.razor.cs new file mode 100644 index 0000000000..64baf56ec5 --- /dev/null +++ b/src/Core/Components/Nav/FluentNav.razor.cs @@ -0,0 +1,257 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a navigation menu component that displays a hamburger icon and provides navigation functionality within +/// a Fluent UI application. +/// +/// +/// Use the FluentNav to present a collapsible navigation menu, typically accessed via a hamburger icon. +/// The component supports customization of the displayed icon and its tooltip text and is designed for integration +/// into Fluent UI layouts. +/// +/// Allowed Child Components: +/// +/// - A simple navigation item. When used as a sub item in a , no icon will be rendered +/// - A grouped set of navigation items +/// - A section header to organize navigation +/// - A visual divider between sections +/// +/// +public partial class FluentNav : FluentComponentBase +{ + private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "Nav/FluentNav.razor.js"; + internal bool _navOpen = true; + private readonly List _categories = []; + private bool _previousUseSingleExpanded; + + /// + public FluentNav(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + _previousUseSingleExpanded = UseSingleExpanded; + } + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-nav") + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the icon to display besides the app title. + /// + [Parameter] + public Icon? AppIcon { get; set; } + + /// + /// Gets or sets the title to display at the top of the menu. + /// + [Parameter] + public string? AppTitle { get; set; } + + /// + /// Gets or sets the link to use for the app item. + /// Defaults to homepage ("/"). + /// + [Parameter] + public string AppLink { get; set; } = "/"; + + /// + /// Gets or sets whether to use the header with the hamburger icon. + /// Defaults to false until we have a good way to make this work with the LayoutHamburger component. + /// + [Parameter] + public bool UseHeader { get; set; } + + /// + /// Gets or sets the icon to display for collapsing/expanding the nav menu. + /// By default, this icon is a hamburger icon. + /// + [Parameter] + public Icon ToggleIcon { get; set; } = new CoreIcons.Regular.Size20.LineHorizontal3(); + + /// + /// Gets or sets whether to enable using icons in the nav items. + /// + [Parameter] + public bool UseIcons { get; set; } = true; + + /// + /// Gets or sets whether to allow just one expanded category or multiple + /// + [Parameter] + public bool UseSingleExpanded { get; set; } + + /// + /// Gets or sets the density of the nav menu item. + /// + [Parameter] + public NavDensity? Density { get; set; } + + /// + /// Gets or sets the content of the nav menu item. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Event callback invoked when the nav menu is toggled. + /// + [Parameter] + public EventCallback OnToggleNav { get; set; } + + /// + protected override async Task OnParametersSetAsync() + { + if (UseSingleExpanded && !_previousUseSingleExpanded) + { + var expandedCategories = _categories.Where(c => c.Expanded).Skip(1).ToList(); + foreach (var category in expandedCategories) + { + await category.SetExpandedAsync(expanded: false); + } + } + + _previousUseSingleExpanded = UseSingleExpanded; + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JSModule.ImportJavaScriptModuleAsync(JAVASCRIPT_FILE); + } + } + + /// + /// Toggles the nav menu open or closed. + /// + public async Task ToggleNavAsync() + { + _navOpen = !_navOpen; + await InvokeAsync(StateHasChanged); + + // Animate the transition + if (JSModule.Imported) + { + if (_navOpen) + { + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Nav.AnimateNavOpen", Id); + } + else + { + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Nav.AnimateNavClose", Id); + } + } + + if (OnToggleNav.HasDelegate) + { + await OnToggleNav.InvokeAsync(_navOpen); + } + } + + /// + /// Expands a specific category by its ID. + /// + /// The ID of the category to expand. + public async Task ExpandCategoryAsync(string categoryId) + { + var category = _categories.FirstOrDefault(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + if (category != null) + { + if (UseSingleExpanded) + { + // Fire-and-forget: start collapse animations for other categories without waiting + var expandedCategories = _categories.Where(c => c != category && c.Expanded).ToList(); + foreach (var otherCategory in expandedCategories) + { + otherCategory.CollapseWithoutAwait(); + } + } + + await category.SetExpandedAsync(expanded: true); + await InvokeAsync(StateHasChanged); + } + } + + /// + /// Collapses a specific category by its ID. + /// + /// The ID of the category to collapse. + public async Task CollapseCategoryAsync(string categoryId) + { + var category = _categories.FirstOrDefault(c => string.Equals(c.Id, categoryId, StringComparison.OrdinalIgnoreCase)); + if (category != null) + { + await category.SetExpandedAsync(expanded: false); + await InvokeAsync(StateHasChanged); + } + } + + /// + /// Collapses all categories in the navigation menu. + /// + public async Task CollapseAllCategoriesAsync() + { + // Update all category states and animate + foreach (var category in _categories) + { + await category.SetExpandedAsync(expanded: false); + } + } + + /// + /// Expands all categories in the navigation menu. + /// When is true, only the first category will be expanded. + /// + public async Task ExpandAllCategoriesAsync() + { + foreach (var category in _categories) + { + await category.SetExpandedAsync(expanded: true); + if (UseSingleExpanded) + { + break; // Only expand the first category if single expanded is enabled + } + } + } + + /// + /// Registers a category with this nav component. + /// + internal void RegisterCategory(FluentNavCategory category) + { + if (!_categories.Contains(category)) + { + _categories.Add(category); + } + } + + /// + /// Unregisters a category from this nav component. + /// + internal void UnregisterCategory(FluentNavCategory category) + { + _categories.Remove(category); + } + + /// + /// Gets all registered categories. + /// + internal IEnumerable GetCategories() + { + return _categories; + } +} diff --git a/src/Core/Components/Nav/FluentNav.razor.css b/src/Core/Components/Nav/FluentNav.razor.css new file mode 100644 index 0000000000..6df45763c8 --- /dev/null +++ b/src/Core/Components/Nav/FluentNav.razor.css @@ -0,0 +1,156 @@ +:root { + --nav-width: 260px; +} + +.fluent-nav-container { + color: var(--colorNeutralForeground1); + overflow: hidden; + display: flex; + height: 100%; + min-width: var(--nav-width); + transition: transform 0.2s cubic-bezier(0.33, 0, 0.67, 1), + opacity 0.2s cubic-bezier(0.33, 0, 0.67, 1); + transform: translate3d(0, 0, 0); + opacity: 1; +} + + .fluent-nav-container.collapsed { + transform: translate3d(calc(var(--nav-width) * -1), 0, 0); + opacity: 0; + } + + [dir="rtl"] .fluent-nav-container.collapsed { + transform: translate3d(var(--nav-width), 0, 0); + } + +.fluent-nav { + overflow: hidden; + max-width: 100vw; + height: auto; + max-height: 100vh; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-start; + color: var(--colorNeutralForeground1); + position: relative; + border-right: var(--strokeWidthThin) solid var(--colorTransparentStroke); + left: 0px; + background-color: var(--colorNeutralBackground4); + align-items: unset; + right: auto; + width: var(--nav-width); + min-width: var(--nav-width); + opacity: 1; + transform: translate3d(0px, 0px, 0px); +} + + .fluent-nav > .nav-header { + width: 100%; + max-width: 100%; + padding: var(--spacingVerticalXXL) var(--spacingHorizontalXXL) var(--spacingVerticalS); + gap: var(--spacingHorizontalS); + align-self: stretch; + display: flex; + flex-direction: column; + box-sizing: border-box; + position: relative; + z-index: 2; + margin: unset; + padding-block: 5px; + padding-inline-start: 14px; + } + + .fluent-nav > .nav-body { + flex: 1 1 0%; + align-self: stretch; + position: relative; + z-index: 1; + overflow: auto; + padding: 0 var(--spacingHorizontalXS) 0 var(--spacingHorizontalMNudge); + flex-direction: column; + display: flex; + align-items: unset; + row-gap: var(--spacingVerticalXXS); + padding-bottom: calc(var(--spacingHorizontalXXL) + 1px); + } + + [dir="rtl"] .fluent-nav > .nav-body { + padding: 0 var(--spacingHorizontalMNudge) 0 var(--spacingHorizontalXS); + } + +.fluent-nav .appitem { + display: flex; + text-transform: none; + position: relative; + justify-content: start; + text-align: left; + border-radius: var(--borderRadiusMedium); + color: var(--colorNeutralForeground2); + text-decoration-line: none; + border: none; + cursor: pointer; + transition-duration: var(--durationFaster); + transition-timing-function: var(--curveLinear); + transition-property: background; + gap: 10px; + padding: var(--spacingVerticalS) var(--spacingHorizontalS) var(--spacingVerticalS) var(--spacingHorizontalMNudge); + align-items: center; + margin-inline-start: -6px; + margin-inline-end: 0px; +} + + .fluent-nav .appitem:hover { + background-color: var(--colorNeutralBackground4Hover); + } + +@keyframes animate_bar { + 0% { + background-color: transparent; + } + 100% { + background-color: var(--colorCompoundBrandForeground1); + } +} + +.fluent-nav .fluent-navitem.active::after, +.fluent-nav .fluent-navsubitem.active::after, +.fluent-nav .fluent-navcategoryitem.active::after { + width: 4px; + height: 20px; + background-color: var(--colorCompoundBrandForeground1); + margin-inline-start: -16px; + animation-name: animate_bar; + animation-timing-function: var(--curveLinear); + animation-fill-mode: both; + animation-duration: var(--durationFaster); + content: ""; + position: absolute; + border-radius: var(--borderRadiusCircular); +} + +.fluent-nav .fluent-navsubitem.active::after { + margin-inline-start: -52px; +} + +.fluent-nav .fluent-navitem:focus-visible, +.fluent-nav .fluent-navsubitem:focus-visible { + outline: var(--strokeWidthThick) solid var(--colorStrokeFocus2); + outline-offset: calc(var(--strokeWidthThick) * -1); +} + +.fluent-nav .fluent-navitem.active, +.fluent-nav .fluent-navsubitem.active, +.fluent-nav .fluent-navcategoryitem.active { + font-weight: var(--fontWeightSemibold); +} + +.fluent-nav .fluent-navitem.disabled, +.fluent-nav .fluent-navsubitem.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.fluent-nav fluent-divider { + margin: var(--spacingVerticalXS) 0; +} diff --git a/src/Core/Components/Nav/FluentNav.razor.ts b/src/Core/Components/Nav/FluentNav.razor.ts new file mode 100644 index 0000000000..18a0c54014 --- /dev/null +++ b/src/Core/Components/Nav/FluentNav.razor.ts @@ -0,0 +1,144 @@ +export namespace Microsoft.FluentUI.Blazor.Nav { + + // Fluent motion tokens (matching React's motionTokens) + const DURATION_FAST = 150; + const DURATION_ULTRA_SLOW = 500; + const CURVE_DECELERATE_MID = 'cubic-bezier(0, 0, 0, 1)'; + const CURVE_ACCELERATE_MIN = 'cubic-bezier(0.8, 0, 0.78, 1)'; + + /** + * Calculates animation duration based on item count (like React's NavGroupMotion) + */ + function calculateDuration(itemCount: number, isSmallDensity: boolean): number { + const durationPerItem = isSmallDensity ? 15 : 25; + const baseDuration = DURATION_FAST + itemCount * durationPerItem; + return Math.min(baseDuration, DURATION_ULTRA_SLOW); + } + + /** + * Creates keyframes for expand/collapse animation + * Keyframes are defined once and can be reversed for collapse. + */ + function createKeyframes(height: number): Keyframe[] { + return [ + { + opacity: 0, + minHeight: 0, + height: 0 + }, + { + opacity: 1, + minHeight: `${height}px`, + height: `${height}px` + } + ]; + } + + /** + * Animates the nav panel open. + */ + export function AnimateNavOpen(navContainerId: string): void { + const navContainer = document.getElementById(navContainerId)?.parentElement as HTMLElement; + if (!navContainer) return; + + navContainer.style.display = ''; + void navContainer.offsetHeight; + } + + /** + * Animates the nav panel closed. + */ + export function AnimateNavClose(navContainerId: string): void { + const navContainer = document.getElementById(navContainerId)?.parentElement as HTMLElement; + if (!navContainer) return; + + void navContainer.offsetHeight; + + navContainer.addEventListener('transitionend', (e) => { + if (e.propertyName === 'transform') { + navContainer.style.display = 'none'; + } + }, { once: true }); + } + + /** + * Animates expansion of a category group element using Web Animations API. + */ + export function AnimateExpand(groupId: string, density: string = 'medium'): void { + const group = document.getElementById(groupId) as HTMLElement; + if (!group) return; + + group.getAnimations().forEach(anim => anim.cancel()); + + const computedStyles = window.getComputedStyle(group); + const isAlreadyVisible = computedStyles.overflow === 'visible'; + + if (isAlreadyVisible) { + group.style.height = 'auto'; + group.style.minHeight = 'auto'; + group.style.opacity = '1'; + group.style.overflow = 'visible'; + return; + } + + const itemCount = group.children.length; + const isSmallDensity = density === 'small'; + const targetHeight = group.scrollHeight; + const duration = calculateDuration(itemCount, isSmallDensity); + + const keyframes = createKeyframes(targetHeight); + + group.style.overflow = 'hidden'; + + const animation = group.animate(keyframes, { + duration: duration, + easing: CURVE_DECELERATE_MID, + fill: 'forwards' + }); + + animation.onfinish = () => { + group.style.height = 'auto'; + group.style.minHeight = 'auto'; + group.style.opacity = '1'; + group.style.overflow = 'visible'; + }; + } + + /** + * Animates collapse of a category group element using Web Animations API. + * Returns a Promise that resolves when the animation completes. + */ + export function AnimateCollapse(groupId: string, density: string = 'medium'): Promise { + return new Promise((resolve) => { + const group = document.getElementById(groupId) as HTMLElement; + if (!group) { + resolve(); + return; + } + + group.getAnimations().forEach(anim => anim.cancel()); + + const itemCount = group.children.length; + const isSmallDensity = density === 'small'; + const currentHeight = group.scrollHeight; + const duration = calculateDuration(itemCount, isSmallDensity); + + const keyframes = [...createKeyframes(currentHeight)].reverse(); + + group.style.overflow = 'hidden'; + + const animation = group.animate(keyframes, { + duration: duration, + easing: CURVE_ACCELERATE_MIN, + fill: 'forwards' + }); + + animation.onfinish = () => { + group.style.height = '0px'; + group.style.minHeight = '0px'; + group.style.opacity = '0'; + resolve(); + }; + }); + } +} diff --git a/src/Core/Components/Nav/FluentNavCategory.razor b/src/Core/Components/Nav/FluentNavCategory.razor new file mode 100644 index 0000000000..b9991e078a --- /dev/null +++ b/src/Core/Components/Nav/FluentNavCategory.razor @@ -0,0 +1,29 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + + + +
+ @ChildContent +
+
diff --git a/src/Core/Components/Nav/FluentNavCategory.razor.cs b/src/Core/Components/Nav/FluentNavCategory.razor.cs new file mode 100644 index 0000000000..25d835e82f --- /dev/null +++ b/src/Core/Components/Nav/FluentNavCategory.razor.cs @@ -0,0 +1,304 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Extensions; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a group of related navigation items for use in a fluent navigation interface. +/// +/// Use this class to organize navigation elements into logical groups when building fluent or +/// hierarchical navigation structures. Grouping navigation items can improve usability and clarity in user interfaces +/// that support complex navigation scenarios. +public partial class FluentNavCategory : FluentComponentBase +{ + private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "Nav/FluentNav.razor.js"; + private bool _isActive; + private readonly List _subitems = []; + private bool _hasBeenManuallyCollapsed; + + /// + public FluentNavCategory(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-navcategoryitem") + .AddClass("active", _isActive) + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the title of the nav menu group. + /// + [Parameter] + public string? Title { get; set; } + + /// + /// Gets or sets the icon of the nav menu group. + /// + [Parameter] + public Icon? Icon { get; set; } = new CoreIcons.Regular.Size20.Folder(); + + /// + /// Gets or sets the expanded state of the nav menu group. + /// + [Parameter] + public bool Expanded { get; set; } + + /// + /// Gets or sets the callback that is invoked when the expanded state changes. + /// + [Parameter] + public EventCallback ExpandedChanged { get; set; } + + /// + /// Gets or sets the tooltip to display when the mouse is placed over the item. + /// + [Parameter] + public string? Tooltip { get; set; } + + /// + /// Gets or sets the content of the nav menu item. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the parent component for this instance. + /// + /// This property is typically set automatically by the Blazor framework when the component is + /// used within a . It enables the component to access shared state or functionality from + /// its parent navigation menu. + [CascadingParameter] + public required FluentNav Owner { get; set; } + + /// + /// Validates that this component is used within a FluentNav. + /// + protected override void OnParametersSet() + { + UpdateActiveState(); + } + + /// + /// Called after the component has been initialized. + /// + protected override void OnInitialized() + { + if (Owner.GetType() != typeof(FluentNav)) + { + throw new InvalidOperationException( + $"{nameof(FluentNavCategory)} can only be used as a direct child of {nameof(FluentNav)}."); + } + + Owner?.RegisterCategory(this); + } + + /// + public override async ValueTask DisposeAsync() + { + Owner.UnregisterCategory(this); + + await base.DisposeAsync(); + GC.SuppressFinalize(this); + } + /// + /// Called after the component has been rendered. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JSModule.ImportJavaScriptModuleAsync(JAVASCRIPT_FILE); + + if (HasActiveSubitem() && !Expanded && !_hasBeenManuallyCollapsed) + { + await UpdateExpandedStateAsync(expanded: true); + } + } + } + + internal async Task SetExpandedAsync(bool expanded) + { + _hasBeenManuallyCollapsed = false; + + if (Expanded == expanded) + { + return; + } + + await UpdateExpandedStateAsync(expanded); + } + + /// + /// Collapses this category without waiting for the animation to complete. + /// Used for simultaneous collapse/expand animations when UseSingleExpanded is true. + /// + internal void CollapseWithoutAwait() + { + _hasBeenManuallyCollapsed = false; + Expanded = false; + UpdateActiveState(); + + if (ExpandedChanged.HasDelegate) + { + _ = ExpandedChanged.InvokeAsync(false); + } + + _ = InvokeAsync(StateHasChanged); + + // Fire animation without awaiting - let it run in parallel + _ = AnimateCollapseAsync(); + } + + /// + /// Animates the collapse and updates final state after animation completes. + /// + private async Task AnimateCollapseAsync() + { + if (JSModule.Imported) + { + var groupId = $"{Id}-group"; + var density = Owner.Density?.ToAttributeValue() ?? "medium"; + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Nav.AnimateCollapse", groupId, density); + } + } + + /// + /// Toggles the expanded state of the nav group. + /// + internal async Task ToggleExpandedAsync() + { + if (!Expanded && Owner.UseSingleExpanded) + { + foreach (var category in Owner.GetCategories().Where(c => c != this && c.Expanded)) + { + category.CollapseWithoutAwait(); + } + } + + _hasBeenManuallyCollapsed = Expanded && HasActiveSubitem(); + + await UpdateExpandedStateAsync(!Expanded); + } + + /// + /// Called by subitems to notify the category when their active state changes. + /// Updates the category's active state and auto-expands if a subitem becomes active. + /// + internal void OnSubitemActiveStateChanged() + { + if (HasActiveSubitem() && !Expanded && !_hasBeenManuallyCollapsed) + { + _ = InvokeAsync(async () => await UpdateExpandedStateAsync(expanded: true)); + } + else + { + UpdateActiveState(); + StateHasChanged(); + } + } + + /// + /// Checks if any FluentNavSubItem is currently active. + /// + /// True if at least one subitem is active; otherwise, false. + internal bool HasActiveSubitem() + { + return _subitems.Exists(item => item.Active); + } + + /// + /// Registers a subitem with this category. + /// + internal void RegisterSubitem(FluentNavItem subitem) + { + if (!_subitems.Contains(subitem)) + { + _subitems.Add(subitem); + } + } + + /// + /// Unregisters a subitem from this category. + /// + internal void UnregisterSubitem(FluentNavItem subitem) + { + _subitems.Remove(subitem); + } + + private async Task UpdateExpandedStateAsync(bool expanded) + { + Expanded = expanded; + + if (expanded) + { + UpdateActiveState(); + } + + if (ExpandedChanged.HasDelegate) + { + await ExpandedChanged.InvokeAsync(expanded); + } + + await AnimateCurrentStateAsync(); + + if (!expanded) + { + UpdateActiveState(); + } + + await InvokeAsync(StateHasChanged); + } + + /// + private async Task AnimateCurrentStateAsync() + { + if (JSModule.Imported) + { + var groupId = $"{Id}-group"; + var density = Owner.Density?.ToAttributeValue() ?? "medium"; + if (Expanded) + { + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Nav.AnimateExpand", groupId, density); + } + else + { + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Nav.AnimateCollapse", groupId, density); + } + } + } + + /// + /// Updates the active state based on whether any subitem is active and the category is collapsed. + /// + private void UpdateActiveState() + { + // Only show active state when category is NOT expanded and has an active subitem + _isActive = !Expanded && HasActiveSubitem(); + } + + /// + /// Gets the inline style for the subitem group to ensure visibility when it contains an active subitem. + /// + private string GetSubitemGroupStyle() + { + if (HasActiveSubitem()) + { + return "height: auto; min-height: auto; opacity: 1; overflow: visible;"; + } + + return string.Empty; + } +} diff --git a/src/Core/Components/Nav/FluentNavCategory.razor.css b/src/Core/Components/Nav/FluentNavCategory.razor.css new file mode 100644 index 0000000000..ed2e9997ae --- /dev/null +++ b/src/Core/Components/Nav/FluentNavCategory.razor.css @@ -0,0 +1,75 @@ +.fluent-nav .fluent-navcategoryitem { + display: flex; + text-transform: none; + position: relative; + justify-content: start; + align-items: center; + gap: var(--spacingVerticalL); + padding: var(--spacingVerticalMNudge) var(--spacingHorizontalS) var(--spacingVerticalMNudge) var(--spacingHorizontalMNudge); + background-color: var(--colorNeutralBackground4); + border-radius: var(--borderRadiusMedium); + color: var(--colorNeutralForeground2); + text-decoration-line: none; + border: none; + cursor: pointer; + transition-duration: var(--durationFaster); + transition-timing-function: var(--curveLinear); + transition-property: background; + width: 100%; +} + +.fluent-nav[density="small"] .fluent-navcategoryitem { + padding: var(--spacingVerticalXS) var(--spacingHorizontalS) var(--spacingVerticalXS) var(--spacingHorizontalMNudge); +} + + .fluent-nav .fluent-navcategoryitem:hover { + background-color: var(--colorNeutralBackground4Hover); + } + + .fluent-nav .fluent-navcategoryitem > .expand-icon { + color: var(--colorNeutralForeground2); + height: 20px; + margin-inline-start: auto; + transition: transform var(--durationNormal) var(--curveDecelerateMid); +} + + .fluent-nav .fluent-navcategoryitem > .expand-icon.expanded { + transform: rotate(180deg); + } + + [dir="rtl"] .fluent-nav .fluent-navcategoryitem > .expand-icon.expanded { + transform: rotate(-180deg); + } + + .fluent-nav .fluent-navcategoryitem:has(+ .fluent-navsubitemgroup .active) > .expand-icon:not(.user-interacted) { + transform: rotate(180deg); + } + + [dir="rtl"] .fluent-nav .fluent-navcategoryitem:has(+ .fluent-navsubitemgroup .active) > .expand-icon:not(.user-interacted) { + transform: rotate(-180deg); + } + +.fluent-nav .fluent-navsubitemgroup { + height: 0; + min-height: 0; + opacity: 0; + overflow: hidden; +} + + .fluent-nav .fluent-navsubitemgroup.expanded { + height: auto; + min-height: auto; + opacity: 1; + overflow: visible; + } + + .fluent-nav .fluent-navsubitemgroup:not(.user-interacted):has(.active) { + height: auto; + min-height: auto; + opacity: 1; + overflow: visible; + } + + .fluent-nav .fluent-navsubitemgroup:has(.fluent-navsubitem) .fluent-navsubitem { + padding-inline-start: 46px; + } diff --git a/src/Core/Components/Nav/FluentNavItem.razor b/src/Core/Components/Nav/FluentNavItem.razor new file mode 100644 index 0000000000..21764565f8 --- /dev/null +++ b/src/Core/Components/Nav/FluentNavItem.razor @@ -0,0 +1,37 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + +@if (!string.IsNullOrEmpty(Href)) +{ + + @RenderIcon + @ChildContent + +} +else +{ + +} diff --git a/src/Core/Components/Nav/FluentNavItem.razor.cs b/src/Core/Components/Nav/FluentNavItem.razor.cs new file mode 100644 index 0000000000..af9219d634 --- /dev/null +++ b/src/Core/Components/Nav/FluentNavItem.razor.cs @@ -0,0 +1,393 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a navigation menu item that renders content within a Fluent UI styled navigation link. +/// +public partial class FluentNavItem : FluentComponentBase +{ + private const string EnableMatchAllForQueryStringAndFragmentSwitchKey = "Microsoft.AspNetCore.Components.Routing.NavLink.EnableMatchAllForQueryStringAndFragment"; + private string? _hrefAbsolute; + + private static readonly CaseInsensitiveCharComparer CaseInsensitiveComparer = new(); + private static readonly bool _enableMatchAllForQueryStringAndFragment = AppContext.TryGetSwitch(EnableMatchAllForQueryStringAndFragmentSwitchKey, out var switchValue) && switchValue; + + /// + protected bool _isActive; + + /// + protected bool _isSubItem => Category != null; + + /// + public FluentNavItem(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-navitem") + .AddClass("fluent-navsubitem", _isSubItem) + .AddClass("disabled", Disabled) + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + [Inject] + public required NavigationManager NavigationManager { get; set; } + + /// + /// Gets or sets the icon of the nav menu item. + /// + [Parameter] + public Icon? Icon { get; set; } + + /// + /// Gets or sets the href of the link. + /// + [Parameter] + public string? Href { get; set; } + + /// + /// The callback to invoke when the item is clicked. + /// + [Parameter] + public EventCallback OnClick { get; set; } + + /// + /// If true, the item will be disabled. + /// + [Parameter] + public bool Disabled { get; set; } + + /// + /// Gets or sets the target attribute that specifies where to open the group, if Href is specified. + /// Possible values: _blank | _self | _parent | _top. + /// + [Parameter] + public LinkTarget? Target { get; set; } + + /// + /// Gets or sets the class names to use to indicate the item is active, separated by space. + /// + [Parameter] + public string ActiveClass { get; set; } = "active"; + + /// + /// Gets or sets how the link should be matched. + /// Defaults to . + /// + [Parameter] + public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix; + + /// + /// Gets or sets the tooltip to display when the mouse is placed over the item. + /// + [Parameter] + public string? Tooltip { get; set; } + + /// + /// Gets or sets the content of the nav menu item. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the parent component for this instance. + /// + /// This property is typically set automatically by the Blazor framework when the component is + /// used within a . It enables the component to access shared state or functionality from + /// its parent navigation menu. + [CascadingParameter] + internal FluentNav? Owner { get; set; } + + /// + /// Gets or sets the parent component for this instance. + /// + /// This property is typically set automatically by the Blazor framework when the component is + /// used within a . It enables the component to access shared state or functionality from + /// its parent navigation menu. + [CascadingParameter(Name = "Category")] + internal FluentNavCategory? Category { get; set; } + + /// + /// Gets the active state on this navigation item + /// + public bool Active => _isActive; + + /// + /// Validates that this component is used within a FluentNav. + /// + protected override void OnParametersSet() + { + _hrefAbsolute = Href == null ? null : NavigationManager.ToAbsoluteUri(Href).AbsoluteUri; + _isActive = ShouldMatch(NavigationManager.Uri); + } + + /// + protected override void OnInitialized() + { + // Validate that this component is used within a FluentNav + if (Owner?.GetType() != typeof(FluentNav)) + { + throw new InvalidOperationException( + $"{nameof(FluentNavItem)} can only be used as a direct child of {nameof(FluentNav)}."); + } + + if (Category != null && Category.GetType() != typeof(FluentNavCategory)) + { + throw new InvalidOperationException( + $"{nameof(FluentNavItem)} can only be used as a direct child of {nameof(FluentNav)} or a {nameof(FluentNavCategory)}."); + } + + // We'll consider re-rendering on each location change + NavigationManager.LocationChanged += OnLocationChanged; + + // Register with parent category if this is a subitem + if (_isSubItem) + { + Category?.RegisterSubitem(this); + } + } + + /// + protected void RenderIcon(RenderTreeBuilder builder) + { + if (_isSubItem) + { + return; + } + + if (Owner is not null && Owner.UseIcons) + { + if (Icon is not null) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Value", NavUtils.GetActiveIcon(Icon, _isActive)); + builder.AddAttribute(2, "Class", "icon"); + builder.AddAttribute(3, "Color", _isActive ? Color.Primary : Color.Default); + builder.CloseComponent(); + } + else + { + builder.OpenElement(4, "span"); + builder.AddAttribute(5, "style", "width: 20px;"); + builder.CloseElement(); + } + } + } + + /// + /// Calls the delegate when specified (and item not disabled) + /// + protected async Task OnClickHandlerAsync(MouseEventArgs args) + { + if (Disabled) + { + return; + } + + if (OnClick.HasDelegate) + { + await OnClick.InvokeAsync(args); + } + } + + /// + /// Handles location change events and updates the active state. + /// + protected virtual void OnLocationChanged(object? sender, LocationChangedEventArgs args) + { + // We could just re-render always, but for this component we know the + // only relevant state change is to the _isActive property. + var shouldBeActiveNow = ShouldMatch(args.Location); + if (shouldBeActiveNow != _isActive) + { + _isActive = shouldBeActiveNow; + + // Notify parent category if this is a subitem + if (_isSubItem) + { + Category?.OnSubitemActiveStateChanged(); + } + } + } + + /// + /// Determines whether the current URI should match the link. + /// + /// The absolute URI of the current location. + /// True if the link should be highlighted as active; otherwise, false. + [ExcludeFromCodeCoverage(Justification = "Copied from Blazor source")] + protected virtual bool ShouldMatch(string uriAbsolute) + { + if (_hrefAbsolute == null) + { + return false; + } + + var uriAbsoluteSpan = uriAbsolute.AsSpan(); + var hrefAbsoluteSpan = _hrefAbsolute.AsSpan(); + if (EqualsHrefExactlyOrIfTrailingSlashAdded(uriAbsoluteSpan, hrefAbsoluteSpan)) + { + return true; + } + + if (Match == NavLinkMatch.Prefix + && IsStrictlyPrefixWithSeparator(uriAbsolute, _hrefAbsolute)) + { + return true; + } + + if (_enableMatchAllForQueryStringAndFragment || Match != NavLinkMatch.All) + { + return false; + } + + var uriWithoutQueryAndFragment = GetUriIgnoreQueryAndFragment(uriAbsoluteSpan); + if (EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan)) + { + return true; + } + + hrefAbsoluteSpan = GetUriIgnoreQueryAndFragment(hrefAbsoluteSpan); + return EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan); + } + + /// + public override async ValueTask DisposeAsync() + { + NavigationManager.LocationChanged -= OnLocationChanged; + + if (_isSubItem) + { + Category?.UnregisterSubitem(this); + } + + await base.DisposeAsync(); + GC.SuppressFinalize(this); + } + + [ExcludeFromCodeCoverage(Justification = "Copied from Blazor source")] + private static ReadOnlySpan GetUriIgnoreQueryAndFragment(ReadOnlySpan uri) + { + if (uri.IsEmpty) + { + return []; + } + + var queryStartPos = uri.IndexOf('?'); + var fragmentStartPos = uri.IndexOf('#'); + + if (queryStartPos < 0 && fragmentStartPos < 0) + { + return uri; + } + + int minPos; + if (queryStartPos < 0) + { + minPos = fragmentStartPos; + } + else if (fragmentStartPos < 0) + { + minPos = queryStartPos; + } + else + { + minPos = Math.Min(queryStartPos, fragmentStartPos); + } + + return uri[..minPos]; + } + + [ExcludeFromCodeCoverage(Justification = "Copied from Blazor source")] + private static bool EqualsHrefExactlyOrIfTrailingSlashAdded(ReadOnlySpan currentUriAbsolute, ReadOnlySpan hrefAbsolute) + { + if (currentUriAbsolute.SequenceEqual(hrefAbsolute, CaseInsensitiveComparer)) + { + return true; + } + + if (currentUriAbsolute.Length == hrefAbsolute.Length - 1) + { + // Special case: highlight links to http://host/path/ even if you're + // at http://host/path (with no trailing slash) + // + // This is because the router accepts an absolute URI value of "same + // as base URI but without trailing slash" as equivalent to "base URI", + // which in turn is because it's common for servers to return the same page + // for http://host/vdir as they do for host://host/vdir/ as it's no + // good to display a blank page in that case. + if (hrefAbsolute[^1] == '/' && + currentUriAbsolute.SequenceEqual(hrefAbsolute[..^1], CaseInsensitiveComparer)) + { + return true; + } + } + + return false; + } + + [ExcludeFromCodeCoverage(Justification = "Copied from Blazor source")] + private static bool IsUnreservedCharacter(char c) + { + // Checks whether it is an unreserved character according to + // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + // Those are characters that are allowed in a URI but do not have a reserved + // purpose (e.g. they do not separate the components of the URI) + return char.IsLetterOrDigit(c) || + c == '-' || + c == '.' || + c == '_' || + c == '~'; + } + + [ExcludeFromCodeCoverage(Justification = "Copied from Blazor source")] + private static bool IsStrictlyPrefixWithSeparator(string value, string prefix) + { + var prefixLength = prefix.Length; + if (value.Length > prefixLength) + { + return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && ( + // Only match when there's a separator character either at the end of the + // prefix or right after it. + // Example: "/abc" is treated as a prefix of "/abc/def" but not "/abcdef" + // Example: "/abc/" is treated as a prefix of "/abc/def" but not "/abcdef" + prefixLength == 0 + || !IsUnreservedCharacter(prefix[prefixLength - 1]) + || !IsUnreservedCharacter(value[prefixLength]) + ); + } + + return false; + } + + [ExcludeFromCodeCoverage(Justification = "Copied from Blazor source")] + private class CaseInsensitiveCharComparer : IEqualityComparer + { + public bool Equals(char x, char y) + { + return char.ToLowerInvariant(x) == char.ToLowerInvariant(y); + } + + public int GetHashCode(char obj) + { + return char.ToLowerInvariant(obj).GetHashCode(); + } + } +} diff --git a/src/Core/Components/Nav/FluentNavItem.razor.css b/src/Core/Components/Nav/FluentNavItem.razor.css new file mode 100644 index 0000000000..5725dd3306 --- /dev/null +++ b/src/Core/Components/Nav/FluentNavItem.razor.css @@ -0,0 +1,36 @@ +.fluent-nav .fluent-navitem, +.fluent-nav .fluent-navsubitem { + display: flex; + text-transform: none; + position: relative; + justify-content: start; + align-items: flex-start; + text-align: left; + gap: var(--spacingVerticalL); + padding: var(--spacingVerticalMNudge) var(--spacingHorizontalS) var(--spacingVerticalMNudge) var(--spacingHorizontalMNudge); + border-radius: var(--borderRadiusMedium); + color: var(--colorNeutralForeground2); + text-decoration-line: none; + border: none; + cursor: pointer; + transition-duration: var(--durationFaster); + transition-timing-function: var(--curveLinear); + transition-property: background; + width: 100%; +} + + .fluent-nav[density="small"] .fluent-navitem, + .fluent-nav[density="small"] .fluent-navsubitem { + padding: var(--spacingVerticalXS) var(--spacingHorizontalS) var(--spacingVerticalXS) var(--spacingHorizontalMNudge); + } + + .fluent-nav .fluent-navitem:hover, + .fluent-nav .fluent-navsubitem:hover { + background-color: var(--colorNeutralBackground4Hover); + } + +[dir="rtl"] .fluent-nav .fluent-navitem, +[dir="rtl"] .fluent-nav .fluent-navsubitem { + text-align: right; + padding: var(--spacingVerticalMNudge) var(--spacingHorizontalMNudge) var(--spacingVerticalMNudge) var(--spacingHorizontalS); +} diff --git a/src/Core/Components/Nav/FluentNavSectionHeader.razor b/src/Core/Components/Nav/FluentNavSectionHeader.razor new file mode 100644 index 0000000000..c23600359f --- /dev/null +++ b/src/Core/Components/Nav/FluentNavSectionHeader.razor @@ -0,0 +1,9 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase +

+ @Title +

+ diff --git a/src/Core/Components/Nav/FluentNavSectionHeader.razor.cs b/src/Core/Components/Nav/FluentNavSectionHeader.razor.cs new file mode 100644 index 0000000000..9da8b5ac5c --- /dev/null +++ b/src/Core/Components/Nav/FluentNavSectionHeader.razor.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// A section header for use within a +/// +public partial class FluentNavSectionHeader : FluentComponentBase +{ + /// + public FluentNavSectionHeader(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .AddClass("fluent-navsectionheader") + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the title of the section header. + /// + [Parameter] + public string? Title { get; set; } + + /// + /// Gets or sets the parent component for this instance. + /// + /// This property is typically set automatically by the Blazor framework when the component is + /// used within a . It enables the component to access shared state or functionality from + /// its parent navigation menu. + [CascadingParameter] + public required FluentNav Owner { get; set; } + + /// + /// Validates that this component is used within a FluentNav. + /// + protected override void OnInitialized() + { + if (Owner.GetType() != typeof(FluentNav)) + { + throw new InvalidOperationException( + $"{nameof(FluentNavSectionHeader)} can only be used as a direct child of {nameof(FluentNav)}."); + } + } +} diff --git a/src/Core/Components/Nav/FluentNavSectionHeader.razor.css b/src/Core/Components/Nav/FluentNavSectionHeader.razor.css new file mode 100644 index 0000000000..8bb5b5edd2 --- /dev/null +++ b/src/Core/Components/Nav/FluentNavSectionHeader.razor.css @@ -0,0 +1,8 @@ +.fluent-nav .fluent-navsectionheader { + color: var(--colorNeutralForeground1); + margin-block: 8px; + font-size: var(--fontSizeBase200); + font-weight: var(--fontWeightSemibold); + margin-inline-start: 10px; + line-height: var(--lineHeightBase200); +} diff --git a/src/Core/Components/Nav/NavUtils.cs b/src/Core/Components/Nav/NavUtils.cs new file mode 100644 index 0000000000..d61dbc3004 --- /dev/null +++ b/src/Core/Components/Nav/NavUtils.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Contains utility methods for navigation components. +/// +public class NavUtils +{ + /// + /// Gets the active (filled) variant of the provided icon. If the filled icon is not available, the original icon is returned. + /// + /// The name of the icon. + /// Whether the icon is active. + /// + [ExcludeFromCodeCoverage(Justification = "We can't test the Icon.* DLLs here")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "")] + public static Icon? GetActiveIcon(Icon icon, bool active) + { + if (active) + { + var iconInfo = new IconInfo + { + + Name = icon.Name, + Size = IconSize.Size20, + Variant = IconVariant.Filled, + }; + + //This cannot be tested as the Icons assembly is not available in bUnit tests + if (iconInfo.TryGetInstance(out var customIcon)) + { + return customIcon; + } + } + + return icon; + } +} diff --git a/src/Core/Enums/NavDensity.cs b/src/Core/Enums/NavDensity.cs new file mode 100644 index 0000000000..13b24bd23e --- /dev/null +++ b/src/Core/Enums/NavDensity.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The density of the items. +/// +public enum NavDensity +{ + /// + /// Medium density + /// + [Description("medium")] + Medium, + + /// + /// Small density + /// + [Description("small")] + Small, +} diff --git a/src/Core/Localization/LanguageResource.resx b/src/Core/Localization/LanguageResource.resx index 66668ec26a..ea5172dfbd 100644 --- a/src/Core/Localization/LanguageResource.resx +++ b/src/Core/Localization/LanguageResource.resx @@ -309,4 +309,7 @@ An error occurred while retrieving data. + + Toggle navigation + \ No newline at end of file diff --git a/tests/Core/Components/Base/ComponentBaseTests.cs b/tests/Core/Components/Base/ComponentBaseTests.cs index d380534f5a..1eed13a37e 100644 --- a/tests/Core/Components/Base/ComponentBaseTests.cs +++ b/tests/Core/Components/Base/ComponentBaseTests.cs @@ -27,6 +27,15 @@ public class ComponentBaseTests : Bunit.BunitContext typeof(FluentTab), // Excluded because the Tab content in rendered in the parent FluentTabs component ]; + /// + /// List of components to exclude from the test. + /// + private static readonly Type[] ExcludedTooltip = + [ + typeof(FluentNavCategory), + typeof(FluentNavItem), + ]; + /// /// List of customized actions to initialize the component with a specific type and optional required parameters. /// @@ -52,6 +61,10 @@ public class ComponentBaseTests : Bunit.BunitContext { typeof(FluentDragContainer<>), Loader.MakeGenericType(typeof(int))}, { typeof(FluentDropZone<>), Loader.MakeGenericType(typeof(int))}, { typeof(FluentTimePicker<>), Loader.MakeGenericType(typeof(DateTime))}, + { typeof(FluentNavItem), Loader.Default.WithCascadingValue(new FluentNav(new LibraryConfiguration())) }, + { typeof(FluentNavCategory), Loader.Default.WithCascadingValue(new FluentNav(new LibraryConfiguration())) }, + { typeof(FluentNavSectionHeader), Loader.Default.WithCascadingValue(new FluentNav(new LibraryConfiguration())) }, + }; /// @@ -161,7 +174,7 @@ public void ComponentBase_TooltipInterface_CorrectRendering() using var context = new DateTimeProviderContext(DateTime.Now); JSInterop.Mode = JSRuntimeMode.Loose; - foreach (var componentType in BaseHelpers.GetDerivedTypes(except: Excluded)) + foreach (var componentType in BaseHelpers.GetDerivedTypes(except: Excluded.Union(ExcludedTooltip))) { // Convert to generic type if needed var type = ComponentInitializer.TryGetValue(componentType, out var value) @@ -210,7 +223,7 @@ public void ComponentBase_TooltipInterface_NotImplemented() using var context = new DateTimeProviderContext(DateTime.Now); JSInterop.Mode = JSRuntimeMode.Loose; - foreach (var componentType in BaseHelpers.GetDerivedTypes(except: Excluded)) + foreach (var componentType in BaseHelpers.GetDerivedTypes(except: Excluded.Union(ExcludedTooltip))) { // Check if the component contains a Tooltip property but without implementing the ITooltipComponent interface var hasTooltipProperty = componentType.GetProperty("Tooltip", BindingFlags.Public | BindingFlags.Instance) != null; diff --git a/tests/Core/Components/Nav/FluentNavCategoryTests.razor b/tests/Core/Components/Nav/FluentNavCategoryTests.razor new file mode 100644 index 0000000000..972330781c --- /dev/null +++ b/tests/Core/Components/Nav/FluentNavCategoryTests.razor @@ -0,0 +1,725 @@ +@using Xunit +@using Microsoft.FluentUI.AspNetCore.Components +@inherits FluentUITestContext + +@code { + public FluentNavCategoryTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentNavCategory_Renders_With_Title() + { + // Arrange & Act + var cut = Render(@ + +
Category Content
+
+
); + + // Assert + Assert.NotNull(cut); + Assert.Contains("Test Category", cut.Markup); + Assert.Contains("fluent-navcategoryitem", cut.Markup); + } + + [Fact] + public void FluentNavCategory_Renders_Expanded_By_Default() + { + // Arrange & Act + var cut = Render(@ + +
Content
+
+
); + + // Assert + Assert.NotNull(cut); + Assert.Contains("aria-expanded", cut.Markup); + } + + [Fact] + public void FluentNavCategory_Renders_Collapsed_When_Not_Expanded() + { + // Arrange & Act + var cut = Render(@ + +
Content
+
+
+ ); + + // Assert + Assert.NotNull(cut); + Assert.DoesNotContain("aria-expanded=\"true\"", cut.Markup); + } + + [Fact] + public async Task FluentNavCategory_Call_ToggleExpanded() + { + // Arrange & Act + var cut = Render(@ + +
Content
+
+
); + + // Act + var category = cut.FindComponent(); + await category.Instance.ToggleExpandedAsync(); + + // Assert + Assert.NotNull(cut); + Assert.True(category.Instance.Expanded); + + await category.Instance.ToggleExpandedAsync(); + Assert.False(category.Instance.Expanded); + + } + + [Fact] + public void FluentNavCategory_Throws_When_Owner_Is_Derived_Type() + { + // Arrange - Create a derived FluentNav (which should fail the exact type check) + var derivedNav = new DerivedFluentNav(Services.GetRequiredService()); + + // Act & Assert + var ex = Assert.Throws(() => + { + Render(@ + +
Content
+
+
); + }); + + Assert.Contains("can only be used as a direct child of", ex.Message); + Assert.Contains("FluentNav", ex.Message); + } + + [Fact] + public void FluentNavCategory_Renders_With_CustomId() + { + // Arrange & Act + var cut = Render(@ + +
Content
+
+
); + + // Assert + Assert.NotNull(cut); + Assert.Contains("id=\"my-category-id\"", cut.Markup); + } + + [Fact] + public void FluentNavCategory_Renders_With_Small_Density() + { + // Arrange & Act + var cut = Render(@ + +
Content
+
+
); + + // Assert + Assert.NotNull(cut); + Assert.Contains("density=", cut.Markup); + } + + [Fact] + public void FluentNavCategory_Provides_CascadingValue_To_SubItems() + { + // Arrange & Act + var cut = Render(@ + + Sub Item + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("Sub Item", cut.Markup); + Assert.Contains("fluent-navsubitem", cut.Markup); + } + + [Fact] + public async Task FluentNavCategory_DisposeAsync() + { + // Arrange & Act + + var calledTimes = 0; + + var cut = Render(@ + + Sub Item + + + ); + + // Assert + await cut.FindComponent().Instance.DisposeAsync(); + cut.Dispose(); + + Assert.Equal(0, calledTimes); + + } + + [Fact] + public void FluentNavCategory_Auto_Expands_When_SubItem_Is_Active_On_Initial_Load() + { + // Arrange + var navManager = Services.GetRequiredService(); + + // Navigate to a test URI that will match the subitem + navManager.NavigateTo("/test-page"); + + // Act - Render with a subitem that matches the current location + var cut = Render(@ + + Active Sub Item + + ); + + // Assert + var category = cut.FindComponent(); + Assert.True(category.Instance.Expanded, "Category should auto-expand when subitem is active on initial load"); + Assert.True(category.Instance.HasActiveSubitem(), "Should have an active subitem"); + Assert.Contains("aria-expanded=\"true\"", cut.Markup); + } + + [Fact] + public void FluentNavCategory_Does_Not_Auto_Expand_When_No_SubItem_Is_Active() + { + // Arrange + var navManager = Services.GetRequiredService(); + navManager.NavigateTo("/other-page"); + + // Act - Render with a subitem that does NOT match the current location + var cut = Render(@ + + Inactive Sub Item + + ); + + // Assert + var category = cut.FindComponent(); + Assert.False(category.Instance.Expanded, "Category should remain collapsed when no subitem is active"); + Assert.False(category.Instance.HasActiveSubitem(), "Should not have an active subitem"); + } + + [Fact] + public async Task FluentNavCategory_SetExpandedAsync_Expands_Category() + { + // Arrange + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + Assert.False(category.Instance.Expanded, "Category should start collapsed"); + + // Act + await category.Instance.SetExpandedAsync(true); + + // Assert + Assert.True(category.Instance.Expanded, "Category should be expanded"); + Assert.Contains("aria-expanded=\"true\"", cut.Markup); + } + + [Fact] + public async Task FluentNavCategory_SetExpandedAsync_Collapses_Category() + { + // Arrange + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + Assert.True(category.Instance.Expanded, "Category should start expanded"); + + // Act + await category.Instance.SetExpandedAsync(false); + + // Assert + Assert.False(category.Instance.Expanded, "Category should be collapsed"); + Assert.DoesNotContain("aria-expanded=\"true\"", cut.Markup); + } + + [Fact] + public async Task FluentNavCategory_SetExpandedAsync_Does_Nothing_When_State_Unchanged() + { + // Arrange + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + var initialExpanded = category.Instance.Expanded; + + // Act - Try to expand when already expanded + await category.Instance.SetExpandedAsync(true); + + // Assert - State should remain unchanged + Assert.Equal(initialExpanded, category.Instance.Expanded); + Assert.True(category.Instance.Expanded, "Category should remain expanded"); + } + + [Fact] + public async Task FluentNavCategory_SetExpandedAsync_Clears_Manual_Collapse_Flag() + { + // Arrange + var navManager = Services.GetRequiredService(); + navManager.NavigateTo("/test-page"); + + var cut = Render(@ + + Active Sub Item + + ); + + var category = cut.FindComponent(); + + // Manually collapse the category (sets _hasBeenManuallyCollapsed flag) + await category.Instance.ToggleExpandedAsync(); + Assert.False(category.Instance.Expanded, "Category should be manually collapsed"); + + // Act - Programmatically collapse via SetExpandedAsync (should clear the flag) + await category.Instance.SetExpandedAsync(false); + + // Navigate to trigger location change - should NOT auto-expand since we used SetExpandedAsync + navManager.NavigateTo("/other-page"); + navManager.NavigateTo("/test-page"); + + // Wait for the location change to process + cut.WaitForState(() => category.Instance.HasActiveSubitem(), timeout: TimeSpan.FromSeconds(2)); + + // Assert - Category should auto-expand because SetExpandedAsync cleared the manual collapse flag + await Task.Delay(100, CancellationToken.None); // Give time for OnSubitemActiveStateChanged to execute + + // The category should auto-expand because programmatic collapse cleared the flag + Assert.True(category.Instance.Expanded, "Category should auto-expand after SetExpandedAsync cleared the manual collapse flag"); + } + + [Fact] + public async Task FluentNavCategory_SetExpandedAsync_Triggers_StateHasChanged() + { + // Arrange + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + var markupBefore = cut.Markup; + + // Act + await category.Instance.SetExpandedAsync(true); + + // Assert - Markup should have changed, indicating StateHasChanged was called + var markupAfter = cut.Markup; + Assert.NotEqual(markupBefore, markupAfter); + Assert.Contains("aria-expanded=\"true\"", markupAfter); + Assert.DoesNotContain("aria-expanded=\"true\"", markupBefore); + } + + [Fact] + public async Task FluentNavCategory_ExpandedChanged_Fires_When_State_Changes() + { + // Arrange + var expandedChangedCallCount = 0; + var lastExpandedValue = false; + + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + Assert.Equal(0, expandedChangedCallCount); + + // Act - Expand the category + await category.Instance.SetExpandedAsync(true); + + // Assert + Assert.Equal(1, expandedChangedCallCount); + Assert.True(lastExpandedValue, "ExpandedChanged should receive true"); + + // Act - Collapse the category + await category.Instance.SetExpandedAsync(false); + + // Assert + Assert.Equal(2, expandedChangedCallCount); + Assert.False(lastExpandedValue, "ExpandedChanged should receive false"); + } + + [Fact] + public async Task FluentNavCategory_ExpandedChanged_Does_Not_Fire_When_State_Unchanged() + { + // Arrange + var expandedChangedCallCount = 0; + + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + Assert.Equal(0, expandedChangedCallCount); + + // Act - Try to expand when already expanded + await category.Instance.SetExpandedAsync(true); + + // Assert - ExpandedChanged should not fire because state didn't change + Assert.Equal(0, expandedChangedCallCount); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Fires_ExpandedChanged() + { + // Arrange + var expandedChangedCallCount = 0; + var lastExpandedValue = false; + + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + + // Act - Toggle to expand + await category.Instance.ToggleExpandedAsync(); + + // Assert + Assert.Equal(1, expandedChangedCallCount); + Assert.True(lastExpandedValue, "ExpandedChanged should receive true"); + + // Act - Toggle to collapse + await category.Instance.ToggleExpandedAsync(); + + // Assert + Assert.Equal(2, expandedChangedCallCount); + Assert.False(lastExpandedValue, "ExpandedChanged should receive false"); + } + + [Fact] + public void FluentNavCategory_RegisterSubitem_Adds_SubItem_To_Collection() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + ); + + var category = cut.FindComponent(); + + // Assert + Assert.True(category.Instance.HasActiveSubitem() || !category.Instance.HasActiveSubitem(), + "Should be able to check for active subitems"); + } + + [Fact] + public async Task FluentNavCategory_UnregisterSubitem_Removes_SubItem_From_Collection() + { + // Arrange + var navManager = Services.GetRequiredService(); + navManager.NavigateTo("/test1"); + + var cut = Render(@ + + Sub Item 1 + + ); + + var category = cut.FindComponent(); + var subitem = cut.FindComponent(); + + // Verify subitem is registered and active + Assert.True(category.Instance.HasActiveSubitem(), "Subitem should be active initially"); + + // Act - Dispose the subitem (which calls UnregisterSubitem) + await subitem.Instance.DisposeAsync(); + + // Assert + Assert.False(category.Instance.HasActiveSubitem(), + "Should have no active subitems after unregistering"); + } + + [Fact] + public void FluentNavCategory_UnregisterSubitem_Does_Not_Throw_When_SubItem_Not_Found() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + ); + + var category = cut.FindComponent(); + + // Create a separate subitem instance (not registered) + var unregisteredSubitem = new FluentNavItem(Services.GetRequiredService()) + { + Owner = null!, + NavigationManager = Services.GetRequiredService(), + Category = null! + }; + + // Act & Assert - Should not throw + category.Instance.UnregisterSubitem(unregisteredSubitem); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Expands_When_Collapsed() + { + // Arrange + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + Assert.False(category.Instance.Expanded, "Should start collapsed"); + + // Act + await category.Instance.ToggleExpandedAsync(); + + // Assert + Assert.True(category.Instance.Expanded, "Should be expanded after toggle"); + Assert.Contains("aria-expanded=\"true\"", cut.Markup); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Collapses_When_Expanded() + { + // Arrange + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + Assert.True(category.Instance.Expanded, "Should start expanded"); + + // Act + await category.Instance.ToggleExpandedAsync(); + + // Assert + Assert.False(category.Instance.Expanded, "Should be collapsed after toggle"); + Assert.DoesNotContain("aria-expanded=\"true\"", cut.Markup); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Sets_Manual_Collapse_Flag_When_Collapsing_With_Active_SubItem() + { + // Arrange + var navManager = Services.GetRequiredService(); + navManager.NavigateTo("/test-page"); + + var cut = Render(@ + + Active Sub Item + + ); + + var category = cut.FindComponent(); + Assert.True(category.Instance.HasActiveSubitem(), "Should have active subitem"); + + // Act - Manually collapse the category + await category.Instance.ToggleExpandedAsync(); + Assert.False(category.Instance.Expanded, "Should be collapsed"); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Clears_Manual_Collapse_Flag_When_Expanding() + { + // Arrange + var navManager = Services.GetRequiredService(); + navManager.NavigateTo("/test-page"); + + var cut = Render(@ + + Active Sub Item + + ); + + var category = cut.FindComponent(); + + // Manually collapse (sets the flag) + await category.Instance.ToggleExpandedAsync(); + Assert.False(category.Instance.Expanded); + + // Act - Manually expand again (should clear the flag) + await category.Instance.ToggleExpandedAsync(); + Assert.True(category.Instance.Expanded); + + // Now collapse programmatically + await category.Instance.SetExpandedAsync(false); + + // Navigate to trigger auto-expansion + navManager.NavigateTo("/other-page"); + navManager.NavigateTo("/test-page"); + + cut.WaitForState(() => category.Instance.HasActiveSubitem(), timeout: TimeSpan.FromSeconds(2)); + await Task.Delay(100, CancellationToken.None); + + // Assert - Should auto-expand because manual toggle cleared the flag + Assert.True(category.Instance.Expanded, + "Should auto-expand because manual expand cleared the collapse flag"); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_With_UseSingleExpanded_Collapses_Other_Categories() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var categories = cut.FindComponents(); + var category1 = categories[0]; + var category2 = categories[1]; + + Assert.True(category1.Instance.Expanded, "Category 1 should start expanded"); + Assert.False(category2.Instance.Expanded, "Category 2 should start collapsed"); + + // Act - Toggle category 2 (should collapse category 1) + await category2.Instance.ToggleExpandedAsync(); + + // Assert + Assert.False(category1.Instance.Expanded, "Category 1 should be collapsed"); + Assert.True(category2.Instance.Expanded, "Category 2 should be expanded"); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_With_UseSingleExpanded_False_Does_Not_Collapse_Others() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var categories = cut.FindComponents(); + var category1 = categories[0]; + var category2 = categories[1]; + + Assert.True(category1.Instance.Expanded, "Category 1 should start expanded"); + Assert.False(category2.Instance.Expanded, "Category 2 should start collapsed"); + + // Act - Toggle category 2 (should NOT collapse category 1) + await category2.Instance.ToggleExpandedAsync(); + + // Assert + Assert.True(category1.Instance.Expanded, "Category 1 should remain expanded"); + Assert.True(category2.Instance.Expanded, "Category 2 should be expanded"); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Does_Not_Collapse_Self_In_Single_Expand_Mode() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var categories = cut.FindComponents(); + var category1 = categories[0]; + var category2 = categories[1]; + + // Act - Toggle category 1 to expand (should collapse category 2 but not itself) + await category1.Instance.ToggleExpandedAsync(); + + // Assert + Assert.True(category1.Instance.Expanded, "Category 1 should be expanded"); + Assert.False(category2.Instance.Expanded, "Category 2 should be collapsed"); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Updates_Active_State() + { + // Arrange + var navManager = Services.GetRequiredService(); + navManager.NavigateTo("/test-page"); + + var cut = Render(@ + + Active Sub Item + + ); + + var category = cut.FindComponent(); + + // When expanded with active subitem, should not show active class + Assert.DoesNotContain("class=\"fluent-navcategoryitem active\"", cut.Markup); + + // Act - Collapse the category + await category.Instance.ToggleExpandedAsync(); + + // Assert - When collapsed with active subitem, should show active class + cut.WaitForAssertion(() => + { + Assert.Contains("fluent-navcategoryitem active", cut.Markup); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNavCategory_ToggleExpandedAsync_Triggers_Animation() + { + // Arrange + var cut = Render(@ + + Sub Item + + ); + + var category = cut.FindComponent(); + + // Act - Toggle to expand + await category.Instance.ToggleExpandedAsync(); + + // Assert - Should have called JS animation (if module is imported) + // Note: In loose mode, JS calls may not be tracked, so we verify state change + Assert.True(category.Instance.Expanded, "Category should be expanded"); + + // Toggle back to collapse + await category.Instance.ToggleExpandedAsync(); + Assert.False(category.Instance.Expanded, "Category should be collapsed"); + } +} diff --git a/tests/Core/Components/Nav/FluentNavItemTests.razor b/tests/Core/Components/Nav/FluentNavItemTests.razor new file mode 100644 index 0000000000..6ca723be5f --- /dev/null +++ b/tests/Core/Components/Nav/FluentNavItemTests.razor @@ -0,0 +1,427 @@ +@using Bunit.TestDoubles +@using Microsoft.AspNetCore.Components.Authorization +@using Xunit +@using Microsoft.FluentUI.AspNetCore.Components +@inherits FluentUITestContext + +@code { + public FluentNavItemTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentNavItem_Renders_As_Link_When_Href_Provided() + { + // Arrange & Act + var cut = Render(@ + Test Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains(" + Test Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains(" + Test Item + + ); + + // Assert + Assert.NotNull(cut); + var button = cut.Find("#custom-id"); + button.Click(); + + Assert.True(clicked); + } + + [Fact] + public void FluentNavItem_With_OnClick_Handler_And_Href() + { + var cut = Render(@ + Test Item + + ); + + // Assert + // Assert + Assert.NotNull(cut); + Assert.Contains(" + // Active Item + // ); + + // // Assert + // Assert.NotNull(cut); + // Assert.Contains("active", cut.Markup); + // } + + [Fact] + public void FluentNavItem_Renders_With_Disabled_State() + { + // Arrange & Act + var cut = Render(@ + Disabled Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("disabled", cut.Markup); + } + + [Fact] + public void FluentNavItem_Renders_With_Disabled_State_As_Button() + { + // Arrange & Act + var cut = Render(@ + Disabled Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("disabled", cut.Markup); + } + + [Fact] + public void FluentNavItem_Renders_With_Disabled_State_As_Button_And_Clicked() + { + // Arrange & Act + bool clicked = false; + + var cut = Render(@ + Test Item + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("disabled", cut.Markup); + + var button = cut.Find("#custom-id"); + button.Click(); + Assert.False(clicked); + } + + [Fact] + public void FluentNavItem_Renders_With_CustomClass() + { + // Arrange & Act + var cut = Render(@ + Test Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("custom-nav-item", cut.Markup); + } + + [Fact] + public void FluentNavItem_Throws_When_Not_Child_Of_Nav() + { + // Arrange - Create a derived FluentNav (which should fail the exact type check) + var derivedNav = new DerivedFluentNav(Services.GetRequiredService()); + + // Arrange & Act & Assert + var ex = Assert.Throws(() => + { + Render(@Test Item); + }); + + Assert.Contains("can only be used as a direct child of", ex.Message); + Assert.Contains("FluentNav", ex.Message); + } + + [Fact] + public void FluentNavItem_Renders_With_Small_Density() + { + // Arrange & Act + var cut = Render(@ + Test Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("density=", cut.Markup); + } + + [Fact] + public void FluentNavItem_Renders_With_Icon() + { + // Arrange & Act + var cut = Render(@ + Test Item + ); + // Assert + Assert.NotNull(cut); + Assert.Contains(" + Test Item + ); + // Assert + Assert.NotNull(cut); + Assert.DoesNotContain("(); + navMan.NavigateTo("https://localhost/test", true); + + var cut = Render(@ + Test Item + ); + + // Assert + Assert.NotNull(cut); + + Assert.Contains("active", cut.Markup); + } + + [Fact] + public async Task FluentNavItem_DisposeAsync() + { + // Arrange & Act + + var calledTimes = 0; + + var cut = Render(@ + Test Item + + ); + + // Assert + await cut.FindComponent().Instance.DisposeAsync(); + cut.Dispose(); + + Assert.Equal(0, calledTimes); + + } + + // Tests for FluentNavItem when used as a subitem (within FluentNavCategory) + + [Fact] + public void FluentNavItem_As_SubItem_Renders_As_Link_When_Href_Provided() + { + // Arrange & Act + var cut = Render(@ + + Sub Item + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains(" + + Sub Item + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains(" + + Disabled Sub Item + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("disabled", cut.Markup); + } + + [Fact] + public void FluentNavItem_As_SubItem_Renders_With_CustomClass() + { + // Arrange & Act + var cut = Render(@ + + Sub Item + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("custom-sub-item", cut.Markup); + } + + [Fact] + public void FluentNavItem_As_SubItem_Renders_With_Small_Density() + { + // Arrange & Act + var cut = Render(@ + + Sub Item + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("density=", cut.Markup); + } + + [Fact] + public void FluentNavItem_As_SubItem_Does_Not_Render_Icon() + { + // Arrange & Act + var cut = Render(@ + + Sub Item + + + ); + + // Assert + Assert.NotNull(cut); + + var i = cut.FindComponent(); + Assert.DoesNotContain("()) + { + Owner = new FluentNav(Services.GetRequiredService()) + }; + + // Arrange & Act & Assert + var ex = Assert.Throws(() => + { + Render(@Sub Item); + }); + + Assert.Contains("can only be used as a direct child of", ex.Message); + Assert.Contains("FluentNav", ex.Message); + } + + [Fact] + public void FluentNavItem_OnLocationChanged_Updates_Active_State() + { + // Arrange + var navMan = Services.GetRequiredService(); + navMan.NavigateTo("https://localhost/other", true); + + var cut = Render(@ + Test Item + ); + + // Assert initial state - should not be active + Assert.DoesNotContain("active", cut.Find("#link").ClassName); + + // Act - Navigate to the matching URL + navMan.NavigateTo("https://localhost/test", true); + + // Assert - should now be active after location change + Assert.Contains("active", cut.Find("#link").ClassName); + } + + [Fact] + public void FluentNavItem_OnLocationChanged_Updates_Active_State_When_Navigating_Away() + { + // Arrange + var navMan = Services.GetRequiredService(); + navMan.NavigateTo("https://localhost/test", true); + + var cut = Render(@ + Test Item + ); + + // Assert initial state - should be active + Assert.Contains("active", cut.Find("#link").ClassName); + + // Act - Navigate away from the matching URL + navMan.NavigateTo("https://localhost/other", true); + + // Assert - should no longer be active after location change + Assert.DoesNotContain("active", cut.Find("#link").ClassName); + } + + [Fact] + public void FluentNavItem_As_SubItem_OnLocationChanged_Notifies_Category() + { + // Arrange + var navMan = Services.GetRequiredService(); + navMan.NavigateTo("https://localhost/other", true); + + var cut = Render(@ + + Sub Item + + ); + + // Assert initial state - subitem should not be active + var subItem = cut.FindComponent(); + Assert.False(subItem.Instance.Active); + + // Act - Navigate to the matching URL + navMan.NavigateTo("https://localhost/test", true); + + // Assert - subitem should now be active + Assert.True(subItem.Instance.Active); + } +} diff --git a/tests/Core/Components/Nav/FluentNavSectionHeaderTests.razor b/tests/Core/Components/Nav/FluentNavSectionHeaderTests.razor new file mode 100644 index 0000000000..248e8bbe42 --- /dev/null +++ b/tests/Core/Components/Nav/FluentNavSectionHeaderTests.razor @@ -0,0 +1,55 @@ +@using Xunit +@using Microsoft.FluentUI.AspNetCore.Components +@inherits FluentUITestContext + +@code { + public FluentNavSectionHeaderTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentNavSectionHeader_Renders_With_Title() + { + // Arrange & Act + var cut = Render(@ + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("My Section", cut.Markup); + Assert.Contains("fluent-navsectionheader", cut.Markup); + Assert.Contains(" + + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("fluent-navsectionheader", cut.Markup); + } + + [Fact] + public void FluentNavSectionHeader_Throws_When_Not_Child_Of_Nav() + { + // Arrange - Create a derived FluentNav (which should fail the exact type check) + var derivedNav = new DerivedFluentNav(Services.GetRequiredService()); + + // Arrange & Act & Assert + var ex = Assert.Throws(() => + { + Render(@); + }); + + Assert.Contains("can only be used as a direct child of", ex.Message); + Assert.Contains("FluentNav", ex.Message); + } +} diff --git a/tests/Core/Components/Nav/FluentNavSubItemTests.razor b/tests/Core/Components/Nav/FluentNavSubItemTests.razor new file mode 100644 index 0000000000..bdb3f68ff7 --- /dev/null +++ b/tests/Core/Components/Nav/FluentNavSubItemTests.razor @@ -0,0 +1,41 @@ +@using Xunit +@using Microsoft.FluentUI.AspNetCore.Components +@inherits FluentUITestContext + +@code { + public FluentNavSubItemTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + // [Fact] + // public void FluentNavSubItem_Renders_With_Active_State() + // { + // // Arrange & Act + // var cut = Render(@ + // + // Active Sub Item + // + // ); + + // // Assert + // Assert.NotNull(cut); + // Assert.Contains("active", cut.Markup); + // } + + // [Fact] + // public void FluentNavSubItem_Throws_When_Direct_Child_Of_NavDrawer() + // { + // // Arrange & Act & Assert + // var ex = Assert.Throws(() => + // { + // Render(@ + // Sub Item + // ); + // }); + + // Assert.Contains("must be used as a child of", ex.Message); + // Assert.Contains("FluentNavCategory", ex.Message); + // } +} diff --git a/tests/Core/Components/Nav/FluentNavTests.razor b/tests/Core/Components/Nav/FluentNavTests.razor new file mode 100644 index 0000000000..79bf145c12 --- /dev/null +++ b/tests/Core/Components/Nav/FluentNavTests.razor @@ -0,0 +1,691 @@ +@using Xunit +@using Microsoft.FluentUI.AspNetCore.Components +@inherits FluentUITestContext + +@code { + + // Test helper: Simulates using FluentNavCategory with wrong parent type + + + public FluentNavTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentNav_Renders_WithDefaultParameters() + { + // Arrange & Act + var cut = Render(@ +
Test Content
+ ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("Test Content", cut.Markup); + Assert.Contains("fluent-nav-container", cut.Markup); + Assert.Contains("fluent-nav", cut.Markup); + } + + [Fact] + public void FluentNav_Renders_AppTitle() + { + // Arrange & Act + var cut = Render(@ + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("My App", cut.Markup); + Assert.Contains("appitem", cut.Markup); + } + + [Fact] + public void FluentNav_Does_Not_Render_AppTitle_When_Empty() + { + // Arrange & Act + var cut = Render(@ + ); + + // Assert + Assert.NotNull(cut); + Assert.DoesNotContain("appitem", cut.Markup); + } + + [Fact] + public void FluentNav_Renders_With_CustomAppLink() + { + // Arrange & Act + var cut = Render(@ + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("href=\"/custom-link\"", cut.Markup); + } + + + [Fact] + public void FluentNav_Renders_With_CustomId() + { + // Arrange & Act + var cut = Render(@ + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("id=\"my-drawer-id\"", cut.Markup); + } + + [Fact] + public void FluentNav_Renders_With_CustomClass() + { + // Arrange & Act + var cut = Render(@ + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("custom-drawer-class", cut.Markup); + } + + [Fact] + public void FluentNav_Renders_With_CustomStyle() + { + // Arrange & Act + var cut = Render(@ + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("background-color: red;", cut.Markup); + } + + [Fact] + public void FluentNav_Renders_With_Small_Density() + { + // Arrange & Act + var cut = Render(@ + Test Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("density=", cut.Markup); + } + + [Fact] + public void FluentNav_Renders_With_Medium_Density() + { + // Arrange & Act + var cut = Render(@ + Test Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("density=", cut.Markup); + } + + [Fact] + public void FluentNav_Renders_With_AppIcon() + { + // Arrange & Act + var cut = Render(@ + ); + // Assert + Assert.NotNull(cut); + Assert.Contains(" + Test Item + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains("); + + // Assert + Assert.NotNull(cut); + Assert.Contains("); + + // Assert + Assert.NotNull(cut); + Assert.DoesNotContain(" + ); + + // Assert + Assert.NotNull(cut); + Assert.Contains(" + + ); + + // Act + var nav = cut.FindComponent(); + await nav.Instance.ToggleNavAsync(); + + // Assert + Assert.NotNull(cut); + Assert.False(nav.Instance._navOpen); + + await nav.Instance.ToggleNavAsync(); + Assert.True(nav.Instance._navOpen); + + } + + [Fact] + public async Task FluentNav_Call_ToggleExpanded_With_Callback() + { + bool callbackInvoked = false; + // Arrange & Act + var cut = Render(@ + + ); + + // Act + var nav = cut.FindComponent(); + await nav.Instance.ToggleNavAsync(); + + // Assert + Assert.NotNull(cut); + Assert.False(nav.Instance._navOpen); + + Assert.True(callbackInvoked); + } + + [Fact] + public async Task FluentNav_OnParametersSetAsync_Collapses_Extra_Categories_When_UseSingleExpanded_Changes_To_True() + { + // Arrange - Start with UseSingleExpanded=false and multiple expanded categories + var useSingleExpanded = false; + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + + Sub Item 3 + + ); + + var categories = cut.FindComponents(); + + // Set initial expanded state programmatically to avoid parameter binding issues + await categories[0].Instance.SetExpandedAsync(true); + await categories[1].Instance.SetExpandedAsync(true); + await categories[2].Instance.SetExpandedAsync(true); + + Assert.True(categories[0].Instance.Expanded, "Category 1 should be expanded"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should be expanded"); + Assert.True(categories[2].Instance.Expanded, "Category 3 should be expanded"); + + // Act - Change UseSingleExpanded to true + useSingleExpanded = true; + cut.Render(parameters => parameters.Add(p => p.UseSingleExpanded, useSingleExpanded)); + await Task.Delay(100, CancellationToken.None); // Wait for async operations + + // Assert - Only first category should remain expanded + cut.WaitForAssertion(() => + { + Assert.True(categories[0].Instance.Expanded, "Category 1 should remain expanded"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should be collapsed"); + Assert.False(categories[2].Instance.Expanded, "Category 3 should be collapsed"); + }, TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task FluentNav_OnParametersSetAsync_Does_Not_Collapse_Categories_When_Already_UseSingleExpanded() + { + // Arrange - Start with UseSingleExpanded=true + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var categories = cut.FindComponents(); + + // Set initial expanded state programmatically to avoid parameter binding issues + await categories[0].Instance.SetExpandedAsync(true); + await categories[1].Instance.SetExpandedAsync(false); + + var category1InitialState = categories[0].Instance.Expanded; + var category2InitialState = categories[1].Instance.Expanded; + + // Act - Re-render with same UseSingleExpanded value + cut.Render(parameters => parameters.Add(p => p.UseSingleExpanded, true)); + + // Assert - States should remain unchanged + Assert.Equal(category1InitialState, categories[0].Instance.Expanded); + Assert.Equal(category2InitialState, categories[1].Instance.Expanded); + } + + [Fact] + public async Task FluentNav_ExpandCategoryAsync_Expands_Specified_Category() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var nav = cut.FindComponent(); + var categories = cut.FindComponents(); + + Assert.False(categories[0].Instance.Expanded, "Category 1 should start collapsed"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should start collapsed"); + + // Act + await nav.Instance.ExpandCategoryAsync("category-1"); + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.True(categories[0].Instance.Expanded, "Category 1 should be expanded"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should remain collapsed"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_ExpandCategoryAsync_Expands_Specified_Category_With_EventHandler() + { + // Arrange + var isExpandedEventFired = false; + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var nav = cut.FindComponent(); + var categories = cut.FindComponents(); + + Assert.False(categories[0].Instance.Expanded, "Category 1 should start collapsed"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should start expanded"); + + // Act + await nav.Instance.ExpandCategoryAsync("category-1"); + + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.True(categories[0].Instance.Expanded, "Category 1 should be expanded"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should remain expanded"); + }, TimeSpan.FromSeconds(1)); + + Assert.True(isExpandedEventFired, "ExpandedChanged event should have been fired"); + } + + [Fact] + public async Task FluentNav_ExpandCategoryAsync_With_UseSingleExpanded_Collapses_Other_Categories() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var nav = cut.FindComponent(); + var categories = cut.FindComponents(); + + // Set initial expanded state programmatically to avoid parameter binding issues + await categories[0].Instance.SetExpandedAsync(true); + await categories[1].Instance.SetExpandedAsync(false); + + Assert.True(categories[0].Instance.Expanded, "Category 1 should start expanded"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should start collapsed"); + + // Act - Expand category 2 + await nav.Instance.ExpandCategoryAsync("category-2"); + await Task.Delay(100, CancellationToken.None); + + // Assert - Category 1 should be collapsed, Category 2 should be expanded + cut.WaitForAssertion(() => + { + Assert.False(categories[0].Instance.Expanded, "Category 1 should be collapsed"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should be expanded"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_ExpandCategoryAsync_With_Case_Insensitive_Id() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + ); + + var nav = cut.FindComponent(); + var category = cut.FindComponent(); + + // Act - Use different casing + await nav.Instance.ExpandCategoryAsync("category-1"); + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.True(category.Instance.Expanded, "Category should be expanded (case-insensitive match)"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_ExpandCategoryAsync_Does_Nothing_When_Category_Not_Found() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + ); + + var nav = cut.FindComponent(); + var category = cut.FindComponent(); + + // Set initial expanded state programmatically to avoid parameter binding issues + await category.Instance.SetExpandedAsync(true); + var initialState = category.Instance.Expanded; + + // Act - Try to collapse non-existent category + await nav.Instance.CollapseCategoryAsync("non-existent-id"); + + // Assert - Should not throw, and existing category state should not change + Assert.Equal(initialState, category.Instance.Expanded); + } + + [Fact] + public async Task FluentNav_CollapseCategoryAsync_Collapses_Specified_Category() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var nav = cut.FindComponent(); + var categories = cut.FindComponents(); + + // Set initial expanded state programmatically to avoid parameter binding issues + await categories[0].Instance.SetExpandedAsync(true); + await categories[1].Instance.SetExpandedAsync(true); + + Assert.True(categories[0].Instance.Expanded, "Category 1 should start expanded"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should start expanded"); + + // Act + await nav.Instance.CollapseCategoryAsync("category-1"); + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.False(categories[0].Instance.Expanded, "Category 1 should be collapsed"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should remain expanded"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_CollapseCategoryAsync_With_Case_Insensitive_Id() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + ); + + var nav = cut.FindComponent(); + var category = cut.FindComponent(); + + // Set initial expanded state programmatically to avoid parameter binding issues + await category.Instance.SetExpandedAsync(true); + + // Act - Use different casing + await nav.Instance.CollapseCategoryAsync("category-1"); + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.False(category.Instance.Expanded, "Category should be collapsed (case-insensitive match)"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_CollapseCategoryAsync_Does_Nothing_When_Category_Not_Found() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + ); + + var nav = cut.FindComponent(); + var category = cut.FindComponent(); + var initialState = category.Instance.Expanded; + + // Act - Try to collapse non-existent category + await nav.Instance.CollapseCategoryAsync("non-existent-id"); + + // Assert - Should not throw, and existing category state should not change + Assert.Equal(initialState, category.Instance.Expanded); + } + + [Fact] + public async Task FluentNav_CollapseAllCategoriesAsync_Collapses_All_Categories() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + + Sub Item 3 + + ); + + var nav = cut.FindComponent(); + var categories = cut.FindComponents(); + + // Set initial expanded state programmatically to avoid parameter binding issues + await categories[0].Instance.SetExpandedAsync(true); + await categories[1].Instance.SetExpandedAsync(true); + await categories[2].Instance.SetExpandedAsync(true); + + Assert.True(categories[0].Instance.Expanded, "Category 1 should start expanded"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should start expanded"); + Assert.True(categories[2].Instance.Expanded, "Category 3 should start expanded"); + + // Act + await nav.Instance.CollapseAllCategoriesAsync(); + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.False(categories[0].Instance.Expanded, "Category 1 should be collapsed"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should be collapsed"); + Assert.False(categories[2].Instance.Expanded, "Category 3 should be collapsed"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_CollapseAllCategoriesAsync_With_No_Categories() + { + // Arrange + var cut = Render(@ + Simple Item + ); + + var nav = cut.FindComponent(); + + // Act & Assert - Should not throw + await nav.Instance.CollapseAllCategoriesAsync(); + } + + [Fact] + public async Task FluentNav_ExpandAllCategoriesAsync_Expands_All_Categories_When_UseSingleExpanded_False() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + + Sub Item 3 + + ); + + var nav = cut.FindComponent(); + var categories = cut.FindComponents(); + + Assert.False(categories[0].Instance.Expanded, "Category 1 should start collapsed"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should start collapsed"); + Assert.False(categories[2].Instance.Expanded, "Category 3 should start collapsed"); + + // Act + await nav.Instance.ExpandAllCategoriesAsync(); + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.True(categories[0].Instance.Expanded, "Category 1 should be expanded"); + Assert.True(categories[1].Instance.Expanded, "Category 2 should be expanded"); + Assert.True(categories[2].Instance.Expanded, "Category 3 should be expanded"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_ExpandAllCategoriesAsync_Expands_Only_First_Category_When_UseSingleExpanded_True() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + + Sub Item 3 + + ); + + var nav = cut.FindComponent(); + var categories = cut.FindComponents(); + + Assert.False(categories[0].Instance.Expanded, "Category 1 should start collapsed"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should start collapsed"); + Assert.False(categories[2].Instance.Expanded, "Category 3 should start collapsed"); + + // Act + await nav.Instance.ExpandAllCategoriesAsync(); + + // Assert - Wait for async state updates to complete + cut.WaitForAssertion(() => + { + Assert.True(categories[0].Instance.Expanded, "Category 1 should be expanded"); + Assert.False(categories[1].Instance.Expanded, "Category 2 should remain collapsed"); + Assert.False(categories[2].Instance.Expanded, "Category 3 should remain collapsed"); + }, TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task FluentNav_ExpandAllCategoriesAsync_With_No_Categories() + { + // Arrange + var cut = Render(@ + Simple Item + ); + + var nav = cut.FindComponent(); + + // Act & Assert - Should not throw + await nav.Instance.ExpandAllCategoriesAsync(); + } + + [Fact] + public async Task FluentNav_GetCategories_Returns_All_Registered_Categories() + { + // Arrange + var cut = Render(@ + + Sub Item 1 + + + Sub Item 2 + + ); + + var nav = cut.FindComponent(); + + // Act + var categories = nav.Instance.GetCategories(); + + // Assert + Assert.Equal(2, categories.Count()); + Assert.Contains(categories, c => c.Id == "category-1"); + Assert.Contains(categories, c => c.Id == "category-2"); + } +} diff --git a/tests/Core/Components/Nav/NavHelpers.cs b/tests/Core/Components/Nav/NavHelpers.cs new file mode 100644 index 0000000000..74ec46e9a3 --- /dev/null +++ b/tests/Core/Components/Nav/NavHelpers.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Nav; + +public class DerivedFluentNav : FluentNav +{ + public DerivedFluentNav(LibraryConfiguration configuration) : base(configuration) + { + } +} + +public class DerivedFluentNavCategory : FluentNavCategory +{ + public DerivedFluentNavCategory(LibraryConfiguration configuration) : base(configuration) + { + Owner = new DerivedFluentNav(configuration); + } +}