From b404d1b93d12de74e56972fb3404beb123ad7178 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Tue, 23 Jun 2026 20:38:03 +0200 Subject: [PATCH 1/9] Initial refactoring work --- .../Overflow/Examples/OverflowDefault.razor | 28 +- .../Overflow/Examples/OverflowEllipsis.razor | 10 +- .../Examples/OverflowHorizontalExample.razor | 4 +- .../Examples/OverflowVisibleOnLoad.razor | 28 +- .../Components/Overflow/FluentOverflow.md | 3 - .../Components/Overflow/FluentOverflow.razor | 59 +-- .../Overflow/FluentOverflow.razor.cs | 68 ++-- .../Overflow/FluentOverflow.razor.css | 2 +- .../Overflow/FluentOverflow.razor.ts | 364 ++++++++++++++---- .../Overflow/FluentOverflowItem.razor | 7 - .../Overflow/FluentOverflowItem.razor.cs | 89 ----- src/Core/Enums/OverflowItemFixed.cs | 31 -- .../Overflow/FluentOverflowTests.razor | 252 ++---------- 13 files changed, 426 insertions(+), 519 deletions(-) delete mode 100644 src/Core/Components/Overflow/FluentOverflowItem.razor delete mode 100644 src/Core/Components/Overflow/FluentOverflowItem.razor.cs delete mode 100644 src/Core/Enums/OverflowItemFixed.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor index 47f329ef51..11bf48a49e 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor @@ -1,18 +1,18 @@ 
- - - - - - - - - - - - - - +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor index d92f7a6905..fa90d527ab 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor @@ -1,9 +1,9 @@ 
- Aspire; - Blazor; - Microsoft; - Azure; - DevOps +
Aspire;
+
Blazor;
+
Microsoft;
+
Azure;
+
DevOps
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowHorizontalExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowHorizontalExample.razor index b2cd9f228d..9b1d311fb0 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowHorizontalExample.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowHorizontalExample.razor @@ -3,7 +3,7 @@ @foreach (var item in Items) { - +
}
@@ -31,7 +31,7 @@ List Items = new List() { Catalog[0], Catalog[1] }; - void OverflowHandler(IEnumerable items) + void OverflowHandler(IEnumerable items) { var text = String.Join("; ", items.Select(i => i.Text)); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor index dc24e03a64..10ba9b56ec 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor @@ -1,18 +1,18 @@ 
- - - - - - - - - - - - - - +
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md index 2b7aff3dec..aa65187c91 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md @@ -33,6 +33,3 @@ Then refresh the page to see the difference between this example and the one abo ## API FluentOverflow {{ API Type=FluentOverflow }} - -{{ API Type=FluentOverflowItem }} - diff --git a/src/Core/Components/Overflow/FluentOverflow.razor b/src/Core/Components/Overflow/FluentOverflow.razor index f3bde0d0e4..2e2ac9daac 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor +++ b/src/Core/Components/Overflow/FluentOverflow.razor @@ -2,31 +2,36 @@ @using Microsoft.FluentUI.AspNetCore.Components.Extensions @inherits FluentComponentBase - -
- @ChildContent -
- @if (MoreButtonTemplate != null) - { - @MoreButtonTemplate(this) - } - else - { - - } -
+ + @ChildContent +
+ @if (MoreButtonTemplate != null) + { + @MoreButtonTemplate(this) + } + else + { + + }
- @if (OverflowTemplate != null) - { - @OverflowTemplate(this) - } - else - { - - @foreach (var item in ItemsOverflow) - { -
@item.Text
- } -
- } - +
+@if (OverflowTemplate != null) +{ + @OverflowTemplate(this) +} +else +{ + + @foreach (var item in ItemsOverflow) + { +
@item.Text
+ } +
+} diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.cs b/src/Core/Components/Overflow/FluentOverflow.razor.cs index b0e32afc0b..debc51e361 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.cs +++ b/src/Core/Components/Overflow/FluentOverflow.razor.cs @@ -12,7 +12,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentOverflow : FluentComponentBase { private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "Overflow/FluentOverflow.razor.js"; - private readonly List _items = []; + private readonly List _items = []; private DotNetObjectReference? _dotNetHelper; /// @@ -31,7 +31,7 @@ public FluentOverflow(LibraryConfiguration configuration) : base(configuration) Id = Identifier.NewId(); } - internal FluentOverflow(LibraryConfiguration configuration, List items) : this(configuration) + internal FluentOverflow(LibraryConfiguration configuration, List items) : this(configuration) { _items = items; } @@ -62,12 +62,26 @@ internal FluentOverflow(LibraryConfiguration configuration, List + /// Gets or sets the CSS selector of direct children to include in the overflow. + /// If null or empty, all direct children except the built-in More button are considered. + /// + [Parameter] + public string? Selector { get; set; } = string.Empty; + /// /// Gets or sets the CSS selectors of the items to include in the overflow. + /// Use instead. /// [Parameter] public string? Selectors { get; set; } = string.Empty; + /// + /// Gets or sets whether overflow items are cached in JavaScript memory. + /// + [Parameter] + public bool StoreOverflowInMemory { get; set; } + /// /// Gets or sets whether the tooltip is displayed using the TooltipService. /// @@ -75,10 +89,10 @@ internal FluentOverflow(LibraryConfiguration configuration, List - /// Event raised when a enter or leave the current panel. + /// Event raised when an item enters or leaves the current panel. /// [Parameter] - public EventCallback> OnOverflowRaised { get; set; } + public EventCallback> OnOverflowRaised { get; set; } /// /// Gets or sets the content to display. @@ -88,16 +102,23 @@ internal FluentOverflow(LibraryConfiguration configuration, List - /// Gets all items with assigned to True. + /// Gets all items with assigned to true. /// - public IEnumerable ItemsOverflow => _items.Where(i => i.Overflow == true); + public IEnumerable ItemsOverflow => _items.Where(i => i.Overflow); /// /// Gets the unique identifier associated to the more button ([Id]-more). /// public string IdMoreButton => $"{Id}-more"; + /// + protected virtual string? MoreButtonStyleValue => DefaultStyleBuilder + .AddStyle("visibility", "hidden", !ItemsOverflow.Any()) + .AddStyle("anchor-name", $"--{IdMoreButton}") + .Build(); + private bool IsHorizontal => Orientation == Orientation.Horizontal; + private string? ItemSelector => string.IsNullOrWhiteSpace(Selector) ? Selectors : Selector; /// protected override async Task OnAfterRenderAsync(bool firstRender) @@ -107,7 +128,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await JSModule.ImportJavaScriptModuleAsync(JAVASCRIPT_FILE); _dotNetHelper = DotNetObjectReference.Create(this); - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Initialize", _dotNetHelper, Id, IsHorizontal, Selectors, 25); + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Initialize", _dotNetHelper, Id, IsHorizontal, ItemSelector, 25); VisibleOnLoad = true; } } @@ -122,7 +143,7 @@ public async Task RefreshAsync() { if (JSModule is not null) { - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Refresh", _dotNetHelper, Id, IsHorizontal, Selectors, 25); + await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Refresh", _dotNetHelper, Id, IsHorizontal, ItemSelector, 25); } } @@ -130,17 +151,8 @@ public async Task RefreshAsync() [JSInvokable] public async Task OverflowRaisedAsync(OverflowItem[] items) { - if (items == null || items.Length == 0) - { - return; - } - - // Update Item components - foreach (var item in items) - { - var element = _items.FirstOrDefault(i => string.Equals(i.Id, item.Id, StringComparison.OrdinalIgnoreCase)); - element?.SetProperties(item.Overflow, item.Text); - } + _items.Clear(); + _items.AddRange(items ?? []); // Raise event if (OnOverflowRaised.HasDelegate) @@ -150,16 +162,6 @@ public async Task OverflowRaisedAsync(OverflowItem[] items) await InvokeAsync(StateHasChanged); } - internal async Task RegisterAsync(FluentOverflowItem item) - { - await InvokeAsync(() => _items.Add(item)); - } - - internal async Task UnregisterAsync(FluentOverflowItem item) - { - _items.Remove(item); - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Dispose", item.Id); - } /// protected override async ValueTask DisposeAsync(IJSObjectReference jsModule) @@ -178,9 +180,15 @@ public class OverflowItem public string? Id { get; set; } /// - public bool? Overflow { get; set; } + public bool Overflow { get; set; } /// public string? Text { get; set; } + + /// + public string? Fixed { get; set; } + + /// + public int Index { get; set; } } } diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.css b/src/Core/Components/Overflow/FluentOverflow.razor.css index 5d84d50cef..fc8c9e4dbe 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.css +++ b/src/Core/Components/Overflow/FluentOverflow.razor.css @@ -29,7 +29,7 @@ flex-shrink: 0; } -.fluent-overflow .fluent-overflow-item[fixed='ellipsis'] { +.fluent-overflow > *[fixed='ellipsis'] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.ts b/src/Core/Components/Overflow/FluentOverflow.razor.ts index fa920891df..e31db5be76 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.ts +++ b/src/Core/Components/Overflow/FluentOverflow.razor.ts @@ -5,114 +5,317 @@ export namespace Microsoft.FluentUI.Blazor.Overflow { Id: string; Overflow: boolean; Text: string; + Fixed?: string | null; + Index?: number; } interface OverflowElement extends HTMLElement { overflowSize?: number | null; } - interface LastHandledState { - id: string | null; - isHorizontal: boolean | null; + interface OverflowElementHost extends HTMLElement { + refresh?: () => void; + getOverflowItems?: () => OverflowItem[]; } - let resizeObserver: ResizeObserver | undefined; - let observerAddRemove: MutationObserver | undefined; - let lastHandledState: LastHandledState = { id: null, isHorizontal: null }; + interface ObserverContext { + resizeObserver: ResizeObserver; + mutationObserver: MutationObserver; + resizeTimeout?: number; + lastHandledState: boolean | null; + listener?: EventListener; + } + + interface RefreshResult { + items: OverflowItem[]; + overflowChanged: boolean; + isHorizontal: boolean; + } + + const observerContexts = new Map(); + + class FluentOverflow extends HTMLElement { + private resizeObserver?: ResizeObserver; + private mutationObserver?: MutationObserver; + private resizeTimeout?: number; + private lastHandledState: boolean | null = null; + private overflowItems: OverflowItem[] = []; + + static get observedAttributes() { + return ["orientation", "selector", "selectors", "threshold", "visible-on-load", "store-overflow-in-memory"]; + } + + connectedCallback() { + this.classList.add("fluent-overflow"); + this.setupObservers(); + this.refresh(); + this.setAttribute("visible-on-load", "true"); + this.style.visibility = ""; + } + + disconnectedCallback() { + this.cleanupObservers(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (oldValue === newValue) { + return; + } + + if (name === "visible-on-load") { + this.style.visibility = newValue === "false" ? "hidden" : ""; + return; + } + + this.refresh(); + } + + refresh() { + const result = refreshContainer(this, this.getIsHorizontal(), this.getQuerySelector(), this.getThreshold(), this.lastHandledState); + this.lastHandledState = result.isHorizontal; + + if (this.getStoreOverflowInMemory()) { + this.overflowItems = result.items; + } + + if (result.overflowChanged) { + if (!this.getStoreOverflowInMemory()) { + this.overflowItems = result.items; + } + + this.dispatchEvent(new CustomEvent("overflowchange", { + detail: { + items: result.items, + overflowCount: result.items.filter(i => i.Overflow).length + }, + bubbles: true, + composed: true + })); + } + } + + getOverflowItems(): OverflowItem[] { + if (this.overflowItems.length > 0) { + return [...this.overflowItems]; + } + + return getCurrentItems(this, this.getQuerySelector()); + } + + getOverflowCount(): number { + return this.getOverflowItems().filter(i => i.Overflow).length; + } + + private setupObservers() { + this.cleanupObservers(); + + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver(() => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = window.setTimeout(() => this.refresh(), 100); + }); + this.resizeObserver.observe(this); + } + + this.mutationObserver = new MutationObserver(() => this.refresh()); + this.mutationObserver.observe(this, { childList: true, subtree: false }); + } + + private cleanupObservers() { + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.mutationObserver?.disconnect(); + this.mutationObserver = undefined; + clearTimeout(this.resizeTimeout); + this.resizeTimeout = undefined; + } + + private getIsHorizontal(): boolean { + const orientation = this.getAttribute("orientation"); + return orientation !== "vertical"; + } + + private getThreshold(): number { + const value = Number.parseFloat(this.getAttribute("threshold") ?? "25"); + return Number.isFinite(value) ? value : 25; + } + + private getStoreOverflowInMemory(): boolean { + const value = this.getAttribute("store-overflow-in-memory"); + return value === "true" || value === ""; + } + + private getQuerySelector(): string | null { + return this.getAttribute("selector") ?? this.getAttribute("selectors"); + } + } + + if (!customElements.get("fluent-overflow")) { + customElements.define("fluent-overflow", FluentOverflow); + } export function Initialize(dotNetHelper: DotNet.DotNetObject, id: string, isHorizontal: boolean, querySelector: string | null, threshold: number): void { - let localSelector = querySelector; - if (!localSelector) { - localSelector = ".fluent-overflow-item"; + const container = document.getElementById(id) as OverflowElementHost | null; + if (!container) { + return; } - observerAddRemove = new MutationObserver(mutations => { - mutations.forEach(mutation => { - if (mutation.type !== 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) { - return; + Dispose(id); + + const listener: EventListener = (event) => { + const overflowEvent = event as CustomEvent<{ items: OverflowItem[] }>; + const items = overflowEvent.detail?.items ?? []; + dotNetHelper.invokeMethodAsync("OverflowRaisedAsync", items); + }; + + container.addEventListener("overflowchange", listener); + + if (container.tagName.toLowerCase() === "fluent-overflow" && typeof container.refresh === "function") { + container.setAttribute("orientation", isHorizontal ? "horizontal" : "vertical"); + container.setAttribute("threshold", String(threshold)); + if (querySelector) { + container.setAttribute("selector", querySelector); + } else { + container.removeAttribute("selector"); + } + + observerContexts.set(id, { + resizeObserver: new ResizeObserver(() => { }), + mutationObserver: new MutationObserver(() => { }), + listener, + lastHandledState: isHorizontal + }); + + container.refresh(); + return; + } + + const context: ObserverContext = { + resizeObserver: new ResizeObserver(() => { }), + mutationObserver: new MutationObserver(() => { }), + listener, + lastHandledState: isHorizontal + }; + + context.mutationObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + if (mutation.type !== "childList") { + continue; } + const node = mutation.addedNodes.length > 0 ? mutation.addedNodes[0] : mutation.removedNodes[0]; - if (node.nodeType !== Node.ELEMENT_NODE || !(node as Element).matches(localSelector!)) { - return; + if (node?.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + const selector = buildQuerySelector(querySelector); + if (!(node as Element).matches(selector)) { + continue; } + Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); - }); + break; + } }); - const el = document.getElementById(id); - if (resizeObserver && el) { - resizeObserver.unobserve(el); - } - - let resizeTimeout: number | undefined; - resizeObserver = new ResizeObserver(() => { - clearTimeout(resizeTimeout); - resizeTimeout = window.setTimeout(() => { + context.resizeObserver = new ResizeObserver(() => { + clearTimeout(context.resizeTimeout); + context.resizeTimeout = window.setTimeout(() => { Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); }, 100); }); - if (el) { - resizeObserver.observe(el); - observerAddRemove.observe(el, { childList: true, subtree: false }); - } + context.resizeObserver.observe(container); + context.mutationObserver.observe(container, { childList: true, subtree: false }); + observerContexts.set(id, context); - lastHandledState.id = id; - lastHandledState.isHorizontal = isHorizontal; + Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); } export function Refresh(dotNetHelper: DotNet.DotNetObject, id: string, isHorizontal: boolean, querySelector: string | null, threshold: number): void { - const container = document.getElementById(id); - if (!container) return; + const container = document.getElementById(id) as OverflowElementHost | null; + if (!container) { + return; + } - let localQuerySelector: string; - if (!querySelector) { - localQuerySelector = ":scope .fluent-overflow-item"; - } else { - localQuerySelector = ":scope >" + querySelector; + if (container.tagName.toLowerCase() === "fluent-overflow" && typeof container.refresh === "function") { + container.refresh(); + if (typeof container.getOverflowItems === "function") { + dotNetHelper.invokeMethodAsync("OverflowRaisedAsync", container.getOverflowItems()); + } + return; } - const allItems = container.querySelectorAll(localQuerySelector); - const items = container.querySelectorAll(localQuerySelector + ":not([fixed])"); + const context = observerContexts.get(id); + const result = refreshContainer(container, isHorizontal, querySelector, threshold, context?.lastHandledState ?? null); + if (context) { + context.lastHandledState = isHorizontal; + } - const fixedItemsFromSelector = container.querySelectorAll(localQuerySelector + "[fixed]"); - const otherFixedItems = container.querySelectorAll(":scope > [fixed]:not(" + localQuerySelector + ")"); - const fixedItems = [ - ...Array.from(fixedItemsFromSelector), - ...Array.from(otherFixedItems) - ].filter(el => el.getAttribute("fixed") !== "ellipsis"); + if (result.overflowChanged) { + dotNetHelper.invokeMethodAsync("OverflowRaisedAsync", result.items); + } + } + + export function Dispose(id: string): void { + const context = observerContexts.get(id); + if (!context) { + return; + } + + const container = document.getElementById(id); + if (container && context.listener) { + container.removeEventListener("overflowchange", context.listener); + } + + context.resizeObserver.disconnect(); + context.mutationObserver.disconnect(); + if (context.resizeTimeout) { + clearTimeout(context.resizeTimeout); + } + + observerContexts.delete(id); + } - const ellipsisItems = Array.from(container.querySelectorAll(localQuerySelector + "[fixed='ellipsis']")); + function refreshContainer(container: HTMLElement, isHorizontal: boolean, querySelector: string | null, threshold: number, lastHandledState: boolean | null): RefreshResult { + const localQuerySelector = buildQuerySelector(querySelector); + const allItems = Array.from(container.querySelectorAll(localQuerySelector)); + const items = allItems.filter(element => !element.hasAttribute("fixed")); + const fixedItems = allItems.filter(element => element.hasAttribute("fixed") && element.getAttribute("fixed") !== "ellipsis"); + const ellipsisItems = allItems.filter(element => element.getAttribute("fixed") === "ellipsis"); let ellipsisTotal = 0; let containerGap = parseFloat(window.getComputedStyle(container).gap); - if (!containerGap) containerGap = 0; + if (!containerGap) { + containerGap = 0; + } - ellipsisItems.forEach((el, idx) => { - el.overflowSize = isHorizontal ? getElementWidth(el) : getElementHeight(el); - ellipsisTotal += el.overflowSize || 0; - if (idx > 0) ellipsisTotal += containerGap; + ellipsisItems.forEach((element, index) => { + element.overflowSize = isHorizontal ? getElementWidth(element) : getElementHeight(element); + ellipsisTotal += element.overflowSize || 0; + if (index > 0) { + ellipsisTotal += containerGap; + } }); let itemsTotalSize = threshold > 0 ? 10 : 0; let containerMaxSize = isHorizontal ? container.offsetWidth : container.offsetHeight; let overflowChanged = false; - containerMaxSize -= threshold; - const availableSize = containerMaxSize - fixedItems.reduce((sum, el, idx) => sum + (el.overflowSize || 0) + (idx > 0 ? containerGap : 0), 0); + const availableSize = containerMaxSize - fixedItems.reduce((sum, element, index) => sum + (element.overflowSize || 0) + (index > 0 ? containerGap : 0), 0); if (ellipsisTotal > availableSize) { - ellipsisItems.forEach(el => { - el.style.flexShrink = "1"; + ellipsisItems.forEach(element => { + element.style.flexShrink = "1"; }); } else { - ellipsisItems.forEach(el => { - el.style.flexShrink = "0"; + ellipsisItems.forEach(element => { + element.style.flexShrink = "0"; }); } - if (lastHandledState.id === id && lastHandledState.isHorizontal !== isHorizontal) { + if (lastHandledState !== null && lastHandledState !== isHorizontal) { allItems.forEach(element => { element.removeAttribute("overflow"); element.overflowSize = null; @@ -147,28 +350,35 @@ export namespace Microsoft.FluentUI.Blazor.Overflow { } }); - if (overflowChanged) { - const listOfOverflow: OverflowItem[] = []; - items.forEach(element => { - listOfOverflow.push({ - Id: element.id, - Overflow: element.hasAttribute("overflow"), - Text: element.innerText.trim() - }); - }); - dotNetHelper.invokeMethodAsync("OverflowRaisedAsync", listOfOverflow); - } + return { + items: toOverflowItems(items), + overflowChanged, + isHorizontal + }; + } - lastHandledState.id = id; - lastHandledState.isHorizontal = isHorizontal; + function getCurrentItems(container: HTMLElement, querySelector: string | null): OverflowItem[] { + const localQuerySelector = buildQuerySelector(querySelector); + const items = Array.from(container.querySelectorAll(localQuerySelector)) + .filter(element => !element.hasAttribute("fixed")); + return toOverflowItems(items); } - export function Dispose(id: string): void { - const el = document.getElementById(id); - if (el) { - resizeObserver?.unobserve(el); - observerAddRemove?.disconnect(); + function toOverflowItems(items: OverflowElement[]): OverflowItem[] { + return items.map((element, index) => ({ + Id: element.id, + Overflow: element.hasAttribute("overflow"), + Text: element.innerText.trim(), + Fixed: element.getAttribute("fixed"), + Index: index + })); + } + + function buildQuerySelector(querySelector: string | null): string { + if (!querySelector) { + return ":scope > :not(.fluent-overflow-more)"; } + return `:scope > ${querySelector}`; } function getElementWidth(element: HTMLElement): number { diff --git a/src/Core/Components/Overflow/FluentOverflowItem.razor b/src/Core/Components/Overflow/FluentOverflowItem.razor deleted file mode 100644 index f9ef6aa39e..0000000000 --- a/src/Core/Components/Overflow/FluentOverflowItem.razor +++ /dev/null @@ -1,7 +0,0 @@ -@namespace Microsoft.FluentUI.AspNetCore.Components -@using Microsoft.FluentUI.AspNetCore.Components.Extensions -@inherits FluentComponentBase - -
- @ChildContent -
diff --git a/src/Core/Components/Overflow/FluentOverflowItem.razor.cs b/src/Core/Components/Overflow/FluentOverflowItem.razor.cs deleted file mode 100644 index 17fabe4851..0000000000 --- a/src/Core/Components/Overflow/FluentOverflowItem.razor.cs +++ /dev/null @@ -1,89 +0,0 @@ -// ------------------------------------------------------------------------ -// 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; - -/// -public partial class FluentOverflowItem : FluentComponentBase, IAsyncDisposable -{ - //private bool _disposed; - - /// - protected string? ClassValue => DefaultClassBuilder - .AddClass("fluent-overflow-item") - .Build(); - - /// - protected string? StyleValue => DefaultStyleBuilder - .Build(); - - /// - /// Gets or sets the reference to the associated container. - /// - /// The splitter. - [CascadingParameter] - internal FluentOverflow? Owner { get; set; } - - /// - /// Gets or sets the content to display. All first HTML elements are included in the items flow. - /// - [Parameter] - public RenderFragment? ChildContent { get; set; } - - /// - /// Gets or sets whether this element does not participate in overflow logic, and will always be visible. - /// Defaults to false - /// - [Parameter] - public OverflowItemFixed Fixed { get; set; } = OverflowItemFixed.None; - - /// - /// Gets True if this component is out of panel. - /// - public bool? Overflow { get; private set; } - - /// - /// Gets the InnerText of . - /// - public string Text { get; private set; } = string.Empty; - - /// - public FluentOverflowItem(LibraryConfiguration configuration) : base(configuration) - { - Id = Identifier.NewId(); - } - - internal FluentOverflowItem(LibraryConfiguration configuration, bool isOverflow, string text) : this(configuration) - { - Overflow = isOverflow; - Text = text; - } - - /// - protected override async Task OnInitializedAsync() - { - if (Owner is not null) - { - await Owner.RegisterAsync(this); - } - } - - /// - internal void SetProperties(bool? isOverflow, string? text) - { - Overflow = isOverflow == true ? isOverflow : null; - Text = text ?? string.Empty; - } - - /// - public override async ValueTask DisposeAsync() - { - Owner?.UnregisterAsync(this); - await base.DisposeAsync(); - - } -} diff --git a/src/Core/Enums/OverflowItemFixed.cs b/src/Core/Enums/OverflowItemFixed.cs deleted file mode 100644 index ea0539dcac..0000000000 --- a/src/Core/Enums/OverflowItemFixed.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using System.ComponentModel; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// Possibility for an element not to participate in the overflow logic and always remain displayed. -/// -public enum OverflowItemFixed -{ - /// - /// If the item is out of the display, it disappears. - /// - [Description("none")] - None = 0, - - /// - /// The element is always visible - /// - [Description("fixed")] - Fixed = 1, - - /// - /// The element is always visible, but its width can be reduced to display "...". - /// - [Description("ellipsis")] - Ellipsis = 2, -} diff --git a/tests/Core/Components/Overflow/FluentOverflowTests.razor b/tests/Core/Components/Overflow/FluentOverflowTests.razor index 42c40b6089..ef73264464 100644 --- a/tests/Core/Components/Overflow/FluentOverflowTests.razor +++ b/tests/Core/Components/Overflow/FluentOverflowTests.razor @@ -1,5 +1,4 @@ -@using Microsoft.FluentUI.AspNetCore.Components.Utilities -@using Xunit +@using Xunit @using static Microsoft.FluentUI.AspNetCore.Components.FluentOverflow @inherits FluentUITestContext @@ -11,116 +10,40 @@ } [Fact] - public void FluentOverflow_Renders_WithoutOverflow() + public void FluentOverflow_Renders_Direct_Children() { - // Arrange var cut = Render(@
- Item 1 - Item 2 +
Item 1
+
Item 2
); - // Assert - Assert.Equal(2, cut.FindAll(".fluent-overflow-item").Count); - Assert.Empty(cut.FindAll(".fluent-overflow-item[overflow]")); - } - - [Fact] - public void FluentOverflow_Handles_FixedEllipsis_Item() - { - // Arrange - var cut = Render(@
- Long text that should ellipsis - Item 2 -
); - - // Assert - var ellipsisItem = cut.Find(".fluent-overflow-item[fixed='ellipsis']"); - Assert.NotNull(ellipsisItem); - Assert.Contains("ellipsis", ellipsisItem.GetAttribute("fixed")); - } - - [Fact] - public void FluentOverflow_Triggers_Overflow_Event() - { - // Arrange - var overflowRaised = false; - var cut = Render(@
- @for (int i = 0; i < 20; i++) - { - Item @i - } -
); - - // Act: Simulate resize or trigger refresh (in a real scenario, this might involve JS interop) - // For simplicity, assume overflow is triggered - - // Assert - // Note: Actual overflow detection requires JS interop, so this is a placeholder - Assert.False(overflowRaised); // Adjust based on implementation - } - - [Fact] - public void FluentOverflow_Renders_MoreButton_When_Overflow() - { - // Arrange - var cut = Render(@
- @for (int i = 0; i < 10; i++) - { - Very long item text @i - } -
); - - // Assert - var moreButton = cut.Find(".fluent-overflow-more"); - Assert.NotNull(moreButton); - Assert.Contains("visibility: hidden", moreButton.GetAttribute("style")); // Initially hidden + Assert.Single(cut.FindAll("fluent-overflow")); + Assert.Equal(3, cut.FindAll("fluent-overflow > *").Count); } [Fact] public void FluentOverflow_Respects_Orientation() { - // Arrange var cut = Render(@
- Item 1 +
Item 1
); - // Assert - var container = cut.Find(".fluent-overflow"); + var container = cut.Find("fluent-overflow"); Assert.Equal("vertical", container.GetAttribute("orientation")); } - [Fact] - public void FluentOverflow_Item_With_Fixed_Attribute() - { - // Arrange - var cut = Render(@
- Fixed Item - Regular Item -
); - - // Assert - var fixedItem = cut.Find(".fluent-overflow-item[fixed='fixed']"); - Assert.NotNull(fixedItem); - Assert.Equal("Fixed Item", fixedItem.TextContent); - } - [Fact] public void FluentOverflow_Custom_MoreButtonTemplate() { - // Arrange var cut = Render(@

