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 51% 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 b2cd9f228d..ccc0361e97 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 + } +
@@ -31,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/OverflowDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowDefault.razor index 47f329ef51..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 d92f7a6905..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,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/OverflowMaxRenderedItemsExample.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Overflow/Examples/OverflowMaxRenderedItemsExample.razor new file mode 100644 index 0000000000..d89b264c42 --- /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 dc24e03a64..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 2b7aff3dec..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,27 +12,51 @@ 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. 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 `behavior="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 }} + +## Item overflow behavior modes + +The `behavior` attribute on direct children supports these modes: + +| Value | Behavior | +| --- | --- | +| `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: + +- `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 items with ellipsis behavior -{{ OverflowHorizontalExample }} +This example demonstrates multiple items with ellipsis behavior (`behavior="ellipsis"`) combined with normal overflowed items. +{{ OverflowMultipleFixedItemsExample }} -## API FluentOverflow +## API FluentOverflow {{ API Type=FluentOverflow }} -{{ API Type=FluentOverflowItem }} +## 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..510677db06 --- /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 799b5ff3a6..70b196dba4 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationIndex.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationIndex.md @@ -48,6 +48,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..59d4ee1a30 --- /dev/null +++ b/src/Core.Scripts/src/Components/Overflow/FluentOverflow.ts @@ -0,0 +1,612 @@ +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; + 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; + firstOverflowIndex: number; + 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; + 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 lastOrderedItemIds: string[] = []; + private overflowItems: OverflowItem[] = []; + + static get observedAttributes() { + 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"; + + this.cachedContainerGap = null; + this.lastContainerSize = 0; + this.lastOverflowCount = 0; + this.lastFirstOverflowIndex = -1; + this.lastOrderedItemIds = []; + 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 = ""; + } + } + + /** + * 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; + } + + if (name === "visible-on-load") { + this.style.visibility = newValue === "false" ? "hidden" : ""; + return; + } + + if (name === "orientation") { + this.lastContainerSize = 0; + this.cachedContainerGap = null; + } + + 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(), + this.getQuerySelector(), + this.getThreshold(), + this.lastHandledState, + this.getContainerGap(), + this.getMaxRenderedItems() + ); + 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: { + items: result.overflowItems, + overflowCount: result.overflowCount, + firstOverflowIndex: result.firstOverflowIndex, + orderedItemIds: result.orderedItemIds + }, + bubbles: true, + composed: true + })); + } + } + + /** + * 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 { + 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 { + 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; + if (currentSize === this.lastContainerSize) { + return; + } + + this.lastContainerSize = currentSize; + this.cachedContainerGap = null; + this.refresh(); + }, 16); + }); + 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; + } + + if (mutation.type === "attributes") { + const target = mutation.target as OverflowElement; + // 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; + } + } + + if (!shouldRefresh) { + return; + } + + 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", "behavior", "class", "style", "hidden"] + }); + } + + /** + * Disconnects and cleans up all observers and timers to prevent memory leaks. + */ + 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("behavior"); + 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, + orderedItemIds: managedItems.map(item => item.id ?? ""), + 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("behavior")); + + 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, + orderedItemIds: managedItems.map(item => item.id ?? "") + }; + } + + 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(), + Behavior: element.getAttribute("behavior"), + 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 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)"; + } + + return `:scope > ${querySelector}`; + } + + function ensureMeasuredSize(element: OverflowElement, isHorizontal: boolean): number { + if (element.overflowSize === null || element.overflowSize === undefined) { + const isEllipsisFixed = element.getAttribute("behavior") === "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; + } + + /** + * 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(); + } + + /** + * 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?.getOverflowState() ?? { + overflowItems: [], + overflowCount: 0, + firstOverflowIndex: -1, + orderedItemIds: [] + }; + } +} diff --git a/src/Core.Scripts/src/FluentUICustomEvents.ts b/src/Core.Scripts/src/FluentUICustomEvents.ts index 990c762026..661d7ec64a 100644 --- a/src/Core.Scripts/src/FluentUICustomEvents.ts +++ b/src/Core.Scripts/src/FluentUICustomEvents.ts @@ -167,6 +167,21 @@ 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, + orderedItemIds: event.detail?.orderedItemIds ?? [], + }; + } + }); + } + // [^^^ 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 881527841b..fbcea15359 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 ^^^] if (blazor.addEventListener && typeof blazor.addEventListener === 'function') { 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..9beaf4f96d 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, args.OrderedItemIds); + await InvokeAsync(StateHasChanged); } /// - [JSInvokable] public async Task OverflowRaisedAsync(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,26 @@ public async Task OverflowRaisedAsync(OverflowItem[] items) await InvokeAsync(StateHasChanged); } - private async Task InitializeOverflowAsync() + private void ApplyOverflowState(int firstOverflowIndex, IReadOnlyList? orderedItemIds) { - if (JSModule is not null) + foreach (var app in _internalAppBarContext.Apps.Values) + { + app.Overflow = false; + } + + if (orderedItemIds is null || orderedItemIds.Count == 0 || firstOverflowIndex < 0) { - 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); + return; + } + + for (var index = 0; index < orderedItemIds.Count; index++) + { + if (!_internalAppBarContext.Apps.TryGetValue(orderedItemIds[index], out var app)) + { + continue; + } + + app.Overflow = index >= firstOverflowIndex; } } @@ -164,8 +156,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 +178,6 @@ private async Task HandlePopoverToggleAsync(bool value) { await PopoverVisibilityChanged.InvokeAsync(_showMoreItems); } - - await Task.CompletedTask; } private void HandleSearch() @@ -196,8 +188,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..4e07e6c834 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); } @@ -105,5 +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 f3bde0d0e4..edfedeb5a0 100644 --- a/src/Core/Components/Overflow/FluentOverflow.razor +++ b/src/Core/Components/Overflow/FluentOverflow.razor @@ -2,31 +2,38 @@ @using Microsoft.FluentUI.AspNetCore.Components.Extensions @inherits FluentComponentBase - -
- @ChildContent -
- @if (MoreButtonTemplate != null) - { - @MoreButtonTemplate(this) - } - else - { - - } -
+ + @ChildContent +
+ @if (MoreTemplate != null) + { + @MoreTemplate(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..dce42f6c2e 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 readonly List _items = []; + private int _overflowCount; /// protected virtual string? ClassValue => DefaultClassBuilder @@ -31,11 +30,6 @@ public FluentOverflow(LibraryConfiguration configuration) : base(configuration) Id = Identifier.NewId(); } - internal FluentOverflow(LibraryConfiguration configuration, List items) : this(configuration) - { - _items = items; - } - /// /// Gets or sets the template to display elements. /// @@ -44,17 +38,17 @@ internal FluentOverflow(LibraryConfiguration configuration, List /// 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; /// - /// 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. @@ -63,22 +57,36 @@ internal FluentOverflow(LibraryConfiguration configuration, List - /// Gets or sets the CSS selectors of the items to include in the overflow. + /// 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 whether overflow items are cached in JavaScript memory. + /// + [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 string? Selectors { get; set; } = string.Empty; + 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 a enter or leave the current panel. + /// Event raised when overflow items change. /// [Parameter] - public EventCallback> OnOverflowRaised { get; set; } + public EventCallback> OnOverflowRaised { get; set; } /// /// Gets or sets the content to display. @@ -88,26 +96,31 @@ internal FluentOverflow(LibraryConfiguration configuration, List - /// Gets all items with assigned to True. + /// Gets the rendered overflow items returned from the web component. /// - public IEnumerable ItemsOverflow => _items.Where(i => i.Overflow == true); + public IEnumerable ItemsOverflow => _items; + + /// + /// Gets the total number of overflowed items. + /// + public int OverflowCount => _overflowCount; /// /// Gets the unique identifier associated to the more button ([Id]-more). /// public string IdMoreButton => $"{Id}-more"; - private bool IsHorizontal => Orientation == Orientation.Horizontal; + /// + protected virtual string? MoreButtonStyleValue => new StyleBuilder() + .AddStyle("visibility", "hidden", OverflowCount == 0) + .AddStyle("anchor-name", $"--{IdMoreButton}") + .Build(); /// 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, Selectors, 25); VisibleOnLoad = true; } } @@ -115,34 +128,39 @@ 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, Selectors, 25); + return; } + + await JSRuntime.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Components.Overflow.Refresh", Id); + await LoadOverflowItemsAsync(); } /// - [JSInvokable] public async Task OverflowRaisedAsync(OverflowItem[] items) { - if (items == null || items.Length == 0) + SetOverflowItems(items, items.Count(item => item.Overflow)); + + if (OnOverflowRaised.HasDelegate) { - return; + await OnOverflowRaised.InvokeAsync(ItemsOverflow); } - // Update Item components - foreach (var item in items) + await InvokeAsync(StateHasChanged); + } + + private async Task OnOverflowChangedAsync(OverflowChangedEventArgs args) + { + if (!string.Equals(args.Id, Id, StringComparison.Ordinal)) { - var element = _items.FirstOrDefault(i => string.Equals(i.Id, item.Id, StringComparison.OrdinalIgnoreCase)); - element?.SetProperties(item.Overflow, item.Text); + return; } - // Raise event + SetOverflowItems(args.Items, args.OverflowCount); + if (OnOverflowRaised.HasDelegate) { await OnOverflowRaised.InvokeAsync(ItemsOverflow); @@ -150,37 +168,45 @@ 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) + private async Task LoadOverflowItemsAsync() { - _items.Remove(item); - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Dispose", item.Id); + var state = await JSRuntime.InvokeAsync("Microsoft.FluentUI.Blazor.Components.Overflow.GetOverflowState", [Id]); + SetOverflowItems(state?.OverflowItems, state?.OverflowCount ?? 0); } - /// - protected override async ValueTask DisposeAsync(IJSObjectReference jsModule) + private void SetOverflowItems(IEnumerable? items, int overflowCount) { - await JSModule.ObjectReference.InvokeVoidAsync("Microsoft.FluentUI.Blazor.Overflow.Dispose", Id); - _dotNetHelper?.Dispose(); + if (items is null) + { + return; + } + + _items.Clear(); + _overflowCount = Math.Max(overflowCount, 0); + + _items.AddRange(items.Where(item => item.Overflow)); } - /// - /// Represents an item that may be subject to overflow handling, typically used in scenarios where content or data - /// exceeds a predefined limit. - /// - public class OverflowItem + private void SetOverflowItems(IEnumerable? items, int overflowCount) { - /// - public string? Id { get; set; } + _items.Clear(); + _overflowCount = Math.Max(overflowCount, 0); - /// - public bool? Overflow { get; set; } + if (items is null) + { + return; + } - /// - public string? Text { get; set; } + _items.AddRange(items + .Where(item => item.Overflow) + .Select(item => new OverflowItem + { + Id = item.Id, + Overflow = item.Overflow, + Text = item.Text, + Behavior = item.Behavior, + Index = item.Index, + })); } } diff --git a/src/Core/Components/Overflow/FluentOverflow.razor.css b/src/Core/Components/Overflow/FluentOverflow.razor.css index 5d84d50cef..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"] { @@ -29,10 +40,11 @@ flex-shrink: 0; } -.fluent-overflow .fluent-overflow-item[fixed='ellipsis'] { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - max-width: fit-content; +.fluent-overflow > *[fixed='ellipsis'] { + 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 fa920891df..0000000000 --- a/src/Core/Components/Overflow/FluentOverflow.razor.ts +++ /dev/null @@ -1,187 +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; - } - - interface OverflowElement extends HTMLElement { - overflowSize?: number | null; - } - - interface LastHandledState { - id: string | null; - isHorizontal: boolean | null; - } - - let resizeObserver: ResizeObserver | undefined; - let observerAddRemove: MutationObserver | undefined; - let lastHandledState: LastHandledState = { id: null, isHorizontal: null }; - - 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"; - } - - observerAddRemove = new MutationObserver(mutations => { - mutations.forEach(mutation => { - if (mutation.type !== 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) { - return; - } - const node = mutation.addedNodes.length > 0 ? mutation.addedNodes[0] : mutation.removedNodes[0]; - if (node.nodeType !== Node.ELEMENT_NODE || !(node as Element).matches(localSelector!)) { - return; - } - Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); - }); - }); - - const el = document.getElementById(id); - if (resizeObserver && el) { - resizeObserver.unobserve(el); - } - - let resizeTimeout: number | undefined; - resizeObserver = new ResizeObserver(() => { - clearTimeout(resizeTimeout); - resizeTimeout = window.setTimeout(() => { - Refresh(dotNetHelper, id, isHorizontal, querySelector, threshold); - }, 100); - }); - - if (el) { - resizeObserver.observe(el); - observerAddRemove.observe(el, { childList: true, subtree: false }); - } - - lastHandledState.id = id; - lastHandledState.isHorizontal = isHorizontal; - } - - export function Refresh(dotNetHelper: DotNet.DotNetObject, id: string, isHorizontal: boolean, querySelector: string | null, threshold: number): void { - const container = document.getElementById(id); - if (!container) return; - - let localQuerySelector: string; - if (!querySelector) { - localQuerySelector = ":scope .fluent-overflow-item"; - } else { - localQuerySelector = ":scope >" + querySelector; - } - - const allItems = container.querySelectorAll(localQuerySelector); - const items = container.querySelectorAll(localQuerySelector + ":not([fixed])"); - - 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"); - - const ellipsisItems = Array.from(container.querySelectorAll(localQuerySelector + "[fixed='ellipsis']")); - - let ellipsisTotal = 0; - let containerGap = parseFloat(window.getComputedStyle(container).gap); - if (!containerGap) containerGap = 0; - - ellipsisItems.forEach((el, idx) => { - el.overflowSize = isHorizontal ? getElementWidth(el) : getElementHeight(el); - ellipsisTotal += el.overflowSize || 0; - if (idx > 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); - - if (ellipsisTotal > availableSize) { - ellipsisItems.forEach(el => { - el.style.flexShrink = "1"; - }); - } else { - ellipsisItems.forEach(el => { - el.style.flexShrink = "0"; - }); - } - - if (lastHandledState.id === id && lastHandledState.isHorizontal !== 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; - } - } - } - }); - - 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); - } - - lastHandledState.id = id; - lastHandledState.isHorizontal = isHorizontal; - } - - export function Dispose(id: string): void { - const el = document.getElementById(id); - if (el) { - resizeObserver?.unobserve(el); - observerAddRemove?.disconnect(); - } - } - - 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/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/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/OverflowItemFixed.cs b/src/Core/Enums/OverflowBehavior.cs similarity index 51% rename from src/Core/Enums/OverflowItemFixed.cs rename to src/Core/Enums/OverflowBehavior.cs index ea0539dcac..bdf5e32363 100644 --- a/src/Core/Enums/OverflowItemFixed.cs +++ b/src/Core/Enums/OverflowBehavior.cs @@ -7,25 +7,19 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// Possibility for an element not to participate in the overflow logic and always remain displayed. +/// Specifies the overflow behavior for an item. /// -public enum OverflowItemFixed +public enum OverflowBehavior { /// - /// If the item is out of the display, it disappears. - /// - [Description("none")] - None = 0, - - /// - /// The element is always visible + /// The item is kept fixed in place and not subject to overflow. /// [Description("fixed")] - Fixed = 1, + Fixed, /// - /// The element is always visible, but its width can be reduced to display "...". + /// The item can be hidden with an ellipsis indicator when space is constrained. /// [Description("ellipsis")] - Ellipsis = 2, + Ellipsis, } 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..f6242796a7 --- /dev/null +++ b/src/Core/Events/OverflowChangedEventArgs.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// 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 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/src/Core/Events/OverflowChangedItem.cs b/src/Core/Events/OverflowChangedItem.cs new file mode 100644 index 0000000000..7d535d13fe --- /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 overflow behavior. + /// + public OverflowBehavior? Behavior { get; set; } + + /// + /// Gets or sets the item index. + /// + public int Index { 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/FluentOverflowTests.razor b/tests/Core/Components/Overflow/FluentOverflowTests.razor index 42c40b6089..4a33cb4179 100644 --- a/tests/Core/Components/Overflow/FluentOverflowTests.razor +++ b/tests/Core/Components/Overflow/FluentOverflowTests.razor @@ -1,4 +1,4 @@ -@using Microsoft.FluentUI.AspNetCore.Components.Utilities +@using System.Reflection @using Xunit @using static Microsoft.FluentUI.AspNetCore.Components.FluentOverflow @inherits FluentUITestContext @@ -11,276 +11,240 @@ } [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]")); + Assert.Single(cut.FindAll("fluent-overflow")); + Assert.Equal(3, cut.FindAll("fluent-overflow > *").Count); } [Fact] - public void FluentOverflow_Handles_FixedEllipsis_Item() + public void FluentOverflow_Respects_Orientation() { - // Arrange - var cut = Render(@
- Long text that should ellipsis - Item 2 + var cut = Render(@
+
Item 1
); - // Assert - var ellipsisItem = cut.Find(".fluent-overflow-item[fixed='ellipsis']"); - Assert.NotNull(ellipsisItem); - Assert.Contains("ellipsis", ellipsisItem.GetAttribute("fixed")); + var container = cut.Find("fluent-overflow"); + Assert.Equal("vertical", container.GetAttribute("orientation")); } [Fact] - public void FluentOverflow_Triggers_Overflow_Event() + public void FluentOverflow_Renders_MaxRenderedItems_Attribute() { - // Arrange - var overflowRaised = false; - var cut = Render(@
- @for (int i = 0; i < 20; i++) - { - Item @i - } + var cut = Render(@
+
Item 1
); - // Act: Simulate resize or trigger refresh (in a real scenario, this might involve JS interop) - // For simplicity, assume overflow is triggered + 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
+
+
); - // Assert - // Note: Actual overflow detection requires JS interop, so this is a placeholder - Assert.False(overflowRaised); // Adjust based on implementation + var customButton = cut.Find("p"); + Assert.NotNull(customButton); + Assert.Equal("Custom More", customButton.TextContent); } [Fact] - public void FluentOverflow_Renders_MoreButton_When_Overflow() + public void FluentOverflow_Custom_OverflowTemplate() { - // Arrange var cut = Render(@
- @for (int i = 0; i < 10; i++) - { - Very long item text @i - } -
); + + Custom Overflow Content + + +
Item 1
+
+
); - // Assert - var moreButton = cut.Find(".fluent-overflow-more"); - Assert.NotNull(moreButton); - Assert.Contains("visibility: hidden", moreButton.GetAttribute("style")); // Initially hidden + var tooltip = cut.Find("fluent-tooltip"); + Assert.NotNull(tooltip); + Assert.Contains("Custom Overflow Content", tooltip.TextContent); } [Fact] - public void FluentOverflow_Respects_Orientation() + public async Task FluentOverflow_Calls_OverflowRaisedAsync() { - // Arrange - var cut = Render(@
- Item 1 + var overflowRaised = false; + var cut = Render(@
+
Item 1
+
Item 2
); - // Assert - var container = cut.Find(".fluent-overflow"); - Assert.Equal("vertical", container.GetAttribute("orientation")); + 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.True(overflowRaised); + Assert.Single(overflow.Instance.ItemsOverflow); + Assert.Equal("Item 1", overflow.Instance.ItemsOverflow.First().Text); } [Fact] - public void FluentOverflow_Item_With_Fixed_Attribute() + public void FluentOverflow_Renders_Default_Tooltip_When_OverflowTemplate_Is_Null() { - // Arrange var cut = Render(@
- Fixed Item - Regular Item +
Item 1
); - // Assert - var fixedItem = cut.Find(".fluent-overflow-item[fixed='fixed']"); - Assert.NotNull(fixedItem); - Assert.Equal("Fixed Item", fixedItem.TextContent); + var tooltip = cut.Find("fluent-tooltip"); + Assert.NotNull(tooltip); + Assert.Equal("below-start", tooltip.GetAttribute("positioning")); } [Fact] - public void FluentOverflow_Custom_MoreButtonTemplate() + public async Task FluentOverflow_RefreshAsync_LoadsOverflowItemsAndCount() { - // Arrange - var cut = Render(@
- -

Custom More

-
- - @for (int i = 0; i < 10; i++) - { - Item 1 - } - + var cut = Render(@
+
Item 1
+
Item 2
); - // Assert - var customButton = cut.Find("p"); - Assert.NotNull(customButton); - Assert.Equal("Custom More", customButton.TextContent); + JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Overflow.Refresh", args => (string?)args.Arguments[0] == "overflow") + .SetVoidResult(); + 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.GetOverflowStatus", args => (string?)args.Arguments[0] == "overflow") + .SetResult(0); + + var overflow = cut.FindComponent(); + await overflow.Instance.RefreshAsync(); + + Assert.Equal(0, overflow.Instance.OverflowCount); + var items = overflow.Instance.ItemsOverflow.ToList(); + 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.GetOverflowState"); } [Fact] - public void FluentOverflow_Custom_OverflowTemplate() + public async Task FluentOverflow_RefreshAsync_NoJsRuntime_DoesNothing() { - // Arrange - var cut = Render(@
- - Custom Overflow Content - - - @for (int i = 0; i < 10; i++) - { - Item @i - } - -
); + var overflow = new FluentOverflow(new LibraryConfiguration()); - // Assert - var tooltip = cut.Find("fluent-tooltip"); - Assert.NotNull(tooltip); - Assert.Contains("Custom Overflow Content", tooltip.TextContent); + await overflow.RefreshAsync(); + + Assert.Equal(0, overflow.OverflowCount); + Assert.Empty(overflow.ItemsOverflow); } [Fact] - public async Task FluentOverflow_Calls_OverflowRaisedAsync() + public async Task FluentOverflow_OnOverflowChangedAsync_UpdatesStateAndRaisesCallback() { - // 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(); + 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", Behavior = OverflowBehavior.Fixed, Index = 0 }, + new OverflowChangedItem { Id = "item2", Overflow = false, Text = "Item 2", Behavior = null, Index = 1 } + ] + }; + + await ((Task)method.Invoke(overflow.Instance, [args])!); - // Assert: Verify the event was raised 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(OverflowBehavior.Fixed, items[0].Behavior); } - [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 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 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", Behavior = OverflowBehavior.Fixed, Index = 2 }, + new() { Id = "item2", Overflow = false, Text = "Item 2", Behavior = 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(OverflowBehavior.Fixed, items[0].Behavior); + 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); + } - [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 - } - -
); - - // 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); - } + 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..a24c54ffdd --- /dev/null +++ b/tests/Core/Components/Overflow/OverflowChangedEventArgsTests.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------------ +// 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); + Assert.Null(args.OrderedItemIds); + } + + [Fact] + public void OverflowChangedEventArgs_Sets_AllProperties() + { + // Arrange + IReadOnlyList items = + [ + new() + { + Id = "item-1", + Overflow = true, + Text = "Item 1", + Behavior = OverflowBehavior.Fixed, + Index = 3 + } + ]; + IReadOnlyList orderedItemIds = ["item-0", "item-1", "item-2"]; + + // Act + var args = new OverflowChangedEventArgs + { + Id = "overflow-1", + Items = items, + OverflowCount = 5, + FirstOverflowIndex = 3, + OrderedItemIds = orderedItemIds + }; + + // Assert + Assert.Equal("overflow-1", args.Id); + Assert.Same(items, args.Items); + Assert.Equal(5, args.OverflowCount); + Assert.Equal(3, args.FirstOverflowIndex); + Assert.Same(orderedItemIds, args.OrderedItemIds); + } +} + +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.Behavior); + Assert.Equal(0, item.Index); + } + + [Fact] + public void OverflowChangedItem_Sets_AllProperties() + { + // Act + var item = new OverflowChangedItem + { + Id = "item-2", + Overflow = true, + Text = "Item 2", + Behavior = OverflowBehavior.Ellipsis, + Index = 4 + }; + + // Assert + Assert.Equal("item-2", item.Id); + Assert.True(item.Overflow); + Assert.Equal("Item 2", item.Text); + Assert.Equal(OverflowBehavior.Ellipsis, item.Behavior); + Assert.Equal(4, 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); + } +}