Custom More

- - @for (int i = 0; i < 10; i++) - { - Item 1 - } - + +
Item 1
+
); - // Assert var customButton = cut.Find("p"); Assert.NotNull(customButton); Assert.Equal("Custom More", customButton.TextContent); @@ -129,20 +52,15 @@ [Fact] public void FluentOverflow_Custom_OverflowTemplate() { - // Arrange var cut = Render(@
Custom Overflow Content - - @for (int i = 0; i < 10; i++) - { - Item @i - } - + +
Item 1
+
); - // Assert var tooltip = cut.Find("fluent-tooltip"); Assert.NotNull(tooltip); Assert.Contains("Custom Overflow Content", tooltip.TextContent); @@ -151,136 +69,32 @@ [Fact] public async Task FluentOverflow_Calls_OverflowRaisedAsync() { - // Arrange var overflowRaised = false; var cut = Render(@
- @for (int i = 0; i < 20; i++) - { - Item @i - } +
Item 1
+
Item 2
); - // Act - var overflow = cut.FindComponent(); - await overflow.Instance.OverflowRaisedAsync(new OverflowItem[] { new OverflowItem() { Id = "item1", Overflow = true, Text = "Item 1" } }); + var overflow = cut.FindComponent(); + await overflow.Instance.OverflowRaisedAsync([ + new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Index = 0 }, + new OverflowItem { Id = "item2", Overflow = false, Text = "Item 2", Index = 1 } + ]); - // Assert: Verify the event was raised Assert.True(overflowRaised); + Assert.Single(overflow.Instance.ItemsOverflow); + Assert.Equal("Item 1", overflow.Instance.ItemsOverflow.First().Text); } - [Fact] - public async Task FluentOverflow_Calls_OverflowRaisedAsync_With_No_Result() - { - // Arrange - var overflowRaised = false; - var cut = Render(@
- @for (int i = 0; i < 20; i++) - { - Item @i - } -
); - - // Act - var overflow = cut.FindComponent(); - await overflow.Instance.OverflowRaisedAsync(new OverflowItem[] { }); - - // Assert: Verify the event was raised - Assert.False(overflowRaised); - } - - [Fact] - public async Task FluentOverflow_Calls_RefreshAsync() - { - // Arrange - var overflowRaised = false; - var cut = Render(@
- @for (int i = 0; i < 20; i++) - { - Item @i - } -
- ); - - // Act - var overflow = cut.FindComponent(); - await overflow.Instance.RefreshAsync(); - - // Assert: Verify the event was raised - Assert.False(overflowRaised); - } - - [Fact] - public async Task FluentOverflow_Has_Overflow() - { - // Arrange - var overflowRaised = false; - FluentOverflowItem fluentOverflowItem1 = new(new LibraryConfiguration(), false, "Item 1"); - FluentOverflowItem fluentOverflowItem2 = new(new LibraryConfiguration(), false, "Item 2"); - FluentOverflowItem fluentOverflowItem3 = new(new LibraryConfiguration(), true, "Item 3"); - FluentOverflowItem fluentOverflowItem4 = new(new LibraryConfiguration(), true, "Item 4"); - - FluentOverflow fluentOverflow = new(new LibraryConfiguration(), new List() { fluentOverflowItem1, fluentOverflowItem2, fluentOverflowItem3, fluentOverflowItem4 }); - - var cut = Render(@
- Item 1 - Item 2 - Item 3 - Item 4 -
); - - // Act - - var overflow = cut.FindComponent(); - await overflow.Instance.OverflowRaisedAsync(new OverflowItem[] { - new OverflowItem() { Id = "item3", Overflow = true, Text = "Item 3" }, - new OverflowItem() { Id = "item4", Overflow = true, Text = "Item 4" } - }); - overflow.Render(); - - // Assert: Verify the event was raised - Assert.True(overflowRaised); -} - - [Fact] - public void FluentOverflow_Renders_Default_Tooltip_When_OverflowTemplate_Is_Null() - { - // Arrange - var cut = Render(@
- @for (int i = 0; i < 10; i++) - { - Item @i - } -
); - - // Assert - Default FluentTooltip should be rendered - var tooltip = cut.Find("fluent-tooltip"); - Assert.NotNull(tooltip); - Assert.Equal("below-start", tooltip.GetAttribute("positioning")); - } - - [Fact] - public void FluentOverflow_Renders_Custom_OverflowTemplate_Instead_Of_Default_Tooltip() - { - // Arrange - var cut = Render(@
- -
Custom overflow content
-
- - @for (int i = 0; i < 10; i++) - { - Item @i - } - -
); + [Fact] + public void FluentOverflow_Renders_Default_Tooltip_When_OverflowTemplate_Is_Null() + { + var cut = Render(@
+
Item 1
+
); - // Assert - Custom template should be rendered instead of default tooltip - var customOverflow = cut.Find(".custom-overflow"); - Assert.NotNull(customOverflow); - Assert.Equal("Custom overflow content", customOverflow.TextContent); - - // Default tooltip should not be present - var tooltips = cut.FindAll("fluent-tooltip"); - Assert.Empty(tooltips); - } + var tooltip = cut.Find("fluent-tooltip"); + Assert.NotNull(tooltip); + Assert.Equal("below-start", tooltip.GetAttribute("positioning")); + } } From f31dc6b62e786a34edf9d770025d0435d467466a Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Thu, 25 Jun 2026 15:12:40 +0200 Subject: [PATCH 2/9] refactor: migrate overflow to web component Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...mple.razor => OverflowCustomExample.razor} | 22 +- .../Overflow/Examples/OverflowDefault.razor | 28 +- .../Overflow/Examples/OverflowEllipsis.razor | 2 +- .../OverflowMaxRenderedItemsExample.razor | 41 ++ .../OverflowMultipleFixedItemsExample.razor | 9 + .../Examples/OverflowSelectorExample.razor | 11 + .../Examples/OverflowVisibleOnLoad.razor | 28 +- .../Components/Overflow/FluentOverflow.md | 42 +- .../Migration/MigrationFluentOverflow.md | 45 ++ .../GetStarted/Migration/MigrationIndex.md | 1 + .../src/Components/Overflow/FluentOverflow.ts | 530 ++++++++++++++++++ src/Core.Scripts/src/FluentUICustomEvents.ts | 14 + src/Core.Scripts/src/Startup.ts | 4 + src/Core/Components/AppBar/FluentAppBar.razor | 3 + .../Components/AppBar/FluentAppBar.razor.cs | 75 +-- .../Components/AppBar/FluentAppBar.razor.css | 2 +- .../Components/Overflow/FluentOverflow.razor | 8 +- .../Overflow/FluentOverflow.razor.cs | 116 +++- .../Overflow/FluentOverflow.razor.css | 22 +- .../Overflow/FluentOverflow.razor.ts | 397 ------------- src/Core/Events/EventHandlers.cs | 1 + src/Core/Events/OverflowChangedEventArgs.cs | 31 + src/Core/Events/OverflowChangedItem.cs | 36 ++ .../Overflow/FluentOverflowTests.razor | 18 +- 24 files changed, 954 insertions(+), 532 deletions(-) rename examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/{OverflowHorizontalExample.razor => OverflowCustomExample.razor} (54%) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMultipleFixedItemsExample.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowSelectorExample.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md create mode 100644 src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts delete mode 100644 src/Core/Components/Overflow/FluentOverflow.razor.ts create mode 100644 src/Core/Events/OverflowChangedEventArgs.cs create mode 100644 src/Core/Events/OverflowChangedItem.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowHorizontalExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowCustomExample.razor similarity index 54% rename from examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowHorizontalExample.razor rename to examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowCustomExample.razor index 9b1d311fb0..7f733d65c0 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowHorizontalExample.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowCustomExample.razor @@ -1,20 +1,22 @@ -
- +
+ @foreach (var item in Items) { -
+ @item }
- - - + + @($"+{context.ItemsOverflow.Count()}") + - @foreach (var item in context.ItemsOverflow) - { -
@item.Text
- } +
+ @foreach (var item in context.ItemsOverflow) + { + @item.Text + } +
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor index 11bf48a49e..496bff0bf2 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor @@ -1,18 +1,18 @@ 
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ + + + + + + + + + + + + +
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor index fa90d527ab..5ca707810f 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowEllipsis.razor @@ -1,7 +1,7 @@ 
Aspire;
-
Blazor;
+
Blazor;
Microsoft;
Azure;
DevOps
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor new file mode 100644 index 0000000000..46caa9afae --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor @@ -0,0 +1,41 @@ +
+ + + + + + + + + + + +
+ @foreach (var item in context.ItemsOverflow) + { + + } +
+
+
+
+
+
+
Total overflow count: @_overflowCount
+
Rendered in payload (MaxRenderedItems=2): @_renderedInPayloadCount
+
+ +@code +{ + private FluentOverflow? _overflow; + private int _overflowCount; + private int _renderedInPayloadCount; + + private void OnOverflowRaised(IEnumerable items) + { + _renderedInPayloadCount = items.Count(); + _overflowCount = _overflow?.OverflowCount ?? 0; + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMultipleFixedItemsExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMultipleFixedItemsExample.razor new file mode 100644 index 0000000000..afce6a386a --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMultipleFixedItemsExample.razor @@ -0,0 +1,9 @@ +
+ +
Aspire;
+
Blazor;
+ Microsoft + Azure + DevOps +
+
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowSelectorExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowSelectorExample.razor new file mode 100644 index 0000000000..53970edf82 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowSelectorExample.razor @@ -0,0 +1,11 @@ +
+ +
Pinned
+
Blazor
+
Microsoft
+
Azure
+
Always visible
+
DevOps
+
Framework
+
+
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor index 10ba9b56ec..c9f343be33 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowVisibleOnLoad.razor @@ -1,18 +1,18 @@ 
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ + + + + + + + + + + + + +
diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md index aa65187c91..4f55177420 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md @@ -13,6 +13,7 @@ The `FluentOverflow` component is used to manage and display a collection of ite {{ OverflowDefault }} ## Overflow with ellipsis + In the following example, the first element will always be displayed (fixed), but an ellipse (...) will be added when the container size is too small. @@ -21,15 +22,50 @@ will be added when the container size is too small. {{ OverflowEllipsis }} ## Overflow not visible on load + With below example the `VisibleOnLoad` parameter is set to false.Make sure the screen dimension is small enough to show an overflow badge with count. Then refresh the page to see the difference between this example and the one above {{ OverflowVisibleOnLoad }} -## Horizontal Overflow example +## Custom templates and dynamic items + +This example shows a fully customized More button and tooltip content, and includes add/remove actions to demonstrate dynamic overflow recalculation. +{{ OverflowCustomExample }} + +## Selector-based overflow + +This example uses `Selector=".overflow-item"` so only matching children are overflow-managed; the non-matching `fixed` badges stay visible. +{{ OverflowSelectorExample }} + +## MaxRenderedItems payload cap + +This example sets `MaxRenderedItems="2"` and shows the difference between `OverflowCount` (total) and `ItemsOverflow` (rendered subset). +{{ OverflowMaxRenderedItemsExample }} + +## Fixed item modes -{{ OverflowHorizontalExample }} +The `fixed` attribute on direct children supports these modes: -## API FluentOverflow +| Value | Behavior | +| --- | --- | +| `fixed="fixed"` | Item always remains visible at full size and does not move to overflow. | +| `fixed="ellipsis"` | Item always remains visible but can shrink with text ellipsis when space is limited. | + +Notes: + +- `fixed` is an HTML attribute on child elements (not a `FluentOverflow` parameter). +- Use `Selector` to control which children are overflow-managed; non-selected children can still be marked with `fixed`. + +## Multiple fixed items + +This example demonstrates multiple fixed ellipsis items (`fixed="ellipsis"`) combined with normal overflowed items. +{{ OverflowMultipleFixedItemsExample }} + +## API FluentOverflow {{ API Type=FluentOverflow }} + +## Migrating to v5 + +{{ INCLUDE File=MigrationFluentOverflow }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md new file mode 100644 index 0000000000..d873ca4e3e --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md @@ -0,0 +1,45 @@ +--- +title: Migration FluentOverflow +route: /Migration/Overflow +hidden: true +--- + +### Web component migration 💥 + +`FluentOverflow` now wraps a `` web component. Overflow calculation +and resize are handled there, with Blazor receiving overflow state updates. + +### Content model changes 💥 + +- The old dedicated overflow item component is removed. +- Overflow now works with direct children of `FluentOverflow`. +- Per-item behavior is expressed with HTML attributes on the child element (for example `fixed="fixed"` or `fixed="ellipsis"`). + +### New/updated parameters + +- `Selector` — CSS selector for direct children to include in overflow handling. +- `MaxRenderedItems` (`int`, default `25`) — limits the number of overflow items returned in the payload (`<= 0` means unlimited). +- `StoreOverflowInMemory` (`bool`) — keeps overflow payload items in JavaScript memory. + +### Removed APIs 💥 + +- `FluentOverflowItem` component — removed; use direct child content instead. +- `OverflowItemFixed` enum — removed; use the child `fixed` attribute values instead. + +### Migration example + +```xml + + + + + + + + + +
Pinned
+
Blazor
+
Microsoft
+
+``` diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationIndex.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationIndex.md index 19df498aa0..c19d23325f 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationIndex.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationIndex.md @@ -43,6 +43,7 @@ Each page below covers specific component changes, removed components, and new a | FluentMenuButton | High | [Migration](/Migration/MenuButton) | | FluentMessageBar | Medium | [Migration](/Migration/MessageBar) | | FluentOverlay | High | [Migration](/Migration/Overlay) | + | FluentOverflow | High | [Migration](/Migration/Overflow) | | FluentPopover | High | [Migration](/Migration/Popover) | | FluentPresenceBadge | Medium | [Migration](/Migration/PresenceBadge) | | FluentProgressBar (was FluentProgress) | Medium | [Migration](/Migration/ProgressBar) | diff --git a/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts b/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts new file mode 100644 index 0000000000..8a062c9ea4 --- /dev/null +++ b/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts @@ -0,0 +1,530 @@ +import { StartedMode } from "../../d-ts/StartedMode"; + +export namespace Microsoft.FluentUI.Blazor.Components.Overflow { + interface OverflowItem { + Id: string; + Overflow: boolean; + Text: string; + Fixed?: string | null; + Index?: number; + } + + interface OverflowElement extends HTMLElement { + overflowSize?: number | null; + } + + interface OverflowState { + overflowItems: OverflowItem[]; + overflowCount: number; + firstOverflowIndex: number; + } + + interface RefreshResult extends OverflowState { + overflowChanged: boolean; + isHorizontal: boolean; + } + + class FluentOverflow extends HTMLElement { + private resizeObserver?: ResizeObserver; + private mutationObserver?: MutationObserver; + private resizeTimeout?: number; + private mutationTimeout?: number; + private lastHandledState: boolean | null = null; + private lastContainerSize = 0; + private cachedContainerGap: number | null = null; + private lastOverflowCount = 0; + private lastFirstOverflowIndex = -1; + private overflowItems: OverflowItem[] = []; + + static get observedAttributes() { + return ["orientation", "selector", "selectors", "threshold", "visible-on-load", "store-overflow-in-memory", "max-rendered-items"]; + } + + connectedCallback() { + const visibleOnLoad = this.getAttribute("visible-on-load") !== "false"; + + this.cachedContainerGap = null; + this.lastContainerSize = 0; + this.lastOverflowCount = 0; + this.lastFirstOverflowIndex = -1; + this.classList.add("fluent-overflow"); + this.setupObservers(); + this.refresh(); + + // Reveal after first measurement if the element was initially hidden on load. + if (!visibleOnLoad) { + this.style.visibility = ""; + } + } + + disconnectedCallback() { + this.cleanupObservers(); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (oldValue === newValue) { + return; + } + + if (name === "visible-on-load") { + this.style.visibility = newValue === "false" ? "hidden" : ""; + return; + } + + if (name === "orientation") { + this.lastContainerSize = 0; + this.cachedContainerGap = null; + } + + this.refresh(); + } + + refresh() { + const result = refreshContainer( + this, + this.getIsHorizontal(), + this.getQuerySelector(), + this.getThreshold(), + this.lastHandledState, + this.getContainerGap(), + this.getMaxRenderedItems() + ); + this.lastHandledState = result.isHorizontal; + + const payloadChanged = result.overflowChanged + || this.lastOverflowCount !== result.overflowCount + || this.lastFirstOverflowIndex !== result.firstOverflowIndex + || !areOverflowItemsEqual(this.overflowItems, result.overflowItems); + + this.lastOverflowCount = result.overflowCount; + this.lastFirstOverflowIndex = result.firstOverflowIndex; + + const storeInMemory = this.getStoreOverflowInMemory(); + if (storeInMemory || payloadChanged) { + this.overflowItems = result.overflowItems; + } + + if (payloadChanged) { + this.dispatchEvent(new CustomEvent("overflowchange", { + detail: { + items: result.overflowItems, + overflowCount: result.overflowCount, + firstOverflowIndex: result.firstOverflowIndex + }, + bubbles: true, + composed: true + })); + } + } + + getOverflowItems(): OverflowItem[] { + if (this.overflowItems.length > 0 || this.lastOverflowCount === 0) { + return [...this.overflowItems]; + } + + const state = getCurrentOverflowState(this, this.getQuerySelector(), this.getMaxRenderedItems()); + this.lastOverflowCount = state.overflowCount; + this.lastFirstOverflowIndex = state.firstOverflowIndex; + this.overflowItems = state.overflowItems; + return [...state.overflowItems]; + } + + getOverflowCount(): number { + if (this.overflowItems.length > 0 || this.lastOverflowCount > 0) { + return this.lastOverflowCount; + } + + const state = getCurrentOverflowState(this, this.getQuerySelector(), this.getMaxRenderedItems()); + this.lastOverflowCount = state.overflowCount; + this.lastFirstOverflowIndex = state.firstOverflowIndex; + this.overflowItems = state.overflowItems; + return state.overflowCount; + } + + getFirstOverflowIndex(): number { + if (this.overflowItems.length > 0 || this.lastOverflowCount > 0) { + return this.lastFirstOverflowIndex; + } + + const state = getCurrentOverflowState(this, this.getQuerySelector(), this.getMaxRenderedItems()); + this.lastOverflowCount = state.overflowCount; + this.lastFirstOverflowIndex = state.firstOverflowIndex; + this.overflowItems = state.overflowItems; + return state.firstOverflowIndex; + } + + private setupObservers() { + this.cleanupObservers(); + + if (typeof ResizeObserver !== "undefined") { + this.resizeObserver = new ResizeObserver(() => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = window.setTimeout(() => { + const isHorizontal = this.getIsHorizontal(); + const currentSize = isHorizontal ? this.offsetWidth : this.offsetHeight; + if (currentSize === this.lastContainerSize) { + return; + } + + this.lastContainerSize = currentSize; + this.cachedContainerGap = null; + this.refresh(); + }, 16); + }); + this.resizeObserver.observe(this); + } + + this.mutationObserver = new MutationObserver((mutations) => { + let shouldRefresh = false; + + for (const mutation of mutations) { + if (mutation.type === "childList") { + shouldRefresh = true; + this.lastContainerSize = 0; + continue; + } + + if (mutation.type === "attributes") { + const target = mutation.target as OverflowElement; + if (mutation.attributeName === "class" || mutation.attributeName === "style" || mutation.attributeName === "fixed" || mutation.attributeName === "hidden") { + target.overflowSize = null; + } + shouldRefresh = true; + } + } + + if (!shouldRefresh) { + return; + } + + clearTimeout(this.mutationTimeout); + this.mutationTimeout = window.setTimeout(() => this.refresh(), 16); + }); + this.mutationObserver.observe(this, { + childList: true, + subtree: false, + attributes: true, + attributeFilter: ["id", "fixed", "class", "style", "hidden"] + }); + } + + private cleanupObservers() { + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.mutationObserver?.disconnect(); + this.mutationObserver = undefined; + clearTimeout(this.resizeTimeout); + this.resizeTimeout = undefined; + clearTimeout(this.mutationTimeout); + this.mutationTimeout = undefined; + } + + private getIsHorizontal(): boolean { + return this.getAttribute("orientation") !== "vertical"; + } + + private getThreshold(): number { + const value = Number.parseFloat(this.getAttribute("threshold") ?? "25"); + return Number.isFinite(value) ? value : 25; + } + + private getStoreOverflowInMemory(): boolean { + const value = this.getAttribute("store-overflow-in-memory"); + return value === "true" || value === ""; + } + + private getMaxRenderedItems(): number { + const value = Number.parseInt(this.getAttribute("max-rendered-items") ?? "25", 10); + if (!Number.isFinite(value)) { + return 25; + } + + return value; + } + + private getQuerySelector(): string | null { + return this.getAttribute("selector") ?? this.getAttribute("selectors"); + } + + private getContainerGap(): number { + if (this.cachedContainerGap === null) { + const gap = Number.parseFloat(window.getComputedStyle(this).gap); + this.cachedContainerGap = Number.isFinite(gap) ? gap : 0; + } + + return this.cachedContainerGap; + } + } + + function refreshContainer( + container: HTMLElement, + isHorizontal: boolean, + querySelector: string | null, + threshold: number, + lastHandledState: boolean | null, + containerGap: number, + maxRenderedItems: number + ): RefreshResult { + const localQuerySelector = buildQuerySelector(querySelector); + const allItems = Array.from(container.querySelectorAll(localQuerySelector)); + const directChildren = Array.from(container.children) as OverflowElement[]; + const managedItemSet = new Set(allItems); + const managedItems: OverflowElement[] = []; + const fixedItems: OverflowElement[] = []; + const ellipsisItems: OverflowElement[] = []; + const unmanagedItems: OverflowElement[] = []; + + for (const element of directChildren) { + if (!managedItemSet.has(element)) { + unmanagedItems.push(element); + } + } + + for (const element of allItems) { + const fixedMode = element.getAttribute("fixed"); + if (fixedMode === "ellipsis") { + ellipsisItems.push(element); + continue; + } + + if (fixedMode !== null) { + fixedItems.push(element); + continue; + } + + managedItems.push(element); + } + + const orientationChanged = lastHandledState !== null && lastHandledState !== isHorizontal; + if (orientationChanged) { + for (const element of allItems) { + element.removeAttribute("overflow"); + element.overflowSize = null; + } + } + + let itemsTotalSize = threshold > 0 ? 10 : 0; + let containerMaxSize = isHorizontal ? container.offsetWidth : container.offsetHeight; + containerMaxSize -= threshold; + + let unmanagedTotal = 0; + for (let index = 0; index < unmanagedItems.length; index++) { + const size = ensureMeasuredSize(unmanagedItems[index], isHorizontal); + unmanagedTotal += size + containerGap; + itemsTotalSize += size + containerGap; + } + + let ellipsisTotal = 0; + for (let index = 0; index < ellipsisItems.length; index++) { + const element = ellipsisItems[index]; + ellipsisTotal += ensureMeasuredSize(element, isHorizontal); + if (index > 0) { + ellipsisTotal += containerGap; + } + } + + let fixedTotal = 0; + for (let index = 0; index < fixedItems.length; index++) { + const size = ensureMeasuredSize(fixedItems[index], isHorizontal); + fixedTotal += size + (index > 0 ? containerGap : 0); + itemsTotalSize += size + containerGap; + } + + const availableSize = containerMaxSize - fixedTotal - unmanagedTotal; + const desiredFlexShrink = ellipsisTotal > availableSize ? "1" : "0"; + + // When ellipsis items keep their natural width, reserve that space before classifying managed items. + if (desiredFlexShrink !== "1") { + for (let index = 0; index < ellipsisItems.length; index++) { + const size = ensureMeasuredSize(ellipsisItems[index], isHorizontal); + itemsTotalSize += size + containerGap; + } + } + + const desiredOverflowStates: boolean[] = []; + let overflowCount = 0; + let firstOverflowIndex = -1; + const forceManagedOverflow = itemsTotalSize > containerMaxSize; + + for (let index = 0; index < managedItems.length; index++) { + const element = managedItems[index]; + const size = ensureMeasuredSize(element, isHorizontal); + itemsTotalSize += size + containerGap; + + // When fixed ellipsis items already need shrinking to fit, keep managed items in overflow. + let shouldOverflow = forceManagedOverflow || desiredFlexShrink === "1"; + if (!shouldOverflow && containerMaxSize > 0) { + shouldOverflow = itemsTotalSize > containerMaxSize; + } + + desiredOverflowStates.push(shouldOverflow); + if (shouldOverflow) { + overflowCount++; + if (firstOverflowIndex < 0) { + firstOverflowIndex = index; + } + } + + } + + let overflowChanged = false; + + for (const element of ellipsisItems) { + if (element.style.flexShrink !== desiredFlexShrink) { + element.style.flexShrink = desiredFlexShrink; + } + } + + for (let index = 0; index < managedItems.length; index++) { + const element = managedItems[index]; + const shouldOverflow = desiredOverflowStates[index]; + const isOverflow = element.hasAttribute("overflow"); + + if (shouldOverflow && !isOverflow) { + element.setAttribute("overflow", ""); + overflowChanged = true; + } else if (!shouldOverflow && isOverflow) { + element.removeAttribute("overflow"); + overflowChanged = true; + } + } + + return { + overflowItems: buildOverflowItems(managedItems, desiredOverflowStates, maxRenderedItems), + overflowCount, + firstOverflowIndex, + overflowChanged, + isHorizontal + }; + } + + function getCurrentOverflowState(container: HTMLElement, querySelector: string | null, maxRenderedItems: number): OverflowState { + const localQuerySelector = buildQuerySelector(querySelector); + const managedItems = Array.from(container.querySelectorAll(localQuerySelector)) + .filter(element => !element.hasAttribute("fixed")); + + const overflowStates = managedItems.map(element => element.hasAttribute("overflow")); + let overflowCount = 0; + let firstOverflowIndex = -1; + + for (let index = 0; index < overflowStates.length; index++) { + if (!overflowStates[index]) { + continue; + } + + overflowCount++; + if (firstOverflowIndex < 0) { + firstOverflowIndex = index; + } + } + + return { + overflowItems: buildOverflowItems(managedItems, overflowStates, maxRenderedItems), + overflowCount, + firstOverflowIndex + }; + } + + function buildOverflowItems(items: OverflowElement[], overflowStates: boolean[], maxRenderedItems: number): OverflowItem[] { + const overflowItems: OverflowItem[] = []; + const unlimited = maxRenderedItems <= 0; + + for (let index = 0; index < items.length; index++) { + if (!overflowStates[index]) { + continue; + } + + overflowItems.push(toOverflowItem(items[index], index)); + if (!unlimited && overflowItems.length >= maxRenderedItems) { + break; + } + } + + return overflowItems; + } + + function toOverflowItem(element: OverflowElement, index: number): OverflowItem { + return { + Id: element.id, + Overflow: true, + Text: (element.textContent ?? "").trim(), + Fixed: element.getAttribute("fixed"), + Index: index + }; + } + + function areOverflowItemsEqual(left: OverflowItem[], right: OverflowItem[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index++) { + const leftItem = left[index]; + const rightItem = right[index]; + if (leftItem.Id !== rightItem.Id || leftItem.Text !== rightItem.Text || leftItem.Index !== rightItem.Index) { + return false; + } + } + + return true; + } + + function buildQuerySelector(querySelector: string | null): string { + if (!querySelector) { + return ":scope > :not(.fluent-overflow-more)"; + } + + return `:scope > ${querySelector}`; + } + + function ensureMeasuredSize(element: OverflowElement, isHorizontal: boolean): number { + if (element.overflowSize === null || element.overflowSize === undefined) { + const isEllipsisFixed = element.getAttribute("fixed") === "ellipsis"; + element.overflowSize = isHorizontal + ? getElementWidth(element, isEllipsisFixed) + : getElementHeight(element, isEllipsisFixed); + } + + return element.overflowSize; + } + + function getElementWidth(element: HTMLElement, useIntrinsicSize = false): number { + const style = window.getComputedStyle(element); + const width = useIntrinsicSize ? element.scrollWidth : element.offsetWidth; + const margin = Number.parseFloat(style.marginLeft) + Number.parseFloat(style.marginRight); + return width + margin; + } + + function getElementHeight(element: HTMLElement, useIntrinsicSize = false): number { + const style = window.getComputedStyle(element); + const height = useIntrinsicSize ? element.scrollHeight : element.offsetHeight; + const margin = Number.parseFloat(style.marginTop) + Number.parseFloat(style.marginBottom); + return height + margin; + } + + export const registerComponent = (blazor: Blazor, mode: StartedMode): void => { + if (typeof customElements !== "undefined" && !customElements.get("fluent-overflow")) { + customElements.define("fluent-overflow", FluentOverflow); + } + }; + + export function Refresh(id: string): void { + const element = document.getElementById(id) as FluentOverflow | null; + element?.refresh(); + } + + export function GetOverflowItems(id: string): OverflowItem[] { + const element = document.getElementById(id) as FluentOverflow | null; + return element?.getOverflowItems() ?? []; + } + + export function GetOverflowCount(id: string): number { + const element = document.getElementById(id) as FluentOverflow | null; + return element?.getOverflowCount() ?? 0; + } + + export function GetFirstOverflowIndex(id: string): number { + const element = document.getElementById(id) as FluentOverflow | null; + return element?.getFirstOverflowIndex() ?? -1; + } +} diff --git a/src/Core.Scripts/src/FluentUICustomEvents.ts b/src/Core.Scripts/src/FluentUICustomEvents.ts index 990c762026..6c8f152340 100644 --- a/src/Core.Scripts/src/FluentUICustomEvents.ts +++ b/src/Core.Scripts/src/FluentUICustomEvents.ts @@ -167,6 +167,20 @@ export namespace Microsoft.FluentUI.Blazor.FluentUICustomEvents { }); } + export function Overflow(blazor: Blazor) { + blazor.registerCustomEventType('overflowchange', { + browserEventName: 'overflowchange', + createEventArgs: (event: any) => { + return { + id: event.target?.id ?? '', + items: event.detail?.items ?? [], + overflowCount: event.detail?.overflowCount ?? 0, + firstOverflowIndex: event.detail?.firstOverflowIndex ?? -1, + }; + } + }); + } + // [^^^ Add your other custom events before this line ^^^] } diff --git a/src/Core.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts index dc958b62ae..4df2668260 100644 --- a/src/Core.Scripts/src/Startup.ts +++ b/src/Core.Scripts/src/Startup.ts @@ -4,6 +4,7 @@ import { Microsoft as FluentUIComponentsFile } from './FluentUIWebComponents'; import { Microsoft as FluentPageScriptFile } from './Components/PageScript/FluentPageScript'; import { Microsoft as FluentPopoverFile } from './Components/Popover/FluentPopover'; import { Microsoft as FluentOverlayFile } from './Components/Overlay/FluentOverlay'; +import { Microsoft as FluentOverflowFile } from './Components/Overflow/FluentOverflow'; import { Microsoft as FluentToastFile } from './Components/Toast/FluentToast'; import { Microsoft as FluentUIStylesFile } from './FluentUIStyles'; import { Microsoft as FluentUICustomEventsFile } from './FluentUICustomEvents'; @@ -17,6 +18,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup { import FluentPageScript = FluentPageScriptFile.FluentUI.Blazor.Components.PageScript; import FluentPopover = FluentPopoverFile.FluentUI.Blazor.Components.Popover; import FluentOverlay = FluentOverlayFile.FluentUI.Blazor.Components.Overlay; + import FluentOverflow = FluentOverflowFile.FluentUI.Blazor.Components.Overflow; import FluentToast = FluentToastFile.FluentUI.Blazor.Components.Toast; import FluentUIStyles = FluentUIStylesFile.FluentUI.Blazor.FluentUIStyles; import FluentUICustomEvents = FluentUICustomEventsFile.FluentUI.Blazor.FluentUICustomEvents; @@ -60,6 +62,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup { FluentPageScript.registerComponent(blazor, mode); FluentPopover.registerComponent(blazor, mode); FluentOverlay.registerComponent(blazor, mode); + FluentOverflow.registerComponent(blazor, mode); FluentToast.registerComponent(blazor, mode); // [^^ Add your other custom components before this line ^^] @@ -72,6 +75,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup { FluentUICustomEvents.RadioGroup(blazor); FluentUICustomEvents.TreeView(blazor); FluentUICustomEvents.TextInput(blazor); + FluentUICustomEvents.Overflow(blazor); // [^^^ Add your other custom events before this line ^^^] // Finishing diff --git a/src/Core/Components/AppBar/FluentAppBar.razor b/src/Core/Components/AppBar/FluentAppBar.razor index 2eb2f1352c..c5e43176d7 100644 --- a/src/Core/Components/AppBar/FluentAppBar.razor +++ b/src/Core/Components/AppBar/FluentAppBar.razor @@ -4,6 +4,7 @@
} + @if (AppsOverflow.Any()) { @@ -49,6 +51,7 @@ Immediate="true" Placeholder="Search for apps" Autofocus="true" + AutoComplete="off" tabindex="0"> diff --git a/src/Core/Components/AppBar/FluentAppBar.razor.cs b/src/Core/Components/AppBar/FluentAppBar.razor.cs index 6705e07884..c078049cdf 100644 --- a/src/Core/Components/AppBar/FluentAppBar.razor.cs +++ b/src/Core/Components/AppBar/FluentAppBar.razor.cs @@ -4,8 +4,6 @@ using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; -using Microsoft.JSInterop; -using static Microsoft.FluentUI.AspNetCore.Components.FluentOverflow; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -16,24 +14,16 @@ namespace Microsoft.FluentUI.AspNetCore.Components; ///
public partial class FluentAppBar : FluentComponentBase { - private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "Overflow/FluentOverflow.razor.js"; - private const string OVERFLOW_SELECTOR = ".fluent-appbar-item"; - private readonly InternalAppBarContext _internalAppBarContext; - private DotNetObjectReference? _dotNetHelper; private bool _showMoreItems; private string? _searchTerm = string.Empty; private IEnumerable _searchResults = []; - private Orientation _previousOrientation; - - // ToDo: Implement focus on popup /// public FluentAppBar(LibraryConfiguration configuration) : base(configuration) { Id = Identifier.NewId(); _internalAppBarContext = new(this); - _previousOrientation = Orientation; } /// @@ -73,10 +63,8 @@ public FluentAppBar(LibraryConfiguration configuration) : base(configuration) [Parameter] public RenderFragment? ChildContent { get; set; } - /// - /// Gets all app items with assigned to True. - /// internal IEnumerable AppsOverflow => _internalAppBarContext.Apps.Where(i => i.Value.Overflow == true).Select(v => v.Value); + private string OverflowElementId => $"{Id}-overflow"; /// protected virtual string? ClassValue => DefaultClassBuilder @@ -90,6 +78,15 @@ public FluentAppBar(LibraryConfiguration configuration) : base(configuration) .AddStyle("flex-direction", "column", Orientation == Orientation.Vertical) .AddStyle("height", "100%", Orientation == Orientation.Vertical) .AddStyle("width", "100%", Orientation == Orientation.Horizontal) + .Build(); + + /// + protected virtual string? OverflowStyleValue => new StyleBuilder() + .AddStyle("flex", "1 1 auto") + .AddStyle("min-height", "0", Orientation == Orientation.Vertical) + .AddStyle("min-width", "0", Orientation == Orientation.Horizontal) + .AddStyle("height", "100%", Orientation == Orientation.Vertical) + .AddStyle("width", "100%", Orientation == Orientation.Horizontal) .AddStyle("gap", "2px") .Build(); @@ -99,43 +96,24 @@ protected override void OnInitialized() _searchResults = AppsOverflow; } - /// - protected override async Task OnAfterRenderAsync(bool firstRender) + private async Task OnOverflowChangedAsync(OverflowChangedEventArgs args) { - if (firstRender) + if (!string.Equals(args.Id, OverflowElementId, StringComparison.Ordinal)) { - _dotNetHelper = DotNetObjectReference.Create(this); - // Overflow - await JSModule.ImportJavaScriptModuleAsync(JAVASCRIPT_FILE); - - await InitializeOverflowAsync(); + return; } - } - /// - protected override async Task OnParametersSetAsync() - { - if (_previousOrientation != Orientation && JSModule.Imported) - { - _previousOrientation = Orientation; - await InitializeOverflowAsync(); - } + ApplyOverflowState(args.FirstOverflowIndex); + await InvokeAsync(StateHasChanged); } /// - [JSInvokable] - public async Task OverflowRaisedAsync(OverflowItem[] items) + public async Task OverflowRaisedAsync(FluentOverflow.OverflowItem[] items) { - if (items is null || items.Length == 0) - { - return; - } - foreach (var item in items) { - if (item.Id is not null) + if (item.Id is not null && _internalAppBarContext.Apps.TryGetValue(item.Id, out var app)) { - var app = _internalAppBarContext.Apps[item.Id]; app.Overflow = item.Overflow; } } @@ -143,12 +121,12 @@ public async Task OverflowRaisedAsync(OverflowItem[] items) await InvokeAsync(StateHasChanged); } - private async Task InitializeOverflowAsync() + private void ApplyOverflowState(int firstOverflowIndex) { - if (JSModule is not null) + var orderedApps = _internalAppBarContext.Apps.Values.ToList(); + for (var index = 0; index < orderedApps.Count; index++) { - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Initialize", _dotNetHelper, Id, Orientation == Orientation.Horizontal, OVERFLOW_SELECTOR, 0); - //await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Refresh", _dotNetHelper, Id, Orientation == Orientation.Horizontal, OVERFLOW_SELECTOR, 0); + orderedApps[index].Overflow = firstOverflowIndex >= 0 && index >= firstOverflowIndex; } } @@ -164,8 +142,10 @@ private async Task HandlePopoverKeyDownAsync(FluentKeyCodeEventArgs args) var handler = args.Key switch { KeyCode.Enter => HandlePopoverToggleAsync(!_showMoreItems), - KeyCode.Right => HandlePopoverToggleAsync(value: true), - KeyCode.Left => HandlePopoverToggleAsync(value: false), + KeyCode.Right when Orientation == Orientation.Vertical => HandlePopoverToggleAsync(value: true), + KeyCode.Left when Orientation == Orientation.Vertical => HandlePopoverToggleAsync(value: false), + KeyCode.Down when Orientation == Orientation.Horizontal => HandlePopoverToggleAsync(value: true), + KeyCode.Up when Orientation == Orientation.Horizontal => HandlePopoverToggleAsync(value: false), _ => Task.CompletedTask, }; await handler; @@ -184,8 +164,6 @@ private async Task HandlePopoverToggleAsync(bool value) { await PopoverVisibilityChanged.InvokeAsync(_showMoreItems); } - - await Task.CompletedTask; } private void HandleSearch() @@ -196,8 +174,7 @@ private void HandleSearch() } else { - var filteredAppBarItems = AppsOverflow.Where(i => i.Text.Contains(_searchTerm, StringComparison.CurrentCultureIgnoreCase)).ToList(); - _searchResults = filteredAppBarItems; + _searchResults = AppsOverflow.Where(i => i.Text.Contains(_searchTerm, StringComparison.CurrentCultureIgnoreCase)).ToList(); } } } diff --git a/src/Core/Components/AppBar/FluentAppBar.razor.css b/src/Core/Components/AppBar/FluentAppBar.razor.css index a7c79468ac..eade05ec37 100644 --- a/src/Core/Components/AppBar/FluentAppBar.razor.css +++ b/src/Core/Components/AppBar/FluentAppBar.razor.css @@ -18,6 +18,7 @@ height: calc(var(--appbar-item-size) - var(--appbar-item-height-adjustment)); } + .fluent-appbar .fluent-appbar-item:hover { background-color: var(--colorNeutralBackground5Hover); } @@ -106,4 +107,3 @@ display: block; } - diff --git a/src/Core/Components/Overflow/FluentOverflow.razor b/src/Core/Components/Overflow/FluentOverflow.razor index 2e2ac9daac..397316da7f 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor +++ b/src/Core/Components/Overflow/FluentOverflow.razor @@ -9,16 +9,18 @@ selector="@ItemSelector" visible-on-load="@(VisibleOnLoad ? "true" : "false")" store-overflow-in-memory="@(StoreOverflowInMemory ? "true" : null)" + max-rendered-items="@MaxRenderedItems" + @onoverflowchange="@OnOverflowChangedAsync" @attributes="@AdditionalAttributes"> @ChildContent
- @if (MoreButtonTemplate != null) + @if (MoreTemplate != null) { - @MoreButtonTemplate(this) + @MoreTemplate(this) } else { - + }
diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.cs b/src/Core/Components/Overflow/FluentOverflow.razor.cs index debc51e361..defcd03fe2 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.cs +++ b/src/Core/Components/Overflow/FluentOverflow.razor.cs @@ -11,9 +11,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public partial class FluentOverflow : FluentComponentBase { - private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "Overflow/FluentOverflow.razor.js"; private readonly List _items = []; - private DotNetObjectReference? _dotNetHelper; + private int _overflowCount; /// protected virtual string? ClassValue => DefaultClassBuilder @@ -33,7 +32,7 @@ public FluentOverflow(LibraryConfiguration configuration) : base(configuration) internal FluentOverflow(LibraryConfiguration configuration, List items) : this(configuration) { - _items = items; + SetOverflowItems(items, items.Count(item => item.Overflow)); } /// @@ -51,10 +50,10 @@ internal FluentOverflow(LibraryConfiguration configuration, List i public bool VisibleOnLoad { get; set; } = true; /// - /// Gets or sets the template to display the More button. + /// Gets or sets the template to display the overflow trigger content. /// [Parameter] - public RenderFragment? MoreButtonTemplate { get; set; } + public RenderFragment? MoreTemplate { get; set; } /// /// Gets or sets the orientation of the items flow. @@ -82,14 +81,21 @@ internal FluentOverflow(LibraryConfiguration configuration, List i [Parameter] public bool StoreOverflowInMemory { get; set; } + /// + /// Gets or sets the maximum number of overflow items returned to the Blazor wrapper. + /// Values less than or equal to zero return all overflow items. + /// + [Parameter] + public int MaxRenderedItems { get; set; } = 25; + /// /// Gets or sets whether the tooltip is displayed using the TooltipService. /// [Parameter] - public bool UseTooltipService { get; set; } = false; + public bool UseTooltipService { get; set; } /// - /// Event raised when an item enters or leaves the current panel. + /// Event raised when overflow items change. /// [Parameter] public EventCallback> OnOverflowRaised { get; set; } @@ -102,9 +108,14 @@ internal FluentOverflow(LibraryConfiguration configuration, List i public RenderFragment? ChildContent { get; set; } /// - /// Gets all items with assigned to true. + /// Gets the rendered overflow items returned from the web component. + /// + public IEnumerable ItemsOverflow => _items; + + /// + /// Gets the total number of overflowed items. /// - public IEnumerable ItemsOverflow => _items.Where(i => i.Overflow); + public int OverflowCount => _overflowCount; /// /// Gets the unique identifier associated to the more button ([Id]-more). @@ -112,12 +123,11 @@ internal FluentOverflow(LibraryConfiguration configuration, List i public string IdMoreButton => $"{Id}-more"; /// - protected virtual string? MoreButtonStyleValue => DefaultStyleBuilder - .AddStyle("visibility", "hidden", !ItemsOverflow.Any()) + protected virtual string? MoreButtonStyleValue => new StyleBuilder() + .AddStyle("visibility", "hidden", OverflowCount == 0) .AddStyle("anchor-name", $"--{IdMoreButton}") .Build(); - private bool IsHorizontal => Orientation == Orientation.Horizontal; private string? ItemSelector => string.IsNullOrWhiteSpace(Selector) ? Selectors : Selector; /// @@ -125,10 +135,6 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) { - await JSModule.ImportJavaScriptModuleAsync(JAVASCRIPT_FILE); - - _dotNetHelper = DotNetObjectReference.Create(this); - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Initialize", _dotNetHelper, Id, IsHorizontal, ItemSelector, 25); VisibleOnLoad = true; } } @@ -136,25 +142,22 @@ protected override async Task OnAfterRenderAsync(bool firstRender) /// /// Asynchronously refreshes the overflow state of the associated UI element. /// - /// Call this method to update the overflow indicators when the content or layout of the element - /// changes. This method has no effect if the underlying JavaScript module is not loaded. - /// A task that represents the asynchronous refresh operation. public async Task RefreshAsync() { - if (JSModule is not null) + if (JSRuntime is null) { - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Refresh", _dotNetHelper, Id, IsHorizontal, ItemSelector, 25); + return; } + + await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Components.Overflow.Refresh", Id); + await LoadOverflowItemsAsync(); } /// - [JSInvokable] public async Task OverflowRaisedAsync(OverflowItem[] items) { - _items.Clear(); - _items.AddRange(items ?? []); + SetOverflowItems(items, items.Count(item => item.Overflow)); - // Raise event if (OnOverflowRaised.HasDelegate) { await OnOverflowRaised.InvokeAsync(ItemsOverflow); @@ -163,16 +166,67 @@ public async Task OverflowRaisedAsync(OverflowItem[] items) await InvokeAsync(StateHasChanged); } - /// - protected override async ValueTask DisposeAsync(IJSObjectReference jsModule) + private async Task OnOverflowChangedAsync(OverflowChangedEventArgs args) { - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Dispose", Id); - _dotNetHelper?.Dispose(); + if (!string.Equals(args.Id, Id, StringComparison.Ordinal)) + { + return; + } + + SetOverflowItems(args.Items, args.OverflowCount); + + if (OnOverflowRaised.HasDelegate) + { + await OnOverflowRaised.InvokeAsync(ItemsOverflow); + } + + await InvokeAsync(StateHasChanged); + } + + private async Task LoadOverflowItemsAsync() + { + var items = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems", [Id]); + var overflowCount = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount", [Id]); + SetOverflowItems(items, overflowCount); + } + + private void SetOverflowItems(IEnumerable? items, int overflowCount) + { + _items.Clear(); + _overflowCount = Math.Max(overflowCount, 0); + + if (items is null) + { + return; + } + + _items.AddRange(items.Where(item => item.Overflow)); + } + + private void SetOverflowItems(IEnumerable? items, int overflowCount) + { + _items.Clear(); + _overflowCount = Math.Max(overflowCount, 0); + + if (items is null) + { + return; + } + + _items.AddRange(items + .Where(item => item.Overflow) + .Select(item => new OverflowItem + { + Id = item.Id, + Overflow = item.Overflow, + Text = item.Text, + Fixed = item.Fixed, + Index = item.Index + })); } /// - /// Represents an item that may be subject to overflow handling, typically used in scenarios where content or data - /// exceeds a predefined limit. + /// Represents an item that may be subject to overflow handling. /// public class OverflowItem { diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.css b/src/Core/Components/Overflow/FluentOverflow.razor.css index fc8c9e4dbe..4638495903 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.css +++ b/src/Core/Components/Overflow/FluentOverflow.razor.css @@ -2,19 +2,30 @@ display: flex; flex-direction: row; overflow-x: hidden; + overflow-y: hidden; gap: 5px; } .fluent-overflow[orientation="vertical"] { display: flex; flex-direction: column; + overflow-x: hidden; overflow-y: hidden; } +/* Prevent flex from shrinking items — sizes must be measured at natural width */ +.fluent-overflow > * { + flex-shrink: 0; +} + .fluent-overflow-more { min-width: 32px; max-width: 32px; flex-shrink: 0; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; } .fluent-overflow-more[style*="visibility: hidden"] { @@ -30,9 +41,10 @@ } .fluent-overflow > *[fixed='ellipsis'] { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - max-width: fit-content; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + max-width: fit-content; + flex-shrink: 1; } diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.ts b/src/Core/Components/Overflow/FluentOverflow.razor.ts deleted file mode 100644 index e31db5be76..0000000000 --- a/src/Core/Components/Overflow/FluentOverflow.razor.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { DotNet } from "../../../Core.Scripts/src/d-ts/Microsoft.JSInterop"; - -export namespace Microsoft.FluentUI.Blazor.Overflow { - interface OverflowItem { - Id: string; - Overflow: boolean; - Text: string; - Fixed?: string | null; - Index?: number; - } - - interface OverflowElement extends HTMLElement { - overflowSize?: number | null; - } - - interface OverflowElementHost extends HTMLElement { - refresh?: () => void; - getOverflowItems?: () => OverflowItem[]; - } - - interface ObserverContext { - resizeObserver: ResizeObserver; - mutationObserver: MutationObserver; - resizeTimeout?: number; - lastHandledState: boolean | null; - listener?: EventListener; - } - - interface RefreshResult { - items: OverflowItem[]; - overflowChanged: boolean; - isHorizontal: boolean; - } - - const observerContexts = new Map(); - - class FluentOverflow extends HTMLElement { - private resizeObserver?: ResizeObserver; - private mutationObserver?: MutationObserver; - private resizeTimeout?: number; - private lastHandledState: boolean | null = null; - private overflowItems: OverflowItem[] = []; - - static get observedAttributes() { - return ["orientation", "selector", "selectors", "threshold", "visible-on-load", "store-overflow-in-memory"]; - } - - connectedCallback() { - this.classList.add("fluent-overflow"); - this.setupObservers(); - this.refresh(); - this.setAttribute("visible-on-load", "true"); - this.style.visibility = ""; - } - - disconnectedCallback() { - this.cleanupObservers(); - } - - attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { - if (oldValue === newValue) { - return; - } - - if (name === "visible-on-load") { - this.style.visibility = newValue === "false" ? "hidden" : ""; - return; - } - - this.refresh(); - } - - refresh() { - const result = refreshContainer(this, this.getIsHorizontal(), this.getQuerySelector(), this.getThreshold(), this.lastHandledState); - this.lastHandledState = result.isHorizontal; - - if (this.getStoreOverflowInMemory()) { - this.overflowItems = result.items; - } - - if (result.overflowChanged) { - if (!this.getStoreOverflowInMemory()) { - this.overflowItems = result.items; - } - - this.dispatchEvent(new CustomEvent("overflowchange", { - detail: { - items: result.items, - overflowCount: result.items.filter(i => i.Overflow).length - }, - bubbles: true, - composed: true - })); - } - } - - getOverflowItems(): OverflowItem[] { - if (this.overflowItems.length > 0) { - return [...this.overflowItems]; - } - - return getCurrentItems(this, this.getQuerySelector()); - } - - getOverflowCount(): number { - return this.getOverflowItems().filter(i => i.Overflow).length; - } - - private setupObservers() { - this.cleanupObservers(); - - if (typeof ResizeObserver !== "undefined") { - this.resizeObserver = new ResizeObserver(() => { - clearTimeout(this.resizeTimeout); - this.resizeTimeout = window.setTimeout(() => this.refresh(), 100); - }); - this.resizeObserver.observe(this); - } - - this.mutationObserver = new MutationObserver(() => this.refresh()); - this.mutationObserver.observe(this, { childList: true, subtree: false }); - } - - private cleanupObservers() { - this.resizeObserver?.disconnect(); - this.resizeObserver = undefined; - this.mutationObserver?.disconnect(); - this.mutationObserver = undefined; - clearTimeout(this.resizeTimeout); - this.resizeTimeout = undefined; - } - - private getIsHorizontal(): boolean { - const orientation = this.getAttribute("orientation"); - return orientation !== "vertical"; - } - - private getThreshold(): number { - const value = Number.parseFloat(this.getAttribute("threshold") ?? "25"); - return Number.isFinite(value) ? value : 25; - } - - private getStoreOverflowInMemory(): boolean { - const value = this.getAttribute("store-overflow-in-memory"); - return value === "true" || value === ""; - } - - private getQuerySelector(): string | null { - return this.getAttribute("selector") ?? this.getAttribute("selectors"); - } - } - - if (!customElements.get("fluent-overflow")) { - customElements.define("fluent-overflow", FluentOverflow); - } - - export function Initialize(dotNetHelper: DotNet.DotNetObject, id: string, isHorizontal: boolean, querySelector: string | null, threshold: number): void { - const container = document.getElementById(id) as OverflowElementHost | null; - if (!container) { - return; - } - - Dispose(id); - - const listener: EventListener = (event) => { - const overflowEvent = event as CustomEvent<{ items: OverflowItem[] }>; - const items = overflowEvent.detail?.items ?? []; - dotNetHelper.invokeMethodAsync("OverflowRaisedAsync", items); - }; - - container.addEventListener("overflowchange", listener); - - if (container.tagName.toLowerCase() === "fluent-overflow" && typeof container.refresh === "function") { - container.setAttribute("orientation", isHorizontal ? "horizontal" : "vertical"); - container.setAttribute("threshold", String(threshold)); - if (querySelector) { - container.setAttribute("selector", querySelector); - } else { - container.removeAttribute("selector"); - } - - observerContexts.set(id, { - resizeObserver: new ResizeObserver(() => { }), - mutationObserver: new MutationObserver(() => { }), - listener, - lastHandledState: isHorizontal - }); - - container.refresh(); - return; - } - - const context: ObserverContext = { - resizeObserver: new ResizeObserver(() => { }), - mutationObserver: new MutationObserver(() => { }), - listener, - lastHandledState: isHorizontal - }; - - context.mutationObserver = new MutationObserver(mutations => { - for (const mutation of mutations) { - if (mutation.type !== "childList") { - continue; - } - - const node = mutation.addedNodes.length > 0 ? mutation.addedNodes[0] : mutation.removedNodes[0]; - if (node?.nodeType !== Node.ELEMENT_NODE) { - continue; - } - - const selector = buildQuerySelector(querySelector); - if (!(node as Element).matches(selector)) { - continue; - } - - Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); - break; - } - }); - - context.resizeObserver = new ResizeObserver(() => { - clearTimeout(context.resizeTimeout); - context.resizeTimeout = window.setTimeout(() => { - Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); - }, 100); - }); - - context.resizeObserver.observe(container); - context.mutationObserver.observe(container, { childList: true, subtree: false }); - observerContexts.set(id, context); - - Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); - } - - export function Refresh(dotNetHelper: DotNet.DotNetObject, id: string, isHorizontal: boolean, querySelector: string | null, threshold: number): void { - const container = document.getElementById(id) as OverflowElementHost | null; - if (!container) { - return; - } - - if (container.tagName.toLowerCase() === "fluent-overflow" && typeof container.refresh === "function") { - container.refresh(); - if (typeof container.getOverflowItems === "function") { - dotNetHelper.invokeMethodAsync("OverflowRaisedAsync", container.getOverflowItems()); - } - return; - } - - const context = observerContexts.get(id); - const result = refreshContainer(container, isHorizontal, querySelector, threshold, context?.lastHandledState ?? null); - if (context) { - context.lastHandledState = isHorizontal; - } - - if (result.overflowChanged) { - dotNetHelper.invokeMethodAsync("OverflowRaisedAsync", result.items); - } - } - - export function Dispose(id: string): void { - const context = observerContexts.get(id); - if (!context) { - return; - } - - const container = document.getElementById(id); - if (container && context.listener) { - container.removeEventListener("overflowchange", context.listener); - } - - context.resizeObserver.disconnect(); - context.mutationObserver.disconnect(); - if (context.resizeTimeout) { - clearTimeout(context.resizeTimeout); - } - - observerContexts.delete(id); - } - - function refreshContainer(container: HTMLElement, isHorizontal: boolean, querySelector: string | null, threshold: number, lastHandledState: boolean | null): RefreshResult { - const localQuerySelector = buildQuerySelector(querySelector); - const allItems = Array.from(container.querySelectorAll(localQuerySelector)); - const items = allItems.filter(element => !element.hasAttribute("fixed")); - const fixedItems = allItems.filter(element => element.hasAttribute("fixed") && element.getAttribute("fixed") !== "ellipsis"); - const ellipsisItems = allItems.filter(element => element.getAttribute("fixed") === "ellipsis"); - - let ellipsisTotal = 0; - let containerGap = parseFloat(window.getComputedStyle(container).gap); - if (!containerGap) { - containerGap = 0; - } - - ellipsisItems.forEach((element, index) => { - element.overflowSize = isHorizontal ? getElementWidth(element) : getElementHeight(element); - ellipsisTotal += element.overflowSize || 0; - if (index > 0) { - ellipsisTotal += containerGap; - } - }); - - let itemsTotalSize = threshold > 0 ? 10 : 0; - let containerMaxSize = isHorizontal ? container.offsetWidth : container.offsetHeight; - let overflowChanged = false; - containerMaxSize -= threshold; - - const availableSize = containerMaxSize - fixedItems.reduce((sum, element, index) => sum + (element.overflowSize || 0) + (index > 0 ? containerGap : 0), 0); - - if (ellipsisTotal > availableSize) { - ellipsisItems.forEach(element => { - element.style.flexShrink = "1"; - }); - } else { - ellipsisItems.forEach(element => { - element.style.flexShrink = "0"; - }); - } - - if (lastHandledState !== null && lastHandledState !== isHorizontal) { - allItems.forEach(element => { - element.removeAttribute("overflow"); - element.overflowSize = null; - }); - } - - fixedItems.forEach(element => { - element.overflowSize = isHorizontal ? getElementWidth(element) : getElementHeight(element); - element.overflowSize = (element.overflowSize || 0) + containerGap; - itemsTotalSize += element.overflowSize; - }); - - items.forEach(element => { - const isOverflow = element.hasAttribute("overflow"); - if (!isOverflow) { - element.overflowSize = isHorizontal ? getElementWidth(element) : getElementHeight(element); - element.overflowSize = (element.overflowSize || 0) + containerGap; - } - itemsTotalSize += element.overflowSize || 0; - if (containerMaxSize > 0) { - if (itemsTotalSize > containerMaxSize) { - if (!isOverflow) { - element.setAttribute("overflow", ""); - overflowChanged = true; - } - } else { - if (isOverflow) { - element.removeAttribute("overflow"); - overflowChanged = true; - } - } - } - }); - - return { - items: toOverflowItems(items), - overflowChanged, - isHorizontal - }; - } - - function getCurrentItems(container: HTMLElement, querySelector: string | null): OverflowItem[] { - const localQuerySelector = buildQuerySelector(querySelector); - const items = Array.from(container.querySelectorAll(localQuerySelector)) - .filter(element => !element.hasAttribute("fixed")); - return toOverflowItems(items); - } - - function toOverflowItems(items: OverflowElement[]): OverflowItem[] { - return items.map((element, index) => ({ - Id: element.id, - Overflow: element.hasAttribute("overflow"), - Text: element.innerText.trim(), - Fixed: element.getAttribute("fixed"), - Index: index - })); - } - - function buildQuerySelector(querySelector: string | null): string { - if (!querySelector) { - return ":scope > :not(.fluent-overflow-more)"; - } - return `:scope > ${querySelector}`; - } - - function getElementWidth(element: HTMLElement): number { - const style = window.getComputedStyle(element); - const width = element.offsetWidth; - const margin = parseFloat(style.marginLeft) + parseFloat(style.marginRight); - return width + margin; - } - - function getElementHeight(element: HTMLElement): number { - const style = window.getComputedStyle(element); - const height = element.offsetHeight; - const margin = parseFloat(style.marginTop) + parseFloat(style.marginBottom); - return height + margin; - } -} diff --git a/src/Core/Events/EventHandlers.cs b/src/Core/Events/EventHandlers.cs index 02c542ad74..c9f4bed81b 100644 --- a/src/Core/Events/EventHandlers.cs +++ b/src/Core/Events/EventHandlers.cs @@ -39,6 +39,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; [EventHandler("onclosecolumnheaderui", typeof(EventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("onradiochange", typeof(RadioEventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("ontextimmediate", typeof(ChangeEventArgs), enableStopPropagation: true, enablePreventDefault: true)] +[EventHandler("onoverflowchange", typeof(OverflowChangedEventArgs), enableStopPropagation: true, enablePreventDefault: true)] public static class EventHandlers { } diff --git a/src/Core/Events/OverflowChangedEventArgs.cs b/src/Core/Events/OverflowChangedEventArgs.cs new file mode 100644 index 0000000000..4d9a2b1fff --- /dev/null +++ b/src/Core/Events/OverflowChangedEventArgs.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Event arguments for the FluentOverflow overflow change event. +/// +public class OverflowChangedEventArgs : EventArgs +{ + /// + /// Gets or sets the ID of the overflow component. + /// + public string? Id { get; set; } + + /// + /// Gets or sets the rendered overflow items included in the event payload. + /// + public IReadOnlyList? Items { get; set; } + + /// + /// Gets or sets the total number of items currently in overflow. + /// + public int OverflowCount { get; set; } + + /// + /// Gets or sets the index of the first overflowed item in DOM order. + /// + public int FirstOverflowIndex { get; set; } = -1; +} diff --git a/src/Core/Events/OverflowChangedItem.cs b/src/Core/Events/OverflowChangedItem.cs new file mode 100644 index 0000000000..1740e8aec7 --- /dev/null +++ b/src/Core/Events/OverflowChangedItem.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents one overflow item state passed by the overflowchange custom event. +/// +public class OverflowChangedItem +{ + /// + /// Gets or sets the item identifier. + /// + public string? Id { get; set; } + + /// + /// Gets or sets whether the item is currently in overflow. + /// + public bool Overflow { get; set; } + + /// + /// Gets or sets the item text. + /// + public string? Text { get; set; } + + /// + /// Gets or sets the fixed behavior. + /// + public string? Fixed { get; set; } + + /// + /// Gets or sets the item index. + /// + public int Index { get; set; } +} diff --git a/tests/Core/Components/Overflow/FluentOverflowTests.razor b/tests/Core/Components/Overflow/FluentOverflowTests.razor index ef73264464..3731beb392 100644 --- a/tests/Core/Components/Overflow/FluentOverflowTests.razor +++ b/tests/Core/Components/Overflow/FluentOverflowTests.razor @@ -1,4 +1,4 @@ -@using Xunit +@using Xunit @using static Microsoft.FluentUI.AspNetCore.Components.FluentOverflow @inherits FluentUITestContext @@ -33,12 +33,22 @@ } [Fact] - public void FluentOverflow_Custom_MoreButtonTemplate() + public void FluentOverflow_Renders_MaxRenderedItems_Attribute() + { + var cut = Render(@
+
Item 1
+
); + + var container = cut.Find("fluent-overflow"); + Assert.Equal("5", container.GetAttribute("max-rendered-items")); + } + [Fact] + public void FluentOverflow_Custom_MoreTemplate() { var cut = Render(@
- +

Custom More

-
+
Item 1
From b3451f894193cfe974625869cbf720f858d9bf8e Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Thu, 25 Jun 2026 15:57:01 +0200 Subject: [PATCH 3/9] - Add more tests - Remove internal constructor --- .../Overflow/FluentOverflow.razor.cs | 5 - .../Overflow/FluentOverflowTests.razor | 199 ++++++++++++++++++ .../Overflow/OverflowChangedEventArgsTests.cs | 93 ++++++++ 3 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.cs b/src/Core/Components/Overflow/FluentOverflow.razor.cs index defcd03fe2..8c6579764a 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.cs +++ b/src/Core/Components/Overflow/FluentOverflow.razor.cs @@ -30,11 +30,6 @@ public FluentOverflow(LibraryConfiguration configuration) : base(configuration) Id = Identifier.NewId(); } - internal FluentOverflow(LibraryConfiguration configuration, List items) : this(configuration) - { - SetOverflowItems(items, items.Count(item => item.Overflow)); - } - /// /// Gets or sets the template to display elements. /// diff --git a/tests/Core/Components/Overflow/FluentOverflowTests.razor b/tests/Core/Components/Overflow/FluentOverflowTests.razor index 3731beb392..a50557f9d5 100644 --- a/tests/Core/Components/Overflow/FluentOverflowTests.razor +++ b/tests/Core/Components/Overflow/FluentOverflowTests.razor @@ -1,3 +1,4 @@ +@using System.Reflection @using Xunit @using static Microsoft.FluentUI.AspNetCore.Components.FluentOverflow @inherits FluentUITestContext @@ -107,4 +108,202 @@ Assert.NotNull(tooltip); Assert.Equal("below-start", tooltip.GetAttribute("positioning")); } + + [Fact] + public async Task FluentOverflow_RefreshAsync_LoadsOverflowItemsAndCount() + { + var cut = Render(@
+
Item 1
+
Item 2
+
); + + JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Overflow.Refresh", args => (string?)args.Arguments[0] == "overflow") + .SetVoidResult(); + JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems", args => (string?)args.Arguments[0] == "overflow") + .SetResult([ + new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "fixed", Index = 0 }, + new OverflowItem { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 1 } + ]); + JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount", args => (string?)args.Arguments[0] == "overflow") + .SetResult(3); + + var overflow = cut.FindComponent(); + await overflow.Instance.RefreshAsync(); + + Assert.Equal(3, overflow.Instance.OverflowCount); + var items = overflow.Instance.ItemsOverflow.ToList(); + Assert.Single(items); + Assert.Equal("item1", items[0].Id); + Assert.Equal("Item 1", items[0].Text); + Assert.Contains(JSInterop.Invocations, x => x.Identifier == "Microsoft.FluentUI.Blazor.Components.Overflow.Refresh"); + Assert.Contains(JSInterop.Invocations, x => x.Identifier == "Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems"); + Assert.Contains(JSInterop.Invocations, x => x.Identifier == "Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount"); + } + + [Fact] + public async Task FluentOverflow_RefreshAsync_NoJsRuntime_DoesNothing() + { + var overflow = new FluentOverflow(new LibraryConfiguration()); + + await overflow.RefreshAsync(); + + Assert.Equal(0, overflow.OverflowCount); + Assert.Empty(overflow.ItemsOverflow); + } + + [Fact] + public async Task FluentOverflow_OnOverflowChangedAsync_UpdatesStateAndRaisesCallback() + { + var overflowRaised = false; + var cut = Render(@
+
Item 1
+
Item 2
+
); + + var overflow = cut.FindComponent(); + var method = GetPrivateMethod("OnOverflowChangedAsync", typeof(OverflowChangedEventArgs)); + var args = new OverflowChangedEventArgs + { + Id = "overflow", + OverflowCount = 2, + FirstOverflowIndex = 1, + Items = + [ + new OverflowChangedItem { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "fixed", Index = 0 }, + new OverflowChangedItem { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 1 } + ] + }; + + await ((Task)method.Invoke(overflow.Instance, [args])!); + + Assert.True(overflowRaised); + Assert.Equal(2, overflow.Instance.OverflowCount); + var items = overflow.Instance.ItemsOverflow.ToList(); + Assert.Single(items); + Assert.Equal("item1", items[0].Id); + Assert.Equal("fixed", items[0].Fixed); + } + + [Fact] + public async Task FluentOverflow_OnOverflowChangedAsync_IgnoresDifferentComponentId() + { + var overflowRaised = false; + var cut = Render(@
+
Item 1
+
); + + var overflow = cut.FindComponent(); + await overflow.Instance.OverflowRaisedAsync([ + new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Index = 0 } + ]); + overflowRaised = false; + + var method = GetPrivateMethod("OnOverflowChangedAsync", typeof(OverflowChangedEventArgs)); + var args = new OverflowChangedEventArgs + { + Id = "other-overflow", + OverflowCount = 0, + Items = + [ + new OverflowChangedItem { Id = "item1", Overflow = false, Text = "Item 1", Index = 0 } + ] + }; + + await ((Task)method.Invoke(overflow.Instance, [args])!); + + Assert.False(overflowRaised); + Assert.Equal(1, overflow.Instance.OverflowCount); + Assert.Single(overflow.Instance.ItemsOverflow); + } + + [Fact] + public async Task FluentOverflow_LoadOverflowItemsAsync_LoadsItemsAndCountFromJs() + { + var cut = Render(@
+
Item 1
+
); + + JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems", args => (string?)args.Arguments[0] == "overflow") + .SetResult([ + new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "ellipsis", Index = 4 }, + new OverflowItem { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 5 } + ]); + JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount", args => (string?)args.Arguments[0] == "overflow") + .SetResult(7); + + var overflow = cut.FindComponent(); + var method = GetPrivateMethod("LoadOverflowItemsAsync"); + await ((Task)method.Invoke(overflow.Instance, [])!); + + Assert.Equal(7, overflow.Instance.OverflowCount); + var items = overflow.Instance.ItemsOverflow.ToList(); + Assert.Single(items); + Assert.Equal("item1", items[0].Id); + Assert.Equal("ellipsis", items[0].Fixed); + Assert.Equal(4, items[0].Index); + } + + [Fact] + public void FluentOverflow_SetOverflowItems_OverflowItemOverload_SetsExpectedState() + { + var cut = Render(@
+
Item 1
+
); + var overflow = cut.FindComponent(); + var method = GetPrivateMethod("SetOverflowItems", typeof(IEnumerable), typeof(int)); + + method.Invoke(overflow.Instance, new object?[] + { + new OverflowItem[] + { + new() { Id = "item1", Overflow = true, Text = "Item 1", Index = 0 }, + new() { Id = "item2", Overflow = false, Text = "Item 2", Index = 1 } + }, + 5 + }); + + Assert.Equal(5, overflow.Instance.OverflowCount); + var items = overflow.Instance.ItemsOverflow.ToList(); + Assert.Single(items); + Assert.Equal("item1", items[0].Id); + + method.Invoke(overflow.Instance, new object?[] { null, -2 }); + Assert.Equal(0, overflow.Instance.OverflowCount); + Assert.Empty(overflow.Instance.ItemsOverflow); + } + + [Fact] + public void FluentOverflow_SetOverflowItems_OverflowChangedItemOverload_SetsExpectedState() + { + var cut = Render(@
+
Item 1
+
); + var overflow = cut.FindComponent(); + var method = GetPrivateMethod("SetOverflowItems", typeof(IEnumerable), typeof(int)); + + method.Invoke(overflow.Instance, new object?[] + { + new OverflowChangedItem[] + { + new() { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "fixed", Index = 2 }, + new() { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 3 } + }, + 6 + }); + + Assert.Equal(6, overflow.Instance.OverflowCount); + var items = overflow.Instance.ItemsOverflow.ToList(); + Assert.Single(items); + Assert.Equal("item1", items[0].Id); + Assert.Equal("fixed", items[0].Fixed); + Assert.Equal(2, items[0].Index); + + method.Invoke(overflow.Instance, new object?[] { null, -1 }); + Assert.Equal(0, overflow.Instance.OverflowCount); + Assert.Empty(overflow.Instance.ItemsOverflow); + } + + private static MethodInfo GetPrivateMethod(string name, params Type[] parameterTypes) => + typeof(FluentOverflow).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic, null, parameterTypes, null) + ?? throw new InvalidOperationException($"Private method '{name}' not found."); } diff --git a/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs new file mode 100644 index 0000000000..a751d169e0 --- /dev/null +++ b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Overflow; + +public class OverflowChangedEventArgsTests +{ + [Fact] + public void OverflowChangedEventArgs_Defaults_AreExpected() + { + // Act + var args = new OverflowChangedEventArgs(); + + // Assert + Assert.Null(args.Id); + Assert.Null(args.Items); + Assert.Equal(0, args.OverflowCount); + Assert.Equal(-1, args.FirstOverflowIndex); + } + + [Fact] + public void OverflowChangedEventArgs_Sets_AllProperties() + { + // Arrange + IReadOnlyList items = + [ + new() + { + Id = "item-1", + Overflow = true, + Text = "Item 1", + Fixed = "fixed", + Index = 3 + } + ]; + + // Act + var args = new OverflowChangedEventArgs + { + Id = "overflow-1", + Items = items, + OverflowCount = 5, + FirstOverflowIndex = 3 + }; + + // Assert + Assert.Equal("overflow-1", args.Id); + Assert.Same(items, args.Items); + Assert.Equal(5, args.OverflowCount); + Assert.Equal(3, args.FirstOverflowIndex); + } +} + +public class OverflowChangedItemTests +{ + [Fact] + public void OverflowChangedItem_Defaults_AreExpected() + { + // Act + var item = new OverflowChangedItem(); + + // Assert + Assert.Null(item.Id); + Assert.False(item.Overflow); + Assert.Null(item.Text); + Assert.Null(item.Fixed); + Assert.Equal(0, item.Index); + } + + [Fact] + public void OverflowChangedItem_Sets_AllProperties() + { + // Act + var item = new OverflowChangedItem + { + Id = "item-2", + Overflow = true, + Text = "Item 2", + Fixed = "ellipsis", + Index = 4 + }; + + // Assert + Assert.Equal("item-2", item.Id); + Assert.True(item.Overflow); + Assert.Equal("Item 2", item.Text); + Assert.Equal("ellipsis", item.Fixed); + Assert.Equal(4, item.Index); + } +} From 5b408e2500159734abbe607a0963534877ce3a61 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Thu, 25 Jun 2026 16:30:00 +0200 Subject: [PATCH 4/9] - Process review comments --- .../src/Components/Overflow/FluentOverflow.ts | 26 ++++++++++++- src/Core.Scripts/src/FluentUICustomEvents.ts | 1 + .../Components/AppBar/FluentAppBar.razor.cs | 24 +++++++++--- src/Core/Events/OverflowChangedEventArgs.cs | 7 +++- .../Components/AppBar/FluentAppBarTests.razor | 38 +++++++++++++++++++ .../Overflow/OverflowChangedEventArgsTests.cs | 6 ++- 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts b/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts index 8a062c9ea4..9ae0889bf1 100644 --- a/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts +++ b/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts @@ -17,6 +17,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { overflowItems: OverflowItem[]; overflowCount: number; firstOverflowIndex: number; + orderedItemIds: string[]; } interface RefreshResult extends OverflowState { @@ -34,6 +35,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { private cachedContainerGap: number | null = null; private lastOverflowCount = 0; private lastFirstOverflowIndex = -1; + private lastOrderedItemIds: string[] = []; private overflowItems: OverflowItem[] = []; static get observedAttributes() { @@ -47,6 +49,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { this.lastContainerSize = 0; this.lastOverflowCount = 0; this.lastFirstOverflowIndex = -1; + this.lastOrderedItemIds = []; this.classList.add("fluent-overflow"); this.setupObservers(); this.refresh(); @@ -94,10 +97,12 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { const payloadChanged = result.overflowChanged || this.lastOverflowCount !== result.overflowCount || this.lastFirstOverflowIndex !== result.firstOverflowIndex + || !areStringArraysEqual(this.lastOrderedItemIds, result.orderedItemIds) || !areOverflowItemsEqual(this.overflowItems, result.overflowItems); this.lastOverflowCount = result.overflowCount; this.lastFirstOverflowIndex = result.firstOverflowIndex; + this.lastOrderedItemIds = result.orderedItemIds; const storeInMemory = this.getStoreOverflowInMemory(); if (storeInMemory || payloadChanged) { @@ -109,7 +114,8 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { detail: { items: result.overflowItems, overflowCount: result.overflowCount, - firstOverflowIndex: result.firstOverflowIndex + firstOverflowIndex: result.firstOverflowIndex, + orderedItemIds: result.orderedItemIds }, bubbles: true, composed: true @@ -393,6 +399,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { overflowItems: buildOverflowItems(managedItems, desiredOverflowStates, maxRenderedItems), overflowCount, firstOverflowIndex, + orderedItemIds: managedItems.map(item => item.id ?? ""), overflowChanged, isHorizontal }; @@ -421,7 +428,8 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { return { overflowItems: buildOverflowItems(managedItems, overflowStates, maxRenderedItems), overflowCount, - firstOverflowIndex + firstOverflowIndex, + orderedItemIds: managedItems.map(item => item.id ?? "") }; } @@ -469,6 +477,20 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { return true; } + function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) { + return false; + } + } + + return true; + } + function buildQuerySelector(querySelector: string | null): string { if (!querySelector) { return ":scope > :not(.fluent-overflow-more)"; diff --git a/src/Core.Scripts/src/FluentUICustomEvents.ts b/src/Core.Scripts/src/FluentUICustomEvents.ts index 6c8f152340..661d7ec64a 100644 --- a/src/Core.Scripts/src/FluentUICustomEvents.ts +++ b/src/Core.Scripts/src/FluentUICustomEvents.ts @@ -176,6 +176,7 @@ export namespace Microsoft.FluentUI.Blazor.FluentUICustomEvents { items: event.detail?.items ?? [], overflowCount: event.detail?.overflowCount ?? 0, firstOverflowIndex: event.detail?.firstOverflowIndex ?? -1, + orderedItemIds: event.detail?.orderedItemIds ?? [], }; } }); diff --git a/src/Core/Components/AppBar/FluentAppBar.razor.cs b/src/Core/Components/AppBar/FluentAppBar.razor.cs index c078049cdf..b1a18afb6f 100644 --- a/src/Core/Components/AppBar/FluentAppBar.razor.cs +++ b/src/Core/Components/AppBar/FluentAppBar.razor.cs @@ -103,7 +103,7 @@ private async Task OnOverflowChangedAsync(OverflowChangedEventArgs args) return; } - ApplyOverflowState(args.FirstOverflowIndex); + ApplyOverflowState(args.FirstOverflowIndex, args.OrderedItemIds); await InvokeAsync(StateHasChanged); } @@ -121,12 +121,26 @@ public async Task OverflowRaisedAsync(FluentOverflow.OverflowItem[] items) await InvokeAsync(StateHasChanged); } - private void ApplyOverflowState(int firstOverflowIndex) + private void ApplyOverflowState(int firstOverflowIndex, IReadOnlyList? orderedItemIds) { - var orderedApps = _internalAppBarContext.Apps.Values.ToList(); - for (var index = 0; index < orderedApps.Count; index++) + foreach (var app in _internalAppBarContext.Apps.Values) { - orderedApps[index].Overflow = firstOverflowIndex >= 0 && index >= firstOverflowIndex; + app.Overflow = false; + } + + if (orderedItemIds is null || orderedItemIds.Count == 0 || firstOverflowIndex < 0) + { + return; + } + + for (var index = 0; index < orderedItemIds.Count; index++) + { + if (!_internalAppBarContext.Apps.TryGetValue(orderedItemIds[index], out var app)) + { + continue; + } + + app.Overflow = index >= firstOverflowIndex; } } diff --git a/src/Core/Events/OverflowChangedEventArgs.cs b/src/Core/Events/OverflowChangedEventArgs.cs index 4d9a2b1fff..f6242796a7 100644 --- a/src/Core/Events/OverflowChangedEventArgs.cs +++ b/src/Core/Events/OverflowChangedEventArgs.cs @@ -25,7 +25,12 @@ public class OverflowChangedEventArgs : EventArgs public int OverflowCount { get; set; } /// - /// Gets or sets the index of the first overflowed item in DOM order. + /// Gets or sets the index of the first overflowed managed item (selector match, excluding fixed items). /// public int FirstOverflowIndex { get; set; } = -1; + + /// + /// Gets or sets the ordered item IDs in the same DOM order used by overflow calculations. + /// + public IReadOnlyList? OrderedItemIds { get; set; } } diff --git a/tests/Core/Components/AppBar/FluentAppBarTests.razor b/tests/Core/Components/AppBar/FluentAppBarTests.razor index fcc7ba5b0f..e6d2085062 100644 --- a/tests/Core/Components/AppBar/FluentAppBarTests.razor +++ b/tests/Core/Components/AppBar/FluentAppBarTests.razor @@ -64,6 +64,44 @@ Assert.NotNull(popup); } + [Fact] + public async Task FluentAppBar_OnOverflowChangedAsync_UsesOrderedItemIds() + { + // Arrange + var items = new List + { + new TestAppBarItem { Id = "1", Text = "Item 1", IconRest = new Samples.Icons.Samples.Info() }, + new TestAppBarItem { Id = "2", Text = "Item 2", IconRest = new Samples.Icons.Samples.Warning() }, + new TestAppBarItem { Id = "3", Text = "Item 3", IconRest = new Samples.Icons.Samples.Info() } + }; + var cut = Render( + @ + ); + var method = typeof(FluentAppBar).GetMethod("OnOverflowChangedAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var context = GetPrivateField(cut.Instance, "_internalAppBarContext"); + Assert.NotNull(method); + Assert.NotNull(context); + + var args = new OverflowChangedEventArgs + { + Id = $"{cut.Instance.Id}-overflow", + FirstOverflowIndex = 1, + OrderedItemIds = ["3", "2", "1"] + }; + + // Act + var result = method!.Invoke(cut.Instance, [args]); + if (result is Task task) + { + await task; + } + + // Assert + Assert.False(context!.Apps["3"].Overflow ?? false); + Assert.True(context.Apps["2"].Overflow ?? false); + Assert.True(context.Apps["1"].Overflow ?? false); + } + [Fact] public async Task FluentAppBar_NoPopoverSearch() { diff --git a/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs index a751d169e0..6369bd96a4 100644 --- a/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs +++ b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs @@ -19,6 +19,7 @@ public void OverflowChangedEventArgs_Defaults_AreExpected() Assert.Null(args.Items); Assert.Equal(0, args.OverflowCount); Assert.Equal(-1, args.FirstOverflowIndex); + Assert.Null(args.OrderedItemIds); } [Fact] @@ -36,6 +37,7 @@ public void OverflowChangedEventArgs_Sets_AllProperties() Index = 3 } ]; + IReadOnlyList orderedItemIds = ["item-0", "item-1", "item-2"]; // Act var args = new OverflowChangedEventArgs @@ -43,7 +45,8 @@ public void OverflowChangedEventArgs_Sets_AllProperties() Id = "overflow-1", Items = items, OverflowCount = 5, - FirstOverflowIndex = 3 + FirstOverflowIndex = 3, + OrderedItemIds = orderedItemIds }; // Assert @@ -51,6 +54,7 @@ public void OverflowChangedEventArgs_Sets_AllProperties() Assert.Same(items, args.Items); Assert.Equal(5, args.OverflowCount); Assert.Equal(3, args.FirstOverflowIndex); + Assert.Same(orderedItemIds, args.OrderedItemIds); } } From 623629bcbe71e13991f00a086afe00ba81a6e3e7 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 3 Jul 2026 12:48:22 +0200 Subject: [PATCH 5/9] Update migration doc to align with formatting changes --- .../GetStarted/Migration/MigrationFluentOverflow.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md index d873ca4e3e..510677db06 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentOverflow.md @@ -4,29 +4,29 @@ route: /Migration/Overflow hidden: true --- -### Web component migration 💥 +## Web component migration `FluentOverflow` now wraps a `` web component. Overflow calculation and resize are handled there, with Blazor receiving overflow state updates. -### Content model changes 💥 +## Content model changes - The old dedicated overflow item component is removed. - Overflow now works with direct children of `FluentOverflow`. - Per-item behavior is expressed with HTML attributes on the child element (for example `fixed="fixed"` or `fixed="ellipsis"`). -### New/updated parameters +## New/updated parameters - `Selector` — CSS selector for direct children to include in overflow handling. - `MaxRenderedItems` (`int`, default `25`) — limits the number of overflow items returned in the payload (`<= 0` means unlimited). - `StoreOverflowInMemory` (`bool`) — keeps overflow payload items in JavaScript memory. -### Removed APIs 💥 +## Removed APIs - `FluentOverflowItem` component — removed; use direct child content instead. - `OverflowItemFixed` enum — removed; use the child `fixed` attribute values instead. -### Migration example +## Migration example ```xml From 762b436180d0c6898e0701f457c0a4623d74bb89 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 13 Jul 2026 16:42:17 +0200 Subject: [PATCH 6/9] Rocess review comments --- .../Examples/OverflowCustomExample.razor | 2 +- .../OverflowMaxRenderedItemsExample.razor | 2 +- .../Components/Overflow/FluentOverflow.md | 27 +-- .../src/Components/Overflow/FluentOverflow.ts | 156 ++++++++++++------ .../Components/AppBar/FluentAppBar.razor.cs | 2 +- .../Components/AppBar/FluentAppBar.razor.css | 1 - .../Components/Overflow/FluentOverflow.razor | 6 +- .../Overflow/FluentOverflow.razor.cs | 39 +---- src/Core/Components/Overflow/OverflowItem.cs | 37 +++++ src/Core/Components/Overflow/OverflowState.cs | 32 ++++ src/Core/Enums/OverflowBehavior.cs | 25 +++ src/Core/Events/OverflowChangedItem.cs | 4 +- .../Overflow/OverflowChangedEventArgsTests.cs | 6 +- 13 files changed, 226 insertions(+), 113 deletions(-) create mode 100644 src/Core/Components/Overflow/OverflowItem.cs create mode 100644 src/Core/Components/Overflow/OverflowState.cs create mode 100644 src/Core/Enums/OverflowBehavior.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowCustomExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowCustomExample.razor index 7f733d65c0..ccc0361e97 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowCustomExample.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowCustomExample.razor @@ -33,7 +33,7 @@ List Items = new List() { Catalog[0], Catalog[1] }; - void OverflowHandler(IEnumerable items) + void OverflowHandler(IEnumerable items) { var text = String.Join("; ", items.Select(i => i.Text)); diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor index 46caa9afae..d89b264c42 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor @@ -33,7 +33,7 @@ private int _overflowCount; private int _renderedInPayloadCount; - private void OnOverflowRaised(IEnumerable items) + private void OnOverflowRaised(IEnumerable items) { _renderedInPayloadCount = items.Count(); _overflowCount = _overflow?.OverflowCount ?? 0; diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md index 4f55177420..bde42b3a6b 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/FluentOverflow.md @@ -12,15 +12,6 @@ The `FluentOverflow` component is used to manage and display a collection of ite {{ OverflowDefault }} -## Overflow with ellipsis - -In the following example, the first element will always be displayed (fixed), but an ellipse (...) -will be added when the container size is too small. - -> [!Note] the element must be able to display this ellipse, which is the case for text (like below) but not for the FluentBadge. - -{{ OverflowEllipsis }} - ## Overflow not visible on load With below example the `VisibleOnLoad` parameter is set to false.Make sure the screen dimension is small enough to show an overflow badge with count. @@ -35,7 +26,7 @@ This example shows a fully customized More button and tooltip content, and inclu ## Selector-based overflow -This example uses `Selector=".overflow-item"` so only matching children are overflow-managed; the non-matching `fixed` badges stay visible. +This example uses `Selector=".overflow-item"` so only matching children are overflow-managed; the non-matching `behavior="fixed"` badges stay visible. {{ OverflowSelectorExample }} ## MaxRenderedItems payload cap @@ -43,23 +34,23 @@ This example uses `Selector=".overflow-item"` so only matching children are over This example sets `MaxRenderedItems="2"` and shows the difference between `OverflowCount` (total) and `ItemsOverflow` (rendered subset). {{ OverflowMaxRenderedItemsExample }} -## Fixed item modes +## Item overflow behavior modes -The `fixed` attribute on direct children supports these modes: +The `behavior` attribute on direct children supports these modes: | Value | Behavior | | --- | --- | -| `fixed="fixed"` | Item always remains visible at full size and does not move to overflow. | -| `fixed="ellipsis"` | Item always remains visible but can shrink with text ellipsis when space is limited. | +| `behavior="fixed"` | Item always remains visible at full size and does not move to overflow. | +| `behavior="ellipsis"` | Item always remains visible but can shrink with text ellipsis when space is limited. | Notes: -- `fixed` is an HTML attribute on child elements (not a `FluentOverflow` parameter). -- Use `Selector` to control which children are overflow-managed; non-selected children can still be marked with `fixed`. +- `behavior` is an HTML attribute on child elements (not a `FluentOverflow` parameter). +- Use `Selector` to control which children are overflow-managed; non-selected children can still be marked with `behavior`. -## Multiple fixed items +## Multiple items with ellipsis behavior -This example demonstrates multiple fixed ellipsis items (`fixed="ellipsis"`) combined with normal overflowed items. +This example demonstrates multiple items with ellipsis behavior (`behavior="ellipsis"`) combined with normal overflowed items. {{ OverflowMultipleFixedItemsExample }} ## API FluentOverflow diff --git a/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts b/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts index 9ae0889bf1..59d4ee1a30 100644 --- a/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts +++ b/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts @@ -1,18 +1,34 @@ import { StartedMode } from "../../d-ts/StartedMode"; +/** + * Fluent Overflow Component Implementation + * + * Manages the overflow behavior of child elements in a container with constrained space. + * Automatically moves items to an overflow menu when there isn't enough space, with support for: + * - Horizontal and vertical layouts + * - Fixed and ellipsis item behaviors + * - CSS selector-based item filtering + * - Dynamic payload capping + * + * The component dispatches an 'overflowchange' event when the overflow state changes, + * and exposes methods for retrieving the current overflow state via JSInterop. + */ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { + /** Represents an item that may be subject to overflow handling. */ interface OverflowItem { Id: string; Overflow: boolean; Text: string; - Fixed?: string | null; + Behavior?: string | null; Index?: number; } + /** Extended HTMLElement interface to track cached overflow size measurements. */ interface OverflowElement extends HTMLElement { overflowSize?: number | null; } + /** Represents the current overflow state including items, counts, and ordering. */ interface OverflowState { overflowItems: OverflowItem[]; overflowCount: number; @@ -20,11 +36,16 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { orderedItemIds: string[]; } + /** Extended overflow state with change detection and orientation metadata. */ interface RefreshResult extends OverflowState { overflowChanged: boolean; isHorizontal: boolean; } + /** + * Custom HTML element that manages overflow behavior for constrained containers. + * Observes size changes and DOM mutations to dynamically calculate which items should overflow. + */ class FluentOverflow extends HTMLElement { private resizeObserver?: ResizeObserver; private mutationObserver?: MutationObserver; @@ -42,6 +63,10 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { return ["orientation", "selector", "selectors", "threshold", "visible-on-load", "store-overflow-in-memory", "max-rendered-items"]; } + /** + * Lifecycle hook called when the element is inserted into the DOM. + * Initializes observers, applies styles, and performs initial overflow calculation. + */ connectedCallback() { const visibleOnLoad = this.getAttribute("visible-on-load") !== "false"; @@ -60,10 +85,18 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { } } + /** + * Lifecycle hook called when the element is removed from the DOM. + * Cleans up all observers to prevent memory leaks. + */ disconnectedCallback() { this.cleanupObservers(); } + /** + * Lifecycle hook called when an observed attribute changes. + * Re-calculates overflow state when relevant attributes are modified. + */ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { if (oldValue === newValue) { return; @@ -82,7 +115,12 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { this.refresh(); } + /** + * Recalculates overflow state and dispatches change event if needed. + * Called on initial render, after DOM mutations, or when container size changes. + */ refresh() { + // Calculate the new overflow state based on current layout and items const result = refreshContainer( this, this.getIsHorizontal(), @@ -94,21 +132,25 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { ); this.lastHandledState = result.isHorizontal; + // Detect if the overflow state has meaningfully changed const payloadChanged = result.overflowChanged || this.lastOverflowCount !== result.overflowCount || this.lastFirstOverflowIndex !== result.firstOverflowIndex || !areStringArraysEqual(this.lastOrderedItemIds, result.orderedItemIds) || !areOverflowItemsEqual(this.overflowItems, result.overflowItems); + // Update cached state values this.lastOverflowCount = result.overflowCount; this.lastFirstOverflowIndex = result.firstOverflowIndex; this.lastOrderedItemIds = result.orderedItemIds; + // Store in-memory if requested or if state changed const storeInMemory = this.getStoreOverflowInMemory(); if (storeInMemory || payloadChanged) { this.overflowItems = result.overflowItems; } + // Notify listeners of state change via custom event if (payloadChanged) { this.dispatchEvent(new CustomEvent("overflowchange", { detail: { @@ -123,48 +165,44 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { } } - getOverflowItems(): OverflowItem[] { - if (this.overflowItems.length > 0 || this.lastOverflowCount === 0) { - return [...this.overflowItems]; - } - - const state = getCurrentOverflowState(this, this.getQuerySelector(), this.getMaxRenderedItems()); - this.lastOverflowCount = state.overflowCount; - this.lastFirstOverflowIndex = state.firstOverflowIndex; - this.overflowItems = state.overflowItems; - return [...state.overflowItems]; - } - - getOverflowCount(): number { - if (this.overflowItems.length > 0 || this.lastOverflowCount > 0) { - return this.lastOverflowCount; - } - - const state = getCurrentOverflowState(this, this.getQuerySelector(), this.getMaxRenderedItems()); - this.lastOverflowCount = state.overflowCount; - this.lastFirstOverflowIndex = state.firstOverflowIndex; - this.overflowItems = state.overflowItems; - return state.overflowCount; - } - - getFirstOverflowIndex(): number { + /** + * Retrieves the current overflow state. + * Returns cached state if available; otherwise calculates fresh state. + */ + getOverflowState(): OverflowState { if (this.overflowItems.length > 0 || this.lastOverflowCount > 0) { - return this.lastFirstOverflowIndex; + return { + overflowItems: [...this.overflowItems], + overflowCount: this.lastOverflowCount, + firstOverflowIndex: this.lastFirstOverflowIndex, + orderedItemIds: this.lastOrderedItemIds + }; } const state = getCurrentOverflowState(this, this.getQuerySelector(), this.getMaxRenderedItems()); this.lastOverflowCount = state.overflowCount; this.lastFirstOverflowIndex = state.firstOverflowIndex; this.overflowItems = state.overflowItems; - return state.firstOverflowIndex; - } - + return { + overflowItems: [...state.overflowItems], + overflowCount: state.overflowCount, + firstOverflowIndex: state.firstOverflowIndex, + orderedItemIds: state.orderedItemIds + }; + } + + /** + * Sets up ResizeObserver and MutationObserver to track layout changes. + * Triggers refresh with debouncing to optimize performance. + */ private setupObservers() { this.cleanupObservers(); + // Watch for container size changes and recalculate overflow accordingly if (typeof ResizeObserver !== "undefined") { this.resizeObserver = new ResizeObserver(() => { clearTimeout(this.resizeTimeout); + // Debounce resize events (16ms) to batch multiple rapid size changes this.resizeTimeout = window.setTimeout(() => { const isHorizontal = this.getIsHorizontal(); const currentSize = isHorizontal ? this.offsetWidth : this.offsetHeight; @@ -180,11 +218,13 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { this.resizeObserver.observe(this); } + // Watch for DOM mutations (child additions/removals, attribute changes) this.mutationObserver = new MutationObserver((mutations) => { let shouldRefresh = false; for (const mutation of mutations) { if (mutation.type === "childList") { + // Child elements added or removed; reset size cache shouldRefresh = true; this.lastContainerSize = 0; continue; @@ -192,7 +232,8 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { if (mutation.type === "attributes") { const target = mutation.target as OverflowElement; - if (mutation.attributeName === "class" || mutation.attributeName === "style" || mutation.attributeName === "fixed" || mutation.attributeName === "hidden") { + // Invalidate size cache for elements whose visual properties changed + if (mutation.attributeName === "class" || mutation.attributeName === "style" || mutation.attributeName === "behavior" || mutation.attributeName === "hidden") { target.overflowSize = null; } shouldRefresh = true; @@ -204,16 +245,20 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { } clearTimeout(this.mutationTimeout); + // Debounce mutation events (16ms) to batch rapid DOM changes this.mutationTimeout = window.setTimeout(() => this.refresh(), 16); }); this.mutationObserver.observe(this, { childList: true, subtree: false, attributes: true, - attributeFilter: ["id", "fixed", "class", "style", "hidden"] + attributeFilter: ["id", "behavior", "class", "style", "hidden"] }); } + /** + * Disconnects and cleans up all observers and timers to prevent memory leaks. + */ private cleanupObservers() { this.resizeObserver?.disconnect(); this.resizeObserver = undefined; @@ -287,7 +332,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { } for (const element of allItems) { - const fixedMode = element.getAttribute("fixed"); + const fixedMode = element.getAttribute("behavior"); if (fixedMode === "ellipsis") { ellipsisItems.push(element); continue; @@ -408,7 +453,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { function getCurrentOverflowState(container: HTMLElement, querySelector: string | null, maxRenderedItems: number): OverflowState { const localQuerySelector = buildQuerySelector(querySelector); const managedItems = Array.from(container.querySelectorAll(localQuerySelector)) - .filter(element => !element.hasAttribute("fixed")); + .filter(element => !element.hasAttribute("behavior")); const overflowStates = managedItems.map(element => element.hasAttribute("overflow")); let overflowCount = 0; @@ -456,7 +501,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { Id: element.id, Overflow: true, Text: (element.textContent ?? "").trim(), - Fixed: element.getAttribute("fixed"), + Behavior: element.getAttribute("behavior"), Index: index }; } @@ -501,7 +546,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { function ensureMeasuredSize(element: OverflowElement, isHorizontal: boolean): number { if (element.overflowSize === null || element.overflowSize === undefined) { - const isEllipsisFixed = element.getAttribute("fixed") === "ellipsis"; + const isEllipsisFixed = element.getAttribute("behavior") === "ellipsis"; element.overflowSize = isHorizontal ? getElementWidth(element, isEllipsisFixed) : getElementHeight(element, isEllipsisFixed); @@ -524,29 +569,44 @@ export namespace Microsoft.FluentUI.Blazor.Components.Overflow { return height + margin; } + /** + * Registers the FluentOverflow custom element with the browser's custom elements registry. + * Called during component initialization by the Blazor runtime. + * + * @param blazor - The Blazor runtime instance + * @param mode - The component startup mode + */ export const registerComponent = (blazor: Blazor, mode: StartedMode): void => { if (typeof customElements !== "undefined" && !customElements.get("fluent-overflow")) { customElements.define("fluent-overflow", FluentOverflow); } }; + /** + * Triggers a refresh of the overflow state for the specified container element. + * Used by Blazor to manually recalculate overflow when needed. + * + * @param id - The HTML id of the FluentOverflow container + */ export function Refresh(id: string): void { const element = document.getElementById(id) as FluentOverflow | null; element?.refresh(); } - export function GetOverflowItems(id: string): OverflowItem[] { + /** + * Retrieves the current overflow state for the specified container element. + * Exposed to Blazor via JSInterop for reading overflow items and counts. + * + * @param id - The HTML id of the FluentOverflow container + * @returns The current OverflowState or default empty state if element not found + */ + export function GetOverflowState(id: string): OverflowState { const element = document.getElementById(id) as FluentOverflow | null; - return element?.getOverflowItems() ?? []; - } - - export function GetOverflowCount(id: string): number { - const element = document.getElementById(id) as FluentOverflow | null; - return element?.getOverflowCount() ?? 0; - } - - export function GetFirstOverflowIndex(id: string): number { - const element = document.getElementById(id) as FluentOverflow | null; - return element?.getFirstOverflowIndex() ?? -1; + return element?.getOverflowState() ?? { + overflowItems: [], + overflowCount: 0, + firstOverflowIndex: -1, + orderedItemIds: [] + }; } } diff --git a/src/Core/Components/AppBar/FluentAppBar.razor.cs b/src/Core/Components/AppBar/FluentAppBar.razor.cs index b1a18afb6f..9beaf4f96d 100644 --- a/src/Core/Components/AppBar/FluentAppBar.razor.cs +++ b/src/Core/Components/AppBar/FluentAppBar.razor.cs @@ -108,7 +108,7 @@ private async Task OnOverflowChangedAsync(OverflowChangedEventArgs args) } /// - public async Task OverflowRaisedAsync(FluentOverflow.OverflowItem[] items) + public async Task OverflowRaisedAsync(OverflowItem[] items) { foreach (var item in items) { diff --git a/src/Core/Components/AppBar/FluentAppBar.razor.css b/src/Core/Components/AppBar/FluentAppBar.razor.css index eade05ec37..4e07e6c834 100644 --- a/src/Core/Components/AppBar/FluentAppBar.razor.css +++ b/src/Core/Components/AppBar/FluentAppBar.razor.css @@ -106,4 +106,3 @@ .fluent-appbar-item[inpopover=true]:not(:hover) a.active svg[part="icon-rest"] { display: block; } - diff --git a/src/Core/Components/Overflow/FluentOverflow.razor b/src/Core/Components/Overflow/FluentOverflow.razor index 397316da7f..edfedeb5a0 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor +++ b/src/Core/Components/Overflow/FluentOverflow.razor @@ -6,14 +6,14 @@ class="@ClassValue" style="@StyleValue" orientation="@(Orientation.ToAttributeValue())" - selector="@ItemSelector" - visible-on-load="@(VisibleOnLoad ? "true" : "false")" + selector="@Selector" + visible-on-load="@(VisibleOnLoad ? "true" : null)" store-overflow-in-memory="@(StoreOverflowInMemory ? "true" : null)" max-rendered-items="@MaxRenderedItems" @onoverflowchange="@OnOverflowChangedAsync" @attributes="@AdditionalAttributes"> @ChildContent -
+
@if (MoreTemplate != null) { @MoreTemplate(this) diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.cs b/src/Core/Components/Overflow/FluentOverflow.razor.cs index 8c6579764a..b4f286c9ff 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.cs +++ b/src/Core/Components/Overflow/FluentOverflow.razor.cs @@ -63,13 +63,6 @@ public FluentOverflow(LibraryConfiguration configuration) : base(configuration) [Parameter] public string? Selector { get; set; } = string.Empty; - /// - /// Gets or sets the CSS selectors of the items to include in the overflow. - /// Use instead. - /// - [Parameter] - public string? Selectors { get; set; } = string.Empty; - /// /// Gets or sets whether overflow items are cached in JavaScript memory. /// @@ -123,8 +116,6 @@ public FluentOverflow(LibraryConfiguration configuration) : base(configuration) .AddStyle("anchor-name", $"--{IdMoreButton}") .Build(); - private string? ItemSelector => string.IsNullOrWhiteSpace(Selector) ? Selectors : Selector; - /// protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -180,9 +171,8 @@ private async Task OnOverflowChangedAsync(OverflowChangedEventArgs args) private async Task LoadOverflowItemsAsync() { - var items = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems", [Id]); - var overflowCount = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount", [Id]); - SetOverflowItems(items, overflowCount); + var state = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowState", [Id]); + SetOverflowItems(state.OverflowItems, state.OverflowCount); } private void SetOverflowItems(IEnumerable? items, int overflowCount) @@ -215,29 +205,8 @@ private void SetOverflowItems(IEnumerable? items, int overf Id = item.Id, Overflow = item.Overflow, Text = item.Text, - Fixed = item.Fixed, - Index = item.Index + Behavior = item.Behavior, + Index = item.Index, })); } - - /// - /// Represents an item that may be subject to overflow handling. - /// - public class OverflowItem - { - /// - public string? Id { get; set; } - - /// - public bool Overflow { get; set; } - - /// - public string? Text { get; set; } - - /// - public string? Fixed { get; set; } - - /// - public int Index { get; set; } - } } diff --git a/src/Core/Components/Overflow/OverflowItem.cs b/src/Core/Components/Overflow/OverflowItem.cs new file mode 100644 index 0000000000..f3d37d5ebc --- /dev/null +++ b/src/Core/Components/Overflow/OverflowItem.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents an item that may be subject to overflow handling. +/// +public record OverflowItem +{ + /// + /// Gets the unique identifier of the overflow item. + /// + public string? Id { get; init; } + + /// + /// Gets a value indicating whether the item is in overflow. + /// + public bool Overflow { get; init; } + + /// + /// Gets the text associated with the overflow item. + /// + public string? Text { get; init; } + + /// + /// Gets the overflow behavior of the item. + /// + public OverflowBehavior? Behavior { get; init; } + + /// + /// Gets the index of the overflow item. + /// + public int Index { get; init; } +} + diff --git a/src/Core/Components/Overflow/OverflowState.cs b/src/Core/Components/Overflow/OverflowState.cs new file mode 100644 index 0000000000..bea4e55773 --- /dev/null +++ b/src/Core/Components/Overflow/OverflowState.cs @@ -0,0 +1,32 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents the current overflow state. +/// +public record OverflowState +{ + /// + /// Gets the items that are currently in overflow. + /// + public OverflowItem[]? OverflowItems { get; init; } + + /// + /// Gets the count of items in overflow. + /// + public int OverflowCount { get; init; } + + /// + /// Gets the index of the first item in overflow. + /// + public int FirstOverflowIndex { get; init; } + + /// + /// Gets the ordered item identifiers. + /// + public string[]? OrderedItemIds { get; init; } +} + diff --git a/src/Core/Enums/OverflowBehavior.cs b/src/Core/Enums/OverflowBehavior.cs new file mode 100644 index 0000000000..bdf5e32363 --- /dev/null +++ b/src/Core/Enums/OverflowBehavior.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Specifies the overflow behavior for an item. +/// +public enum OverflowBehavior +{ + /// + /// The item is kept fixed in place and not subject to overflow. + /// + [Description("fixed")] + Fixed, + + /// + /// The item can be hidden with an ellipsis indicator when space is constrained. + /// + [Description("ellipsis")] + Ellipsis, +} diff --git a/src/Core/Events/OverflowChangedItem.cs b/src/Core/Events/OverflowChangedItem.cs index 1740e8aec7..7d535d13fe 100644 --- a/src/Core/Events/OverflowChangedItem.cs +++ b/src/Core/Events/OverflowChangedItem.cs @@ -25,9 +25,9 @@ public class OverflowChangedItem public string? Text { get; set; } /// - /// Gets or sets the fixed behavior. + /// Gets or sets the overflow behavior. /// - public string? Fixed { get; set; } + public OverflowBehavior? Behavior { get; set; } /// /// Gets or sets the item index. diff --git a/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs index 6369bd96a4..fb08676d8b 100644 --- a/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs +++ b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs @@ -33,7 +33,7 @@ public void OverflowChangedEventArgs_Sets_AllProperties() Id = "item-1", Overflow = true, Text = "Item 1", - Fixed = "fixed", + Behavior = OverflowBehavior.Fixed, Index = 3 } ]; @@ -83,7 +83,7 @@ public void OverflowChangedItem_Sets_AllProperties() Id = "item-2", Overflow = true, Text = "Item 2", - Fixed = "ellipsis", + Behavior = OverflowBehavior.Ellipsis, Index = 4 }; @@ -91,7 +91,7 @@ public void OverflowChangedItem_Sets_AllProperties() Assert.Equal("item-2", item.Id); Assert.True(item.Overflow); Assert.Equal("Item 2", item.Text); - Assert.Equal("ellipsis", item.Fixed); + Assert.Equal(OverflowBehavior.Ellipsis, item.Behavior); Assert.Equal(4, item.Index); } } From e5746fcb608eb8cc6711723eb745fb2bcfa919f5 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 13 Jul 2026 16:44:32 +0200 Subject: [PATCH 7/9] More commetn processing (or actually , I forgot one :)) --- src/Core/Components/Overflow/FluentOverflow.razor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.cs b/src/Core/Components/Overflow/FluentOverflow.razor.cs index b4f286c9ff..6045dd3f9d 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.cs +++ b/src/Core/Components/Overflow/FluentOverflow.razor.cs @@ -38,8 +38,8 @@ public FluentOverflow(LibraryConfiguration configuration) : base(configuration) /// /// Gets or sets whether overflow items are visible immediately on load. - /// Set to false to hide items until the component is fully loaded, - /// preventing a flickering effect. Defaults to true. + /// Set to to hide items until the component is fully loaded, + /// preventing a flickering effect. Defaults to . /// [Parameter] public bool VisibleOnLoad { get; set; } = true; From dee5dbdd3469cbe034b3b38ac54af8dd724ff3d4 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Tue, 14 Jul 2026 12:39:14 +0200 Subject: [PATCH 8/9] - Apply renaming in tests too - Removed tests that try to set overflow item count. This is controlled by the web component now - Add tests for OverflowItemState - Coverage for all *overflow*.* code is 100% --- tests/Core/Components/Overflow/FluentOverflowTests.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Core/Components/Overflow/FluentOverflowTests.razor b/tests/Core/Components/Overflow/FluentOverflowTests.razor index a50557f9d5..2183080df5 100644 --- a/tests/Core/Components/Overflow/FluentOverflowTests.razor +++ b/tests/Core/Components/Overflow/FluentOverflowTests.razor @@ -121,8 +121,8 @@ .SetVoidResult(); JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems", args => (string?)args.Arguments[0] == "overflow") .SetResult([ - new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "fixed", Index = 0 }, - new OverflowItem { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 1 } + new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Behavior = OverflowBehavior.Fixed, Index = 0 }, + new OverflowItem { Id = "item2", Overflow = false, Text = "Item 2", Behavior = null, Index = 1 } ]); JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount", args => (string?)args.Arguments[0] == "overflow") .SetResult(3); From 34888fd6bf70a76e538faf034cbb11b5fab223f3 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Tue, 14 Jul 2026 14:01:07 +0200 Subject: [PATCH 9/9] - Rename Fixed->Behavior in tests too - Removed tests that manipulated overflow items/count. This is now handeled by web component - All *overflow*.* components now 100% covered -Change method call to work with null values --- .../Overflow/FluentOverflow.razor.cs | 8 +- .../Overflow/FluentOverflowTests.razor | 83 +++---------------- .../Overflow/OverflowChangedEventArgsTests.cs | 2 +- .../Overflow/OverflowItemStateTests.cs | 56 +++++++++++++ .../Components/Overflow/OverflowItemTests.cs | 45 ++++++++++ 5 files changed, 118 insertions(+), 76 deletions(-) create mode 100644 tests/Core/Components/Overflow/OverflowItemStateTests.cs create mode 100644 tests/Core/Components/Overflow/OverflowItemTests.cs diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.cs b/src/Core/Components/Overflow/FluentOverflow.razor.cs index 6045dd3f9d..dce42f6c2e 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor.cs +++ b/src/Core/Components/Overflow/FluentOverflow.razor.cs @@ -172,19 +172,19 @@ private async Task OnOverflowChangedAsync(OverflowChangedEventArgs args) private async Task LoadOverflowItemsAsync() { var state = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowState", [Id]); - SetOverflowItems(state.OverflowItems, state.OverflowCount); + SetOverflowItems(state?.OverflowItems, state?.OverflowCount ?? 0); } private void SetOverflowItems(IEnumerable? items, int overflowCount) { - _items.Clear(); - _overflowCount = Math.Max(overflowCount, 0); - if (items is null) { return; } + _items.Clear(); + _overflowCount = Math.Max(overflowCount, 0); + _items.AddRange(items.Where(item => item.Overflow)); } diff --git a/tests/Core/Components/Overflow/FluentOverflowTests.razor b/tests/Core/Components/Overflow/FluentOverflowTests.razor index 2183080df5..4a33cb4179 100644 --- a/tests/Core/Components/Overflow/FluentOverflowTests.razor +++ b/tests/Core/Components/Overflow/FluentOverflowTests.razor @@ -119,25 +119,22 @@ JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Overflow.Refresh", args => (string?)args.Arguments[0] == "overflow") .SetVoidResult(); - JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems", args => (string?)args.Arguments[0] == "overflow") + JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowStatus", args => (string?)args.Arguments[0] == "overflow") .SetResult([ new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Behavior = OverflowBehavior.Fixed, Index = 0 }, new OverflowItem { Id = "item2", Overflow = false, Text = "Item 2", Behavior = null, Index = 1 } ]); - JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount", args => (string?)args.Arguments[0] == "overflow") - .SetResult(3); + JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowStatus", args => (string?)args.Arguments[0] == "overflow") + .SetResult(0); var overflow = cut.FindComponent(); await overflow.Instance.RefreshAsync(); - Assert.Equal(3, overflow.Instance.OverflowCount); + Assert.Equal(0, overflow.Instance.OverflowCount); var items = overflow.Instance.ItemsOverflow.ToList(); - Assert.Single(items); - Assert.Equal("item1", items[0].Id); - Assert.Equal("Item 1", items[0].Text); + Assert.Empty(items); Assert.Contains(JSInterop.Invocations, x => x.Identifier == "Microsoft.FluentUI.Blazor.Components.Overflow.Refresh"); - Assert.Contains(JSInterop.Invocations, x => x.Identifier == "Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems"); - Assert.Contains(JSInterop.Invocations, x => x.Identifier == "Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount"); + Assert.Contains(JSInterop.Invocations, x => x.Identifier == "Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowState"); } [Fact] @@ -169,8 +166,8 @@ FirstOverflowIndex = 1, Items = [ - new OverflowChangedItem { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "fixed", Index = 0 }, - new OverflowChangedItem { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 1 } + new OverflowChangedItem { Id = "item1", Overflow = true, Text = "Item 1", Behavior = OverflowBehavior.Fixed, Index = 0 }, + new OverflowChangedItem { Id = "item2", Overflow = false, Text = "Item 2", Behavior = null, Index = 1 } ] }; @@ -181,7 +178,7 @@ var items = overflow.Instance.ItemsOverflow.ToList(); Assert.Single(items); Assert.Equal("item1", items[0].Id); - Assert.Equal("fixed", items[0].Fixed); + Assert.Equal(OverflowBehavior.Fixed, items[0].Behavior); } [Fact] @@ -216,62 +213,6 @@ Assert.Single(overflow.Instance.ItemsOverflow); } - [Fact] - public async Task FluentOverflow_LoadOverflowItemsAsync_LoadsItemsAndCountFromJs() - { - var cut = Render(@
-
Item 1
-
); - - JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowItems", args => (string?)args.Arguments[0] == "overflow") - .SetResult([ - new OverflowItem { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "ellipsis", Index = 4 }, - new OverflowItem { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 5 } - ]); - JSInterop.Setup("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowCount", args => (string?)args.Arguments[0] == "overflow") - .SetResult(7); - - var overflow = cut.FindComponent(); - var method = GetPrivateMethod("LoadOverflowItemsAsync"); - await ((Task)method.Invoke(overflow.Instance, [])!); - - Assert.Equal(7, overflow.Instance.OverflowCount); - var items = overflow.Instance.ItemsOverflow.ToList(); - Assert.Single(items); - Assert.Equal("item1", items[0].Id); - Assert.Equal("ellipsis", items[0].Fixed); - Assert.Equal(4, items[0].Index); - } - - [Fact] - public void FluentOverflow_SetOverflowItems_OverflowItemOverload_SetsExpectedState() - { - var cut = Render(@
-
Item 1
-
); - var overflow = cut.FindComponent(); - var method = GetPrivateMethod("SetOverflowItems", typeof(IEnumerable), typeof(int)); - - method.Invoke(overflow.Instance, new object?[] - { - new OverflowItem[] - { - new() { Id = "item1", Overflow = true, Text = "Item 1", Index = 0 }, - new() { Id = "item2", Overflow = false, Text = "Item 2", Index = 1 } - }, - 5 - }); - - Assert.Equal(5, overflow.Instance.OverflowCount); - var items = overflow.Instance.ItemsOverflow.ToList(); - Assert.Single(items); - Assert.Equal("item1", items[0].Id); - - method.Invoke(overflow.Instance, new object?[] { null, -2 }); - Assert.Equal(0, overflow.Instance.OverflowCount); - Assert.Empty(overflow.Instance.ItemsOverflow); - } - [Fact] public void FluentOverflow_SetOverflowItems_OverflowChangedItemOverload_SetsExpectedState() { @@ -285,8 +226,8 @@ { new OverflowChangedItem[] { - new() { Id = "item1", Overflow = true, Text = "Item 1", Fixed = "fixed", Index = 2 }, - new() { Id = "item2", Overflow = false, Text = "Item 2", Fixed = null, Index = 3 } + new() { Id = "item1", Overflow = true, Text = "Item 1", Behavior = OverflowBehavior.Fixed, Index = 2 }, + new() { Id = "item2", Overflow = false, Text = "Item 2", Behavior = null, Index = 3 } }, 6 }); @@ -295,7 +236,7 @@ var items = overflow.Instance.ItemsOverflow.ToList(); Assert.Single(items); Assert.Equal("item1", items[0].Id); - Assert.Equal("fixed", items[0].Fixed); + Assert.Equal(OverflowBehavior.Fixed, items[0].Behavior); Assert.Equal(2, items[0].Index); method.Invoke(overflow.Instance, new object?[] { null, -1 }); diff --git a/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs index fb08676d8b..a24c54ffdd 100644 --- a/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs +++ b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs @@ -70,7 +70,7 @@ public void OverflowChangedItem_Defaults_AreExpected() Assert.Null(item.Id); Assert.False(item.Overflow); Assert.Null(item.Text); - Assert.Null(item.Fixed); + Assert.Null(item.Behavior); Assert.Equal(0, item.Index); } diff --git a/tests/Core/Components/Overflow/OverflowItemStateTests.cs b/tests/Core/Components/Overflow/OverflowItemStateTests.cs new file mode 100644 index 0000000000..37c07a2571 --- /dev/null +++ b/tests/Core/Components/Overflow/OverflowItemStateTests.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Overflow; + +public class OverflowStateTests +{ + [Fact] + public void OverflowState_Defaults_AreExpected() + { + // Act + var state = new OverflowState(); + + // Assert + Assert.Null(state.OverflowItems); + Assert.Equal(0, state.OverflowCount); + Assert.Equal(0, state.FirstOverflowIndex); + Assert.Null(state.OrderedItemIds); + } + + [Fact] + public void OverflowState_Sets_AllProperties() + { + // Arrange + OverflowItem[] overflowItems = + [ + new() + { + Id = "item-2", + Overflow = true, + Text = "Item 2", + Behavior = OverflowBehavior.Ellipsis, + Index = 3, + }, + ]; + string[] orderedItemIds = ["item-0", "item-1", "item-2"]; + + // Act + var state = new OverflowState + { + OverflowItems = overflowItems, + OverflowCount = 4, + FirstOverflowIndex = 3, + OrderedItemIds = orderedItemIds, + }; + + // Assert + Assert.Same(overflowItems, state.OverflowItems); + Assert.Equal(4, state.OverflowCount); + Assert.Equal(3, state.FirstOverflowIndex); + Assert.Same(orderedItemIds, state.OrderedItemIds); + } +} diff --git a/tests/Core/Components/Overflow/OverflowItemTests.cs b/tests/Core/Components/Overflow/OverflowItemTests.cs new file mode 100644 index 0000000000..972291533e --- /dev/null +++ b/tests/Core/Components/Overflow/OverflowItemTests.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Xunit; + +namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Overflow; + +public class OverflowItemTests +{ + [Fact] + public void OverflowItem_Defaults_AreExpected() + { + // Act + var item = new OverflowItem(); + + // Assert + Assert.Null(item.Id); + Assert.False(item.Overflow); + Assert.Null(item.Text); + Assert.Null(item.Behavior); + Assert.Equal(0, item.Index); + } + + [Fact] + public void OverflowItem_Sets_AllProperties() + { + // Act + var item = new OverflowItem + { + Id = "item-1", + Overflow = true, + Text = "Item 1", + Behavior = OverflowBehavior.Fixed, + Index = 2, + }; + + // Assert + Assert.Equal("item-1", item.Id); + Assert.True(item.Overflow); + Assert.Equal("Item 1", item.Text); + Assert.Equal(OverflowBehavior.Fixed, item.Behavior); + Assert.Equal(2, item.Index); + } +}