From 5ef0f8f38c0d355910bdc5385dbeec8562572d3a Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Tue, 24 Feb 2026 20:46:06 +0100 Subject: [PATCH 01/20] Initial Toast work --- .../Toast/Examples/FluentToastDefault.razor | 44 +++ .../Components/Toast/FluentToast.md | 25 ++ .../src/Components/Toast/FluentToast.ts | 276 ++++++++++++++++++ src/Core.Scripts/src/Startup.ts | 5 +- src/Core/Components/Toast/FluentToast.razor | 22 ++ .../Components/Toast/FluentToast.razor.cs | 118 ++++++++ .../Components/Toast/FluentToastBody.razor | 14 + .../Toast/FluentToastBody.razor.css | 9 + .../Components/Toast/FluentToastTitle.razor | 39 +++ .../Toast/FluentToastTitle.razor.cs | 46 +++ .../Toast/FluentToastTitle.razor.css | 23 ++ src/Core/Enums/ToastIntent.cs | 23 ++ src/Core/Enums/ToastPosition.cs | 37 +++ 13 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md create mode 100644 src/Core.Scripts/src/Components/Toast/FluentToast.ts create mode 100644 src/Core/Components/Toast/FluentToast.razor create mode 100644 src/Core/Components/Toast/FluentToast.razor.cs create mode 100644 src/Core/Components/Toast/FluentToastBody.razor create mode 100644 src/Core/Components/Toast/FluentToastBody.razor.css create mode 100644 src/Core/Components/Toast/FluentToastTitle.razor create mode 100644 src/Core/Components/Toast/FluentToastTitle.razor.cs create mode 100644 src/Core/Components/Toast/FluentToastTitle.razor.css create mode 100644 src/Core/Enums/ToastIntent.cs create mode 100644 src/Core/Enums/ToastPosition.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor new file mode 100644 index 0000000000..40f2a556a2 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -0,0 +1,44 @@ + + Show Toast + + + + Success Toast + + + + This is a toast notification using the FluentToast component. + + It will auto-dismiss in 5 seconds. + + + + + Show Error Toast + + + + Error Occurred + + + Dismiss + + + + This toast has a persistent error message and custom offsets. + + + + + +@code { + bool Visible = false; + bool ErrorVisible = false; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md new file mode 100644 index 0000000000..4c459b5f90 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -0,0 +1,25 @@ +--- +title: Toast +route: /Toast +category: 20|Components +icon: FoodToast +--- + +# Toast + +The `FluentToast` component is a temporary notification that appears on the edge of the screen. + +To display the `FluentToast` component, you need to set the `Opened` parameter to `true`. +This parameter is bindable, so you can control the visibility of the `FluentToast` from your code. + +## Examples + + +### Default + +{{ FluentToastDefault }} + +## API Documentation + +{{ API Type=FluentToast }} + diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts new file mode 100644 index 0000000000..0edbf77d56 --- /dev/null +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -0,0 +1,276 @@ +import { StartedMode } from "../../d-ts/StartedMode"; + +export namespace Microsoft.FluentUI.Blazor.Components.Toast { + + class FluentToast extends HTMLElement { + private dialog: ToastElement; + private timeoutId: number | null = null; + + // Creates a new FluentToast element. + constructor() { + super(); + + const shadow = this.attachShadow({ mode: 'open' }); + + // Create the dialog element + this.dialog = document.createElement('div') as ToastElement; + this.dialog.setAttribute('fuib', ''); + this.dialog.setAttribute('popover', 'manual'); + this.dialog.setAttribute('part', 'dialog'); // To allow styling using `fluent-toast-b::part(dialog)` + + // Dispatch the toggle event when the toast is opened or closed + this.dialog.addEventListener('toggle', (e: any) => { + e.stopPropagation(); + this.dispatchOpenedEvent(e.newState === 'open'); + }); + + // Set initial styles for the dialog + const sheet = new CSSStyleSheet(); + sheet.replaceSync(` + :host(:not([opened='true']):not(.animating)) { + display: none; + } + + :host { + display: contents; + } + + :host div[fuib][popover] { + position: fixed; + margin: 0; + z-index: 2000; + min-width: 292px; + max-width: 292px; + color: var(--colorNeutralForeground1); + background-color: var(--colorNeutralBackground1); + border: 1px solid var(--colorTransparentStroke); + border-radius: var(--borderRadiusMedium); + box-shadow: var(--shadow8); + padding: 12px; + flex-direction: column; + gap: 8px; + display: grid; + grid-template-columns: auto 1fr auto; + font-size: var(--fontSizeBase300); + font-weight: var(--fontWeightSemibold); +} + + /* Fade out by default when hidden */ + opacity: 0; + } + + /* Animations */ + :host div[fuib][popover]:popover-open { + display: flex; + opacity: 1; + animation: toast-enter 0.25s cubic-bezier(0.33, 0, 0, 1) forwards; + } + + :host div[fuib][popover].closing { + animation: toast-exit 0.2s cubic-bezier(0.33, 0, 0, 1) forwards; + } + + @keyframes toast-enter { + from { opacity: 0; transform: var(--toast-enter-from, translateY(16px)); } + to { opacity: 1; transform: var(--toast-enter-to, translateY(0)); } + } + + @keyframes toast-exit { + from { opacity: 1; transform: var(--toast-enter-to, translateY(0)); } + to { opacity: 0; transform: scale(0.95); } + } + `); + this.shadowRoot!.adoptedStyleSheets = [ + ...(this.shadowRoot!.adoptedStyleSheets || []), + sheet + ]; + + // Slot for user content + const slot = document.createElement('slot'); + this.dialog.appendChild(slot); + shadow.appendChild(this.dialog); + } + + connectedCallback() { + window.addEventListener('resize', this.handleWindowChange, true); + } + + // Disposes the toast by clearing the timeout. + disconnectedCallback() { + window.removeEventListener('resize', this.handleWindowChange, true); + this.clearTimeout(); + } + + private handleWindowChange = () => { + if (this.dialogIsOpen) { + this.updatePosition(); + } + }; + + private get dialogIsOpen(): boolean { + return this.dialog.matches(':popover-open'); + } + + // Getter and setter for the opened property + public get opened(): boolean { + return this.getAttribute('opened') === 'true'; + } + + public set opened(value: boolean) { + this.setAttribute('opened', String(value)); + if (value && !this.dialogIsOpen) { + this.showToast(); + } else if (!value && this.dialogIsOpen) { + this.closeToast(); + } + } + + static get observedAttributes() { return ['opened', 'timeout', 'position', 'vertical-offset', 'horizontal-offset']; } + + attributeChangedCallback(name: string, oldValue: string, newValue: string) { + if (oldValue !== newValue) { + if (name === 'opened') { + this.opened = newValue === 'true'; + } + if ((name === 'position' || name === 'vertical-offset' || name === 'horizontal-offset') && this.dialogIsOpen) { + this.updatePosition(); + } + } + } + + public showToast() { + if (!this.dialog) return; + + this.dialog.showPopover(); + this.updatePosition(); + this.startTimeout(); + } + + public async closeToast() { + if (this.dialogIsOpen) { + this.classList.add('animating'); + this.dialog.classList.add('closing'); + + // Wait for the exit animation to complete + await new Promise(resolve => { + const onAnimationEnd = (e: AnimationEvent) => { + if (e.animationName === 'toast-exit') { + this.dialog.removeEventListener('animationend', onAnimationEnd); + resolve(true); + } + }; + this.dialog.addEventListener('animationend', onAnimationEnd); + // Fallback in case animation doesn't fire + setTimeout(() => resolve(false), 300); + }); + + this.dialog.hidePopover(); + this.dialog.classList.remove('closing'); + this.classList.remove('animating'); + this.clearTimeout(); + } + } + + private startTimeout() { + this.clearTimeout(); + const timeoutAttr = this.getAttribute('timeout'); + const timeout = timeoutAttr ? parseInt(timeoutAttr) : 0; + if (timeout > 0) { + this.timeoutId = window.setTimeout(() => { + this.opened = false; + }, timeout); + } + } + + private clearTimeout() { + if (this.timeoutId !== null) { + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } + + private updatePosition() { + const isRtl = getComputedStyle(this).direction === 'rtl'; + const position = this.getAttribute('position') || (isRtl ? 'bottom-left' : 'bottom-right'); + const horizontalOffset = parseInt(this.getAttribute('horizontal-offset') || '16'); + const verticalOffset = parseInt(this.getAttribute('vertical-offset') || '16'); + + this.dialog.style.top = 'auto'; + this.dialog.style.left = 'auto'; + this.dialog.style.right = 'auto'; + this.dialog.style.bottom = 'auto'; + + let enterFrom = 'translateY(16px)'; + let enterTo = 'translateY(0)'; + + switch (position) { + case 'top-right': + this.dialog.style.top = `${verticalOffset}px`; + this.dialog.style.right = `${horizontalOffset}px`; + enterFrom = 'translateX(16px)'; + enterTo = 'translateX(0)'; + break; + case 'top-left': + this.dialog.style.top = `${verticalOffset}px`; + this.dialog.style.left = `${horizontalOffset}px`; + enterFrom = 'translateX(-16px)'; + enterTo = 'translateX(0)'; + break; + case 'bottom-right': + this.dialog.style.bottom = `${verticalOffset}px`; + this.dialog.style.right = `${horizontalOffset}px`; + enterFrom = 'translateX(16px)'; + enterTo = 'translateX(0)'; + break; + case 'bottom-left': + this.dialog.style.bottom = `${verticalOffset}px`; + this.dialog.style.left = `${horizontalOffset}px`; + enterFrom = 'translateX(-16px)'; + enterTo = 'translateX(0)'; + break; + case 'top-center': + this.dialog.style.top = `${verticalOffset}px`; + this.dialog.style.left = '50%'; + enterFrom = 'translate(-50%, -16px)'; + enterTo = 'translate(-50%, 0)'; + break; + case 'bottom-center': + this.dialog.style.bottom = `${verticalOffset}px`; + this.dialog.style.left = '50%'; + enterFrom = 'translate(-50%, 16px)'; + enterTo = 'translate(-50%, 0)'; + break; + } + + this.dialog.style.setProperty('--toast-enter-from', enterFrom); + this.dialog.style.setProperty('--toast-enter-to', enterTo); + + // Centers need translate(-50%, 0) applied permanently if they are open + if (position.includes('center')) { + this.dialog.style.transform = enterTo; + } + } + + private dispatchOpenedEvent(opened: boolean) { + this.dispatchEvent(new CustomEvent('toggle', { + detail: { + oldState: opened ? 'closed' : 'open', + newState: opened ? 'open' : 'closed', + }, + bubbles: true, + composed: true + })); + } + } + + interface ToastElement extends HTMLDivElement { + showPopover: () => void; + hidePopover: () => void; + } + + export const registerComponent = (blazor: Blazor, mode: StartedMode): void => { + if (typeof blazor.addEventListener === 'function' && mode === StartedMode.Web) { + customElements.define('fluent-toast-b', FluentToast); + } + }; +} diff --git a/src/Core.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts index 4cdeb4c528..d3b72b1adb 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 FluentToastFile } from './Components/Toast/FluentToast'; import { Microsoft as FluentUIStylesFile } from './FluentUIStyles'; import { Microsoft as FluentUICustomEventsFile } from './FluentUICustomEvents'; import { StartedMode } from './d-ts/StartedMode'; @@ -16,6 +17,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 FluentToast = FluentToastFile.FluentUI.Blazor.Components.Toast; import FluentUIStyles = FluentUIStylesFile.FluentUI.Blazor.FluentUIStyles; import FluentUICustomEvents = FluentUICustomEventsFile.FluentUI.Blazor.FluentUICustomEvents; @@ -55,7 +57,8 @@ export namespace Microsoft.FluentUI.Blazor.Startup { FluentPageScript.registerComponent(blazor, mode); FluentPopover.registerComponent(blazor, mode); FluentOverlay.registerComponent(blazor, mode); - // [^^^ Add your other custom components before this line ^^^] + FluentToast.registerComponent(blazor, mode); + // [^^ Add your other custom components before this line ^^] // Register all custom events FluentUICustomEvents.Accordion(blazor); diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor new file mode 100644 index 0000000000..fa17e55050 --- /dev/null +++ b/src/Core/Components/Toast/FluentToast.razor @@ -0,0 +1,22 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + + + + @ChildContent + + diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs new file mode 100644 index 0000000000..d6d722dc96 --- /dev/null +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -0,0 +1,118 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// A toast is a temporary notification that appears in the corner of the screen. +/// +public partial class FluentToast : FluentComponentBase +{ + /// + public FluentToast(LibraryConfiguration configuration) : base(configuration) + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the content to be displayed in the toast. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets whether the toast is opened. + /// + [Parameter] + public bool Opened { get; set; } + + /// + /// Gets or sets the event callback for when the opened state changes. + /// + [Parameter] + public EventCallback OpenedChanged { get; set; } + + /// + /// Gets or sets the timeout in milliseconds. Default is 5000ms. + /// Set to 0 to disable auto-dismiss. + /// + [Parameter] + public int Timeout { get; set; } = 50000; + + /// + /// Gets or sets the toast position. + /// Default is TopRight. + /// + [Parameter] + public ToastPosition? Position { get; set; } + + /// + /// Gets or sets the vertical offset for stacking multiple toasts. + /// + [Parameter] + public int VerticalOffset { get; set; } = 16; + + /// + /// Gets or sets the horizontal offset. + /// + [Parameter] + public int HorizontalOffset { get; set; } = 16; + + /// + /// Gets or sets the intent of the toast. + /// Default is Info. + /// + [Parameter] + public ToastIntent Intent { get; set; } = ToastIntent.Info; + + /// + /// Gets or sets the event callback for when the toast is toggled. + /// + [Parameter] + public EventCallback OnToggle { get; set; } + + internal async Task OnToggleAsync(DialogToggleEventArgs args) + { + var newState = string.Empty; + var argsId = string.Empty; + + if (args is DialogToggleEventArgs toggleArgs) + { + newState = toggleArgs.NewState; + argsId = toggleArgs.Id; + } + + if (string.CompareOrdinal(argsId, Id) != 0) + { + return; + } + + var toggled = string.Equals(newState, "open", StringComparison.OrdinalIgnoreCase); + if (Opened != toggled) + { + Opened = toggled; + + if (OnToggle.HasDelegate) + { + await OnToggle.InvokeAsync(toggled); + } + + if (OpenedChanged.HasDelegate) + { + await OpenedChanged.InvokeAsync(toggled); + } + } + } +} diff --git a/src/Core/Components/Toast/FluentToastBody.razor b/src/Core/Components/Toast/FluentToastBody.razor new file mode 100644 index 0000000000..6218464d4b --- /dev/null +++ b/src/Core/Components/Toast/FluentToastBody.razor @@ -0,0 +1,14 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components + +
+ @ChildContent + @if (Subtitle is not null) + { +
@Subtitle
+ } +
+ +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public RenderFragment? Subtitle { get; set; } +} diff --git a/src/Core/Components/Toast/FluentToastBody.razor.css b/src/Core/Components/Toast/FluentToastBody.razor.css new file mode 100644 index 0000000000..9378f2e2d0 --- /dev/null +++ b/src/Core/Components/Toast/FluentToastBody.razor.css @@ -0,0 +1,9 @@ +.fluent-toast-body { + grid-column: 2 / 3; + padding-top: 6px; + font-size: var(--fontSizeBase300); + line-height: var(--fontSizeBase300); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground1); + word-break: break-word; +} diff --git a/src/Core/Components/Toast/FluentToastTitle.razor b/src/Core/Components/Toast/FluentToastTitle.razor new file mode 100644 index 0000000000..2bf53803d6 --- /dev/null +++ b/src/Core/Components/Toast/FluentToastTitle.razor @@ -0,0 +1,39 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components + + + @if (Media is not null) + { +
@Media
+ } + else if (ToastIntent is not null) + { + + } +
@ChildContent
+ @if (Action is not null) + { +
@Action
+ } + + +@code { + + private RenderFragment? GetIcon(ToastIntent? intent) + { + if (intent is null) + { + return null; + } + + return intent switch + { + Components.ToastIntent.Success =>@ , + Components.ToastIntent.Warning =>@ , + Components.ToastIntent.Error =>@ , + Components.ToastIntent.Info =>@ , + _ => null, + }; + } +} diff --git a/src/Core/Components/Toast/FluentToastTitle.razor.cs b/src/Core/Components/Toast/FluentToastTitle.razor.cs new file mode 100644 index 0000000000..fddb35419e --- /dev/null +++ b/src/Core/Components/Toast/FluentToastTitle.razor.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// FluentToastTitle is a component that represents the title of a toast. It can contain an icon, a title, and an action. +/// +public partial class FluentToastTitle +{ + /// + /// Gets or sets the content to be rendered inside the component. + /// + /// Use this parameter to specify child elements or markup that will be rendered within the + /// component's body. Typically used to allow consumers of the component to provide custom UI content. + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the media content to render within the component. + /// + /// Use this property to provide custom media elements, such as images, icons, or videos, that + /// will be displayed in the designated area of the component. The content is rendered as a fragment and can include + /// any valid Blazor markup. + [Parameter] + public RenderFragment? Media { get; set; } + + /// + /// Gets or sets the content to render as the action area of the component. + /// + /// Use this property to provide custom interactive elements, such as buttons or links, that + /// appear in the action area. The content is rendered as a fragment and can include arbitrary markup or + /// components. + + [Parameter] + public RenderFragment? Action { get; set; } + + /// + /// Gets or sets the intent of the toast notification, indicating its purpose or severity. + /// + [CascadingParameter] + public ToastIntent? ToastIntent { get; set; } +} diff --git a/src/Core/Components/Toast/FluentToastTitle.razor.css b/src/Core/Components/Toast/FluentToastTitle.razor.css new file mode 100644 index 0000000000..1d88ac203e --- /dev/null +++ b/src/Core/Components/Toast/FluentToastTitle.razor.css @@ -0,0 +1,23 @@ +.fluent-toast-title-media { + display: flex; + padding-top: 2px; + grid-column-end: 2; + padding-right: 8px; + font-size: 16px; + color: var(--colorNeutralForeground1); +} + +.fluent-toast-title { + display: flex; + grid-column-end: 3; + color: var(--colorNeutralForeground1); + word-break: break-word; +} + +.fluent-toast-title-action { + display: flex; + align-items: start; + padding-left: 12px; + grid-column-end: -1; + color: var(--colorBrandForeground1); +} diff --git a/src/Core/Enums/ToastIntent.cs b/src/Core/Enums/ToastIntent.cs new file mode 100644 index 0000000000..4f74ffc3cf --- /dev/null +++ b/src/Core/Enums/ToastIntent.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The intent of the toast. +/// +public enum ToastIntent +{ + /// + Info, + + /// + Success, + + /// + Warning, + + /// + Error, +} diff --git a/src/Core/Enums/ToastPosition.cs b/src/Core/Enums/ToastPosition.cs new file mode 100644 index 0000000000..0c59f8f1b9 --- /dev/null +++ b/src/Core/Enums/ToastPosition.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// The position of the toast on the screen. +/// +public enum ToastPosition +{ + /// + [Description("top-right")] + TopRight, + + /// + [Description("top-left")] + TopLeft, + + /// + [Description("top-center")] + TopCenter, + + /// + [Description("bottom-right")] + BottomRight, + + /// + [Description("bottom-left")] + BottomLeft, + + /// + [Description("bottom-center")] + BottomCenter, +} From deab3817c565970a162711c9995d3c3a37218fa7 Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Tue, 10 Mar 2026 20:06:26 +0100 Subject: [PATCH 02/20] Make it work with toast service --- .../Toast/DebugPages/DebugToast.razor | 38 +++---- .../Toast/DebugPages/DebugToastContent.razor | 24 ++-- .../src/Components/Toast/FluentToast.ts | 7 +- src/Core/Components/Toast/FluentToast.razor | 14 +-- .../Components/Toast/FluentToast.razor.cs | 106 ++++++++++++++++-- 5 files changed, 136 insertions(+), 53 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index 15a012be35..0db6ba2d94 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -1,32 +1,32 @@ -@page "/Toast/Debug/Service" +@page "/Toast/Debug/Service" @inject IToastService ToastService - Open Toast + Open Toast @code { - private async Task OpenToastAsync() - { - var result = await ToastService.ShowToastAsync(options => + private async Task OpenToastAsync() { - options.Parameters.Add(nameof(DebugToastContent.Name), "John"); + var result = await ToastService.ShowToastAsync(options => + { + options.Parameters.Add(nameof(DebugToastContent.Name), "John"); - options.OnStateChange = (e) => - { - Console.WriteLine($"State changed: {e.State}"); - }; - }); + options.OnStateChange = (e) => + { + Console.WriteLine($"State changed: {e.State}"); + }; + }); - if (result.Cancelled) - { - Console.WriteLine($"Toast Canceled: {result.Value}"); - } - else - { - Console.WriteLine($"Toast Confirmed: {result.Value}"); + if (result.Cancelled) + { + Console.WriteLine($"Toast Canceled: {result.Value}"); + } + else + { + Console.WriteLine($"Toast Confirmed: {result.Value}"); + } } - } } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor index 6987f1db9f..47aa98d256 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor @@ -1,11 +1,17 @@ -
- Toast Content -
-
- OK - Cancel +
+
+ +
+
Email sent
+
+ +
+
This is a toast body
+
Subtitle
+
@code { @@ -26,7 +32,7 @@ { await Toast.CloseAsync(ToastResult.Ok("Yes")); } - + private async Task btnCancel_Click() { await Toast.CloseAsync(ToastResult.Cancel("No")); diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 0edbf77d56..863a5df282 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -50,10 +50,9 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { flex-direction: column; gap: 8px; display: grid; - grid-template-columns: auto 1fr auto; - font-size: var(--fontSizeBase300); - font-weight: var(--fontWeightSemibold); -} + grid-template-columns: auto 1fr auto; + font-size: var(--fontSizeBase300); + font-weight: var(--fontWeightSemibold); /* Fade out by default when hidden */ opacity: 0; diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 0ffd1fb2aa..7dcc102195 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -6,8 +6,8 @@ } - - @ChildContent - @ondialogbeforetoggle="@OnToggleAsync" - @ondialogtoggle="@OnToggleAsync" - @attributes="AdditionalAttributes"> + @ondialogtoggle="@OnToggleAsync" + @ondialogbeforetoggle="@OnToggleAsync"> @if (Instance is not null) { @@ -35,4 +31,4 @@ { @ChildContent } -
+ diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index d6d722dc96..db2d6d9937 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; @@ -13,6 +14,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentToast : FluentComponentBase { /// + [DynamicDependency(nameof(OnToggleAsync))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DialogToggleEventArgs))] public FluentToast(LibraryConfiguration configuration) : base(configuration) { Id = Identifier.NewId(); @@ -24,8 +27,24 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// protected string? StyleValue => DefaultStyleBuilder + // TDOD: Remode these styles (only for testing purposes) + .AddStyle("border", "1px solid #ccc;") + .AddStyle("padding", "16px") + .AddStyle("position", "fixed") + .AddStyle("top", "50%") + .AddStyle("left", "50%") .Build(); + /// + [Inject] + private IToastService? ToastService { get; set; } + + /// + /// Gets or sets the instance used by the . + /// + [Parameter] + public IToastInstance? Instance { get; set; } + /// /// Gets or sets the content to be displayed in the toast. /// @@ -49,7 +68,7 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// Set to 0 to disable auto-dismiss. /// [Parameter] - public int Timeout { get; set; } = 50000; + public int Timeout { get; set; } = 7000; /// /// Gets or sets the toast position. @@ -83,23 +102,36 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public EventCallback OnToggle { get; set; } - internal async Task OnToggleAsync(DialogToggleEventArgs args) - { - var newState = string.Empty; - var argsId = string.Empty; + /// + /// Command executed when the user clicks on the button. + /// + [Parameter] + public EventCallback OnStateChange { get; set; } - if (args is DialogToggleEventArgs toggleArgs) - { - newState = toggleArgs.NewState; - argsId = toggleArgs.Id; - } + /// + private bool LaunchedFromService => Instance is not null; + + /// + internal Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) => RaiseOnStateChangeAsync(new ToastEventArgs(this, args)); + + /// + internal Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) => RaiseOnStateChangeAsync(new ToastEventArgs(instance, state)); - if (string.CompareOrdinal(argsId, Id) != 0) + internal async Task OnToggleAsync(DialogToggleEventArgs args) + { + // Validate that the event belongs to this toast. For service-launched toasts, the DOM id + // is set to Instance.Id. For standalone usage it's the component Id. + var expectedId = Instance?.Id ?? Id; + if (string.CompareOrdinal(args.Id, expectedId) != 0) { return; } - var toggled = string.Equals(newState, "open", StringComparison.OrdinalIgnoreCase); + // Raise the event received from the Web Component + var toastEventArgs = await RaiseOnStateChangeAsync(args); + + // Keep the Opened parameter in sync for both standalone and service usage. + var toggled = string.Equals(args.NewState, "open", StringComparison.OrdinalIgnoreCase); if (Opened != toggled) { Opened = toggled; @@ -114,5 +146,55 @@ internal async Task OnToggleAsync(DialogToggleEventArgs args) await OpenedChanged.InvokeAsync(toggled); } } + + if (LaunchedFromService) + { + switch (toastEventArgs.State) + { + case DialogState.Closing: + (Instance as ToastInstance)?.ResultCompletion.TrySetResult(ToastResult.Cancel()); + break; + + case DialogState.Closed: + if (ToastService is ToastService toastService) + { + await toastService.RemoveToastFromProviderAsync(Instance); + } + + break; + } + } + } + + /// + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && LaunchedFromService) + { + var instance = Instance as ToastInstance; + if (instance is not null) + { + instance.FluentToast = this; + } + + if (!Opened) + { + Opened = true; + return InvokeAsync(StateHasChanged); + } + } + + return Task.CompletedTask; + } + + /// + private async Task RaiseOnStateChangeAsync(ToastEventArgs args) + { + if (OnStateChange.HasDelegate) + { + await InvokeAsync(() => OnStateChange.InvokeAsync(args)); + } + + return args; } } From 75f495f407b5088f4e0a109c63365f6e3d0f4ba6 Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Wed, 18 Mar 2026 16:08:59 +0100 Subject: [PATCH 03/20] Big refactor. Use a FluentToast and FluentProgressToast as component and FluentToastComponentBase as as shell --- .../Toast/DebugPages/DebugToast.razor | 97 +++++-- .../Toast/Examples/FluentToastDefault.razor | 45 +--- .../src/Components/Toast/FluentToast.ts | 237 +++++++++++++++--- .../Components/Toast/FluentProgressToast.cs | 97 +++++++ .../Toast/FluentProgressToastContent.razor | 58 +++++ .../Toast/FluentProgressToastContent.razor.cs | 22 ++ src/Core/Components/Toast/FluentToast.cs | 92 +++++++ src/Core/Components/Toast/FluentToast.razor | 34 --- .../Components/Toast/FluentToastBody.razor | 14 -- .../Toast/FluentToastBody.razor.css | 9 - .../Toast/FluentToastComponentBase.razor | 26 ++ ...r.cs => FluentToastComponentBase.razor.cs} | 152 +++++------ .../Toast/FluentToastComponentBase.razor.css | 75 ++++++ .../Components/Toast/FluentToastContent.razor | 54 ++++ .../Toast/FluentToastContent.razor.cs | 22 ++ .../Toast/FluentToastProvider.razor | 15 +- .../Toast/FluentToastProvider.razor.cs | 47 ++++ .../Components/Toast/FluentToastTitle.razor | 39 --- .../Toast/FluentToastTitle.razor.cs | 46 ---- .../Toast/FluentToastTitle.razor.css | 23 -- .../Toast/Services/IToastInstance.cs | 3 + .../Toast/Services/ToastEventArgs.cs | 4 +- .../Toast/Services/ToastInstance.cs | 7 +- .../Components/Toast/Services/ToastOptions.cs | 5 + .../Components/Toast/Services/ToastService.cs | 7 +- src/Core/Enums/ToastPoliteness.cs | 21 ++ src/Core/Events/DialogToggleEventArgs.cs | 2 +- ...soft.FluentUI.AspNetCore.Components.csproj | 3 + .../Components/Toast/FluentToastTests.razor | 2 +- 29 files changed, 919 insertions(+), 339 deletions(-) create mode 100644 src/Core/Components/Toast/FluentProgressToast.cs create mode 100644 src/Core/Components/Toast/FluentProgressToastContent.razor create mode 100644 src/Core/Components/Toast/FluentProgressToastContent.razor.cs create mode 100644 src/Core/Components/Toast/FluentToast.cs delete mode 100644 src/Core/Components/Toast/FluentToast.razor delete mode 100644 src/Core/Components/Toast/FluentToastBody.razor delete mode 100644 src/Core/Components/Toast/FluentToastBody.razor.css create mode 100644 src/Core/Components/Toast/FluentToastComponentBase.razor rename src/Core/Components/Toast/{FluentToast.razor.cs => FluentToastComponentBase.razor.cs} (59%) create mode 100644 src/Core/Components/Toast/FluentToastComponentBase.razor.css create mode 100644 src/Core/Components/Toast/FluentToastContent.razor create mode 100644 src/Core/Components/Toast/FluentToastContent.razor.cs delete mode 100644 src/Core/Components/Toast/FluentToastTitle.razor delete mode 100644 src/Core/Components/Toast/FluentToastTitle.razor.cs delete mode 100644 src/Core/Components/Toast/FluentToastTitle.razor.css create mode 100644 src/Core/Enums/ToastPoliteness.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index 0db6ba2d94..98ced44cbf 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -1,32 +1,101 @@ @page "/Toast/Debug/Service" @inject IToastService ToastService - - Open Toast - + + + + Open confirmation toast + + + Open communication toast + + + Open progress toast + + + Open determinate progress toast + + + +
+ Last result: @_lastResult +
+
@code { - private async Task OpenToastAsync() + private string _lastResult = "(none)"; + + private async Task OpenDismissableToastAsync() { - var result = await ToastService.ShowToastAsync(options => + var result = await ToastService.ShowToastAsync(options => { - options.Parameters.Add(nameof(DebugToastContent.Name), "John"); - + options.Timeout = 70000; + options.Parameters[nameof(FluentToast.Intent)] = ToastIntent.Warning; + options.Parameters[nameof(FluentToast.Title)] = "Delete item?"; + options.Parameters[nameof(FluentToast.Body)] = "This action can't be undone."; + options.Parameters[nameof(FluentToast.QuickAction1)] = "Delete"; + options.Parameters[nameof(FluentToast.QuickAction2)] = "Cancel"; + options.Parameters[nameof(FluentToast.ShowDismissButton)] = true; options.OnStateChange = (e) => { Console.WriteLine($"State changed: {e.State}"); }; }); - if (result.Cancelled) + _lastResult = result.Cancelled ? "Confirmation: Cancelled" : "Confirmation: Ok"; + } + + private async Task OpenToastAsync() + { + var result = await ToastService.ShowToastAsync(options => { - Console.WriteLine($"Toast Canceled: {result.Value}"); - } - else + options.Timeout = 70000; + options.Parameters[nameof(FluentToast.Intent)] = ToastIntent.Info; + options.Parameters[nameof(FluentToast.Title)] = "Email sent"; + options.Parameters[nameof(FluentToast.Body)] = "Your message was delivered."; + options.Parameters[nameof(FluentToast.Subtitle)] = "Just now"; + options.Parameters[nameof(FluentToast.QuickAction1)] = "Undo"; + options.Parameters[nameof(FluentToast.QuickAction2)] = "Dismiss"; + options.Parameters[nameof(FluentToast.ShowDismissButton)] = false; + }); + + _lastResult = result.Cancelled ? "Communication: Cancelled" : "Communication: Ok"; + } + + private async Task OpenProgressToastAsync() + { + var result = await ToastService.ShowToastAsync(options => + { + options.Timeout = 0; + options.Parameters[nameof(FluentProgressToast.Intent)] = ToastIntent.Info; + options.Parameters[nameof(FluentProgressToast.Title)] = "Uploading"; + options.Parameters[nameof(FluentProgressToast.Body)] = "Please wait while your files are uploaded."; + options.Parameters[nameof(FluentProgressToast.Status)] = "Uploading 3 files..."; + options.Parameters[nameof(FluentProgressToast.Indeterminate)] = true; + options.Parameters[nameof(FluentProgressToast.QuickAction1)] = "Hide"; + options.Parameters[nameof(FluentProgressToast.QuickAction2)] = "Cancel"; + options.Parameters[nameof(FluentProgressToast.ShowDismissButton)] = false; + }); + + _lastResult = result.Cancelled ? "Progress: Cancelled" : "Progress: Ok"; + } + + private async Task OpenProgressToast2Async() + { + var result = await ToastService.ShowToastAsync(options => { - Console.WriteLine($"Toast Confirmed: {result.Value}"); - } + options.Timeout = 0; + options.Parameters[nameof(FluentProgressToast.Intent)] = ToastIntent.Info; + options.Parameters[nameof(FluentProgressToast.Title)] = "Uploading"; + options.Parameters[nameof(FluentProgressToast.Body)] = "Please wait while your files are uploaded."; + options.Parameters[nameof(FluentProgressToast.Status)] = "Uploading 3 files..."; + options.Parameters[nameof(FluentProgressToast.Indeterminate)] = false; + options.Parameters[nameof(FluentProgressToast.QuickAction1)] = "Hide"; + options.Parameters[nameof(FluentProgressToast.QuickAction2)] = "Cancel"; + options.Parameters[nameof(FluentProgressToast.ShowDismissButton)] = false; + }); + + _lastResult = result.Cancelled ? "Progress: Cancelled" : "Progress: Ok"; } } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index 40f2a556a2..e02abfc9b0 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1,44 +1 @@ - - Show Toast - - - - Success Toast - - - - This is a toast notification using the FluentToast component. - - It will auto-dismiss in 5 seconds. - - - - - Show Error Toast - - - - Error Occurred - - - Dismiss - - - - This toast has a persistent error message and custom offsets. - - - - - -@code { - bool Visible = false; - bool ErrorVisible = false; -} + diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 863a5df282..4561ff1d4e 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -3,8 +3,12 @@ import { StartedMode } from "../../d-ts/StartedMode"; export namespace Microsoft.FluentUI.Blazor.Components.Toast { class FluentToast extends HTMLElement { + private static readonly stackGap = 12; private dialog: ToastElement; private timeoutId: number | null = null; + private remainingTimeout: number | null = null; + private timeoutStartedAt: number | null = null; + private pauseReasons: Set = new Set(); // Creates a new FluentToast element. constructor() { @@ -18,10 +22,19 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.dialog.setAttribute('popover', 'manual'); this.dialog.setAttribute('part', 'dialog'); // To allow styling using `fluent-toast-b::part(dialog)` - // Dispatch the toggle event when the toast is opened or closed + // Dispatch the toggle events when the toast is opened or closed + this.dialog.addEventListener('beforetoggle', (e: any) => { + e.stopPropagation(); + const oldState = e?.oldState ?? (this.dialogIsOpen ? 'open' : 'closed'); + const newState = e?.newState ?? (oldState === 'open' ? 'closed' : 'open'); + this.dispatchDialogToggleEvent('beforetoggle', oldState, newState); + }); + this.dialog.addEventListener('toggle', (e: any) => { e.stopPropagation(); - this.dispatchOpenedEvent(e.newState === 'open'); + const oldState = e?.oldState ?? (this.dialogIsOpen ? 'open' : 'closed'); + const newState = e?.newState ?? (oldState === 'open' ? 'closed' : 'open'); + this.dispatchDialogToggleEvent('toggle', oldState, newState); }); // Set initial styles for the dialog @@ -36,31 +49,13 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { } :host div[fuib][popover] { - position: fixed; - margin: 0; - z-index: 2000; - min-width: 292px; - max-width: 292px; - color: var(--colorNeutralForeground1); - background-color: var(--colorNeutralBackground1); - border: 1px solid var(--colorTransparentStroke); - border-radius: var(--borderRadiusMedium); - box-shadow: var(--shadow8); - padding: 12px; - flex-direction: column; - gap: 8px; - display: grid; - grid-template-columns: auto 1fr auto; - font-size: var(--fontSizeBase300); - font-weight: var(--fontWeightSemibold); - - /* Fade out by default when hidden */ - opacity: 0; + border: 0; + background: transparent; } /* Animations */ :host div[fuib][popover]:popover-open { - display: flex; + display: block; opacity: 1; animation: toast-enter 0.25s cubic-bezier(0.33, 0, 0, 1) forwards; } @@ -92,14 +87,69 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { connectedCallback() { window.addEventListener('resize', this.handleWindowChange, true); + this.addEventListener('pointerover', this.handlePointerOver, true); + this.addEventListener('pointerout', this.handlePointerOut, true); + this.addEventListener('focusin', this.handleFocusIn, true); + this.addEventListener('focusout', this.handleFocusOut, true); + window.addEventListener('blur', this.handleWindowBlur, true); + window.addEventListener('focus', this.handleWindowFocus, true); + this.updateAccessibility(); } // Disposes the toast by clearing the timeout. disconnectedCallback() { window.removeEventListener('resize', this.handleWindowChange, true); + this.removeEventListener('pointerover', this.handlePointerOver, true); + this.removeEventListener('pointerout', this.handlePointerOut, true); + this.removeEventListener('focusin', this.handleFocusIn, true); + this.removeEventListener('focusout', this.handleFocusOut, true); + window.removeEventListener('blur', this.handleWindowBlur, true); + window.removeEventListener('focus', this.handleWindowFocus, true); this.clearTimeout(); } + private handlePointerOver = () => { + if (this.attributeIsTrue('pause-on-hover')) { + this.pause('hover'); + } + }; + + private handlePointerOut = (e: PointerEvent) => { + const related = e.relatedTarget as Node | null; + if (related && this.contains(related)) { + return; + } + + if (this.attributeIsTrue('pause-on-hover')) { + this.resume('hover'); + } + }; + + private handleFocusIn = () => { + this.pause('focus'); + }; + + private handleFocusOut = (e: FocusEvent) => { + const related = e.relatedTarget as Node | null; + if (related && this.contains(related)) { + return; + } + + this.resume('focus'); + }; + + private handleWindowBlur = () => { + if (this.attributeIsTrue('pause-on-window-blur')) { + this.pause('window'); + } + }; + + private handleWindowFocus = () => { + if (this.attributeIsTrue('pause-on-window-blur')) { + this.resume('window'); + } + }; + private handleWindowChange = () => { if (this.dialogIsOpen) { this.updatePosition(); @@ -124,16 +174,28 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { } } - static get observedAttributes() { return ['opened', 'timeout', 'position', 'vertical-offset', 'horizontal-offset']; } + static get observedAttributes() { return ['opened', 'timeout', 'position', 'vertical-offset', 'horizontal-offset', 'intent', 'politeness', 'pause-on-hover', 'pause-on-window-blur']; } attributeChangedCallback(name: string, oldValue: string, newValue: string) { if (oldValue !== newValue) { if (name === 'opened') { this.opened = newValue === 'true'; } + if (name === 'timeout' && this.dialogIsOpen) { + this.startTimeout(); + } if ((name === 'position' || name === 'vertical-offset' || name === 'horizontal-offset') && this.dialogIsOpen) { this.updatePosition(); } + if (name === 'intent' || name === 'politeness') { + this.updateAccessibility(); + } + if (name === 'pause-on-hover' && !this.attributeIsTrue('pause-on-hover')) { + this.resume('hover'); + } + if (name === 'pause-on-window-blur' && !this.attributeIsTrue('pause-on-window-blur')) { + this.resume('window'); + } } } @@ -142,6 +204,8 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.dialog.showPopover(); this.updatePosition(); + this.updateToastStack(); + this.updateAccessibility(); this.startTimeout(); } @@ -166,7 +230,9 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.dialog.hidePopover(); this.dialog.classList.remove('closing'); this.classList.remove('animating'); + this.pauseReasons.clear(); this.clearTimeout(); + this.updateToastStack(); } } @@ -175,9 +241,14 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { const timeoutAttr = this.getAttribute('timeout'); const timeout = timeoutAttr ? parseInt(timeoutAttr) : 0; if (timeout > 0) { + this.remainingTimeout = timeout; + this.timeoutStartedAt = Date.now(); this.timeoutId = window.setTimeout(() => { this.opened = false; }, timeout); + } else { + this.remainingTimeout = null; + this.timeoutStartedAt = null; } } @@ -188,11 +259,68 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { } } + private pause(reason: string) { + if (this.pauseReasons.has(reason)) { + return; + } + + this.pauseReasons.add(reason); + this.pauseTimeout(); + } + + private resume(reason: string) { + if (!this.pauseReasons.has(reason)) { + return; + } + + this.pauseReasons.delete(reason); + if (this.pauseReasons.size === 0) { + this.resumeTimeout(); + } + } + + private pauseTimeout() { + if (this.timeoutId === null || this.remainingTimeout === null || this.timeoutStartedAt === null) { + return; + } + + const elapsed = Date.now() - this.timeoutStartedAt; + this.remainingTimeout = Math.max(0, this.remainingTimeout - elapsed); + this.timeoutStartedAt = null; + + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + + private resumeTimeout() { + if (!this.dialogIsOpen || this.remainingTimeout === null) { + return; + } + + if (this.pauseReasons.size > 0) { + return; + } + + if (this.remainingTimeout <= 0) { + this.opened = false; + return; + } + + if (this.timeoutId !== null) { + return; + } + + this.timeoutStartedAt = Date.now(); + this.timeoutId = window.setTimeout(() => { + this.opened = false; + }, this.remainingTimeout); + } + private updatePosition() { const isRtl = getComputedStyle(this).direction === 'rtl'; const position = this.getAttribute('position') || (isRtl ? 'bottom-left' : 'bottom-right'); - const horizontalOffset = parseInt(this.getAttribute('horizontal-offset') || '16'); - const verticalOffset = parseInt(this.getAttribute('vertical-offset') || '16'); + const horizontalOffset = parseInt(this.getAttribute('horizontal-offset') || '20'); + const verticalOffset = parseInt(this.getAttribute('vertical-offset') || '16') + this.getStackOffset(position); this.dialog.style.top = 'auto'; this.dialog.style.left = 'auto'; @@ -250,16 +378,65 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { } } - private dispatchOpenedEvent(opened: boolean) { - this.dispatchEvent(new CustomEvent('toggle', { + private getStackOffset(position: string): number { + const toastElements = Array.from(document.querySelectorAll('fluent-toast-b')) as FluentToast[]; + const toastsBeforeCurrent = toastElements + .slice(0, toastElements.indexOf(this)) + .filter(toast => toast.getToastPosition() === position && toast.dialogIsOpen); + + return toastsBeforeCurrent.reduce((offset, toast) => { + const height = toast.dialog.getBoundingClientRect().height; + return offset + height + FluentToast.stackGap; + }, 0); + } + + private getToastPosition(): string { + const isRtl = getComputedStyle(this).direction === 'rtl'; + return this.getAttribute('position') || (isRtl ? 'bottom-left' : 'bottom-right'); + } + + private updateToastStack() { + const toastElements = Array.from(document.querySelectorAll('fluent-toast-b')) as FluentToast[]; + toastElements + .filter(toast => toast.dialogIsOpen) + .forEach(toast => toast.updatePosition()); + } + + private dispatchDialogToggleEvent(type: string, oldState: string, newState: string) { + this.dispatchEvent(new CustomEvent(type, { detail: { - oldState: opened ? 'closed' : 'open', - newState: opened ? 'open' : 'closed', + oldState, + newState, }, bubbles: true, composed: true })); } + + private updateAccessibility() { + const intent = (this.getAttribute('intent') || '').toLowerCase(); + const politeness = (this.getAttribute('politeness') || '').toLowerCase(); + + const live = politeness === 'polite' || politeness === 'assertive' + ? politeness + : ((intent !== '' && intent !== 'info') ? 'assertive' : 'polite'); + + this.dialog.setAttribute('aria-live', live); + this.dialog.setAttribute('role', live === 'assertive' ? 'alert' : 'status'); + } + + private attributeIsTrue(attributeName: string): boolean { + const value = this.getAttribute(attributeName); + if (value === null) { + return false; + } + + if (value === '') { + return true; + } + + return value.toLowerCase() === 'true'; + } } interface ToastElement extends HTMLDivElement { diff --git a/src/Core/Components/Toast/FluentProgressToast.cs b/src/Core/Components/Toast/FluentProgressToast.cs new file mode 100644 index 0000000000..459a398791 --- /dev/null +++ b/src/Core/Components/Toast/FluentProgressToast.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +#pragma warning disable CS1591, MA0051, MA0123, CA1822 + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// A toast that displays progress for an ongoing operation. +/// +public partial class FluentProgressToast : FluentToastComponentBase +{ + /// + public FluentProgressToast(LibraryConfiguration configuration) : base(configuration) + { + } + + /// + /// Gets or sets the title of the toast. + /// + [Parameter] + public string? Title { get; set; } + + /// + /// Gets or sets the body text of the toast. + /// + [Parameter] + public string? Body { get; set; } + + /// + /// Gets or sets the status text of the toast. + /// + [Parameter] + public string? Status { get; set; } + + /// + /// Gets or sets the maximum progress value. + /// + [Parameter] + public int? Max { get; set; } = 100; + + /// + /// Gets or sets the current progress value. + /// + [Parameter] + public int? Value { get; set; } + + /// + /// Gets or sets whether the progress is indeterminate. + /// + [Parameter] + public bool Indeterminate { get; set; } = true; + + /// + /// Gets or sets the first quick action label. + /// + [Parameter] + public string? QuickAction1 { get; set; } + + /// + /// Gets or sets the second quick action label. + /// + [Parameter] + public string? QuickAction2 { get; set; } + + /// + /// Gets or sets whether the dismiss button is shown. + /// + [Parameter] + public bool ShowDismissButton { get; set; } + + internal static Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); + + public new Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) + => base.RaiseOnStateChangeAsync(args); + + public new Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) + => base.RaiseOnStateChangeAsync(instance, state); + + public new Task OnToggleAsync(DialogToggleEventArgs args) + => base.OnToggleAsync(args); + + protected override void BuildRenderTree(RenderTreeBuilder __builder) + => BuildToastShell(__builder, BuildContent); + + private void BuildContent(RenderTreeBuilder __builder) + => BuildOwnedContent(__builder); + internal Task OnActionClickedAsync(bool primary) + { + return primary ? Instance!.CloseAsync() : Instance!.CancelAsync(); + } +} + +#pragma warning restore CS1591, MA0051, MA0123, CA1822 diff --git a/src/Core/Components/Toast/FluentProgressToastContent.razor b/src/Core/Components/Toast/FluentProgressToastContent.razor new file mode 100644 index 0000000000..d3704f0a76 --- /dev/null +++ b/src/Core/Components/Toast/FluentProgressToastContent.razor @@ -0,0 +1,58 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components + +
+
+ @if (Owner.Indeterminate) + { + + } + else + { + + } +
+
+
@Owner.Title
+
+ @if (Owner.ShowDismissButton) + { +
+ +
+ } + + @if (!string.IsNullOrEmpty(Owner.Body)) + { +
+
@Owner.Body
+
+ } + + @if (!string.IsNullOrEmpty(Owner.Status)) + { +
@Owner.Status
+ } + + @if (!string.IsNullOrEmpty(Owner.QuickAction1) || !string.IsNullOrEmpty(Owner.QuickAction2)) + { + + } +
diff --git a/src/Core/Components/Toast/FluentProgressToastContent.razor.cs b/src/Core/Components/Toast/FluentProgressToastContent.razor.cs new file mode 100644 index 0000000000..a399a343d7 --- /dev/null +++ b/src/Core/Components/Toast/FluentProgressToastContent.razor.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +#pragma warning disable CS1591 + +public partial class FluentProgressToastContent +{ + [Parameter, EditorRequired] + public FluentProgressToast Owner { get; set; } = default!; + + private Task OnActionClickedAsync(bool primary) + { + return Owner.OnActionClickedAsync(primary); + } + +#pragma warning restore CS1591 +} diff --git a/src/Core/Components/Toast/FluentToast.cs b/src/Core/Components/Toast/FluentToast.cs new file mode 100644 index 0000000000..bc92d33665 --- /dev/null +++ b/src/Core/Components/Toast/FluentToast.cs @@ -0,0 +1,92 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +#pragma warning disable CS1591, MA0051, MA0123, CA1822 + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// A toast is a temporary notification that appears in the corner of the screen. +/// +public partial class FluentToast : FluentToastComponentBase +{ + /// + public FluentToast(LibraryConfiguration configuration) : base(configuration) + { + } + + /// + /// Gets or sets the title displayed in the toast. + /// + [Parameter] + public string? Title { get; set; } + + /// + /// Gets or sets the body content displayed in the toast. + /// + [Parameter] + public string? Body { get; set; } + + /// + /// Gets or sets the optional subtitle displayed below the body. + /// + [Parameter] + public string? Subtitle { get; set; } + + /// + /// Gets or sets the first quick action label. + /// + [Parameter] + public string? QuickAction1 { get; set; } + + /// + /// Gets or sets the second quick action label. + /// + [Parameter] + public string? QuickAction2 { get; set; } + + /// + /// Gets or sets whether to render the dismiss button. + /// + [Parameter] + public bool ShowDismissButton { get; set; } = true; + + internal Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); + + public new Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) + => base.RaiseOnStateChangeAsync(args); + + public new Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) + => base.RaiseOnStateChangeAsync(instance, state); + + public new Task OnToggleAsync(DialogToggleEventArgs args) + => base.OnToggleAsync(args); + + protected override void BuildRenderTree(RenderTreeBuilder __builder) + => BuildToastShell(__builder, BuildContent); + + private void BuildContent(RenderTreeBuilder __builder) + => BuildOwnedContent(__builder); + + internal Task OnActionClickedAsync(bool primary) + { + return primary ? Instance!.CloseAsync() : Instance!.CancelAsync(); + } + +#pragma warning restore CS1591, MA0051, MA0123, CA1822 + + internal static Color GetIntentColor(ToastIntent intent) + { + return intent switch + { + ToastIntent.Success => Color.Success, + ToastIntent.Warning => Color.Warning, + ToastIntent.Error => Color.Error, + _ => Color.Info, + }; + } +} diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor deleted file mode 100644 index 7dcc102195..0000000000 --- a/src/Core/Components/Toast/FluentToast.razor +++ /dev/null @@ -1,34 +0,0 @@ -@namespace Microsoft.FluentUI.AspNetCore.Components -@using Microsoft.FluentUI.AspNetCore.Components.Extensions -@inherits FluentComponentBase -@{ -#pragma warning disable IL2111 -} - - - @if (Instance is not null) - { - - - - } - else - { - @ChildContent - } - diff --git a/src/Core/Components/Toast/FluentToastBody.razor b/src/Core/Components/Toast/FluentToastBody.razor deleted file mode 100644 index 6218464d4b..0000000000 --- a/src/Core/Components/Toast/FluentToastBody.razor +++ /dev/null @@ -1,14 +0,0 @@ -@namespace Microsoft.FluentUI.AspNetCore.Components - -
- @ChildContent - @if (Subtitle is not null) - { -
@Subtitle
- } -
- -@code { - [Parameter] public RenderFragment? ChildContent { get; set; } - [Parameter] public RenderFragment? Subtitle { get; set; } -} diff --git a/src/Core/Components/Toast/FluentToastBody.razor.css b/src/Core/Components/Toast/FluentToastBody.razor.css deleted file mode 100644 index 9378f2e2d0..0000000000 --- a/src/Core/Components/Toast/FluentToastBody.razor.css +++ /dev/null @@ -1,9 +0,0 @@ -.fluent-toast-body { - grid-column: 2 / 3; - padding-top: 6px; - font-size: var(--fontSizeBase300); - line-height: var(--fontSizeBase300); - font-weight: var(--fontWeightRegular); - color: var(--colorNeutralForeground1); - word-break: break-word; -} diff --git a/src/Core/Components/Toast/FluentToastComponentBase.razor b/src/Core/Components/Toast/FluentToastComponentBase.razor new file mode 100644 index 0000000000..cef54c5310 --- /dev/null +++ b/src/Core/Components/Toast/FluentToastComponentBase.razor @@ -0,0 +1,26 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase +@using Microsoft.FluentUI.AspNetCore.Components.Extensions + + @if (ChildContent is not null) + { + @ChildContent + } + diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToastComponentBase.razor.cs similarity index 59% rename from src/Core/Components/Toast/FluentToast.razor.cs rename to src/Core/Components/Toast/FluentToastComponentBase.razor.cs index db2d6d9937..fad1ce50fe 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToastComponentBase.razor.cs @@ -2,135 +2,133 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +#pragma warning disable CS1591 + +using ComponentModel = System.ComponentModel; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; -/// -/// A toast is a temporary notification that appears in the corner of the screen. -/// -public partial class FluentToast : FluentComponentBase +[ComponentModel.EditorBrowsable(ComponentModel.EditorBrowsableState.Never)] +public partial class FluentToastComponentBase : FluentComponentBase { - /// [DynamicDependency(nameof(OnToggleAsync))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DialogToggleEventArgs))] - public FluentToast(LibraryConfiguration configuration) : base(configuration) + public FluentToastComponentBase(LibraryConfiguration configuration) : base(configuration) { Id = Identifier.NewId(); } - /// - protected string? ClassValue => DefaultClassBuilder - .Build(); - - /// - protected string? StyleValue => DefaultStyleBuilder - // TDOD: Remode these styles (only for testing purposes) - .AddStyle("border", "1px solid #ccc;") - .AddStyle("padding", "16px") - .AddStyle("position", "fixed") - .AddStyle("top", "50%") - .AddStyle("left", "50%") - .Build(); - - /// - [Inject] - private IToastService? ToastService { get; set; } + [Parameter] + public FluentToastComponentBase? Owner { get; set; } - /// - /// Gets or sets the instance used by the . - /// [Parameter] public IToastInstance? Instance { get; set; } - /// - /// Gets or sets the content to be displayed in the toast. - /// [Parameter] public RenderFragment? ChildContent { get; set; } - /// - /// Gets or sets whether the toast is opened. - /// [Parameter] public bool Opened { get; set; } - /// - /// Gets or sets the event callback for when the opened state changes. - /// [Parameter] public EventCallback OpenedChanged { get; set; } - /// - /// Gets or sets the timeout in milliseconds. Default is 5000ms. - /// Set to 0 to disable auto-dismiss. - /// [Parameter] public int Timeout { get; set; } = 7000; - /// - /// Gets or sets the toast position. - /// Default is TopRight. - /// [Parameter] public ToastPosition? Position { get; set; } - /// - /// Gets or sets the vertical offset for stacking multiple toasts. - /// [Parameter] public int VerticalOffset { get; set; } = 16; - /// - /// Gets or sets the horizontal offset. - /// [Parameter] - public int HorizontalOffset { get; set; } = 16; + public int HorizontalOffset { get; set; } = 20; - /// - /// Gets or sets the intent of the toast. - /// Default is Info. - /// [Parameter] public ToastIntent Intent { get; set; } = ToastIntent.Info; - /// - /// Gets or sets the event callback for when the toast is toggled. - /// + [Parameter] + public bool ShowIntentIcon { get; set; } + + [Parameter] + public RenderFragment? Media { get; set; } + + [Parameter] + public ToastPoliteness? Politeness { get; set; } + + [Parameter] + public bool PauseOnHover { get; set; } + + [Parameter] + public bool PauseOnWindowBlur { get; set; } + [Parameter] public EventCallback OnToggle { get; set; } - /// - /// Command executed when the user clicks on the button. - /// [Parameter] public EventCallback OnStateChange { get; set; } - /// + [Inject] + private IToastService? ToastService { get; set; } + + internal Icon IntentIcon => Intent switch + { + ToastIntent.Success => new CoreIcons.Filled.Size20.CheckmarkCircle(), + ToastIntent.Warning => new CoreIcons.Filled.Size20.Warning(), + ToastIntent.Error => new CoreIcons.Filled.Size20.DismissCircle(), + _ => new CoreIcons.Filled.Size20.Info(), + }; + + internal string? ClassValue => DefaultClassBuilder + .Build(); + + internal string? StyleValue => DefaultStyleBuilder + .AddStyle("border", "1px solid #ccc;") + .AddStyle("padding", "16px") + .AddStyle("position", "fixed") + .AddStyle("top", "50%") + .AddStyle("left", "50%") + .Build(); + private bool LaunchedFromService => Instance is not null; - /// - internal Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) => RaiseOnStateChangeAsync(new ToastEventArgs(this, args)); + internal Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) + => RaiseOnStateChangeAsync(new ToastEventArgs(this, args)); - /// - internal Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) => RaiseOnStateChangeAsync(new ToastEventArgs(instance, state)); + internal Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) + => RaiseOnStateChangeAsync(new ToastEventArgs(instance, state)); + + protected void BuildToastShell(RenderTreeBuilder builder, RenderFragment childContent) + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(Owner), this); + builder.AddAttribute(2, nameof(ChildContent), childContent); + builder.CloseComponent(); + } + + protected void BuildOwnedContent< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TContent>(RenderTreeBuilder builder) + where TContent : Microsoft.AspNetCore.Components.IComponent + { + builder.OpenComponent(0); + builder.AddAttribute(1, "Owner", this); + builder.CloseComponent(); + } internal async Task OnToggleAsync(DialogToggleEventArgs args) { - // Validate that the event belongs to this toast. For service-launched toasts, the DOM id - // is set to Instance.Id. For standalone usage it's the component Id. var expectedId = Instance?.Id ?? Id; if (string.CompareOrdinal(args.Id, expectedId) != 0) { return; } - // Raise the event received from the Web Component var toastEventArgs = await RaiseOnStateChangeAsync(args); - - // Keep the Opened parameter in sync for both standalone and service usage. var toggled = string.Equals(args.NewState, "open", StringComparison.OrdinalIgnoreCase); if (Opened != toggled) { @@ -166,7 +164,6 @@ internal async Task OnToggleAsync(DialogToggleEventArgs args) } } - /// protected override Task OnAfterRenderAsync(bool firstRender) { if (firstRender && LaunchedFromService) @@ -187,7 +184,16 @@ protected override Task OnAfterRenderAsync(bool firstRender) return Task.CompletedTask; } - /// + protected override void OnParametersSet() + { + if (GetType().Equals(typeof(FluentToastComponentBase)) && (Owner is null || ChildContent is null)) + { + throw new InvalidOperationException($"{nameof(FluentToastComponentBase)} must be used as a shell with child content and cannot be rendered directly."); + } + + base.OnParametersSet(); + } + private async Task RaiseOnStateChangeAsync(ToastEventArgs args) { if (OnStateChange.HasDelegate) @@ -198,3 +204,5 @@ private async Task RaiseOnStateChangeAsync(ToastEventArgs args) return args; } } + +#pragma warning restore CS1591 diff --git a/src/Core/Components/Toast/FluentToastComponentBase.razor.css b/src/Core/Components/Toast/FluentToastComponentBase.razor.css new file mode 100644 index 0000000000..386cf13f2c --- /dev/null +++ b/src/Core/Components/Toast/FluentToastComponentBase.razor.css @@ -0,0 +1,75 @@ +.fluent-toast-layout { + display: grid; + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); + font-weight: var(--fontWeightSemibold); + color: var(--colorNeutralForeground1); + grid-template-columns: auto 1fr auto; + background-color: var(--colorNeutralBackground1); + border: 1px solid var(--colorTransparentStroke); + border-radius: var(--borderRadiusMedium); + box-shadow: var(--shadow8); + padding: 12px; + min-width: 292px; + max-width: 292px; +} + +.fluent-toast-title-media { + display: flex; + padding-top: 2px; + grid-column-end: 2; + padding-inline-end: 8px; + font-size: var(--fontSizeBase400); + color: var(--colorNeutralForeground1); +} + +.fluent-toast-title { + display: flex; + grid-column-end: 3; + align-items: center; + color: var(--colorNeutralForeground1); + word-break: break-word; +} + +.fluent-toast-title-action { + display: flex; + align-items: start; + padding-inline-start: 12px; + grid-column-end: -1; + color: var(--colorBrandForeground1); +} + + .fluent-toast-title-action.time { + font-size: var(--fontSizeBase200); + color: var(--colorNeutralForeground1); + } + +.fluent-toast-body { + grid-column-start: 2; + grid-column-end: 3; + padding-top: 6px; + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground1); + word-break: break-word; +} + +.fluent-toast-subtitle { + padding-top: 4px; + grid-column-start: 2; + grid-column-end: 3; + font-size: var(--fontSizeBase200); + line-height: var(--lineHeightBase200); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground2); +} + +.fluent-toast-footer { + padding-top: 16px; + grid-column-start: 2; + grid-column-end: 3; + display: flex; + align-items: center; + gap: 14px; +} diff --git a/src/Core/Components/Toast/FluentToastContent.razor b/src/Core/Components/Toast/FluentToastContent.razor new file mode 100644 index 0000000000..3d67b1b5df --- /dev/null +++ b/src/Core/Components/Toast/FluentToastContent.razor @@ -0,0 +1,54 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components + +@if (Owner.ChildContent is not null) +{ + @Owner.ChildContent +} +else +{ +
+ +
+
@Owner.Title
+
+ @if (Owner.ShowDismissButton) + { +
+ +
+ } + +
+
@Owner.Body
+ @if (!string.IsNullOrEmpty(Owner.Subtitle)) + { +
@Owner.Subtitle
+ } +
+ + @if (!string.IsNullOrEmpty(Owner.QuickAction1) || !string.IsNullOrEmpty(Owner.QuickAction2)) + { + + } +
+} diff --git a/src/Core/Components/Toast/FluentToastContent.razor.cs b/src/Core/Components/Toast/FluentToastContent.razor.cs new file mode 100644 index 0000000000..8169e8b10b --- /dev/null +++ b/src/Core/Components/Toast/FluentToastContent.razor.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +#pragma warning disable CS1591 + +public partial class FluentToastContent +{ + [Parameter, EditorRequired] + public FluentToast Owner { get; set; } = default!; + + private Task OnActionClickedAsync(bool primary) + { + return Owner.OnActionClickedAsync(primary); + } + +#pragma warning restore CS1591 +} diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index dc72b414e7..0ce30fcece 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -1,5 +1,8 @@ @namespace Microsoft.FluentUI.AspNetCore.Components @inherits FluentComponentBase +@{ +#pragma warning disable IL2111 +}
i.Index)) { - + } }
+@{ +#pragma warning restore IL2111 +} diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 2ab3e305a2..381126665f 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -2,6 +2,8 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +#pragma warning disable IL2111 + using System.Globalization; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; @@ -54,6 +56,49 @@ protected override void OnInitialized() /// private static Action EmptyOnStateChange => (_) => { }; + private static Type GetToastType(IToastInstance toast) + { + if (Equals(toast.ComponentType, typeof(FluentToast)) || Equals(toast.ComponentType, typeof(FluentProgressToast))) + { + return toast.ComponentType; + } + + throw new InvalidOperationException($"Unsupported toast component type '{toast.ComponentType.FullName}'. Only {nameof(FluentToast)} and {nameof(FluentProgressToast)} are supported."); + } + + private Dictionary GetToastParameters(IToastInstance toast) + { + var toastOptions = toast.Options; + var parameters = new Dictionary(StringComparer.Ordinal) + { + [nameof(FluentToastComponentBase.Id)] = toast.Id, + [nameof(FluentToastComponentBase.Class)] = toastOptions?.ClassValue, + [nameof(FluentToastComponentBase.Style)] = toastOptions?.StyleValue, + [nameof(FluentToastComponentBase.Data)] = toastOptions?.Data, + [nameof(FluentToastComponentBase.Timeout)] = toastOptions?.Timeout ?? 5000, + [nameof(FluentToastComponentBase.Instance)] = toast, + [nameof(FluentToastComponentBase.OnStateChange)] = EventCallback.Factory.Create(this, toastOptions?.OnStateChange ?? EmptyOnStateChange), + [nameof(FluentToastComponentBase.AdditionalAttributes)] = toastOptions?.AdditionalAttributes, + }; + + if (toastOptions is null || toastOptions.Parameters is null) + { + return parameters; + } + + foreach (var parameter in toastOptions.Parameters) + { + if (string.Equals(parameter.Key, nameof(FluentToastComponentBase.Instance), StringComparison.Ordinal)) + { + continue; + } + + parameters[parameter.Key] = parameter.Value; + } + + return parameters; + } + /// /// Only for Unit Tests /// @@ -68,3 +113,5 @@ internal void UpdateId(string? id) } } } + +#pragma warning restore IL2111 diff --git a/src/Core/Components/Toast/FluentToastTitle.razor b/src/Core/Components/Toast/FluentToastTitle.razor deleted file mode 100644 index 2bf53803d6..0000000000 --- a/src/Core/Components/Toast/FluentToastTitle.razor +++ /dev/null @@ -1,39 +0,0 @@ -@namespace Microsoft.FluentUI.AspNetCore.Components - - - @if (Media is not null) - { -
@Media
- } - else if (ToastIntent is not null) - { - - } -
@ChildContent
- @if (Action is not null) - { -
@Action
- } - - -@code { - - private RenderFragment? GetIcon(ToastIntent? intent) - { - if (intent is null) - { - return null; - } - - return intent switch - { - Components.ToastIntent.Success =>@ , - Components.ToastIntent.Warning =>@ , - Components.ToastIntent.Error =>@ , - Components.ToastIntent.Info =>@ , - _ => null, - }; - } -} diff --git a/src/Core/Components/Toast/FluentToastTitle.razor.cs b/src/Core/Components/Toast/FluentToastTitle.razor.cs deleted file mode 100644 index fddb35419e..0000000000 --- a/src/Core/Components/Toast/FluentToastTitle.razor.cs +++ /dev/null @@ -1,46 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using Microsoft.AspNetCore.Components; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// FluentToastTitle is a component that represents the title of a toast. It can contain an icon, a title, and an action. -/// -public partial class FluentToastTitle -{ - /// - /// Gets or sets the content to be rendered inside the component. - /// - /// Use this parameter to specify child elements or markup that will be rendered within the - /// component's body. Typically used to allow consumers of the component to provide custom UI content. - [Parameter] - public RenderFragment? ChildContent { get; set; } - - /// - /// Gets or sets the media content to render within the component. - /// - /// Use this property to provide custom media elements, such as images, icons, or videos, that - /// will be displayed in the designated area of the component. The content is rendered as a fragment and can include - /// any valid Blazor markup. - [Parameter] - public RenderFragment? Media { get; set; } - - /// - /// Gets or sets the content to render as the action area of the component. - /// - /// Use this property to provide custom interactive elements, such as buttons or links, that - /// appear in the action area. The content is rendered as a fragment and can include arbitrary markup or - /// components. - - [Parameter] - public RenderFragment? Action { get; set; } - - /// - /// Gets or sets the intent of the toast notification, indicating its purpose or severity. - /// - [CascadingParameter] - public ToastIntent? ToastIntent { get; set; } -} diff --git a/src/Core/Components/Toast/FluentToastTitle.razor.css b/src/Core/Components/Toast/FluentToastTitle.razor.css deleted file mode 100644 index 1d88ac203e..0000000000 --- a/src/Core/Components/Toast/FluentToastTitle.razor.css +++ /dev/null @@ -1,23 +0,0 @@ -.fluent-toast-title-media { - display: flex; - padding-top: 2px; - grid-column-end: 2; - padding-right: 8px; - font-size: 16px; - color: var(--colorNeutralForeground1); -} - -.fluent-toast-title { - display: flex; - grid-column-end: 3; - color: var(--colorNeutralForeground1); - word-break: break-word; -} - -.fluent-toast-title-action { - display: flex; - align-items: start; - padding-left: 12px; - grid-column-end: -1; - color: var(--colorBrandForeground1); -} diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index 4ee9bcd2b3..e31fc23017 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -2,6 +2,8 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.FluentUI.AspNetCore.Components; /// @@ -12,6 +14,7 @@ public interface IToastInstance /// /// Gets the component type of the Toast. /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] internal Type ComponentType { get; } /// diff --git a/src/Core/Components/Toast/Services/ToastEventArgs.cs b/src/Core/Components/Toast/Services/ToastEventArgs.cs index dc16d0766b..73bf0b403f 100644 --- a/src/Core/Components/Toast/Services/ToastEventArgs.cs +++ b/src/Core/Components/Toast/Services/ToastEventArgs.cs @@ -10,13 +10,13 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastEventArgs : EventArgs { /// - internal ToastEventArgs(FluentToast toast, DialogToggleEventArgs args) + internal ToastEventArgs(FluentToastComponentBase toast, DialogToggleEventArgs args) : this(toast, args.Id, args.Type, args.OldState, args.NewState) { } /// - internal ToastEventArgs(FluentToast toast, string? id, string? eventType, string? oldState, string? newState) + internal ToastEventArgs(FluentToastComponentBase toast, string? id, string? eventType, string? oldState, string? newState) { Id = id ?? string.Empty; Instance = toast.Instance; diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 05c0b2f6ac..328514e7b5 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -2,6 +2,7 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ +using System.Diagnostics.CodeAnalysis; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -12,11 +13,12 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastInstance : IToastInstance { private static long _counter; + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] private readonly Type _componentType; internal readonly TaskCompletionSource ResultCompletion = new(); /// - internal ToastInstance(IToastService toastService, Type componentType, ToastOptions options) + internal ToastInstance(IToastService toastService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType, ToastOptions options) { _componentType = componentType; Options = options; @@ -26,13 +28,14 @@ internal ToastInstance(IToastService toastService, Type componentType, ToastOpti } /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type IToastInstance.ComponentType => _componentType; /// internal IToastService ToastService { get; } /// - internal FluentToast? FluentToast { get; set; } + internal FluentToastComponentBase? FluentToast { get; set; } /// public ToastOptions Options { get; internal set; } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 91eeb55ddf..1644bf07ec 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -66,6 +66,11 @@ public ToastOptions(Action implementationFactory) /// public IReadOnlyDictionary? AdditionalAttributes { get; set; } + /// + /// Gets or sets the timeout duration for the Toast in milliseconds. + /// + public int Timeout { get; set; } = 5000; + /// /// Gets a list of Toast parameters. /// Each parameter must correspond to a `[Parameter]` property defined in the component. diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index a1dc17d599..117f0d4c70 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -62,7 +62,7 @@ public async Task CloseAsync(IToastInstance Toast, ToastResult result) /// public Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(Action options) where TToast : ComponentBase { - return ShowToastAsync(typeof(TToast), new ToastOptions(options)); + return ShowToastAsync(new ToastOptions(options)); } /// @@ -73,6 +73,11 @@ private async Task ShowToastAsync([DynamicallyAccessedMembers(Dynam throw new ArgumentException($"{componentType.FullName} must be a Blazor Component", nameof(componentType)); } + if (!Equals(componentType, typeof(FluentToast)) && !Equals(componentType, typeof(FluentProgressToast))) + { + throw new ArgumentException($"{componentType.FullName} must be {nameof(FluentToast)} or {nameof(FluentProgressToast)}", nameof(componentType)); + } + if (this.ProviderNotAvailable()) { throw new FluentServiceProviderException(); diff --git a/src/Core/Enums/ToastPoliteness.cs b/src/Core/Enums/ToastPoliteness.cs new file mode 100644 index 0000000000..62d49b844b --- /dev/null +++ b/src/Core/Enums/ToastPoliteness.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Controls the politeness setting of the toast live region. +/// +public enum ToastPoliteness +{ + /// + [Description("polite")] + Polite, + + /// + [Description("assertive")] + Assertive, +} diff --git a/src/Core/Events/DialogToggleEventArgs.cs b/src/Core/Events/DialogToggleEventArgs.cs index 70915086dc..275d133816 100644 --- a/src/Core/Events/DialogToggleEventArgs.cs +++ b/src/Core/Events/DialogToggleEventArgs.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// /// Event arguments for the FluentDialog toggle event. /// -internal class DialogToggleEventArgs : EventArgs +public class DialogToggleEventArgs : EventArgs { /// /// Gets or sets the ID of the dialog. diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 2dd66265dc..153ac2c380 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -152,4 +152,7 @@ CS1591 + + + diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 6b9127b8d4..ec769343d1 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -1,4 +1,4 @@ -@using Xunit; +@using Xunit; @inherits FluentUITestContext @code { From 7a85d82ac2f3baaf4ffc49f7f652f5b17e3f6246 Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Thu, 19 Mar 2026 21:05:14 +0100 Subject: [PATCH 04/20] Refactoring again --- .../Toast/DebugPages/DebugToast.razor | 68 ++--- .../src/Components/Toast/FluentToast.ts | 258 +++++++++++++++++- .../MessageBar/FluentMessageBar.razor.cs | 6 +- .../Components/Toast/FluentProgressToast.cs | 93 +------ .../Toast/FluentProgressToastContent.razor | 58 ---- .../Toast/FluentProgressToastContent.razor.cs | 22 -- src/Core/Components/Toast/FluentToast.cs | 92 ------- src/Core/Components/Toast/FluentToast.razor | 113 ++++++++ ...nentBase.razor.cs => FluentToast.razor.cs} | 196 +++++++------ .../Toast/FluentToastComponentBase.razor | 26 -- .../Toast/FluentToastComponentBase.razor.css | 75 ----- .../Components/Toast/FluentToastContent.razor | 54 ---- .../Toast/FluentToastContent.razor.cs | 22 -- .../Toast/FluentToastProvider.razor | 42 ++- .../Toast/FluentToastProvider.razor.cs | 50 +--- .../Toast/Services/IToastInstance.cs | 15 +- .../Toast/Services/IToastService.cs | 27 +- .../Toast/Services/ToastEventArgs.cs | 4 +- .../Toast/Services/ToastInstance.cs | 18 +- .../Components/Toast/Services/ToastOptions.cs | 116 +++++++- .../Components/Toast/Services/ToastService.cs | 37 ++- ...soft.FluentUI.AspNetCore.Components.csproj | 1 + tests/Core/Components.Tests.csproj | 4 + .../Components/Toast/FluentToastTests.razor | 221 ++++++--------- .../Toast/Templates/ToastRender.razor | 54 ---- .../Toast/Templates/ToastRenderOptions.cs | 16 -- .../Toast/Templates/ToastWithInstance.razor | 18 -- 27 files changed, 811 insertions(+), 895 deletions(-) delete mode 100644 src/Core/Components/Toast/FluentProgressToastContent.razor delete mode 100644 src/Core/Components/Toast/FluentProgressToastContent.razor.cs delete mode 100644 src/Core/Components/Toast/FluentToast.cs create mode 100644 src/Core/Components/Toast/FluentToast.razor rename src/Core/Components/Toast/{FluentToastComponentBase.razor.cs => FluentToast.razor.cs} (55%) delete mode 100644 src/Core/Components/Toast/FluentToastComponentBase.razor delete mode 100644 src/Core/Components/Toast/FluentToastComponentBase.razor.css delete mode 100644 src/Core/Components/Toast/FluentToastContent.razor delete mode 100644 src/Core/Components/Toast/FluentToastContent.razor.cs delete mode 100644 tests/Core/Components/Toast/Templates/ToastRender.razor delete mode 100644 tests/Core/Components/Toast/Templates/ToastRenderOptions.cs delete mode 100644 tests/Core/Components/Toast/Templates/ToastWithInstance.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index 98ced44cbf..0e0559df35 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -28,15 +28,15 @@ private async Task OpenDismissableToastAsync() { - var result = await ToastService.ShowToastAsync(options => + var result = await ToastService.ShowToastAsync(options => { options.Timeout = 70000; - options.Parameters[nameof(FluentToast.Intent)] = ToastIntent.Warning; - options.Parameters[nameof(FluentToast.Title)] = "Delete item?"; - options.Parameters[nameof(FluentToast.Body)] = "This action can't be undone."; - options.Parameters[nameof(FluentToast.QuickAction1)] = "Delete"; - options.Parameters[nameof(FluentToast.QuickAction2)] = "Cancel"; - options.Parameters[nameof(FluentToast.ShowDismissButton)] = true; + options.Intent = ToastIntent.Warning; + options.Title = "Delete item?"; + options.Body = "This action can't be undone."; + options.QuickAction1 = "Delete"; + options.QuickAction2 = "Cancel"; + options.ShowDismissButton = true; options.OnStateChange = (e) => { Console.WriteLine($"State changed: {e.State}"); @@ -48,16 +48,16 @@ private async Task OpenToastAsync() { - var result = await ToastService.ShowToastAsync(options => + var result = await ToastService.ShowToastAsync(options => { options.Timeout = 70000; - options.Parameters[nameof(FluentToast.Intent)] = ToastIntent.Info; - options.Parameters[nameof(FluentToast.Title)] = "Email sent"; - options.Parameters[nameof(FluentToast.Body)] = "Your message was delivered."; - options.Parameters[nameof(FluentToast.Subtitle)] = "Just now"; - options.Parameters[nameof(FluentToast.QuickAction1)] = "Undo"; - options.Parameters[nameof(FluentToast.QuickAction2)] = "Dismiss"; - options.Parameters[nameof(FluentToast.ShowDismissButton)] = false; + options.Intent = ToastIntent.Info; + options.Title = "Email sent"; + options.Body = "Your message was delivered."; + options.Subtitle = "Just now"; + options.QuickAction1 = "Undo"; + options.QuickAction2 = "Dismiss"; + options.ShowDismissButton = false; }); _lastResult = result.Cancelled ? "Communication: Cancelled" : "Communication: Ok"; @@ -65,17 +65,18 @@ private async Task OpenProgressToastAsync() { - var result = await ToastService.ShowToastAsync(options => + var result = await ToastService.ShowToastAsync(options => { options.Timeout = 0; - options.Parameters[nameof(FluentProgressToast.Intent)] = ToastIntent.Info; - options.Parameters[nameof(FluentProgressToast.Title)] = "Uploading"; - options.Parameters[nameof(FluentProgressToast.Body)] = "Please wait while your files are uploaded."; - options.Parameters[nameof(FluentProgressToast.Status)] = "Uploading 3 files..."; - options.Parameters[nameof(FluentProgressToast.Indeterminate)] = true; - options.Parameters[nameof(FluentProgressToast.QuickAction1)] = "Hide"; - options.Parameters[nameof(FluentProgressToast.QuickAction2)] = "Cancel"; - options.Parameters[nameof(FluentProgressToast.ShowDismissButton)] = false; + options.Intent = ToastIntent.Info; + options.Title = "Uploading"; + options.Body = "Please wait while your files are uploaded."; + options.Status = "Uploading 3 files..."; + options.ShowProgress = true; + options.Indeterminate = true; + options.QuickAction1 = "Hide"; + options.QuickAction2 = "Cancel"; + options.ShowDismissButton = false; }); _lastResult = result.Cancelled ? "Progress: Cancelled" : "Progress: Ok"; @@ -83,17 +84,18 @@ private async Task OpenProgressToast2Async() { - var result = await ToastService.ShowToastAsync(options => + var result = await ToastService.ShowToastAsync(options => { options.Timeout = 0; - options.Parameters[nameof(FluentProgressToast.Intent)] = ToastIntent.Info; - options.Parameters[nameof(FluentProgressToast.Title)] = "Uploading"; - options.Parameters[nameof(FluentProgressToast.Body)] = "Please wait while your files are uploaded."; - options.Parameters[nameof(FluentProgressToast.Status)] = "Uploading 3 files..."; - options.Parameters[nameof(FluentProgressToast.Indeterminate)] = false; - options.Parameters[nameof(FluentProgressToast.QuickAction1)] = "Hide"; - options.Parameters[nameof(FluentProgressToast.QuickAction2)] = "Cancel"; - options.Parameters[nameof(FluentProgressToast.ShowDismissButton)] = false; + options.Intent = ToastIntent.Info; + options.Title = "Uploading"; + options.Body = "Please wait while your files are uploaded."; + options.Status = "Uploading 3 files..."; + options.ShowProgress = true; + options.Indeterminate = false; + options.QuickAction1 = "Hide"; + options.QuickAction2 = "Cancel"; + options.ShowDismissButton = false; }); _lastResult = result.Cancelled ? "Progress: Cancelled" : "Progress: Ok"; diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 4561ff1d4e..1a3bec6918 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -5,6 +5,19 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { class FluentToast extends HTMLElement { private static readonly stackGap = 12; private dialog: ToastElement; + private mediaRegion: HTMLDivElement; + private titleRegion: HTMLDivElement; + private dismissRegion: HTMLDivElement; + private bodyRegion: HTMLDivElement; + private subtitleRegion: HTMLDivElement; + private footerRegion: HTMLDivElement; + private mediaSlot: HTMLSlotElement; + private titleSlot: HTMLSlotElement; + private dismissSlot: HTMLSlotElement; + private bodySlot: HTMLSlotElement; + private subtitleSlot: HTMLSlotElement; + private footerSlot: HTMLSlotElement; + private resizeObserver: ResizeObserver | null = null; private timeoutId: number | null = null; private remainingTimeout: number | null = null; private timeoutStartedAt: number | null = null; @@ -37,6 +50,50 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.dispatchDialogToggleEvent('toggle', oldState, newState); }); + this.mediaRegion = document.createElement('div'); + this.mediaRegion.classList.add('media'); + this.mediaSlot = document.createElement('slot'); + this.mediaSlot.name = 'media'; + this.mediaRegion.appendChild(this.mediaSlot); + + this.titleRegion = document.createElement('div'); + this.titleRegion.classList.add('title'); + this.titleSlot = document.createElement('slot'); + this.titleSlot.name = 'title'; + this.titleRegion.appendChild(this.titleSlot); + + this.dismissRegion = document.createElement('div'); + this.dismissRegion.classList.add('dismiss'); + this.dismissSlot = document.createElement('slot'); + this.dismissSlot.name = 'dismiss'; + this.dismissRegion.appendChild(this.dismissSlot); + + this.bodyRegion = document.createElement('div'); + this.bodyRegion.classList.add('body'); + this.bodySlot = document.createElement('slot'); + this.bodyRegion.appendChild(this.bodySlot); + + this.subtitleRegion = document.createElement('div'); + this.subtitleRegion.classList.add('subtitle'); + this.subtitleSlot = document.createElement('slot'); + this.subtitleSlot.name = 'subtitle'; + this.subtitleRegion.appendChild(this.subtitleSlot); + + this.footerRegion = document.createElement('div'); + this.footerRegion.classList.add('footer'); + this.footerSlot = document.createElement('slot'); + this.footerSlot.name = 'footer'; + this.footerRegion.appendChild(this.footerSlot); + + this.dialog.append( + this.mediaRegion, + this.titleRegion, + this.dismissRegion, + this.bodyRegion, + this.subtitleRegion, + this.footerRegion, + ); + // Set initial styles for the dialog const sheet = new CSSStyleSheet(); sheet.replaceSync(` @@ -49,19 +106,131 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { } :host div[fuib][popover] { + display: grid; + grid-template-columns: auto 1fr auto; border: 0; - background: transparent; + background: var(--colorNeutralBackground1); + padding: 0; + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); + font-weight: var(--fontWeightSemibold); + color: var(--colorNeutralForeground1); + border: 1px solid var(--colorTransparentStroke); + border-radius: var(--borderRadiusMedium); + box-shadow: var(--shadow8); + box-sizing: border-box; + min-width: 292px; + max-width: 292px; + padding: 12px; + } + + .media, + .title, + .dismiss, + .body, + .subtitle, + .footer { + min-width: 0; + } + + .media { + display: flex; + grid-column: 1; + grid-row: 1; + padding-top: 2px; + padding-inline-end: 8px; + font-size: var(--fontSizeBase400); + color: var(--colorNeutralForeground1); + } + + .title { + display: flex; + align-items: center; + grid-column: 2; + grid-row: 1; + color: var(--colorNeutralForeground1); + word-break: break-word; + } + + :host(:not([has-dismiss])) .title { + grid-column: 2 / -1; + } + + :host(:not([has-media])) .title { + grid-column: 1 / span 2; + } + + :host(:not([has-media]):not([has-dismiss])) .title { + grid-column: 1 / -1; + } + + .dismiss { + display: flex; + align-items: start; + justify-content: end; + grid-column: 3; + grid-row: 1; + padding-inline-start: 12px; + color: var(--colorBrandForeground1); + } + + .body { + grid-column: 2 / -1; + padding-top: 6px; + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground1); + word-break: break-word; + } + + .subtitle { + grid-column: 2 / -1; + padding-top: 4px; + font-size: var(--fontSizeBase200); + line-height: var(--lineHeightBase200); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground2); + } + + .footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px; + grid-column: 2 / -1; + padding-top: 16px; + } + + :host(:not([has-media])) .body, + :host(:not([has-media])) .subtitle, + :host(:not([has-media])) .footer { + grid-column: 1 / -1; + } + + .media[hidden], + .title[hidden], + .dismiss[hidden], + .body[hidden], + .subtitle[hidden], + .footer[hidden] { + display: none !important; } /* Animations */ :host div[fuib][popover]:popover-open { - display: block; opacity: 1; animation: toast-enter 0.25s cubic-bezier(0.33, 0, 0, 1) forwards; } :host div[fuib][popover].closing { - animation: toast-exit 0.2s cubic-bezier(0.33, 0, 0, 1) forwards; + pointer-events: none; + overflow: hidden; + will-change: opacity, height, margin, padding; + animation: + toast-exit 600ms cubic-bezier(0.33, 0, 0.67, 1) forwards, + toast-dismiss-collapse-height 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards, + toast-dismiss-collapse-spacing 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards; } @keyframes toast-enter { @@ -69,9 +238,33 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { to { opacity: 1; transform: var(--toast-enter-to, translateY(0)); } } - @keyframes toast-exit { - from { opacity: 1; transform: var(--toast-enter-to, translateY(0)); } - to { opacity: 0; transform: scale(0.95); } + @keyframes toast-exit { + 0% { + opacity: 1; + height: var(--toast-height); + margin-top: var(--toast-margin-top, 0px); + margin-bottom: var(--toast-margin-bottom, 0px); + padding-top: var(--toast-padding-top, 0px); + padding-bottom: var(--toast-padding-bottom, 0px); + } + + 66.666% { + opacity: 0; + height: var(--toast-height); + margin-top: var(--toast-margin-top, 0px); + margin-bottom: var(--toast-margin-bottom, 0px); + padding-top: var(--toast-padding-top, 0px); + padding-bottom: var(--toast-padding-bottom, 0px); + } + + 100% { + opacity: 0; + height: 0; + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } } `); this.shadowRoot!.adoptedStyleSheets = [ @@ -79,10 +272,14 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { sheet ]; - // Slot for user content - const slot = document.createElement('slot'); - this.dialog.appendChild(slot); shadow.appendChild(this.dialog); + + this.mediaSlot.addEventListener('slotchange', () => this.updateSlotState(this.mediaRegion, this.mediaSlot, 'has-media')); + this.titleSlot.addEventListener('slotchange', () => this.updateSlotState(this.titleRegion, this.titleSlot, 'has-title')); + this.dismissSlot.addEventListener('slotchange', () => this.updateSlotState(this.dismissRegion, this.dismissSlot, 'has-dismiss')); + this.bodySlot.addEventListener('slotchange', () => this.updateSlotState(this.bodyRegion, this.bodySlot, 'has-body')); + this.subtitleSlot.addEventListener('slotchange', () => this.updateSlotState(this.subtitleRegion, this.subtitleSlot, 'has-subtitle')); + this.footerSlot.addEventListener('slotchange', () => this.updateSlotState(this.footerRegion, this.footerSlot, 'has-footer')); } connectedCallback() { @@ -93,6 +290,15 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.addEventListener('focusout', this.handleFocusOut, true); window.addEventListener('blur', this.handleWindowBlur, true); window.addEventListener('focus', this.handleWindowFocus, true); + this.updateSlotStates(); + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => { + if (this.dialogIsOpen) { + this.updateToastStack(); + } + }); + this.resizeObserver.observe(this.dialog); + } this.updateAccessibility(); } @@ -105,7 +311,10 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.removeEventListener('focusout', this.handleFocusOut, true); window.removeEventListener('blur', this.handleWindowBlur, true); window.removeEventListener('focus', this.handleWindowFocus, true); + this.resizeObserver?.disconnect(); + this.resizeObserver = null; this.clearTimeout(); + this.updateToastStack(); } private handlePointerOver = () => { @@ -202,6 +411,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { public showToast() { if (!this.dialog) return; + this.updateSlotStates(); this.dialog.showPopover(); this.updatePosition(); this.updateToastStack(); @@ -224,7 +434,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { }; this.dialog.addEventListener('animationend', onAnimationEnd); // Fallback in case animation doesn't fire - setTimeout(() => resolve(false), 300); + setTimeout(() => resolve(false), 650); }); this.dialog.hidePopover(); @@ -374,7 +584,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { // Centers need translate(-50%, 0) applied permanently if they are open if (position.includes('center')) { - this.dialog.style.transform = enterTo; + this.dialog.style.transform = enterTo; } } @@ -437,6 +647,32 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { return value.toLowerCase() === 'true'; } + + private updateSlotStates() { + this.updateSlotState(this.mediaRegion, this.mediaSlot, 'has-media'); + this.updateSlotState(this.titleRegion, this.titleSlot, 'has-title'); + this.updateSlotState(this.dismissRegion, this.dismissSlot, 'has-dismiss'); + this.updateSlotState(this.bodyRegion, this.bodySlot, 'has-body'); + this.updateSlotState(this.subtitleRegion, this.subtitleSlot, 'has-subtitle'); + this.updateSlotState(this.footerRegion, this.footerSlot, 'has-footer'); + } + + private updateSlotState(region: HTMLDivElement, slot: HTMLSlotElement, hostAttribute: string) { + const hasContent = slot.assignedNodes({ flatten: true }).some(node => + node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()) + ); + + region.hidden = !hasContent; + if (hasContent) { + this.setAttribute(hostAttribute, ''); + } else { + this.removeAttribute(hostAttribute); + } + + if (this.dialogIsOpen) { + this.updateToastStack(); + } + } } interface ToastElement extends HTMLDivElement { diff --git a/src/Core/Components/MessageBar/FluentMessageBar.razor.cs b/src/Core/Components/MessageBar/FluentMessageBar.razor.cs index 0df41ebc0a..9981134598 100644 --- a/src/Core/Components/MessageBar/FluentMessageBar.razor.cs +++ b/src/Core/Components/MessageBar/FluentMessageBar.razor.cs @@ -13,7 +13,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentMessageBar : FluentComponentBase { private static readonly Icon IconInfo = new CoreIcons.Regular.Size20.Info().WithColor("var(--info)"); - private static readonly Icon IconWarning= new CoreIcons.Filled.Size20.Warning().WithColor("var(--warning)"); + private static readonly Icon IconWarning = new CoreIcons.Filled.Size20.Warning().WithColor("var(--warning)"); private static readonly Icon IconSuccess = new CoreIcons.Filled.Size20.CheckmarkCircle().WithColor("var(--success)"); private static readonly Icon IconError = new CoreIcons.Filled.Size20.DismissCircle().WithColor("var(--error)"); @@ -29,14 +29,14 @@ public FluentMessageBar(LibraryConfiguration configuration) : base(configuration .Build(); /// - /// Gets or sets the intent of the message bar. + /// Gets or sets the intent of the message bar. /// Default is . /// [Parameter] public MessageBarIntent? Intent { get; set; } /// - /// Gets or sets the layout of the message bar. + /// Gets or sets the layout of the message bar. /// Default is . /// [Parameter] diff --git a/src/Core/Components/Toast/FluentProgressToast.cs b/src/Core/Components/Toast/FluentProgressToast.cs index 459a398791..0c6a3b4fa0 100644 --- a/src/Core/Components/Toast/FluentProgressToast.cs +++ b/src/Core/Components/Toast/FluentProgressToast.cs @@ -2,96 +2,13 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -#pragma warning disable CS1591, MA0051, MA0123, CA1822 - -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.FluentUI.AspNetCore.Components; -/// -/// A toast that displays progress for an ongoing operation. -/// -public partial class FluentProgressToast : FluentToastComponentBase -{ - /// - public FluentProgressToast(LibraryConfiguration configuration) : base(configuration) - { - } - - /// - /// Gets or sets the title of the toast. - /// - [Parameter] - public string? Title { get; set; } - - /// - /// Gets or sets the body text of the toast. - /// - [Parameter] - public string? Body { get; set; } - - /// - /// Gets or sets the status text of the toast. - /// - [Parameter] - public string? Status { get; set; } - - /// - /// Gets or sets the maximum progress value. - /// - [Parameter] - public int? Max { get; set; } = 100; - - /// - /// Gets or sets the current progress value. - /// - [Parameter] - public int? Value { get; set; } - - /// - /// Gets or sets whether the progress is indeterminate. - /// - [Parameter] - public bool Indeterminate { get; set; } = true; - - /// - /// Gets or sets the first quick action label. - /// - [Parameter] - public string? QuickAction1 { get; set; } +#pragma warning disable CS1591 - /// - /// Gets or sets the second quick action label. - /// - [Parameter] - public string? QuickAction2 { get; set; } - - /// - /// Gets or sets whether the dismiss button is shown. - /// - [Parameter] - public bool ShowDismissButton { get; set; } - - internal static Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); - - public new Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) - => base.RaiseOnStateChangeAsync(args); - - public new Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) - => base.RaiseOnStateChangeAsync(instance, state); - - public new Task OnToggleAsync(DialogToggleEventArgs args) - => base.OnToggleAsync(args); - - protected override void BuildRenderTree(RenderTreeBuilder __builder) - => BuildToastShell(__builder, BuildContent); - - private void BuildContent(RenderTreeBuilder __builder) - => BuildOwnedContent(__builder); - internal Task OnActionClickedAsync(bool primary) - { - return primary ? Instance!.CloseAsync() : Instance!.CancelAsync(); - } +[Obsolete($"Use {nameof(FluentToast)} with {nameof(FluentToast.ShowProgress)} instead.", error: false)] +public sealed class FluentProgressToast +{ } -#pragma warning restore CS1591, MA0051, MA0123, CA1822 +#pragma warning restore CS1591 diff --git a/src/Core/Components/Toast/FluentProgressToastContent.razor b/src/Core/Components/Toast/FluentProgressToastContent.razor deleted file mode 100644 index d3704f0a76..0000000000 --- a/src/Core/Components/Toast/FluentProgressToastContent.razor +++ /dev/null @@ -1,58 +0,0 @@ -@namespace Microsoft.FluentUI.AspNetCore.Components - -
-
- @if (Owner.Indeterminate) - { - - } - else - { - - } -
-
-
@Owner.Title
-
- @if (Owner.ShowDismissButton) - { -
- -
- } - - @if (!string.IsNullOrEmpty(Owner.Body)) - { -
-
@Owner.Body
-
- } - - @if (!string.IsNullOrEmpty(Owner.Status)) - { -
@Owner.Status
- } - - @if (!string.IsNullOrEmpty(Owner.QuickAction1) || !string.IsNullOrEmpty(Owner.QuickAction2)) - { - - } -
diff --git a/src/Core/Components/Toast/FluentProgressToastContent.razor.cs b/src/Core/Components/Toast/FluentProgressToastContent.razor.cs deleted file mode 100644 index a399a343d7..0000000000 --- a/src/Core/Components/Toast/FluentProgressToastContent.razor.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using Microsoft.AspNetCore.Components; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -#pragma warning disable CS1591 - -public partial class FluentProgressToastContent -{ - [Parameter, EditorRequired] - public FluentProgressToast Owner { get; set; } = default!; - - private Task OnActionClickedAsync(bool primary) - { - return Owner.OnActionClickedAsync(primary); - } - -#pragma warning restore CS1591 -} diff --git a/src/Core/Components/Toast/FluentToast.cs b/src/Core/Components/Toast/FluentToast.cs deleted file mode 100644 index bc92d33665..0000000000 --- a/src/Core/Components/Toast/FluentToast.cs +++ /dev/null @@ -1,92 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -#pragma warning disable CS1591, MA0051, MA0123, CA1822 - -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// A toast is a temporary notification that appears in the corner of the screen. -/// -public partial class FluentToast : FluentToastComponentBase -{ - /// - public FluentToast(LibraryConfiguration configuration) : base(configuration) - { - } - - /// - /// Gets or sets the title displayed in the toast. - /// - [Parameter] - public string? Title { get; set; } - - /// - /// Gets or sets the body content displayed in the toast. - /// - [Parameter] - public string? Body { get; set; } - - /// - /// Gets or sets the optional subtitle displayed below the body. - /// - [Parameter] - public string? Subtitle { get; set; } - - /// - /// Gets or sets the first quick action label. - /// - [Parameter] - public string? QuickAction1 { get; set; } - - /// - /// Gets or sets the second quick action label. - /// - [Parameter] - public string? QuickAction2 { get; set; } - - /// - /// Gets or sets whether to render the dismiss button. - /// - [Parameter] - public bool ShowDismissButton { get; set; } = true; - - internal Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); - - public new Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) - => base.RaiseOnStateChangeAsync(args); - - public new Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) - => base.RaiseOnStateChangeAsync(instance, state); - - public new Task OnToggleAsync(DialogToggleEventArgs args) - => base.OnToggleAsync(args); - - protected override void BuildRenderTree(RenderTreeBuilder __builder) - => BuildToastShell(__builder, BuildContent); - - private void BuildContent(RenderTreeBuilder __builder) - => BuildOwnedContent(__builder); - - internal Task OnActionClickedAsync(bool primary) - { - return primary ? Instance!.CloseAsync() : Instance!.CancelAsync(); - } - -#pragma warning restore CS1591, MA0051, MA0123, CA1822 - - internal static Color GetIntentColor(ToastIntent intent) - { - return intent switch - { - ToastIntent.Success => Color.Success, - ToastIntent.Warning => Color.Warning, - ToastIntent.Error => Color.Error, - _ => Color.Info, - }; - } -} diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor new file mode 100644 index 0000000000..4eb540952e --- /dev/null +++ b/src/Core/Components/Toast/FluentToast.razor @@ -0,0 +1,113 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentComponentBase +@using Microsoft.FluentUI.AspNetCore.Components.Extensions + + + @if (Media is not null) + { + @Media + } + else if (ShowProgress) + { +
+ @if (Indeterminate) + { + + } + else + { + + } +
+ } + else + { + + } + + @if (TitleContent is not null) + { +
@TitleContent
+ } + else if (!string.IsNullOrEmpty(Title)) + { +
@Title
+ } + + @if (ShowDismissButton) + { + @if (DismissContent is not null) + { + @DismissContent + } + else + { + + } + } + + @if (ChildContent is not null) + { +
@ChildContent
+ } + else if (!string.IsNullOrEmpty(Body)) + { +
@Body
+ } + + @if (SubtitleContent is not null) + { +
@SubtitleContent
+ } + else if (!string.IsNullOrEmpty(ShowProgress ? Status : Subtitle)) + { +
@(ShowProgress ? Status : Subtitle)
+ } + + @if (FooterContent is not null) + { +
@FooterContent
+ } + else if (!string.IsNullOrEmpty(QuickAction1) || !string.IsNullOrEmpty(QuickAction2)) + { +
+ @if (!string.IsNullOrEmpty(QuickAction1)) + { + + @QuickAction1 + + } + + @if (!string.IsNullOrEmpty(QuickAction2)) + { + + @QuickAction2 + + } +
+ } +
diff --git a/src/Core/Components/Toast/FluentToastComponentBase.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs similarity index 55% rename from src/Core/Components/Toast/FluentToastComponentBase.razor.cs rename to src/Core/Components/Toast/FluentToast.razor.cs index fad1ce50fe..74d14e120a 100644 --- a/src/Core/Components/Toast/FluentToastComponentBase.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -2,35 +2,24 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -#pragma warning disable CS1591 - -using ComponentModel = System.ComponentModel; -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; -[ComponentModel.EditorBrowsable(ComponentModel.EditorBrowsableState.Never)] -public partial class FluentToastComponentBase : FluentComponentBase +#pragma warning disable CS1591, MA0051, MA0123, CA1822 + +public partial class FluentToast { - [DynamicDependency(nameof(OnToggleAsync))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DialogToggleEventArgs))] - public FluentToastComponentBase(LibraryConfiguration configuration) : base(configuration) + /// + public FluentToast(LibraryConfiguration configuration) : base(configuration) { Id = Identifier.NewId(); } - [Parameter] - public FluentToastComponentBase? Owner { get; set; } - [Parameter] public IToastInstance? Instance { get; set; } - [Parameter] - public RenderFragment? ChildContent { get; set; } - [Parameter] public bool Opened { get; set; } @@ -52,12 +41,6 @@ public FluentToastComponentBase(LibraryConfiguration configuration) : base(confi [Parameter] public ToastIntent Intent { get; set; } = ToastIntent.Info; - [Parameter] - public bool ShowIntentIcon { get; set; } - - [Parameter] - public RenderFragment? Media { get; set; } - [Parameter] public ToastPoliteness? Politeness { get; set; } @@ -73,8 +56,76 @@ public FluentToastComponentBase(LibraryConfiguration configuration) : base(confi [Parameter] public EventCallback OnStateChange { get; set; } - [Inject] - private IToastService? ToastService { get; set; } + /// + /// Gets or sets the title displayed in the toast. + /// + [Parameter] + public string? Title { get; set; } + + /// + /// Gets or sets the body content displayed in the toast. + /// + [Parameter] + public string? Body { get; set; } + + /// + /// Gets or sets the optional subtitle displayed below the body. + /// + [Parameter] + public string? Subtitle { get; set; } + + /// + /// Gets or sets the first quick action label. + /// + [Parameter] + public string? QuickAction1 { get; set; } + + /// + /// Gets or sets the second quick action label. + /// + [Parameter] + public string? QuickAction2 { get; set; } + + /// + /// Gets or sets whether to render the dismiss button. + /// + [Parameter] + public bool ShowDismissButton { get; set; } = true; + + [Parameter] + public string? Status { get; set; } + + [Parameter] + public bool ShowProgress { get; set; } + + [Parameter] + public bool Indeterminate { get; set; } = true; + + [Parameter] + public int? Value { get; set; } + + [Parameter] + public int? Max { get; set; } = 100; + + [Parameter] + public RenderFragment? Media { get; set; } + + [Parameter] + public RenderFragment? TitleContent { get; set; } + + [Parameter] + public RenderFragment? SubtitleContent { get; set; } + + [Parameter] + public RenderFragment? FooterContent { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public RenderFragment? DismissContent { get; set; } + + internal Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); internal Icon IntentIcon => Intent switch { @@ -84,43 +135,56 @@ public FluentToastComponentBase(LibraryConfiguration configuration) : base(confi _ => new CoreIcons.Filled.Size20.Info(), }; - internal string? ClassValue => DefaultClassBuilder - .Build(); + internal string? ClassValue => DefaultClassBuilder.Build(); - internal string? StyleValue => DefaultStyleBuilder - .AddStyle("border", "1px solid #ccc;") - .AddStyle("padding", "16px") - .AddStyle("position", "fixed") - .AddStyle("top", "50%") - .AddStyle("left", "50%") - .Build(); + internal string? StyleValue => DefaultStyleBuilder.Build(); - private bool LaunchedFromService => Instance is not null; + [Inject] + private IToastService? ToastService { get; set; } - internal Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) + public Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) => RaiseOnStateChangeAsync(new ToastEventArgs(this, args)); - internal Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) + public Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) => RaiseOnStateChangeAsync(new ToastEventArgs(instance, state)); - protected void BuildToastShell(RenderTreeBuilder builder, RenderFragment childContent) + public Task OnToggleAsync(DialogToggleEventArgs args) + => HandleToggleAsync(args); + + internal Task OnActionClickedAsync(bool primary) { - builder.OpenComponent(0); - builder.AddAttribute(1, nameof(Owner), this); - builder.AddAttribute(2, nameof(ChildContent), childContent); - builder.CloseComponent(); + return primary ? Instance!.CloseAsync() : Instance!.CancelAsync(); } - protected void BuildOwnedContent< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TContent>(RenderTreeBuilder builder) - where TContent : Microsoft.AspNetCore.Components.IComponent + internal static Color GetIntentColor(ToastIntent intent) { - builder.OpenComponent(0); - builder.AddAttribute(1, "Owner", this); - builder.CloseComponent(); + return intent switch + { + ToastIntent.Success => Color.Success, + ToastIntent.Warning => Color.Warning, + ToastIntent.Error => Color.Error, + _ => Color.Info, + }; + } + + /// + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && Instance is ToastInstance instance) + { + instance.FluentToast = this; + + if (!Opened) + { + Opened = true; + return InvokeAsync(StateHasChanged); + } + } + + return Task.CompletedTask; } - internal async Task OnToggleAsync(DialogToggleEventArgs args) + private async Task HandleToggleAsync(DialogToggleEventArgs args) { var expectedId = Instance?.Id ?? Id; if (string.CompareOrdinal(args.Id, expectedId) != 0) @@ -145,12 +209,12 @@ internal async Task OnToggleAsync(DialogToggleEventArgs args) } } - if (LaunchedFromService) + if (Instance is ToastInstance toastInstance) { switch (toastEventArgs.State) { case DialogState.Closing: - (Instance as ToastInstance)?.ResultCompletion.TrySetResult(ToastResult.Cancel()); + toastInstance.ResultCompletion.TrySetResult(ToastResult.Cancel()); break; case DialogState.Closed: @@ -164,36 +228,6 @@ internal async Task OnToggleAsync(DialogToggleEventArgs args) } } - protected override Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender && LaunchedFromService) - { - var instance = Instance as ToastInstance; - if (instance is not null) - { - instance.FluentToast = this; - } - - if (!Opened) - { - Opened = true; - return InvokeAsync(StateHasChanged); - } - } - - return Task.CompletedTask; - } - - protected override void OnParametersSet() - { - if (GetType().Equals(typeof(FluentToastComponentBase)) && (Owner is null || ChildContent is null)) - { - throw new InvalidOperationException($"{nameof(FluentToastComponentBase)} must be used as a shell with child content and cannot be rendered directly."); - } - - base.OnParametersSet(); - } - private async Task RaiseOnStateChangeAsync(ToastEventArgs args) { if (OnStateChange.HasDelegate) @@ -205,4 +239,4 @@ private async Task RaiseOnStateChangeAsync(ToastEventArgs args) } } -#pragma warning restore CS1591 +#pragma warning restore CS1591, MA0051, MA0123, CA1822 diff --git a/src/Core/Components/Toast/FluentToastComponentBase.razor b/src/Core/Components/Toast/FluentToastComponentBase.razor deleted file mode 100644 index cef54c5310..0000000000 --- a/src/Core/Components/Toast/FluentToastComponentBase.razor +++ /dev/null @@ -1,26 +0,0 @@ -@namespace Microsoft.FluentUI.AspNetCore.Components -@inherits FluentComponentBase -@using Microsoft.FluentUI.AspNetCore.Components.Extensions - - @if (ChildContent is not null) - { - @ChildContent - } - diff --git a/src/Core/Components/Toast/FluentToastComponentBase.razor.css b/src/Core/Components/Toast/FluentToastComponentBase.razor.css deleted file mode 100644 index 386cf13f2c..0000000000 --- a/src/Core/Components/Toast/FluentToastComponentBase.razor.css +++ /dev/null @@ -1,75 +0,0 @@ -.fluent-toast-layout { - display: grid; - font-size: var(--fontSizeBase300); - line-height: var(--lineHeightBase300); - font-weight: var(--fontWeightSemibold); - color: var(--colorNeutralForeground1); - grid-template-columns: auto 1fr auto; - background-color: var(--colorNeutralBackground1); - border: 1px solid var(--colorTransparentStroke); - border-radius: var(--borderRadiusMedium); - box-shadow: var(--shadow8); - padding: 12px; - min-width: 292px; - max-width: 292px; -} - -.fluent-toast-title-media { - display: flex; - padding-top: 2px; - grid-column-end: 2; - padding-inline-end: 8px; - font-size: var(--fontSizeBase400); - color: var(--colorNeutralForeground1); -} - -.fluent-toast-title { - display: flex; - grid-column-end: 3; - align-items: center; - color: var(--colorNeutralForeground1); - word-break: break-word; -} - -.fluent-toast-title-action { - display: flex; - align-items: start; - padding-inline-start: 12px; - grid-column-end: -1; - color: var(--colorBrandForeground1); -} - - .fluent-toast-title-action.time { - font-size: var(--fontSizeBase200); - color: var(--colorNeutralForeground1); - } - -.fluent-toast-body { - grid-column-start: 2; - grid-column-end: 3; - padding-top: 6px; - font-size: var(--fontSizeBase300); - line-height: var(--lineHeightBase300); - font-weight: var(--fontWeightRegular); - color: var(--colorNeutralForeground1); - word-break: break-word; -} - -.fluent-toast-subtitle { - padding-top: 4px; - grid-column-start: 2; - grid-column-end: 3; - font-size: var(--fontSizeBase200); - line-height: var(--lineHeightBase200); - font-weight: var(--fontWeightRegular); - color: var(--colorNeutralForeground2); -} - -.fluent-toast-footer { - padding-top: 16px; - grid-column-start: 2; - grid-column-end: 3; - display: flex; - align-items: center; - gap: 14px; -} diff --git a/src/Core/Components/Toast/FluentToastContent.razor b/src/Core/Components/Toast/FluentToastContent.razor deleted file mode 100644 index 3d67b1b5df..0000000000 --- a/src/Core/Components/Toast/FluentToastContent.razor +++ /dev/null @@ -1,54 +0,0 @@ -@namespace Microsoft.FluentUI.AspNetCore.Components - -@if (Owner.ChildContent is not null) -{ - @Owner.ChildContent -} -else -{ -
- -
-
@Owner.Title
-
- @if (Owner.ShowDismissButton) - { -
- -
- } - -
-
@Owner.Body
- @if (!string.IsNullOrEmpty(Owner.Subtitle)) - { -
@Owner.Subtitle
- } -
- - @if (!string.IsNullOrEmpty(Owner.QuickAction1) || !string.IsNullOrEmpty(Owner.QuickAction2)) - { - - } -
-} diff --git a/src/Core/Components/Toast/FluentToastContent.razor.cs b/src/Core/Components/Toast/FluentToastContent.razor.cs deleted file mode 100644 index 8169e8b10b..0000000000 --- a/src/Core/Components/Toast/FluentToastContent.razor.cs +++ /dev/null @@ -1,22 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using Microsoft.AspNetCore.Components; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -#pragma warning disable CS1591 - -public partial class FluentToastContent -{ - [Parameter, EditorRequired] - public FluentToast Owner { get; set; } = default!; - - private Task OnActionClickedAsync(bool primary) - { - return Owner.OnActionClickedAsync(primary); - } - -#pragma warning restore CS1591 -} diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 0ce30fcece..2093ddf00c 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -1,8 +1,5 @@ @namespace Microsoft.FluentUI.AspNetCore.Components @inherits FluentComponentBase -@{ -#pragma warning disable IL2111 -}
@if (ToastService != null) { - @foreach (var toast in ToastService.Items.Values.OrderBy(i => i.Index)) + @foreach (var toast in ToastService.Items.Values.OrderByDescending(i => i.Index)) { - + } }
-@{ -#pragma warning restore IL2111 -} diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 381126665f..07c29f0951 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -2,8 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -#pragma warning disable IL2111 - using System.Globalization; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; @@ -54,50 +52,10 @@ protected override void OnInitialized() } /// - private static Action EmptyOnStateChange => (_) => { }; - - private static Type GetToastType(IToastInstance toast) - { - if (Equals(toast.ComponentType, typeof(FluentToast)) || Equals(toast.ComponentType, typeof(FluentProgressToast))) - { - return toast.ComponentType; - } - - throw new InvalidOperationException($"Unsupported toast component type '{toast.ComponentType.FullName}'. Only {nameof(FluentToast)} and {nameof(FluentProgressToast)} are supported."); - } - - private Dictionary GetToastParameters(IToastInstance toast) - { - var toastOptions = toast.Options; - var parameters = new Dictionary(StringComparer.Ordinal) - { - [nameof(FluentToastComponentBase.Id)] = toast.Id, - [nameof(FluentToastComponentBase.Class)] = toastOptions?.ClassValue, - [nameof(FluentToastComponentBase.Style)] = toastOptions?.StyleValue, - [nameof(FluentToastComponentBase.Data)] = toastOptions?.Data, - [nameof(FluentToastComponentBase.Timeout)] = toastOptions?.Timeout ?? 5000, - [nameof(FluentToastComponentBase.Instance)] = toast, - [nameof(FluentToastComponentBase.OnStateChange)] = EventCallback.Factory.Create(this, toastOptions?.OnStateChange ?? EmptyOnStateChange), - [nameof(FluentToastComponentBase.AdditionalAttributes)] = toastOptions?.AdditionalAttributes, - }; + internal static Action EmptyOnStateChange => (_) => { }; - if (toastOptions is null || toastOptions.Parameters is null) - { - return parameters; - } - - foreach (var parameter in toastOptions.Parameters) - { - if (string.Equals(parameter.Key, nameof(FluentToastComponentBase.Instance), StringComparison.Ordinal)) - { - continue; - } - - parameters[parameter.Key] = parameter.Value; - } - - return parameters; - } + private EventCallback GetOnStateChangeCallback(IToastInstance toast) + => EventCallback.Factory.Create(this, toast.Options.OnStateChange ?? EmptyOnStateChange); /// /// Only for Unit Tests @@ -113,5 +71,3 @@ internal void UpdateId(string? id) } } } - -#pragma warning restore IL2111 diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index e31fc23017..6dac8aac4f 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -2,8 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; - namespace Microsoft.FluentUI.AspNetCore.Components; /// @@ -11,12 +9,6 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public interface IToastInstance { - /// - /// Gets the component type of the Toast. - /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] - internal Type ComponentType { get; } - /// /// Gets the unique identifier for the Toast. /// If this value is not set in the , a new identifier is generated. @@ -63,4 +55,11 @@ public interface IToastInstance /// Result to close the Toast with. /// Task CloseAsync(T result); + + /// + /// Updates the toast options while the toast is shown. + /// + /// The action that mutates the current options. + /// + Task UpdateAsync(Action update); } diff --git a/src/Core/Components/Toast/Services/IToastService.cs b/src/Core/Components/Toast/Services/IToastService.cs index 6822346e27..5d0be38c49 100644 --- a/src/Core/Components/Toast/Services/IToastService.cs +++ b/src/Core/Components/Toast/Services/IToastService.cs @@ -2,9 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Components; - namespace Microsoft.FluentUI.AspNetCore.Components; /// @@ -21,18 +18,22 @@ public partial interface IToastService : IFluentServiceBase Task CloseAsync(IToastInstance Toast, ToastResult result); /// - /// Shows a Toast with the component type as the body. + /// Shows a toast using the supplied options. /// - /// Type of component to display. - /// Options to configure the toast component. - Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(ToastOptions? options = null) - where TToast : ComponentBase; + /// Options to configure the toast. + Task ShowToastAsync(ToastOptions? options = null); /// - /// Shows a toast with the component type as the body. + /// Shows a toast by configuring an options object. /// - /// Type of component to display. - /// Options to configure the toast component. - Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(Action options) - where TToast : ComponentBase; + /// Action used to configure the toast. + Task ShowToastAsync(Action options); + + /// + /// Updates a shown toast. + /// + /// The toast instance to update. + /// The action that mutates the current options. + /// + Task UpdateToastAsync(IToastInstance toast, Action update); } diff --git a/src/Core/Components/Toast/Services/ToastEventArgs.cs b/src/Core/Components/Toast/Services/ToastEventArgs.cs index 73bf0b403f..dc16d0766b 100644 --- a/src/Core/Components/Toast/Services/ToastEventArgs.cs +++ b/src/Core/Components/Toast/Services/ToastEventArgs.cs @@ -10,13 +10,13 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastEventArgs : EventArgs { /// - internal ToastEventArgs(FluentToastComponentBase toast, DialogToggleEventArgs args) + internal ToastEventArgs(FluentToast toast, DialogToggleEventArgs args) : this(toast, args.Id, args.Type, args.OldState, args.NewState) { } /// - internal ToastEventArgs(FluentToastComponentBase toast, string? id, string? eventType, string? oldState, string? newState) + internal ToastEventArgs(FluentToast toast, string? id, string? eventType, string? oldState, string? newState) { Id = id ?? string.Empty; Instance = toast.Instance; diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 328514e7b5..774dc09f9f 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -2,7 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; @@ -13,29 +12,22 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastInstance : IToastInstance { private static long _counter; - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] - private readonly Type _componentType; internal readonly TaskCompletionSource ResultCompletion = new(); /// - internal ToastInstance(IToastService toastService, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType, ToastOptions options) + internal ToastInstance(IToastService toastService, ToastOptions options) { - _componentType = componentType; Options = options; ToastService = toastService; Id = string.IsNullOrEmpty(options.Id) ? Identifier.NewId() : options.Id; Index = Interlocked.Increment(ref _counter); } - /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] - Type IToastInstance.ComponentType => _componentType; - /// internal IToastService ToastService { get; } /// - internal FluentToastComponentBase? FluentToast { get; set; } + internal FluentToast? FluentToast { get; set; } /// public ToastOptions Options { get; internal set; } @@ -72,4 +64,10 @@ public Task CloseAsync(ToastResult result) { return ToastService.CloseAsync(this, result); } + + /// + public Task UpdateAsync(Action update) + { + return ToastService.UpdateToastAsync(this, update); + } } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 1644bf07ec..f19514f7f5 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -2,6 +2,7 @@ // 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; @@ -72,10 +73,119 @@ public ToastOptions(Action implementationFactory) public int Timeout { get; set; } = 5000; /// - /// Gets a list of Toast parameters. - /// Each parameter must correspond to a `[Parameter]` property defined in the component. + /// Gets or sets the toast position on screen. /// - public IDictionary Parameters { get; set; } = new Dictionary(StringComparer.Ordinal); + public ToastPosition? Position { get; set; } + + /// + /// Gets or sets the vertical offset in pixels. + /// + public int VerticalOffset { get; set; } = 16; + + /// + /// Gets or sets the horizontal offset in pixels. + /// + public int HorizontalOffset { get; set; } = 20; + + /// + /// Gets or sets the toast intent. + /// + public ToastIntent Intent { get; set; } = ToastIntent.Info; + + /// + /// Gets or sets the politeness level used for accessibility. + /// + public ToastPoliteness? Politeness { get; set; } + + /// + /// Gets or sets a value indicating whether the timeout pauses while hovering the toast. + /// + public bool PauseOnHover { get; set; } + + /// + /// Gets or sets a value indicating whether the timeout pauses while the window is blurred. + /// + public bool PauseOnWindowBlur { get; set; } + + /// + /// Gets or sets the toast title. + /// + public string? Title { get; set; } + + /// + /// Gets or sets the body text of the toast. + /// + public string? Body { get; set; } + + /// + /// Gets or sets the subtitle of the toast. + /// + public string? Subtitle { get; set; } + + /// + /// Gets or sets the status text shown for progress toasts. + /// + public string? Status { get; set; } + + /// + /// Gets or sets the first quick action label. + /// + public string? QuickAction1 { get; set; } + + /// + /// Gets or sets the second quick action label. + /// + public string? QuickAction2 { get; set; } + + /// + /// Gets or sets a value indicating whether the dismiss button is shown. + /// + public bool ShowDismissButton { get; set; } = true; + + /// + /// Gets or sets a value indicating whether progress visuals are shown. + /// + public bool ShowProgress { get; set; } + + /// + /// Gets or sets a value indicating whether the progress is indeterminate. + /// + public bool Indeterminate { get; set; } = true; + + /// + /// Gets or sets the current progress value. + /// + public int? Value { get; set; } + + /// + /// Gets or sets the maximum progress value. + /// + public int? Max { get; set; } = 100; + + /// + /// Gets or sets custom media rendered in the media slot. + /// + public RenderFragment? Media { get; set; } + + /// + /// Gets or sets custom content rendered in the title slot. + /// + public RenderFragment? TitleContent { get; set; } + + /// + /// Gets or sets custom content rendered in the subtitle slot. + /// + public RenderFragment? SubtitleContent { get; set; } + + /// + /// Gets or sets custom content rendered in the footer slot. + /// + public RenderFragment? FooterContent { get; set; } + + /// + /// Gets or sets custom content rendered in the default slot. + /// + public RenderFragment? ChildContent { get; set; } /// /// Gets or sets the action raised when the Toast is opened or closed. diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index 117f0d4c70..3859339211 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -2,8 +2,6 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; -using Microsoft.AspNetCore.Components; using Microsoft.Extensions.DependencyInjection; using Microsoft.JSInterop; @@ -22,9 +20,6 @@ public partial class ToastService : FluentServiceBase, IToastSer /// /// List of services available in the application. /// Localizer for the application. - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ToastEventArgs))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ToastInstance))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(IToastInstance))] public ToastService(IServiceProvider serviceProvider, IFluentLocalizer? localizer) { _serviceProvider = serviceProvider; @@ -53,37 +48,39 @@ public async Task CloseAsync(IToastInstance Toast, ToastResult result) ToastInstance?.FluentToast?.RaiseOnStateChangeAsync(Toast, DialogState.Closed); } - /// - public Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(ToastOptions? options = null) where TToast : ComponentBase + /// + public Task ShowToastAsync(ToastOptions? options = null) { - return ShowToastAsync(typeof(TToast), options ?? new ToastOptions()); + return ShowToastCoreAsync(options ?? new ToastOptions()); } - /// - public Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(Action options) where TToast : ComponentBase + /// + public Task ShowToastAsync(Action options) { - return ShowToastAsync(new ToastOptions(options)); + return ShowToastAsync(new ToastOptions(options)); } - /// - private async Task ShowToastAsync([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType, ToastOptions options) + /// + public async Task UpdateToastAsync(IToastInstance toast, Action update) { - if (!componentType.IsSubclassOf(typeof(ComponentBase))) + if (toast is not ToastInstance instance) { - throw new ArgumentException($"{componentType.FullName} must be a Blazor Component", nameof(componentType)); + throw new ArgumentException($"{nameof(toast)} must be a {nameof(ToastInstance)}.", nameof(toast)); } - if (!Equals(componentType, typeof(FluentToast)) && !Equals(componentType, typeof(FluentProgressToast))) - { - throw new ArgumentException($"{componentType.FullName} must be {nameof(FluentToast)} or {nameof(FluentProgressToast)}", nameof(componentType)); - } + update(instance.Options); + await ServiceProvider.OnUpdatedAsync.Invoke(instance); + } + /// + private async Task ShowToastCoreAsync(ToastOptions options) + { if (this.ProviderNotAvailable()) { throw new FluentServiceProviderException(); } - var instance = new ToastInstance(this, componentType, options); + var instance = new ToastInstance(this, options); // Add the Toast to the service, and render it. ServiceProvider.Items.TryAdd(instance?.Id ?? "", instance ?? throw new InvalidOperationException("Failed to create FluentToast.")); diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 153ac2c380..33a774a158 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -153,6 +153,7 @@ + diff --git a/tests/Core/Components.Tests.csproj b/tests/Core/Components.Tests.csproj index 54203facdb..bdf546ed13 100644 --- a/tests/Core/Components.Tests.csproj +++ b/tests/Core/Components.Tests.csproj @@ -73,4 +73,8 @@ FluentLocalizer.resx + + + + diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index ec769343d1..19ded10015 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -27,15 +27,12 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_Render() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + _ = ToastService.ShowToastAsync(options => + { + options.Title = "Toast title"; + options.Body = "Toast Content - John"; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -54,30 +51,27 @@ // Assert Assert.Contains("Hello World", cut.Markup); - Assert.Contains("fuib", cut.Markup); + Assert.Contains("fluent-toast-b", cut.Markup); } [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_OpenClose() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions() + // Act + var toastTask = ToastService.ShowToastAsync(options => { - AutoClose = true, - }; + options.Body = "Auto-close body"; + }); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + await toast.Instance.Instance!.CloseAsync(true); - // Wait for the toast to be closed (auto-closed on second render) + // Wait for the toast to be closed var result = await toastTask; // Assert - Assert.Equal(1, renderOptions.OnInitializedCount); Assert.False(result.Cancelled); Assert.True(result.GetValue()); } @@ -85,16 +79,12 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_Instance() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Id = "my-toast"; - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + var toastTask = ToastService.ShowToastAsync(options => + { + options.Id = "my-toast"; + options.Body = "Instance body"; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -118,15 +108,11 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_Instance_Cancel() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Cancelable body"; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -145,15 +131,11 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_Instance_CloseWithResult() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Close with result body"; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -173,15 +155,11 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_Instance_CloseNoValue() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Close no value body"; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -206,21 +184,18 @@ [InlineData(DialogState.Closing, "beforetoggle", "open", "any-new")] public async Task FluentToast_Toggle_StateChange(DialogState expectedState, string eventType, string oldState, string newState) { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); ToastEventArgs? capturedArgs = null; // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Id = "my-id"; - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - options.OnStateChange = (args) => - { - capturedArgs = args; - }; - }); + _ = ToastService.ShowToastAsync(options => + { + options.Id = "my-id"; + options.Body = "State change body"; + options.OnStateChange = (args) => + { + capturedArgs = args; + }; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -246,16 +221,12 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_Toggle_IdMismatch() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Id = "my-id"; - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + _ = ToastService.ShowToastAsync(options => + { + options.Id = "my-id"; + options.Body = "Toast Content"; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -274,20 +245,17 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_StateChange_ViaClosed() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); var stateChanges = new List(); // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - options.OnStateChange = (args) => - { - stateChanges.Add(args.State); - }; - }); + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "State change body"; + options.OnStateChange = (args) => + { + stateChanges.Add(args.State); + }; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -309,16 +277,11 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_OnStateChange_NoDelegate() { - // Arrange — show toast WITHOUT an OnStateChange callback - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - // No OnStateChange set — provider passes EmptyOnStateChange - }); + _ = ToastService.ShowToastAsync(options => + { + options.Body = "No delegate body"; + }); await Task.CompletedTask; @@ -336,25 +299,21 @@ } [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_ComponentRule() + public async Task FluentToast_UpdateBody() { - // Arrange — use reflection to call the private ShowToastAsync(Type, ToastOptions) - // with System.String which is not a Blazor ComponentBase - var method = typeof(ToastService).GetMethod( - "ShowToastAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, - [typeof(Type), typeof(ToastOptions)]); - - Assert.NotNull(method); - - // Act - var ex = await Assert.ThrowsAsync(async () => + _ = ToastService.ShowToastAsync(options => { - await (Task)method!.Invoke(ToastService, [typeof(string), new ToastOptions()])!; + options.Body = "Initial body"; }); - // Assert - Assert.Equal("System.String must be a Blazor Component (Parameter 'componentType')", ex.Message); + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + await toast.Instance.Instance!.UpdateAsync(options => options.Body = "Updated body"); + + ToastProvider.Render(); + + Assert.Contains("Updated body", ToastProvider.Markup); } [Fact(Timeout = TEST_TIMEOUT)] @@ -366,10 +325,7 @@ // Act var ex = await Assert.ThrowsAsync>(async () => { - _ = await ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); + _ = await ToastService.ShowToastAsync(options => { options.Body = "Provider required"; }); }); // Assert @@ -455,12 +411,11 @@ Style = "color: red;", Margin = "10px", Padding = "20px", + Body = "Spacing body", }; - toastOptions.Parameters.Add(nameof(Templates.ToastRender.Options), new Templates.ToastRenderOptions()); - toastOptions.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); // Act - var toastTask = ToastService.ShowToastAsync(toastOptions); + _ = ToastService.ShowToastAsync(toastOptions); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -487,22 +442,19 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_AdditionalAttributes() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - // Act - var toastTask = ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(Templates.ToastRender.Options), renderOptions); - options.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - options.AdditionalAttributes = new Dictionary { { "data-test", "my-toast" } }; - }); + _ = ToastService.ShowToastAsync(options => + { + options.Body = "Toast Content"; + options.AdditionalAttributes = new Dictionary { { "data-test", "my-toast" } }; + }); // Don't wait for the toast to be closed await Task.CompletedTask; // Assert: toast content rendered inside the provider Assert.Contains("Toast Content", ToastProvider.Markup); + Assert.Contains("data-test=\"my-toast\"", ToastProvider.Markup); } [Fact] @@ -520,10 +472,18 @@ [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_WithInstance() + public async Task FluentToast_Progress() { // Act - var toastTask = ToastService.ShowToastAsync(); + _ = ToastService.ShowToastAsync(options => + { + options.Title = "Uploading"; + options.Body = "Please wait while your files are uploaded."; + options.Status = "Uploading 3 files..."; + options.ShowProgress = true; + options.Indeterminate = true; + options.ShowDismissButton = false; + }); // Don't wait for the toast to be closed await Task.CompletedTask; @@ -531,7 +491,8 @@ ToastProvider.Render(); // Assert - var x = ToastProvider.Markup; - ToastProvider.Verify(); + Assert.Contains("Uploading", ToastProvider.Markup); + Assert.Contains("Uploading 3 files...", ToastProvider.Markup); } } + diff --git a/tests/Core/Components/Toast/Templates/ToastRender.razor b/tests/Core/Components/Toast/Templates/ToastRender.razor deleted file mode 100644 index c114c48da8..0000000000 --- a/tests/Core/Components/Toast/Templates/ToastRender.razor +++ /dev/null @@ -1,54 +0,0 @@ -@implements IDisposable -
Toast Content - @Name
- -@code { - - private System.Threading.Timer? _timer; - - [CascadingParameter] - public required IToastInstance Toast { get; set; } - - [Parameter] - public string? Name { get; set; } - - [Parameter] - public ToastRenderOptions Options { get; set; } = new(); - - protected override async Task OnInitializedAsync() - { - Options.OnInitializedCount++; - await Task.Delay(100, Xunit.TestContext.Current.CancellationToken); - } - - protected override async Task OnParametersSetAsync() - { - await Task.Delay(10, Xunit.TestContext.Current.CancellationToken); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { - if (Options.AutoClose && Options.AutoCloseDelay > 0) - { - _timer = new System.Threading.Timer(async _ => - { - await Toast.CloseAsync(Options.AutoCloseResult); - }, null, Options.AutoCloseDelay, 5000); - } - - return; - } - - if (Options.AutoClose && Options.AutoCloseDelay <= 0) - { - await Task.Delay(10, Xunit.TestContext.Current.CancellationToken); - await Toast.CloseAsync(Options.AutoCloseResult); - } - } - - public void Dispose() - { - _timer?.Dispose(); - } -} diff --git a/tests/Core/Components/Toast/Templates/ToastRenderOptions.cs b/tests/Core/Components/Toast/Templates/ToastRenderOptions.cs deleted file mode 100644 index 98e5c91d8f..0000000000 --- a/tests/Core/Components/Toast/Templates/ToastRenderOptions.cs +++ /dev/null @@ -1,16 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Toast.Templates; - -public class ToastRenderOptions -{ - public bool AutoClose { get; set; } - - public int AutoCloseDelay { get; set; } - - public ToastResult AutoCloseResult { get; set; } = ToastResult.Ok(true); - - public int OnInitializedCount { get; set; } -} diff --git a/tests/Core/Components/Toast/Templates/ToastWithInstance.razor b/tests/Core/Components/Toast/Templates/ToastWithInstance.razor deleted file mode 100644 index 7d764d3a36..0000000000 --- a/tests/Core/Components/Toast/Templates/ToastWithInstance.razor +++ /dev/null @@ -1,18 +0,0 @@ -@inherits FluentToastInstance -
Toast With Instance
- - - -@code { - protected override async Task OnActionClickedAsync(bool primary) - { - if (primary) - { - await ToastInstance.CloseAsync("Yes"); - } - else - { - await ToastInstance.CancelAsync(); - } - } -} From de285b1f69d74ab7c230f2a2e670a2210ef51d8c Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Fri, 20 Mar 2026 12:14:32 +0100 Subject: [PATCH 05/20] Make dismiss use close action --- .../src/Components/Toast/FluentToast.ts | 10 +++++++++- src/Core/Components/Toast/FluentToast.razor.cs | 15 ++++++++++++++- .../Components/Toast/Services/ToastInstance.cs | 3 +++ .../Components/Toast/Services/ToastService.cs | 7 +++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 1a3bec6918..fc47320488 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -198,7 +198,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { align-items: center; flex-wrap: wrap; gap: 14px; - grid-column: 2 / -1; + grid-column: 2 / 3; padding-top: 16px; } @@ -421,6 +421,14 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { public async closeToast() { if (this.dialogIsOpen) { + const styles = getComputedStyle(this.dialog); + + this.dialog.style.setProperty('--toast-height', `${this.dialog.offsetHeight}px`); + this.dialog.style.setProperty('--toast-margin-top', styles.marginTop); + this.dialog.style.setProperty('--toast-margin-bottom', styles.marginBottom); + this.dialog.style.setProperty('--toast-padding-top', styles.paddingTop); + this.dialog.style.setProperty('--toast-padding-bottom', styles.paddingBottom); + this.classList.add('animating'); this.dialog.classList.add('closing'); diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 74d14e120a..652fe26c08 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -151,6 +151,17 @@ public Task RaiseOnStateChangeAsync(IToastInstance instance, Dia public Task OnToggleAsync(DialogToggleEventArgs args) => HandleToggleAsync(args); + internal Task RequestCloseAsync() + { + if (!Opened) + { + return Task.CompletedTask; + } + + Opened = false; + return InvokeAsync(StateHasChanged); + } + internal Task OnActionClickedAsync(bool primary) { return primary ? Instance!.CloseAsync() : Instance!.CancelAsync(); @@ -214,10 +225,12 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) switch (toastEventArgs.State) { case DialogState.Closing: - toastInstance.ResultCompletion.TrySetResult(ToastResult.Cancel()); break; case DialogState.Closed: + toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingResult ?? ToastResult.Cancel()); + toastInstance.PendingResult = null; + if (ToastService is ToastService toastService) { await toastService.RemoveToastFromProviderAsync(Instance); diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 774dc09f9f..8230e5050b 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -29,6 +29,9 @@ internal ToastInstance(IToastService toastService, ToastOptions options) /// internal FluentToast? FluentToast { get; set; } + /// + internal ToastResult? PendingResult { get; set; } + /// public ToastOptions Options { get; internal set; } diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index 3859339211..3c6bd26b9e 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -35,6 +35,13 @@ public async Task CloseAsync(IToastInstance Toast, ToastResult result) { var ToastInstance = Toast as ToastInstance; + if (ToastInstance?.FluentToast is FluentToast fluentToast) + { + ToastInstance.PendingResult = result; + await fluentToast.RequestCloseAsync(); + return; + } + // Raise the ToastState.Closing event ToastInstance?.FluentToast?.RaiseOnStateChangeAsync(Toast, DialogState.Closing); From 623740e6acad051b2fe7b5434c6e7630e7c28225 Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Fri, 20 Mar 2026 14:47:42 +0100 Subject: [PATCH 06/20] Fix closing animation --- .../src/Components/Toast/FluentToast.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index fc47320488..a6feb45038 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -228,9 +228,9 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { overflow: hidden; will-change: opacity, height, margin, padding; animation: - toast-exit 600ms cubic-bezier(0.33, 0, 0.67, 1) forwards, - toast-dismiss-collapse-height 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards, - toast-dismiss-collapse-spacing 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards; + toast-exit 400ms cubic-bezier(0.33, 0, 0.67, 1) forwards, + toast-collapse-height 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards, + toast-collapse-spacing 200ms cubic-bezier(0.33, 0, 0.67, 1) 400ms forwards; } @keyframes toast-enter { @@ -238,28 +238,32 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { to { opacity: 1; transform: var(--toast-enter-to, translateY(0)); } } - @keyframes toast-exit { - 0% { + @keyframes toast-exit { + from { opacity: 1; - height: var(--toast-height); - margin-top: var(--toast-margin-top, 0px); - margin-bottom: var(--toast-margin-bottom, 0px); - padding-top: var(--toast-padding-top, 0px); - padding-bottom: var(--toast-padding-bottom, 0px); } - - 66.666% { + to { opacity: 0; + } + } + + @keyframes toast-collapse-height { + from { height: var(--toast-height); + } + to { + height: 0; + } + } + + @keyframes toast-collapse-spacing { + from { margin-top: var(--toast-margin-top, 0px); margin-bottom: var(--toast-margin-bottom, 0px); padding-top: var(--toast-padding-top, 0px); padding-bottom: var(--toast-padding-bottom, 0px); } - - 100% { - opacity: 0; - height: 0; + to { margin-top: 0; margin-bottom: 0; padding-top: 0; @@ -435,7 +439,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { // Wait for the exit animation to complete await new Promise(resolve => { const onAnimationEnd = (e: AnimationEvent) => { - if (e.animationName === 'toast-exit') { + if (e.animationName === 'toast-collapse-spacing') { this.dialog.removeEventListener('animationend', onAnimationEnd); resolve(true); } From 0a247af5912daf38f5c8958bbd517b5d92a40768 Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Mon, 23 Mar 2026 15:37:18 +0100 Subject: [PATCH 07/20] Fix layout, styling and animations --- .../src/Components/Toast/FluentToast.ts | 151 +++++++++++++----- src/Core/Components/Toast/FluentToast.razor | 2 +- 2 files changed, 111 insertions(+), 42 deletions(-) diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index a6feb45038..3d9cd26f51 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -7,13 +7,13 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { private dialog: ToastElement; private mediaRegion: HTMLDivElement; private titleRegion: HTMLDivElement; - private dismissRegion: HTMLDivElement; + private actionRegion: HTMLDivElement; private bodyRegion: HTMLDivElement; private subtitleRegion: HTMLDivElement; private footerRegion: HTMLDivElement; private mediaSlot: HTMLSlotElement; private titleSlot: HTMLSlotElement; - private dismissSlot: HTMLSlotElement; + private actionSlot: HTMLSlotElement; private bodySlot: HTMLSlotElement; private subtitleSlot: HTMLSlotElement; private footerSlot: HTMLSlotElement; @@ -62,11 +62,11 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.titleSlot.name = 'title'; this.titleRegion.appendChild(this.titleSlot); - this.dismissRegion = document.createElement('div'); - this.dismissRegion.classList.add('dismiss'); - this.dismissSlot = document.createElement('slot'); - this.dismissSlot.name = 'dismiss'; - this.dismissRegion.appendChild(this.dismissSlot); + this.actionRegion = document.createElement('div'); + this.actionRegion.classList.add('action'); + this.actionSlot = document.createElement('slot'); + this.actionSlot.name = 'action'; + this.actionRegion.appendChild(this.actionSlot); this.bodyRegion = document.createElement('div'); this.bodyRegion.classList.add('body'); @@ -88,7 +88,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.dialog.append( this.mediaRegion, this.titleRegion, - this.dismissRegion, + this.actionRegion, this.bodyRegion, this.subtitleRegion, this.footerRegion, @@ -108,9 +108,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { :host div[fuib][popover] { display: grid; grid-template-columns: auto 1fr auto; - border: 0; background: var(--colorNeutralBackground1); - padding: 0; font-size: var(--fontSizeBase300); line-height: var(--lineHeightBase300); font-weight: var(--fontWeightSemibold); @@ -122,60 +120,91 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { min-width: 292px; max-width: 292px; padding: 12px; + transition: + top 240ms cubic-bezier(0.22, 1, 0.36, 1), + bottom 240ms cubic-bezier(0.22, 1, 0.36, 1), + left 240ms cubic-bezier(0.22, 1, 0.36, 1), + right 240ms cubic-bezier(0.22, 1, 0.36, 1), + transform 240ms cubic-bezier(0.22, 1, 0.36, 1); } - .media, - .title, - .dismiss, - .body, - .subtitle, - .footer { - min-width: 0; + :host([inverted]) div[fuib][popover]{ + color: var(--colorNeutralForegroundInverted2); + background-color: var(--colorNeutralBackgroundInverted); } .media { display: flex; - grid-column: 1; - grid-row: 1; + grid-column-end: 2; padding-top: 2px; padding-inline-end: 8px; font-size: var(--fontSizeBase400); color: var(--colorNeutralForeground1); } + :host([inverted]) .media { + color: var(--colorNeutralForegroundInverted); + } + + .media[data-intent="success"] { + color: var(--colorStatusSuccessForeground1); + } + + .media[data-intent="error"] { + color: var(--colorStatusDangerForeground1); + } + + .media[data-intent="warning"] { + color: var(--colorStatusWarningForeground1); + } + + .media[data-intent="info"] { + color: var(--colorNeutralForeground2); + } + + :host([inverted]) .media[data-intent="success"] { + color: var(--colorStatusSuccessForegroundInverted); + } + + :host([inverted]) .media[data-intent="error"] { + color: var(--colorStatusDangerForegroundInverted); + } + + :host([inverted]) .media[data-intent="warning"] { + color: var(--colorStatusWarningForegroundInverted); + } + + :host([inverted]) .media[data-intent="info"] { + color: var(--colorNeutralForegroundInverted2); + } + .title { display: flex; align-items: center; - grid-column: 2; - grid-row: 1; + grid-column-end: 3; color: var(--colorNeutralForeground1); word-break: break-word; } - :host(:not([has-dismiss])) .title { - grid-column: 2 / -1; + :host([inverted]) .title { + color: var(--colorNeutralForegroundInverted2); } - :host(:not([has-media])) .title { - grid-column: 1 / span 2; - } - - :host(:not([has-media]):not([has-dismiss])) .title { - grid-column: 1 / -1; - } - - .dismiss { + .action { display: flex; align-items: start; justify-content: end; - grid-column: 3; - grid-row: 1; + grid-column-end: -1; padding-inline-start: 12px; color: var(--colorBrandForeground1); } + :host([inverted]) .action { + color: var(--colorBrandForegroundInverted); + } + .body { - grid-column: 2 / -1; + grid-column: 2 / 3; padding-top: 6px; font-size: var(--fontSizeBase300); line-height: var(--lineHeightBase300); @@ -184,8 +213,12 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { word-break: break-word; } + :host([inverted]) .body { + color: var(--colorNeutralForegroundInverted2); + } + .subtitle { - grid-column: 2 / -1; + grid-column: 2 / 3; padding-top: 4px; font-size: var(--fontSizeBase200); line-height: var(--lineHeightBase200); @@ -193,6 +226,10 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { color: var(--colorNeutralForeground2); } + :host([inverted]) .subtitle { + color: var(--colorNeutralForegroundInverted2); + } + .footer { display: flex; align-items: center; @@ -202,15 +239,23 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { padding-top: 16px; } + .footer ::slotted(*) { + display: contents; + } + :host(:not([has-media])) .body, :host(:not([has-media])) .subtitle, :host(:not([has-media])) .footer { grid-column: 1 / -1; } + :host(:not([has-action])) .title { + grid-column: 2 / -1; + } + .media[hidden], .title[hidden], - .dismiss[hidden], + .action[hidden], .body[hidden], .subtitle[hidden], .footer[hidden] { @@ -280,7 +325,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { this.mediaSlot.addEventListener('slotchange', () => this.updateSlotState(this.mediaRegion, this.mediaSlot, 'has-media')); this.titleSlot.addEventListener('slotchange', () => this.updateSlotState(this.titleRegion, this.titleSlot, 'has-title')); - this.dismissSlot.addEventListener('slotchange', () => this.updateSlotState(this.dismissRegion, this.dismissSlot, 'has-dismiss')); + this.actionSlot.addEventListener('slotchange', () => this.updateActionState()); this.bodySlot.addEventListener('slotchange', () => this.updateSlotState(this.bodyRegion, this.bodySlot, 'has-body')); this.subtitleSlot.addEventListener('slotchange', () => this.updateSlotState(this.subtitleRegion, this.subtitleSlot, 'has-subtitle')); this.footerSlot.addEventListener('slotchange', () => this.updateSlotState(this.footerRegion, this.footerSlot, 'has-footer')); @@ -602,9 +647,15 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { private getStackOffset(position: string): number { const toastElements = Array.from(document.querySelectorAll('fluent-toast-b')) as FluentToast[]; + const currentIndex = toastElements.indexOf(this); + const toastsBeforeCurrent = toastElements - .slice(0, toastElements.indexOf(this)) - .filter(toast => toast.getToastPosition() === position && toast.dialogIsOpen); + .slice(0, currentIndex) + .filter(toast => + toast.getToastPosition() === position && + toast.dialogIsOpen && + !toast.dialog.classList.contains('closing') + ); return toastsBeforeCurrent.reduce((offset, toast) => { const height = toast.dialog.getBoundingClientRect().height; @@ -663,12 +714,30 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { private updateSlotStates() { this.updateSlotState(this.mediaRegion, this.mediaSlot, 'has-media'); this.updateSlotState(this.titleRegion, this.titleSlot, 'has-title'); - this.updateSlotState(this.dismissRegion, this.dismissSlot, 'has-dismiss'); + this.updateActionState(); this.updateSlotState(this.bodyRegion, this.bodySlot, 'has-body'); this.updateSlotState(this.subtitleRegion, this.subtitleSlot, 'has-subtitle'); this.updateSlotState(this.footerRegion, this.footerSlot, 'has-footer'); } + private updateActionState() { + const hasContent = this.actionSlot.assignedNodes({ flatten: true }).some(node => + node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()) + ); + + if (hasContent) { + this.setAttribute('has-action', ''); + } else { + this.removeAttribute('has-action'); + } + + this.actionRegion.hidden = !hasContent; + + if (this.dialogIsOpen) { + this.updateToastStack(); + } + } + private updateSlotState(region: HTMLDivElement, slot: HTMLSlotElement, hostAttribute: string) { const hasContent = slot.assignedNodes({ flatten: true }).some(node => node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()) diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 4eb540952e..798d061756 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -90,7 +90,7 @@ @if (FooterContent is not null) { -
@FooterContent
+
@FooterContent
} else if (!string.IsNullOrEmpty(QuickAction1) || !string.IsNullOrEmpty(QuickAction2)) { From 0b3f01723d529f3ce4e43682ba0204c6f5f3ec96 Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Tue, 24 Mar 2026 14:55:38 +0100 Subject: [PATCH 08/20] Implement action calbacks, add close reason, remove ToastResult, rename dismiss slot --- .../Toast/DebugPages/DebugToast.razor | 20 +++-- .../Toast/DebugPages/DebugToastContent.razor | 40 --------- src/Core/Components/Toast/FluentToast.razor | 6 +- .../Components/Toast/FluentToast.razor.cs | 19 +++- .../Toast/Services/IToastInstance.cs | 21 ++--- .../Toast/Services/IToastService.cs | 10 +-- .../Toast/Services/ToastInstance.cs | 22 ++--- .../Components/Toast/Services/ToastOptions.cs | 10 +++ .../Components/Toast/Services/ToastResult.cs | 89 ------------------ .../Components/Toast/Services/ToastService.cs | 14 +-- src/Core/Enums/ToastCloseReason.cs | 31 +++++++ .../Components/Toast/FluentToastTests.razor | 90 +++++++++++-------- 12 files changed, 152 insertions(+), 220 deletions(-) delete mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor delete mode 100644 src/Core/Components/Toast/Services/ToastResult.cs create mode 100644 src/Core/Enums/ToastCloseReason.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index 0e0559df35..a962a35f6c 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -30,12 +30,21 @@ { var result = await ToastService.ShowToastAsync(options => { - options.Timeout = 70000; options.Intent = ToastIntent.Warning; options.Title = "Delete item?"; options.Body = "This action can't be undone."; options.QuickAction1 = "Delete"; + options.QuickAction1Callback = () => + { + Console.WriteLine("Delete action executed."); + return Task.CompletedTask; + }; options.QuickAction2 = "Cancel"; + options.QuickAction2Callback = () => + { + Console.WriteLine("Cancel action executed."); + return Task.CompletedTask; + }; options.ShowDismissButton = true; options.OnStateChange = (e) => { @@ -43,14 +52,13 @@ }; }); - _lastResult = result.Cancelled ? "Confirmation: Cancelled" : "Confirmation: Ok"; + _lastResult = result.ToString() ?? string.Empty; } private async Task OpenToastAsync() { var result = await ToastService.ShowToastAsync(options => { - options.Timeout = 70000; options.Intent = ToastIntent.Info; options.Title = "Email sent"; options.Body = "Your message was delivered."; @@ -60,7 +68,7 @@ options.ShowDismissButton = false; }); - _lastResult = result.Cancelled ? "Communication: Cancelled" : "Communication: Ok"; + _lastResult = result.ToString() ?? string.Empty; } private async Task OpenProgressToastAsync() @@ -79,7 +87,7 @@ options.ShowDismissButton = false; }); - _lastResult = result.Cancelled ? "Progress: Cancelled" : "Progress: Ok"; + _lastResult = result.ToString() ?? string.Empty; } private async Task OpenProgressToast2Async() @@ -98,6 +106,6 @@ options.ShowDismissButton = false; }); - _lastResult = result.Cancelled ? "Progress: Cancelled" : "Progress: Ok"; + _lastResult = result.ToString() ?? string.Empty; } } diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor deleted file mode 100644 index 47aa98d256..0000000000 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor +++ /dev/null @@ -1,40 +0,0 @@ -
-
- -
-
Email sent
-
- -
-
This is a toast body
-
Subtitle
- -
- -@code { - // If you want to use this razor component in standalone mode, - // you can use a nullable IToastInstance property. - // If the value is not null, the component is running using the ToastService. - // `public IToastInstance? FluentToast { get; set; }` - [CascadingParameter] - public required IToastInstance Toast { get; set; } - - [Inject] - public required IToastService ToastService { get; set; } - - [Parameter] - public string? Name { get; set; } - - private async Task btnOK_Click() - { - await Toast.CloseAsync(ToastResult.Ok("Yes")); - } - - private async Task btnCancel_Click() - { - await Toast.CloseAsync(ToastResult.Cancel("No")); - } -} diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 798d061756..317691edd7 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -65,7 +65,7 @@ IconOnly="true" IconStart="@DismissIcon" Title="Dismiss" - slot="dismiss" + slot="action" OnClick="@(() => Instance!.CancelAsync())" /> } } @@ -97,14 +97,14 @@
@if (!string.IsNullOrEmpty(QuickAction1)) { - + @QuickAction1 } @if (!string.IsNullOrEmpty(QuickAction2)) { - + @QuickAction2 } diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 652fe26c08..3bdb496725 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -162,9 +162,20 @@ internal Task RequestCloseAsync() return InvokeAsync(StateHasChanged); } - internal Task OnActionClickedAsync(bool primary) + internal Task OnQuickAction1ClickedAsync() + => HandleActionClickedAsync(Instance?.Options.QuickAction1Callback); + + internal Task OnQuickAction2ClickedAsync() + => HandleActionClickedAsync(Instance?.Options.QuickAction2Callback); + + private async Task HandleActionClickedAsync(Func? callback) { - return primary ? Instance!.CloseAsync() : Instance!.CancelAsync(); + await Instance!.CloseAsync(ToastCloseReason.QuickAction); + + if (callback is not null) + { + await callback(); + } } internal static Color GetIntentColor(ToastIntent intent) @@ -228,8 +239,8 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) break; case DialogState.Closed: - toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingResult ?? ToastResult.Cancel()); - toastInstance.PendingResult = null; + toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingCloseReason ?? ToastCloseReason.TimedOut); + toastInstance.PendingCloseReason = null; if (ToastService is ToastService toastService) { diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index 6dac8aac4f..3910e34d3b 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -26,35 +26,28 @@ public interface IToastInstance ToastOptions Options { get; } /// - /// Gets the result of the Toast. + /// Gets the close reason of the Toast. /// - Task Result { get; } + Task Result { get; } /// - /// Closes the Toast with a Cancel result. + /// Closes the Toast as dismissed. /// /// Task CancelAsync(); /// - /// Closes the Toast with the specified result. + /// Closes the Toast programmatically. /// /// Task CloseAsync(); /// - /// Closes the Toast with the specified result. + /// Closes the Toast with the specified reason. /// - /// Result to close the Toast with. + /// Reason to close the Toast with. /// - Task CloseAsync(ToastResult result); - - /// - /// Closes the Toast with the specified result. - /// - /// Result to close the Toast with. - /// - Task CloseAsync(T result); + Task CloseAsync(ToastCloseReason reason); /// /// Updates the toast options while the toast is shown. diff --git a/src/Core/Components/Toast/Services/IToastService.cs b/src/Core/Components/Toast/Services/IToastService.cs index 5d0be38c49..d6484949e1 100644 --- a/src/Core/Components/Toast/Services/IToastService.cs +++ b/src/Core/Components/Toast/Services/IToastService.cs @@ -10,24 +10,24 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial interface IToastService : IFluentServiceBase { /// - /// Closes the toast with the specified result. + /// Closes the toast with the specified reason. /// /// Instance of the toast to close. - /// Result of closing the toast. + /// Reason for closing the toast. /// - Task CloseAsync(IToastInstance Toast, ToastResult result); + Task CloseAsync(IToastInstance Toast, ToastCloseReason reason); /// /// Shows a toast using the supplied options. /// /// Options to configure the toast. - Task ShowToastAsync(ToastOptions? options = null); + Task ShowToastAsync(ToastOptions? options = null); /// /// Shows a toast by configuring an options object. /// /// Action used to configure the toast. - Task ShowToastAsync(Action options); + Task ShowToastAsync(Action options); /// /// Updates a shown toast. diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 8230e5050b..888f67a947 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -12,7 +12,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastInstance : IToastInstance { private static long _counter; - internal readonly TaskCompletionSource ResultCompletion = new(); + internal readonly TaskCompletionSource ResultCompletion = new(); /// internal ToastInstance(IToastService toastService, ToastOptions options) @@ -30,13 +30,13 @@ internal ToastInstance(IToastService toastService, ToastOptions options) internal FluentToast? FluentToast { get; set; } /// - internal ToastResult? PendingResult { get; set; } + internal ToastCloseReason? PendingCloseReason { get; set; } /// public ToastOptions Options { get; internal set; } /// - public Task Result => ResultCompletion.Task; + public Task Result => ResultCompletion.Task; /// " public string Id { get; } @@ -47,25 +47,19 @@ internal ToastInstance(IToastService toastService, ToastOptions options) /// public Task CancelAsync() { - return ToastService.CloseAsync(this, ToastResult.Cancel()); + return ToastService.CloseAsync(this, ToastCloseReason.Dismissed); } /// public Task CloseAsync() { - return ToastService.CloseAsync(this, ToastResult.Ok()); + return ToastService.CloseAsync(this, ToastCloseReason.Programmatic); } - /// - public Task CloseAsync(T result) + /// + public Task CloseAsync(ToastCloseReason reason) { - return ToastService.CloseAsync(this, ToastResult.Ok(result)); - } - - /// - public Task CloseAsync(ToastResult result) - { - return ToastService.CloseAsync(this, result); + return ToastService.CloseAsync(this, reason); } /// diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index f19514f7f5..15bd780f51 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -132,11 +132,21 @@ public ToastOptions(Action implementationFactory) /// public string? QuickAction1 { get; set; } + /// + /// Gets or sets the callback invoked when the first quick action is clicked. + /// + public Func? QuickAction1Callback { get; set; } + /// /// Gets or sets the second quick action label. /// public string? QuickAction2 { get; set; } + /// + /// Gets or sets the callback invoked when the second quick action is clicked. + /// + public Func? QuickAction2Callback { get; set; } + /// /// Gets or sets a value indicating whether the dismiss button is shown. /// diff --git a/src/Core/Components/Toast/Services/ToastResult.cs b/src/Core/Components/Toast/Services/ToastResult.cs deleted file mode 100644 index 97e5081664..0000000000 --- a/src/Core/Components/Toast/Services/ToastResult.cs +++ /dev/null @@ -1,89 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// Represents the result of a Toast. -/// -public class ToastResult -{ - /// - /// Initializes a new instance of the class. - /// - /// - /// - protected internal ToastResult(TContent? content, bool cancelled) - { - Value = content; - Cancelled = cancelled; - } - - /// - /// Gets the content of the Toast result. - /// - public TContent? Value { get; } - - /// - /// Gets a value indicating whether the Toast was cancelled. - /// - public bool Cancelled { get; } - - /// - /// Creates a Toast result with the specified content. - /// - /// Type of the content. - /// The content of the Toast result. - /// The Toast result. - public static ToastResult Ok(T result) => new(result, cancelled: false); - - /// - /// Creates a Toast result with the specified content. - /// - /// The Toast result. - public static ToastResult Ok() => Ok(result: null); - - /// - /// Creates a Toast result with the specified content. - /// - /// The content of the Toast result. - /// The Toast result. - public static ToastResult Cancel(T result) => new(result, cancelled: true); - - /// - /// Creates a Toast result with the specified content. - /// - /// The Toast result. - public static ToastResult Cancel() => Cancel(result: null); -} - -/// -/// Represents the result of a Toast. -/// -public class ToastResult : ToastResult -{ - /// - /// Initializes a new instance of the class. - /// - /// - /// - protected internal ToastResult(object? content, bool cancelled) : base(content, cancelled) - { - } - - /// - /// Gets the content of the Toast result. - /// - /// - /// - public T GetValue() - { - if (Value is T variable) - { - return variable; - } - - return default(T)!; - } -} diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index 3c6bd26b9e..4de549a4b8 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -30,14 +30,14 @@ public ToastService(IServiceProvider serviceProvider, IFluentLocalizer? localize /// protected IFluentLocalizer Localizer { get; } - /// - public async Task CloseAsync(IToastInstance Toast, ToastResult result) + /// + public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) { var ToastInstance = Toast as ToastInstance; if (ToastInstance?.FluentToast is FluentToast fluentToast) { - ToastInstance.PendingResult = result; + ToastInstance.PendingCloseReason = reason; await fluentToast.RequestCloseAsync(); return; } @@ -49,20 +49,20 @@ public async Task CloseAsync(IToastInstance Toast, ToastResult result) await RemoveToastFromProviderAsync(Toast); // Set the result of the Toast - ToastInstance?.ResultCompletion.TrySetResult(result); + ToastInstance?.ResultCompletion.TrySetResult(reason); // Raise the ToastState.Closed event ToastInstance?.FluentToast?.RaiseOnStateChangeAsync(Toast, DialogState.Closed); } /// - public Task ShowToastAsync(ToastOptions? options = null) + public Task ShowToastAsync(ToastOptions? options = null) { return ShowToastCoreAsync(options ?? new ToastOptions()); } /// - public Task ShowToastAsync(Action options) + public Task ShowToastAsync(Action options) { return ShowToastAsync(new ToastOptions(options)); } @@ -80,7 +80,7 @@ public async Task UpdateToastAsync(IToastInstance toast, Action up } /// - private async Task ShowToastCoreAsync(ToastOptions options) + private async Task ShowToastCoreAsync(ToastOptions options) { if (this.ProviderNotAvailable()) { diff --git a/src/Core/Enums/ToastCloseReason.cs b/src/Core/Enums/ToastCloseReason.cs new file mode 100644 index 0000000000..0026c7d3b3 --- /dev/null +++ b/src/Core/Enums/ToastCloseReason.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Describes why a toast was closed. +/// +public enum ToastCloseReason +{ + /// + /// The toast was dismissed by the user. + /// + Dismissed, + + /// + /// The toast closed after its timeout elapsed. + /// + TimedOut, + + /// + /// The toast closed after a quick action was clicked. + /// + QuickAction, + + /// + /// The toast was closed programmatically. + /// + Programmatic, +} diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 19ded10015..9b13c7eee7 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -66,14 +66,13 @@ await Task.CompletedTask; var toast = ToastProvider.FindComponent(); - await toast.Instance.Instance!.CloseAsync(true); + await toast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); // Wait for the toast to be closed var result = await toastTask; // Assert - Assert.False(result.Cancelled); - Assert.True(result.GetValue()); + Assert.Equal(ToastCloseReason.Programmatic, result); } [Fact(Timeout = TEST_TIMEOUT)] @@ -89,20 +88,19 @@ // Don't wait for the toast to be closed await Task.CompletedTask; - // Find the toast component and close it with a typed value + // Find the toast component and close it programmatically var toast = ToastProvider.FindComponent(); var instanceId = toast.Instance.Instance?.Id; var instanceIndex = toast.Instance.Instance?.Index; - await toast.Instance.Instance!.CloseAsync(42); + await toast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); // Wait for the toast to be closed var result = await toastTask; // Assert Assert.Equal("my-toast", instanceId); - Assert.Equal(42, result.GetValue()); Assert.True(instanceIndex > 0); - Assert.False(result.Cancelled); + Assert.Equal(ToastCloseReason.Programmatic, result); } [Fact(Timeout = TEST_TIMEOUT)] @@ -125,7 +123,7 @@ var result = await toastTask; // Assert - Assert.True(result.Cancelled); + Assert.Equal(ToastCloseReason.Dismissed, result); } [Fact(Timeout = TEST_TIMEOUT)] @@ -140,16 +138,15 @@ // Don't wait for the toast to be closed await Task.CompletedTask; - // Find the toast and close it with an explicit ToastResult + // Find the toast and close it programmatically var toast = ToastProvider.FindComponent(); - await toast.Instance.Instance!.CloseAsync(ToastResult.Ok("explicit-result")); + await toast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); // Wait for the toast to be closed var result = await toastTask; // Assert - Assert.False(result.Cancelled); - Assert.Equal("explicit-result", result.GetValue()); + Assert.Equal(ToastCloseReason.Programmatic, result); } [Fact(Timeout = TEST_TIMEOUT)] @@ -172,8 +169,7 @@ var result = await toastTask; // Assert - Assert.False(result.Cancelled); - Assert.Null(result.Value); + Assert.Equal(ToastCloseReason.Programmatic, result); } [Theory(Timeout = TEST_TIMEOUT)] @@ -262,7 +258,7 @@ // Close the toast via the service — should raise Closing then Closed states var toast = ToastProvider.FindComponent(); - await ToastService.CloseAsync(toast.Instance.Instance!, ToastResult.Ok("done")); + await ToastService.CloseAsync(toast.Instance.Instance!, ToastCloseReason.Programmatic); // Wait for the task var result = await toastTask; @@ -270,8 +266,7 @@ // Assert Assert.Contains(DialogState.Closing, stateChanges); Assert.Contains(DialogState.Closed, stateChanges); - Assert.False(result.Cancelled); - Assert.Equal("done", result.GetValue()); + Assert.Equal(ToastCloseReason.Programmatic, result); } [Fact(Timeout = TEST_TIMEOUT)] @@ -336,57 +331,50 @@ public void FluentToast_ToastResult() { // Arrange - var result = new ToastResult(content: "OK", cancelled: false); + var result = ToastCloseReason.Programmatic; // Assert - Assert.Equal("OK", result.Value); - Assert.Equal("OK", result.GetValue()); - Assert.Equal(0, result.GetValue()); - Assert.False(result.Cancelled); + Assert.Equal(ToastCloseReason.Programmatic, result); } [Fact] - public void FluentToast_ToastResult_Ok() + public void FluentToast_ToastCloseReason_Dismissed() { // Arrange - var result = ToastResult.Ok("My content"); + var result = ToastCloseReason.Dismissed; // Assert - Assert.Equal("My content", result.Value); - Assert.False(result.Cancelled); + Assert.Equal(ToastCloseReason.Dismissed, result); } [Fact] - public void FluentToast_ToastResult_Ok_NoValue() + public void FluentToast_ToastCloseReason_TimedOut() { // Arrange - var result = ToastResult.Ok(); + var result = ToastCloseReason.TimedOut; // Assert - Assert.Null(result.Value); - Assert.False(result.Cancelled); + Assert.Equal(ToastCloseReason.TimedOut, result); } [Fact] - public void FluentToast_ToastResult_Cancel() + public void FluentToast_ToastCloseReason_QuickAction() { // Arrange - var result = ToastResult.Cancel("My content"); + var result = ToastCloseReason.QuickAction; // Assert - Assert.Equal("My content", result.Value); - Assert.True(result.Cancelled); + Assert.Equal(ToastCloseReason.QuickAction, result); } [Fact] - public void FluentToast_ToastResult_Cancel_NoValue() + public void FluentToast_ToastCloseReason_Programmatic() { // Arrange - var result = ToastResult.Cancel(); + var result = ToastCloseReason.Programmatic; // Assert - Assert.Null(result.Value); - Assert.True(result.Cancelled); + Assert.Equal(ToastCloseReason.Programmatic, result); } [Fact] @@ -457,6 +445,32 @@ Assert.Contains("data-test=\"my-toast\"", ToastProvider.Markup); } + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToast_QuickActionCallback() + { + var invoked = false; + + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Toast Content"; + options.QuickAction1 = "Undo"; + options.QuickAction1Callback = () => + { + invoked = true; + return Task.CompletedTask; + }; + }); + + await Task.CompletedTask; + + ToastProvider.Find("a").Click(); + + var result = await toastTask; + + Assert.True(invoked); + Assert.Equal(ToastCloseReason.QuickAction, result); + } + [Fact] public async Task FluentToast_RemoveToast_NullInstance() { From d6a5275be23fc96940ecc5754bc19252a6d08200 Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Tue, 24 Mar 2026 15:33:12 +0100 Subject: [PATCH 09/20] Implement queueing --- .../Toast/DebugPages/DebugToast.razor | 4 +- .../Components/Toast/FluentToast.razor.cs | 54 +++++++------ .../Toast/FluentToastProvider.razor | 4 +- .../Toast/FluentToastProvider.razor.cs | 53 ++++++++++++- .../Toast/Services/IToastInstance.cs | 5 ++ .../Toast/Services/ToastEventArgs.cs | 27 ++----- .../Toast/Services/ToastInstance.cs | 3 + .../Components/Toast/Services/ToastOptions.cs | 4 +- .../Components/Toast/Services/ToastService.cs | 8 +- src/Core/Enums/ToastStatus.cs | 31 ++++++++ .../Components/Toast/FluentToastTests.razor | 75 +++++++++++++------ 11 files changed, 190 insertions(+), 78 deletions(-) create mode 100644 src/Core/Enums/ToastStatus.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index a962a35f6c..e22f40cfd1 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -46,9 +46,9 @@ return Task.CompletedTask; }; options.ShowDismissButton = true; - options.OnStateChange = (e) => + options.OnStatusChange = (e) => { - Console.WriteLine($"State changed: {e.State}"); + Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); }; }); diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 3bdb496725..f1fca30f2b 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -54,7 +54,7 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) public EventCallback OnToggle { get; set; } [Parameter] - public EventCallback OnStateChange { get; set; } + public EventCallback OnStatusChange { get; set; } /// /// Gets or sets the title displayed in the toast. @@ -142,11 +142,11 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Inject] private IToastService? ToastService { get; set; } - public Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) - => RaiseOnStateChangeAsync(new ToastEventArgs(this, args)); + public Task RaiseOnStatusChangeAsync(DialogToggleEventArgs args) + => RaiseOnStatusChangeAsync(new ToastEventArgs(this, args)); - public Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) - => RaiseOnStateChangeAsync(new ToastEventArgs(instance, state)); + public Task RaiseOnStatusChangeAsync(IToastInstance instance, ToastStatus status) + => RaiseOnStatusChangeAsync(new ToastEventArgs(instance, status)); public Task OnToggleAsync(DialogToggleEventArgs args) => HandleToggleAsync(args); @@ -214,7 +214,18 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) return; } - var toastEventArgs = await RaiseOnStateChangeAsync(args); + if (Instance is not ToastInstance toastInstance) + { + return; + } + + var toastEventArgs = new ToastEventArgs(this, args); + if (toastEventArgs.Status == ToastStatus.Dismissed) + { + toastInstance.Status = ToastStatus.Dismissed; + await RaiseOnStatusChangeAsync(toastEventArgs); + } + var toggled = string.Equals(args.NewState, "open", StringComparison.OrdinalIgnoreCase); if (Opened != toggled) { @@ -231,32 +242,27 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) } } - if (Instance is ToastInstance toastInstance) + if (string.Equals(args.Type, "toggle", StringComparison.OrdinalIgnoreCase) + && string.Equals(args.NewState, "closed", StringComparison.OrdinalIgnoreCase)) { - switch (toastEventArgs.State) - { - case DialogState.Closing: - break; - - case DialogState.Closed: - toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingCloseReason ?? ToastCloseReason.TimedOut); - toastInstance.PendingCloseReason = null; + toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingCloseReason ?? ToastCloseReason.TimedOut); + toastInstance.PendingCloseReason = null; + toastInstance.Status = ToastStatus.Unmounted; - if (ToastService is ToastService toastService) - { - await toastService.RemoveToastFromProviderAsync(Instance); - } - - break; + if (ToastService is ToastService toastService) + { + await toastService.RemoveToastFromProviderAsync(Instance); } + + await RaiseOnStatusChangeAsync(toastInstance, ToastStatus.Unmounted); } } - private async Task RaiseOnStateChangeAsync(ToastEventArgs args) + private async Task RaiseOnStatusChangeAsync(ToastEventArgs args) { - if (OnStateChange.HasDelegate) + if (OnStatusChange.HasDelegate) { - await InvokeAsync(() => OnStateChange.InvokeAsync(args)); + await InvokeAsync(() => OnStatusChange.InvokeAsync(args)); } return args; diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 2093ddf00c..1865d82803 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -7,7 +7,7 @@ @attributes="AdditionalAttributes"> @if (ToastService != null) { - @foreach (var toast in ToastService.Items.Values.OrderByDescending(i => i.Index)) + @foreach (var toast in GetRenderedToasts()) { public partial class FluentToastProvider : FluentComponentBase { + private const int _defaultMaxToastCount = 4; + /// public FluentToastProvider(LibraryConfiguration configuration) : base(configuration) { Id = Identifier.NewId(); } + /// + /// Gets or sets the maximum number of toasts displayed at the same time. + /// + [Parameter] + public int MaxToastCount { get; set; } = _defaultMaxToastCount; + /// internal string? ClassValue => DefaultClassBuilder .AddClass("fluent-toast-provider") @@ -46,16 +54,55 @@ protected override void OnInitialized() ToastService.ProviderId = Id; ToastService.OnUpdatedAsync = async (item) => { + SynchronizeToastQueue(); await InvokeAsync(StateHasChanged); }; + + SynchronizeToastQueue(); } } /// - internal static Action EmptyOnStateChange => (_) => { }; + internal static Action EmptyOnStatusChange => (_) => { }; + + private EventCallback GetOnStatusChangeCallback(IToastInstance toast) + => EventCallback.Factory.Create(this, toast.Options.OnStatusChange ?? EmptyOnStatusChange); + + private IEnumerable GetRenderedToasts() + => ToastService?.Items.Values + .Where(toast => toast.Status is ToastStatus.Visible or ToastStatus.Dismissed) + .OrderByDescending(toast => toast.Index) + ?? Enumerable.Empty(); + + private void SynchronizeToastQueue() + { + if (ToastService is null) + { + return; + } + + var maxToastCount = MaxToastCount <= 0 ? _defaultMaxToastCount : MaxToastCount; + var activeCount = ToastService.Items.Values.Count(toast => toast.Status is ToastStatus.Visible or ToastStatus.Dismissed); + var queuedToasts = ToastService.Items.Values + .Where(toast => toast.Status == ToastStatus.Queued) + .OrderBy(toast => toast.Index) + .ToList(); - private EventCallback GetOnStateChangeCallback(IToastInstance toast) - => EventCallback.Factory.Create(this, toast.Options.OnStateChange ?? EmptyOnStateChange); + foreach (var toast in queuedToasts) + { + if (activeCount >= maxToastCount) + { + break; + } + + if (toast is ToastInstance instance) + { + instance.Status = ToastStatus.Visible; + toast.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastStatus.Visible)); + activeCount++; + } + } + } /// /// Only for Unit Tests diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index 3910e34d3b..5c429d4ab8 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -30,6 +30,11 @@ public interface IToastInstance /// Task Result { get; } + /// + /// Gets the lifecycle status of the toast. + /// + ToastStatus Status { get; } + /// /// Closes the Toast as dismissed. /// diff --git a/src/Core/Components/Toast/Services/ToastEventArgs.cs b/src/Core/Components/Toast/Services/ToastEventArgs.cs index dc16d0766b..31d0af0a95 100644 --- a/src/Core/Components/Toast/Services/ToastEventArgs.cs +++ b/src/Core/Components/Toast/Services/ToastEventArgs.cs @@ -20,41 +20,30 @@ internal ToastEventArgs(FluentToast toast, string? id, string? eventType, string { Id = id ?? string.Empty; Instance = toast.Instance; + Status = ToastStatus.Queued; if (string.Equals(eventType, "toggle", StringComparison.OrdinalIgnoreCase)) { if (string.Equals(newState, "open", StringComparison.OrdinalIgnoreCase)) { - State = DialogState.Open; - } - else if (string.Equals(newState, "closed", StringComparison.OrdinalIgnoreCase)) - { - State = DialogState.Closed; + Status = ToastStatus.Visible; } } else if (string.Equals(eventType, "beforetoggle", StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(oldState, "closed", StringComparison.OrdinalIgnoreCase)) - { - State = DialogState.Opening; - } - else if (string.Equals(oldState, "open", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(oldState, "open", StringComparison.OrdinalIgnoreCase)) { - State = DialogState.Closing; + Status = ToastStatus.Dismissed; } } - else - { - State = DialogState.Closed; - } } /// - internal ToastEventArgs(IToastInstance instance, DialogState state) + internal ToastEventArgs(IToastInstance instance, ToastStatus status) { Id = instance.Id; Instance = instance; - State = state; + Status = status; } /// @@ -63,9 +52,9 @@ internal ToastEventArgs(IToastInstance instance, DialogState state) public string Id { get; } /// - /// Gets the state of the FluentToast component. + /// Gets the lifecycle status of the FluentToast component. /// - public DialogState State { get; } + public ToastStatus Status { get; } /// /// Gets the instance used by the . diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 888f67a947..732543927b 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -38,6 +38,9 @@ internal ToastInstance(IToastService toastService, ToastOptions options) /// public Task Result => ResultCompletion.Task; + /// + public ToastStatus Status { get; internal set; } = ToastStatus.Queued; + /// " public string Id { get; } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 15bd780f51..9d28ab4d3f 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -198,9 +198,9 @@ public ToastOptions(Action implementationFactory) public RenderFragment? ChildContent { get; set; } /// - /// Gets or sets the action raised when the Toast is opened or closed. + /// Gets or sets the action raised when the toast lifecycle status changes. /// - public Action? OnStateChange { get; set; } + public Action? OnStatusChange { get; set; } /// /// Gets the class, including the optional and values. diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index 4de549a4b8..b2940a9d9f 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -42,17 +42,14 @@ public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) return; } - // Raise the ToastState.Closing event - ToastInstance?.FluentToast?.RaiseOnStateChangeAsync(Toast, DialogState.Closing); - // Remove the Toast from the ToastProvider await RemoveToastFromProviderAsync(Toast); // Set the result of the Toast ToastInstance?.ResultCompletion.TrySetResult(reason); - // Raise the ToastState.Closed event - ToastInstance?.FluentToast?.RaiseOnStateChangeAsync(Toast, DialogState.Closed); + // Raise the final ToastStatus.Unmounted event + ToastInstance?.FluentToast?.RaiseOnStatusChangeAsync(Toast, ToastStatus.Unmounted); } /// @@ -88,6 +85,7 @@ private async Task ShowToastCoreAsync(ToastOptions options) } var instance = new ToastInstance(this, options); + options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastStatus.Queued)); // Add the Toast to the service, and render it. ServiceProvider.Items.TryAdd(instance?.Id ?? "", instance ?? throw new InvalidOperationException("Failed to create FluentToast.")); diff --git a/src/Core/Enums/ToastStatus.cs b/src/Core/Enums/ToastStatus.cs new file mode 100644 index 0000000000..89bd77c10c --- /dev/null +++ b/src/Core/Enums/ToastStatus.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Describes the current lifecycle status of a toast. +/// +public enum ToastStatus +{ + /// + /// The toast has been queued for display. + /// + Queued, + + /// + /// The toast is visible. + /// + Visible, + + /// + /// The toast has been dismissed and is leaving the active surface. + /// + Dismissed, + + /// + /// The toast has been unmounted from the provider. + /// + Unmounted, +} diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 9b13c7eee7..3dea48c69f 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -43,6 +43,42 @@ ToastProvider.Verify(); } + [Fact(Timeout = TEST_TIMEOUT)] + public async Task FluentToast_QueuedUntilProviderHasRoom() + { + var provider = Render(parameters => parameters.Add(p => p.MaxToastCount, 1)); + var statuses = new List(); + + var firstToastTask = ToastService.ShowToastAsync(options => + { + options.Body = "First toast"; + }); + + var secondToastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Second toast"; + options.OnStatusChange = args => statuses.Add(args.Status); + }); + + await Task.CompletedTask; + + Assert.Contains("First toast", provider.Markup); + Assert.DoesNotContain("Second toast", provider.Markup); + Assert.Contains(ToastStatus.Queued, statuses); + + var firstToast = provider.FindComponent(); + await firstToast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); + await firstToastTask; + await Task.CompletedTask; + + Assert.Contains("Second toast", provider.Markup); + Assert.Contains(ToastStatus.Visible, statuses); + + var secondToast = provider.FindComponent(); + await secondToast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); + await secondToastTask; + } + [Fact] public void FluentToast_ChildContent() { @@ -173,12 +209,9 @@ } [Theory(Timeout = TEST_TIMEOUT)] - [InlineData(DialogState.Closed, "unknown", "any-old", "any-new")] - [InlineData(DialogState.Open, "toggle", "any-old", "open")] - [InlineData(DialogState.Closed, "toggle", "any-old", "closed")] - [InlineData(DialogState.Opening, "beforetoggle", "closed", "any-new")] - [InlineData(DialogState.Closing, "beforetoggle", "open", "any-new")] - public async Task FluentToast_Toggle_StateChange(DialogState expectedState, string eventType, string oldState, string newState) + [InlineData(ToastStatus.Visible, "toggle", "any-old", "open")] + [InlineData(ToastStatus.Dismissed, "beforetoggle", "open", "any-new")] + public async Task FluentToast_Toggle_StatusChange(ToastStatus expectedStatus, string eventType, string oldState, string newState) { ToastEventArgs? capturedArgs = null; @@ -187,7 +220,7 @@ { options.Id = "my-id"; options.Body = "State change body"; - options.OnStateChange = (args) => + options.OnStatusChange = (args) => { capturedArgs = args; }; @@ -196,7 +229,7 @@ // Don't wait for the toast to be closed await Task.CompletedTask; - // Find the toast and raise a state change via RaiseOnStateChangeAsync + // Find the toast and raise a status change via RaiseOnStatusChangeAsync var toast = ToastProvider.FindComponent(); var toggleArgs = new DialogToggleEventArgs() { @@ -205,11 +238,11 @@ OldState = oldState, NewState = newState, }; - await toast.Instance.RaiseOnStateChangeAsync(toggleArgs); + await toast.Instance.RaiseOnStatusChangeAsync(toggleArgs); // Assert Assert.NotNull(capturedArgs); - Assert.Equal(expectedState, capturedArgs.State); + Assert.Equal(expectedStatus, capturedArgs.Status); Assert.Equal("my-id", capturedArgs.Id); Assert.NotNull(capturedArgs.Instance); } @@ -241,22 +274,22 @@ [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_StateChange_ViaClosed() { - var stateChanges = new List(); + var statusChanges = new List(); // Act var toastTask = ToastService.ShowToastAsync(options => { options.Body = "State change body"; - options.OnStateChange = (args) => + options.OnStatusChange = (args) => { - stateChanges.Add(args.State); + statusChanges.Add(args.Status); }; }); // Don't wait for the toast to be closed await Task.CompletedTask; - // Close the toast via the service — should raise Closing then Closed states + // Close the toast via the service — should raise dismissed then unmounted statuses var toast = ToastProvider.FindComponent(); await ToastService.CloseAsync(toast.Instance.Instance!, ToastCloseReason.Programmatic); @@ -264,13 +297,13 @@ var result = await toastTask; // Assert - Assert.Contains(DialogState.Closing, stateChanges); - Assert.Contains(DialogState.Closed, stateChanges); + Assert.Contains(ToastStatus.Dismissed, statusChanges); + Assert.Contains(ToastStatus.Unmounted, statusChanges); Assert.Equal(ToastCloseReason.Programmatic, result); } [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_OnStateChange_NoDelegate() + public async Task FluentToast_OnStatusChange_NoDelegate() { // Act _ = ToastService.ShowToastAsync(options => @@ -280,17 +313,17 @@ await Task.CompletedTask; - // Raise a state change — should not throw even when callback is the empty delegate + // Raise a status change — should not throw even when callback is the empty delegate var toast = ToastProvider.FindComponent(); - var args = await toast.Instance.RaiseOnStateChangeAsync(new DialogToggleEventArgs + var args = await toast.Instance.RaiseOnStatusChangeAsync(new DialogToggleEventArgs { Id = toast.Instance.Instance?.Id, Type = "toggle", NewState = "open", }); - // Assert: state correctly mapped, no exception thrown - Assert.Equal(DialogState.Open, args.State); + // Assert: status correctly mapped, no exception thrown + Assert.Equal(ToastStatus.Visible, args.Status); } [Fact(Timeout = TEST_TIMEOUT)] From 82c6326b65641537c8b34e53fb0409c4855dcdca Mon Sep 17 00:00:00 2001 From: vnbaaij Date: Tue, 24 Mar 2026 21:17:26 +0100 Subject: [PATCH 10/20] Add more tests, some small refactoring --- .../Toast/DebugPages/DebugToast.razor | 10 +- .../src/Components/Toast/FluentToast.ts | 48 ++- src/Core/Components/Toast/FluentToast.razor | 37 +- .../Components/Toast/FluentToast.razor.cs | 28 +- .../Components/Toast/FluentToastInstance.cs | 34 -- .../Toast/FluentToastProvider.razor | 19 +- .../Toast/FluentToastProvider.razor.cs | 71 +++- .../Toast/Services/IToastService.cs | 13 + .../Components/Toast/Services/ToastOptions.cs | 37 +- .../Components/Toast/Services/ToastService.cs | 35 +- src/Core/Enums/ToastPosition.cs | 16 +- ...soft.FluentUI.AspNetCore.Components.csproj | 4 - src/Core/Properties/AssemblyInfo.cs | 7 + tests/Core/Components.Tests.csproj | 4 - .../Toast/FluentToastProviderTests.razor | 219 ++++++++++ ...sts.FluentToast_Render.verified.razor.html | 12 +- .../Components/Toast/FluentToastTests.razor | 383 +++++++++++------- .../Components/Toast/ToastInstanceTests.razor | 102 +++++ tests/Core/Verify/FluentAssertOptions.cs | 4 +- 19 files changed, 764 insertions(+), 319 deletions(-) delete mode 100644 src/Core/Components/Toast/FluentToastInstance.cs create mode 100644 src/Core/Properties/AssemblyInfo.cs create mode 100644 tests/Core/Components/Toast/FluentToastProviderTests.razor create mode 100644 tests/Core/Components/Toast/ToastInstanceTests.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index e22f40cfd1..ae1f3e5a18 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -45,7 +45,7 @@ Console.WriteLine("Cancel action executed."); return Task.CompletedTask; }; - options.ShowDismissButton = true; + options.IsDismissable = true; options.OnStatusChange = (e) => { Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); @@ -65,7 +65,7 @@ options.Subtitle = "Just now"; options.QuickAction1 = "Undo"; options.QuickAction2 = "Dismiss"; - options.ShowDismissButton = false; + options.IsDismissable = false; }); _lastResult = result.ToString() ?? string.Empty; @@ -81,10 +81,9 @@ options.Body = "Please wait while your files are uploaded."; options.Status = "Uploading 3 files..."; options.ShowProgress = true; - options.Indeterminate = true; options.QuickAction1 = "Hide"; options.QuickAction2 = "Cancel"; - options.ShowDismissButton = false; + options.IsDismissable = false; }); _lastResult = result.ToString() ?? string.Empty; @@ -100,10 +99,9 @@ options.Body = "Please wait while your files are uploaded."; options.Status = "Uploading 3 files..."; options.ShowProgress = true; - options.Indeterminate = false; options.QuickAction1 = "Hide"; options.QuickAction2 = "Cancel"; - options.ShowDismissButton = false; + options.IsDismissable = false; }); _lastResult = result.ToString() ?? string.Empty; diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 3d9cd26f51..a55fd600af 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -585,7 +585,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { private updatePosition() { const isRtl = getComputedStyle(this).direction === 'rtl'; - const position = this.getAttribute('position') || (isRtl ? 'bottom-left' : 'bottom-right'); + const position = this.getAttribute('position') || (isRtl ? 'bottom-start' : 'bottom-end'); const horizontalOffset = parseInt(this.getAttribute('horizontal-offset') || '20'); const verticalOffset = parseInt(this.getAttribute('vertical-offset') || '16') + this.getStackOffset(position); @@ -598,28 +598,48 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { let enterTo = 'translateY(0)'; switch (position) { - case 'top-right': + case 'top-end': this.dialog.style.top = `${verticalOffset}px`; - this.dialog.style.right = `${horizontalOffset}px`; - enterFrom = 'translateX(16px)'; + if (isRtl) { + this.dialog.style.left = `${horizontalOffset}px`; + enterFrom = 'translateX(-16px)'; + } else { + this.dialog.style.right = `${horizontalOffset}px`; + enterFrom = 'translateX(16px)'; + } enterTo = 'translateX(0)'; break; - case 'top-left': + case 'top-start': this.dialog.style.top = `${verticalOffset}px`; - this.dialog.style.left = `${horizontalOffset}px`; - enterFrom = 'translateX(-16px)'; + if (isRtl) { + this.dialog.style.right = `${horizontalOffset}px`; + enterFrom = 'translateX(16px)'; + } else { + this.dialog.style.left = `${horizontalOffset}px`; + enterFrom = 'translateX(-16px)'; + } enterTo = 'translateX(0)'; break; - case 'bottom-right': + case 'bottom-end': this.dialog.style.bottom = `${verticalOffset}px`; - this.dialog.style.right = `${horizontalOffset}px`; - enterFrom = 'translateX(16px)'; + if (isRtl) { + this.dialog.style.left = `${horizontalOffset}px`; + enterFrom = 'translateX(-16px)'; + } else { + this.dialog.style.right = `${horizontalOffset}px`; + enterFrom = 'translateX(16px)'; + } enterTo = 'translateX(0)'; break; - case 'bottom-left': + case 'bottom-start': this.dialog.style.bottom = `${verticalOffset}px`; - this.dialog.style.left = `${horizontalOffset}px`; - enterFrom = 'translateX(-16px)'; + if (isRtl) { + this.dialog.style.right = `${horizontalOffset}px`; + enterFrom = 'translateX(16px)'; + } else { + this.dialog.style.left = `${horizontalOffset}px`; + enterFrom = 'translateX(-16px)'; + } enterTo = 'translateX(0)'; break; case 'top-center': @@ -665,7 +685,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { private getToastPosition(): string { const isRtl = getComputedStyle(this).direction === 'rtl'; - return this.getAttribute('position') || (isRtl ? 'bottom-left' : 'bottom-right'); + return this.getAttribute('position') || (isRtl ? 'bottom-start' : 'bottom-end'); } private updateToastStack() { diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 317691edd7..67f65bab98 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -27,14 +27,7 @@ else if (ShowProgress) {
- @if (Indeterminate) - { - - } - else - { - - } +
} else @@ -44,20 +37,18 @@ } - @if (TitleContent is not null) - { -
@TitleContent
- } - else if (!string.IsNullOrEmpty(Title)) + @if (!string.IsNullOrEmpty(Title)) {
@Title
} - @if (ShowDismissButton) + @if (IsDismissable) { - @if (DismissContent is not null) + @if (!string.IsNullOrEmpty(DismissAction)) { - @DismissContent + + @DismissAction + } else { @@ -79,20 +70,12 @@
@Body
} - @if (SubtitleContent is not null) - { -
@SubtitleContent
- } - else if (!string.IsNullOrEmpty(ShowProgress ? Status : Subtitle)) + @if (!string.IsNullOrEmpty(Subtitle)) { -
@(ShowProgress ? Status : Subtitle)
+
@Subtitle
} - @if (FooterContent is not null) - { -
@FooterContent
- } - else if (!string.IsNullOrEmpty(QuickAction1) || !string.IsNullOrEmpty(QuickAction2)) + @if (!string.IsNullOrEmpty(QuickAction1) || !string.IsNullOrEmpty(QuickAction2)) {
@if (!string.IsNullOrEmpty(QuickAction1)) diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index f1fca30f2b..271902d787 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -87,19 +87,22 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) public string? QuickAction2 { get; set; } /// - /// Gets or sets whether to render the dismiss button. + /// Gets or sets whether the toast is dismissable by the user. /// [Parameter] - public bool ShowDismissButton { get; set; } = true; + public bool IsDismissable { get; set; } + /// + /// Gets or sets the dismiss action label + /// [Parameter] - public string? Status { get; set; } + public string? DismissAction { get; set; } [Parameter] - public bool ShowProgress { get; set; } + public string? Status { get; set; } [Parameter] - public bool Indeterminate { get; set; } = true; + public bool ShowProgress { get; set; } [Parameter] public int? Value { get; set; } @@ -110,21 +113,9 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public RenderFragment? Media { get; set; } - [Parameter] - public RenderFragment? TitleContent { get; set; } - - [Parameter] - public RenderFragment? SubtitleContent { get; set; } - - [Parameter] - public RenderFragment? FooterContent { get; set; } - [Parameter] public RenderFragment? ChildContent { get; set; } - [Parameter] - public RenderFragment? DismissContent { get; set; } - internal Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); internal Icon IntentIcon => Intent switch @@ -162,6 +153,9 @@ internal Task RequestCloseAsync() return InvokeAsync(StateHasChanged); } + internal Task OnDismissActionClickedAsync() + => HandleActionClickedAsync(Instance?.Options.DismissActionCallback); + internal Task OnQuickAction1ClickedAsync() => HandleActionClickedAsync(Instance?.Options.QuickAction1Callback); diff --git a/src/Core/Components/Toast/FluentToastInstance.cs b/src/Core/Components/Toast/FluentToastInstance.cs deleted file mode 100644 index 2098cd16a7..0000000000 --- a/src/Core/Components/Toast/FluentToastInstance.cs +++ /dev/null @@ -1,34 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using Microsoft.AspNetCore.Components; - -namespace Microsoft.FluentUI.AspNetCore.Components; - -/// -/// -/// -public abstract class FluentToastInstance : ComponentBase -{ - /// - /// Gets or sets the Toast instance. - /// - [CascadingParameter] - public virtual required IToastInstance ToastInstance { get; set; } - - /// - /// Gets or sets the localizer. - /// - [Inject] - public virtual required IFluentLocalizer Localizer { get; set; } - - /// - /// Method invoked when an action is clicked. - /// - /// Override this method if you will perform an asynchronous operation - /// when the user clicks an action button. - /// - /// - protected abstract Task OnActionClickedAsync(bool primary); -} diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 1865d82803..90b2905f5c 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -14,14 +14,14 @@ Class="@toast.Options.ClassValue" Style="@toast.Options.StyleValue" Data="@toast.Options.Data" - Timeout="@toast.Options.Timeout" - Position="@toast.Options.Position" - VerticalOffset="@toast.Options.VerticalOffset" - HorizontalOffset="@toast.Options.HorizontalOffset" + Timeout="@GetTimeout(toast)" + Position="@GetPosition(toast)" + VerticalOffset="@GetVerticalOffset(toast)" + HorizontalOffset="@GetHorizontalOffset(toast)" Intent="@toast.Options.Intent" Politeness="@toast.Options.Politeness" - PauseOnHover="@toast.Options.PauseOnHover" - PauseOnWindowBlur="@toast.Options.PauseOnWindowBlur" + PauseOnHover="@GetPauseOnHover(toast)" + PauseOnWindowBlur="@GetPauseOnWindowBlur(toast)" Instance="@toast" OnStatusChange="@GetOnStatusChangeCallback(toast)" AdditionalAttributes="@toast.Options.AdditionalAttributes" @@ -31,15 +31,12 @@ Status="@toast.Options.Status" QuickAction1="@toast.Options.QuickAction1" QuickAction2="@toast.Options.QuickAction2" - ShowDismissButton="@toast.Options.ShowDismissButton" + IsDismissable="@toast.Options.IsDismissable" + DismissAction="@toast.Options.DismissAction" ShowProgress="@toast.Options.ShowProgress" - Indeterminate="@toast.Options.Indeterminate" Value="@toast.Options.Value" Max="@toast.Options.Max" Media="@toast.Options.Media" - TitleContent="@toast.Options.TitleContent" - SubtitleContent="@toast.Options.SubtitleContent" - FooterContent="@toast.Options.FooterContent" ChildContent="@toast.Options.ChildContent" /> } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 1fced6383f..10e7162597 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -12,6 +12,9 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentToastProvider : FluentComponentBase { private const int _defaultMaxToastCount = 4; + private const int _defaultTimeout = 7000; + private const int _defaultVerticalOffset = 16; + private const int _defaultHorizontalOffset = 20; /// public FluentToastProvider(LibraryConfiguration configuration) : base(configuration) @@ -25,6 +28,42 @@ public FluentToastProvider(LibraryConfiguration configuration) : base(configurat [Parameter] public int MaxToastCount { get; set; } = _defaultMaxToastCount; + /// + /// Gets or sets the default timeout duration in milliseconds for visible toasts. + /// + [Parameter] + public int Timeout { get; set; } = _defaultTimeout; + + /// + /// Gets or sets the default toast position. + /// + [Parameter] + public ToastPosition? Position { get; set; } + + /// + /// Gets or sets the default vertical offset in pixels. + /// + [Parameter] + public int VerticalOffset { get; set; } = _defaultVerticalOffset; + + /// + /// Gets or sets the default horizontal offset in pixels. + /// + [Parameter] + public int HorizontalOffset { get; set; } = _defaultHorizontalOffset; + + /// + /// Gets or sets a value indicating whether visible toasts pause timeout while hovered. + /// + [Parameter] + public bool PauseOnHover { get; set; } + + /// + /// Gets or sets a value indicating whether visible toasts pause timeout while the window is blurred. + /// + [Parameter] + public bool PauseOnWindowBlur { get; set; } + /// internal string? ClassValue => DefaultClassBuilder .AddClass("fluent-toast-provider") @@ -68,6 +107,24 @@ protected override void OnInitialized() private EventCallback GetOnStatusChangeCallback(IToastInstance toast) => EventCallback.Factory.Create(this, toast.Options.OnStatusChange ?? EmptyOnStatusChange); + private int GetTimeout(IToastInstance toast) + => toast.Options.Timeout ?? Timeout; + + private ToastPosition? GetPosition(IToastInstance toast) + => toast.Options.Position ?? Position; + + private int GetVerticalOffset(IToastInstance toast) + => toast.Options.VerticalOffset ?? VerticalOffset; + + private int GetHorizontalOffset(IToastInstance toast) + => toast.Options.HorizontalOffset ?? HorizontalOffset; + + private bool GetPauseOnHover(IToastInstance toast) + => toast.Options.PauseOnHover ?? PauseOnHover; + + private bool GetPauseOnWindowBlur(IToastInstance toast) + => toast.Options.PauseOnWindowBlur ?? PauseOnWindowBlur; + private IEnumerable GetRenderedToasts() => ToastService?.Items.Values .Where(toast => toast.Status is ToastStatus.Visible or ToastStatus.Dismissed) @@ -103,18 +160,4 @@ private void SynchronizeToastQueue() } } } - - /// - /// Only for Unit Tests - /// - /// - internal void UpdateId(string? id) - { - Id = id; - - if (ToastService is not null) - { - ToastService.ProviderId = id; - } - } } diff --git a/src/Core/Components/Toast/Services/IToastService.cs b/src/Core/Components/Toast/Services/IToastService.cs index d6484949e1..61de9d6127 100644 --- a/src/Core/Components/Toast/Services/IToastService.cs +++ b/src/Core/Components/Toast/Services/IToastService.cs @@ -17,6 +17,19 @@ public partial interface IToastService : IFluentServiceBase /// Task CloseAsync(IToastInstance Toast, ToastCloseReason reason); + /// + /// Dismisses the toast with the specified identifier. + /// + /// The identifier of the toast to dismiss. + /// true when a matching toast was found; otherwise false. + Task DismissAsync(string toastId); + + /// + /// Dismisses all current toasts. + /// + /// The number of toasts that were dismissed. + Task DismissAllAsync(); + /// /// Shows a toast using the supplied options. /// diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 9d28ab4d3f..6d221e2e26 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -70,7 +70,7 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets the timeout duration for the Toast in milliseconds. /// - public int Timeout { get; set; } = 5000; + public int? Timeout { get; set; } /// /// Gets or sets the toast position on screen. @@ -80,12 +80,12 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets the vertical offset in pixels. /// - public int VerticalOffset { get; set; } = 16; + public int? VerticalOffset { get; set; } /// /// Gets or sets the horizontal offset in pixels. /// - public int HorizontalOffset { get; set; } = 20; + public int? HorizontalOffset { get; set; } /// /// Gets or sets the toast intent. @@ -100,12 +100,12 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets a value indicating whether the timeout pauses while hovering the toast. /// - public bool PauseOnHover { get; set; } + public bool? PauseOnHover { get; set; } /// /// Gets or sets a value indicating whether the timeout pauses while the window is blurred. /// - public bool PauseOnWindowBlur { get; set; } + public bool? PauseOnWindowBlur { get; set; } /// /// Gets or sets the toast title. @@ -148,19 +148,24 @@ public ToastOptions(Action implementationFactory) public Func? QuickAction2Callback { get; set; } /// - /// Gets or sets a value indicating whether the dismiss button is shown. + /// Gets or sets a value indicating whether the toast can be dismissed by the user. /// - public bool ShowDismissButton { get; set; } = true; + public bool IsDismissable { get; set; } /// - /// Gets or sets a value indicating whether progress visuals are shown. + /// Gets or sets dismiss action label. /// - public bool ShowProgress { get; set; } + public string? DismissAction { get; set; } /// - /// Gets or sets a value indicating whether the progress is indeterminate. + /// Gets or sets the callback invoked when the dismiss action is clicked. /// - public bool Indeterminate { get; set; } = true; + public Func? DismissActionCallback { get; set; } + + /// + /// Gets or sets a value indicating whether progress visuals are shown. + /// + public bool ShowProgress { get; set; } /// /// Gets or sets the current progress value. @@ -177,16 +182,6 @@ public ToastOptions(Action implementationFactory) /// public RenderFragment? Media { get; set; } - /// - /// Gets or sets custom content rendered in the title slot. - /// - public RenderFragment? TitleContent { get; set; } - - /// - /// Gets or sets custom content rendered in the subtitle slot. - /// - public RenderFragment? SubtitleContent { get; set; } - /// /// Gets or sets custom content rendered in the footer slot. /// diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index b2940a9d9f..2181e62fc3 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -42,6 +42,11 @@ public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) return; } + if (ToastInstance is not null) + { + ToastInstance.Status = ToastStatus.Unmounted; + } + // Remove the Toast from the ToastProvider await RemoveToastFromProviderAsync(Toast); @@ -49,7 +54,35 @@ public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) ToastInstance?.ResultCompletion.TrySetResult(reason); // Raise the final ToastStatus.Unmounted event - ToastInstance?.FluentToast?.RaiseOnStatusChangeAsync(Toast, ToastStatus.Unmounted); + if (ToastInstance is not null) + { + ToastInstance.Options.OnStatusChange?.Invoke(new ToastEventArgs(ToastInstance, ToastStatus.Unmounted)); + } + } + + /// + public async Task DismissAsync(string toastId) + { + if (string.IsNullOrWhiteSpace(toastId) || !ServiceProvider.Items.TryGetValue(toastId, out var toast)) + { + return false; + } + + await CloseAsync(toast, ToastCloseReason.Dismissed); + return true; + } + + /// + public async Task DismissAllAsync() + { + var toasts = ServiceProvider.Items.Values.ToList(); + + foreach (var toast in toasts) + { + await CloseAsync(toast, ToastCloseReason.Dismissed); + } + + return toasts.Count; } /// diff --git a/src/Core/Enums/ToastPosition.cs b/src/Core/Enums/ToastPosition.cs index 0c59f8f1b9..1a1d982c28 100644 --- a/src/Core/Enums/ToastPosition.cs +++ b/src/Core/Enums/ToastPosition.cs @@ -12,24 +12,24 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public enum ToastPosition { /// - [Description("top-right")] - TopRight, + [Description("top-end")] + TopEnd, /// - [Description("top-left")] - TopLeft, + [Description("top-start")] + TopStart, /// [Description("top-center")] TopCenter, /// - [Description("bottom-right")] - BottomRight, + [Description("bottom-end")] + BottomEnd, /// - [Description("bottom-left")] - BottomLeft, + [Description("bottom-start")] + BottomStart, /// [Description("bottom-center")] diff --git a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj index 33a774a158..2dd66265dc 100644 --- a/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj +++ b/src/Core/Microsoft.FluentUI.AspNetCore.Components.csproj @@ -152,8 +152,4 @@ CS1591 - - - - diff --git a/src/Core/Properties/AssemblyInfo.cs b/src/Core/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a0cf3d99fa --- /dev/null +++ b/src/Core/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.FluentUI.AspNetCore.Components.Tests")] diff --git a/tests/Core/Components.Tests.csproj b/tests/Core/Components.Tests.csproj index bdf546ed13..54203facdb 100644 --- a/tests/Core/Components.Tests.csproj +++ b/tests/Core/Components.Tests.csproj @@ -73,8 +73,4 @@ FluentLocalizer.resx - - - - diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor new file mode 100644 index 0000000000..11e8e7f559 --- /dev/null +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -0,0 +1,219 @@ +@using System.Collections.Concurrent +@using Xunit; +@inherits FluentUITestContext +@code +{ + + public FluentToastProviderTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + + ToastService = Services.GetRequiredService(); + ToastProvider = Render(); + } + + public IToastService ToastService { get; } + + public IRenderedComponent ToastProvider { get; } + + private static async Task CloseToastAndWaitAsync(IRenderedComponent toast, ToastCloseReason reason) + { + await toast.Instance.Instance!.CloseAsync(reason); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance.Id, + Type = "beforetoggle", + OldState = "open", + NewState = "closed", + }); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + } + + [Fact] + public async Task FluentToast_ProviderDefaults_Applied() + { + var provider = Render(parameters => parameters + .Add(p => p.Timeout, 1234) + .Add(p => p.Position, ToastPosition.TopEnd) + .Add(p => p.VerticalOffset, 44) + .Add(p => p.HorizontalOffset, 55) + .Add(p => p.PauseOnHover, true) + .Add(p => p.PauseOnWindowBlur, true)); + + _ = ToastService.ShowToastAsync(options => + { + options.Body = "Uses provider defaults"; + }); + + await Task.CompletedTask; + + var toast = provider.FindComponent(); + Assert.Equal(1234, toast.Instance.Timeout); + Assert.Equal(ToastPosition.TopEnd, toast.Instance.Position); + Assert.Equal(44, toast.Instance.VerticalOffset); + Assert.Equal(55, toast.Instance.HorizontalOffset); + Assert.True(toast.Instance.PauseOnHover); + Assert.True(toast.Instance.PauseOnWindowBlur); + } + + [Fact] + public async Task FluentToast_PerToastOverrides_ProviderDefaults() + { + var provider = Render(parameters => parameters + .Add(p => p.Timeout, 1234) + .Add(p => p.Position, ToastPosition.TopEnd) + .Add(p => p.VerticalOffset, 44) + .Add(p => p.HorizontalOffset, 55) + .Add(p => p.PauseOnHover, false) + .Add(p => p.PauseOnWindowBlur, false)); + + _ = ToastService.ShowToastAsync(options => + { + options.Body = "Uses overrides"; + options.Timeout = 4321; + options.Position = ToastPosition.BottomStart; + options.VerticalOffset = 11; + options.HorizontalOffset = 22; + options.PauseOnHover = true; + options.PauseOnWindowBlur = true; + }); + + await Task.CompletedTask; + + var toast = provider.FindComponent(); + Assert.Equal(4321, toast.Instance.Timeout); + Assert.Equal(ToastPosition.BottomStart, toast.Instance.Position); + Assert.Equal(11, toast.Instance.VerticalOffset); + Assert.Equal(22, toast.Instance.HorizontalOffset); + Assert.True(toast.Instance.PauseOnHover); + Assert.True(toast.Instance.PauseOnWindowBlur); + } + + [Fact] + public async Task FluentToast_QueuedUntilProviderHasRoom() + { + var provider = Render(parameters => parameters.Add(p => p.MaxToastCount, 1)); + var statuses = new List(); + + var firstToastTask = ToastService.ShowToastAsync(options => + { + options.Body = "First toast"; + }); + + var secondToastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Second toast"; + options.OnStatusChange = args => statuses.Add(args.Status); + }); + + await Task.CompletedTask; + + Assert.Contains("First toast", provider.Markup); + Assert.DoesNotContain("Second toast", provider.Markup); + Assert.Contains(ToastStatus.Queued, statuses); + + var firstToast = provider.FindComponent(); + await CloseToastAndWaitAsync(firstToast, ToastCloseReason.Programmatic); + await firstToastTask; + await Task.CompletedTask; + + Assert.Contains("Second toast", provider.Markup); + Assert.Contains(ToastStatus.Visible, statuses); + + var secondToast = provider.FindComponent(); + await CloseToastAndWaitAsync(secondToast, ToastCloseReason.Programmatic); + await secondToastTask; + } + + [Fact] + public async Task FluentToast_ProviderClassStyle() + { + Assert.Contains("fluent-toast-provider", ToastProvider.Markup); + Assert.Contains("z-index", ToastProvider.Markup); + + await Task.CompletedTask; + } + + [Fact] + public void FluentToast_SynchronizeToastQueue_WithNullToastService_DoesNothing() + { + var provider = Render(); + var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + + var exception = Record.Exception(() => method.Invoke(provider.Instance, null)); + + Assert.Null(exception); + } + + [Fact] + public void FluentToast_SynchronizeToastQueue_PromotesQueuedToastsInIndexOrder() + { + var service = new TestToastService(); + var firstToast = new ToastInstance(service, new ToastOptions { Id = "first" }); + var secondToast = new ToastInstance(service, new ToastOptions { Id = "second" }); + + service.Items.TryAdd(secondToast.Id, secondToast); + service.Items.TryAdd(firstToast.Id, firstToast); + + var provider = Render(parameters => parameters + .Add(p => p.OverrideToastService, service) + .Add(p => p.MaxToastCount, 1)); + + var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + method.Invoke(provider.Instance, null); + + Assert.Equal(ToastStatus.Visible, firstToast.Status); + Assert.Equal(ToastStatus.Queued, secondToast.Status); + } + + private sealed class TestFluentToastProvider : FluentToastProvider + { + public TestFluentToastProvider(LibraryConfiguration configuration) + : base(configuration) + { + } + + [Parameter] + public IToastService? OverrideToastService { get; set; } + + protected override IToastService? ToastService => OverrideToastService; + } + + private sealed class TestToastService : IToastService + { + public string? ProviderId { get; set; } + + public ConcurrentDictionary Items { get; } = new(); + + public Func OnUpdatedAsync { get; set; } = _ => Task.CompletedTask; + + public Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) + => Task.CompletedTask; + + public Task DismissAsync(string toastId) + => Task.FromResult(false); + + public Task DismissAllAsync() + => Task.FromResult(0); + + public void Dispose() + { + } + + public Task ShowToastAsync(ToastOptions? options = null) + => Task.FromResult(ToastCloseReason.Programmatic); + + public Task ShowToastAsync(Action options) + => Task.FromResult(ToastCloseReason.Programmatic); + + public Task UpdateToastAsync(IToastInstance toast, Action update) + => Task.CompletedTask; + } +} diff --git a/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html b/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html index ca803b07ca..711f3dfbd8 100644 --- a/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html +++ b/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html @@ -2,9 +2,15 @@
-
-
Toast Content - John
-
+ + +
Toast title
+
Toast Content - John
+
\ No newline at end of file diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 3dea48c69f..e49668cc61 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -2,8 +2,6 @@ @inherits FluentUITestContext @code { - // A timeout can be set when you open a toast and do not close it. - private const int TEST_TIMEOUT = 3000; public FluentToastTests() { @@ -24,7 +22,29 @@ ///
public IRenderedComponent ToastProvider { get; } - [Fact(Timeout = TEST_TIMEOUT)] + private static async Task CloseToastAndWaitAsync(IRenderedComponent toast, ToastCloseReason reason) + { + await toast.Instance.Instance!.CloseAsync(reason); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance.Id, + Type = "beforetoggle", + OldState = "open", + NewState = "closed", + }); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + } + + private static Task DismissToastAndWaitAsync(IRenderedComponent toast) + => CloseToastAndWaitAsync(toast, ToastCloseReason.Dismissed); + + [Fact] public async Task FluentToast_Render() { // Act @@ -34,8 +54,6 @@ options.Body = "Toast Content - John"; }); - // Don't wait for the toast to be closed - await Task.CompletedTask; // Assert Assert.Contains("fluent-toast-provider", ToastProvider.Markup); @@ -43,54 +61,61 @@ ToastProvider.Verify(); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_QueuedUntilProviderHasRoom() + [Fact] + public void FluentToast_ChildContent() { - var provider = Render(parameters => parameters.Add(p => p.MaxToastCount, 1)); - var statuses = new List(); + // Arrange & Act + var cut = Render(@Hello World); - var firstToastTask = ToastService.ShowToastAsync(options => - { - options.Body = "First toast"; - }); + // Assert + Assert.Contains("Hello World", cut.Markup); + Assert.Contains("fluent-toast-b", cut.Markup); + } - var secondToastTask = ToastService.ShowToastAsync(options => - { - options.Body = "Second toast"; - options.OnStatusChange = args => statuses.Add(args.Status); - }); + [Fact] + public void FluentToast_Media_RendersCustomContent() + { + RenderFragment media = @
Custom media
; + var cut = Render(@); - await Task.CompletedTask; + Assert.Contains("Custom media", cut.Markup); + } - Assert.Contains("First toast", provider.Markup); - Assert.DoesNotContain("Second toast", provider.Markup); - Assert.Contains(ToastStatus.Queued, statuses); + [Fact] + public void FluentToast_ShowProgress_RendersSpinner() + { + var cut = Render(@); - var firstToast = provider.FindComponent(); - await firstToast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); - await firstToastTask; - await Task.CompletedTask; + Assert.Contains("fluent-spinner", cut.Markup); + } - Assert.Contains("Second toast", provider.Markup); - Assert.Contains(ToastStatus.Visible, statuses); + [Fact] + public void FluentToast_Subtitle_RendersWhenNotEmpty() + { + var cut = Render(@); - var secondToast = provider.FindComponent(); - await secondToast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); - await secondToastTask; + Assert.Contains("Toast subtitle", cut.Markup); } [Fact] - public void FluentToast_ChildContent() + public void FluentToast_IsDismissable_TrueWithDismissAction_RendersDismissLink() { - // Arrange & Act - var cut = Render(@Hello World); + var cut = Render(@); - // Assert - Assert.Contains("Hello World", cut.Markup); - Assert.Contains("fluent-toast-b", cut.Markup); + Assert.Contains("Dismiss now", cut.Markup); + Assert.Contains("fluent-link", cut.Markup); + } + + [Fact] + public void FluentToast_IsDismissable_TrueWithNullDismissAction_RendersDismissButton() + { + var cut = Render(@); + + Assert.Contains("Title=\"Dismiss\"", cut.Markup, StringComparison.OrdinalIgnoreCase); + Assert.Contains("fluent-button", cut.Markup); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_OpenClose() { // Act @@ -102,7 +127,7 @@ await Task.CompletedTask; var toast = ToastProvider.FindComponent(); - await toast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); + await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); // Wait for the toast to be closed var result = await toastTask; @@ -111,7 +136,19 @@ Assert.Equal(ToastCloseReason.Programmatic, result); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] + public async Task FluentToast_RequestCloseAsync_WhenNotOpened_DoesNothing() + { + var cut = Render(parameters => parameters.Add(p => p.Body, "Toast body")); + var method = typeof(FluentToast).GetMethod("RequestCloseAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + + var task = (Task)method.Invoke(cut.Instance, null)!; + await task; + + Assert.False(cut.Instance.Opened); + } + + [Fact] public async Task FluentToast_Instance() { // Act @@ -128,7 +165,7 @@ var toast = ToastProvider.FindComponent(); var instanceId = toast.Instance.Instance?.Id; var instanceIndex = toast.Instance.Instance?.Index; - await toast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); + await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); // Wait for the toast to be closed var result = await toastTask; @@ -139,7 +176,7 @@ Assert.Equal(ToastCloseReason.Programmatic, result); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_Instance_Cancel() { // Act @@ -153,7 +190,7 @@ // Find the toast and cancel it var toast = ToastProvider.FindComponent(); - await toast.Instance.Instance!.CancelAsync(); + await DismissToastAndWaitAsync(toast); // Wait for the toast to be closed var result = await toastTask; @@ -162,7 +199,7 @@ Assert.Equal(ToastCloseReason.Dismissed, result); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_Instance_CloseWithResult() { // Act @@ -176,7 +213,7 @@ // Find the toast and close it programmatically var toast = ToastProvider.FindComponent(); - await toast.Instance.Instance!.CloseAsync(ToastCloseReason.Programmatic); + await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); // Wait for the toast to be closed var result = await toastTask; @@ -185,7 +222,7 @@ Assert.Equal(ToastCloseReason.Programmatic, result); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_Instance_CloseNoValue() { // Act @@ -199,7 +236,7 @@ // Find the toast and close it without a value var toast = ToastProvider.FindComponent(); - await toast.Instance.Instance!.CloseAsync(); + await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); // Wait for the toast to be closed var result = await toastTask; @@ -208,7 +245,7 @@ Assert.Equal(ToastCloseReason.Programmatic, result); } - [Theory(Timeout = TEST_TIMEOUT)] + [Theory] [InlineData(ToastStatus.Visible, "toggle", "any-old", "open")] [InlineData(ToastStatus.Dismissed, "beforetoggle", "open", "any-new")] public async Task FluentToast_Toggle_StatusChange(ToastStatus expectedStatus, string eventType, string oldState, string newState) @@ -247,7 +284,7 @@ Assert.NotNull(capturedArgs.Instance); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_Toggle_IdMismatch() { // Act @@ -271,7 +308,7 @@ Assert.Contains("Toast Content", ToastProvider.Markup); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_StateChange_ViaClosed() { var statusChanges = new List(); @@ -280,6 +317,7 @@ var toastTask = ToastService.ShowToastAsync(options => { options.Body = "State change body"; + options.Timeout = 1000; options.OnStatusChange = (args) => { statusChanges.Add(args.Status); @@ -292,6 +330,20 @@ // Close the toast via the service — should raise dismissed then unmounted statuses var toast = ToastProvider.FindComponent(); await ToastService.CloseAsync(toast.Instance.Instance!, ToastCloseReason.Programmatic); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance!.Id, + Type = "beforetoggle", + OldState = "open", + NewState = "closed", + }); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance!.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); // Wait for the task var result = await toastTask; @@ -302,7 +354,7 @@ Assert.Equal(ToastCloseReason.Programmatic, result); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_OnStatusChange_NoDelegate() { // Act @@ -326,7 +378,7 @@ Assert.Equal(ToastStatus.Visible, args.Status); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_UpdateBody() { _ = ToastService.ShowToastAsync(options => @@ -344,85 +396,45 @@ Assert.Contains("Updated body", ToastProvider.Markup); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_ProviderRequired() - { - // Arrange - ToastProvider.Instance.UpdateId(null); - - // Act - var ex = await Assert.ThrowsAsync>(async () => - { - _ = await ToastService.ShowToastAsync(options => { options.Body = "Provider required"; }); - }); - - // Assert - Assert.Equal("FluentToastProvider needs to be added to the page/component hierarchy of your application/site. Usually this will be 'MainLayout' but depending on your setup it could be at a different location.", ex.Message); - } - [Fact] - public void FluentToast_ToastResult() - { - // Arrange - var result = ToastCloseReason.Programmatic; - - // Assert - Assert.Equal(ToastCloseReason.Programmatic, result); - } - - [Fact] - public void FluentToast_ToastCloseReason_Dismissed() + public void FluentToast_Options_Ctor() { // Arrange - var result = ToastCloseReason.Dismissed; + var options = new ToastOptions() { Data = "My data" }; + var optionsWithFactory = new ToastOptions(o => o.Id = "my-id"); // Assert - Assert.Equal(ToastCloseReason.Dismissed, result); + Assert.Equal("My data", options.Data); + Assert.Equal("my-id", optionsWithFactory.Id); } - [Fact] - public void FluentToast_ToastCloseReason_TimedOut() + [Theory] + [InlineData(ToastIntent.Info, Color.Info)] + [InlineData(ToastIntent.Success, Color.Success)] + [InlineData(ToastIntent.Warning, Color.Warning)] + [InlineData(ToastIntent.Error, Color.Error)] + public void FluentToast_Intent_MapsToExpectedColor(ToastIntent intent, Color expectedColor) { - // Arrange - var result = ToastCloseReason.TimedOut; + var color = FluentToast.GetIntentColor(intent); - // Assert - Assert.Equal(ToastCloseReason.TimedOut, result); + Assert.Equal(expectedColor, color); } - [Fact] - public void FluentToast_ToastCloseReason_QuickAction() + [Theory] + [InlineData(ToastIntent.Info, "Info")] + [InlineData(ToastIntent.Success, "CheckmarkCircle")] + [InlineData(ToastIntent.Warning, "Warning")] + [InlineData(ToastIntent.Error, "DismissCircle")] + public void FluentToast_IntentIcon_MapsToExpectedIcon(ToastIntent intent, string expectedIconTypeName) { - // Arrange - var result = ToastCloseReason.QuickAction; + var cut = Render(parameters => parameters.Add(p => p.Intent, intent)); + var intentIcon = cut.Instance.IntentIcon; - // Assert - Assert.Equal(ToastCloseReason.QuickAction, result); + Assert.NotNull(intentIcon); + Assert.Equal(expectedIconTypeName, intentIcon.GetType().Name); } [Fact] - public void FluentToast_ToastCloseReason_Programmatic() - { - // Arrange - var result = ToastCloseReason.Programmatic; - - // Assert - Assert.Equal(ToastCloseReason.Programmatic, result); - } - - [Fact] - public void FluentToast_Options_Ctor() - { - // Arrange - var options = new ToastOptions() { Data = "My data" }; - var optionsWithFactory = new ToastOptions(o => o.Id = "my-id"); - - // Assert - Assert.Equal("My data", options.Data); - Assert.Equal("my-id", optionsWithFactory.Id); - } - - [Fact(Timeout = TEST_TIMEOUT)] public async Task FluentToast_MarginPadding() { // Arrange @@ -449,18 +461,7 @@ Assert.Contains("color: red;", toastOptions.StyleValue); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_ProviderClassStyle() - { - // Assert: provider always adds "fluent-toast-provider" class and z-index style - Assert.Contains("fluent-toast-provider", ToastProvider.Markup); - Assert.Contains("z-index", ToastProvider.Markup); - - // Don't wait for the toast to be closed - await Task.CompletedTask; - } - - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] public async Task FluentToast_AdditionalAttributes() { // Act @@ -478,8 +479,8 @@ Assert.Contains("data-test=\"my-toast\"", ToastProvider.Markup); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_QuickActionCallback() + [Fact] + public async Task FluentToast_QuickAction1Callback() { var invoked = false; @@ -494,9 +495,47 @@ }; }); - await Task.CompletedTask; + ToastProvider.Find("fluent-link").Click(); + var toast = ToastProvider.FindComponent(); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance!.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + + var result = await toastTask; + + Assert.True(invoked); + Assert.Equal(ToastCloseReason.QuickAction, result); + } + + [Fact] + public async Task FluentToast_QuickAction2Callback() + { + var invoked = false; + + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Toast Content"; + options.QuickAction2 = "Undo"; + options.QuickAction2Callback = () => + { + invoked = true; + return Task.CompletedTask; + }; + }); - ToastProvider.Find("a").Click(); + ToastProvider.Find("fluent-link").Click(); + var toast = ToastProvider.FindComponent(); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance!.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); var result = await toastTask; @@ -505,41 +544,77 @@ } [Fact] - public async Task FluentToast_RemoveToast_NullInstance() + public async Task FluentToast_DismissById() { - // Arrange: cast service to concrete type to access the internal method - var service = (ToastService)ToastService; + var toastTask = ToastService.ShowToastAsync(options => + { + options.Id = "dismiss-me"; + options.Body = "Dismiss by id"; + }); - // Act: removing a null instance should return without error - await service.RemoveToastFromProviderAsync(null); + await Task.CompletedTask; - // Assert: no exception thrown - Assert.True(true); + var dismissed = await ToastService.DismissAsync("dismiss-me"); + var toast = ToastProvider.FindComponent(); + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance!.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + var result = await toastTask; + + Assert.True(dismissed); + Assert.Equal(ToastCloseReason.Dismissed, result); } - - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_Progress() + [Fact] + public async Task FluentToast_DismissAll() { - // Act - _ = ToastService.ShowToastAsync(options => + var firstToastTask = ToastService.ShowToastAsync(options => { - options.Title = "Uploading"; - options.Body = "Please wait while your files are uploaded."; - options.Status = "Uploading 3 files..."; - options.ShowProgress = true; - options.Indeterminate = true; - options.ShowDismissButton = false; + options.Id = "dismiss-all-1"; + options.Body = "First toast"; + }); + + var secondToastTask = ToastService.ShowToastAsync(options => + { + options.Id = "dismiss-all-2"; + options.Body = "Second toast"; }); - // Don't wait for the toast to be closed await Task.CompletedTask; - ToastProvider.Render(); + var dismissedCount = await ToastService.DismissAllAsync(); + var toasts = ToastProvider.FindComponents().ToList(); + foreach (var toast in toasts) + { + await toast.Instance.OnToggleAsync(new() + { + Id = toast.Instance.Instance!.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + } + + Assert.Equal(2, dismissedCount); + Assert.Equal(ToastCloseReason.Dismissed, await firstToastTask); + Assert.Equal(ToastCloseReason.Dismissed, await secondToastTask); + } - // Assert - Assert.Contains("Uploading", ToastProvider.Markup); - Assert.Contains("Uploading 3 files...", ToastProvider.Markup); + [Fact] + public async Task FluentToast_RemoveToast_NullInstance() + { + // Arrange: cast service to concrete type to access the internal method + var service = (ToastService)ToastService; + + // Act: removing a null instance should return without error + await service.RemoveToastFromProviderAsync(null); + + // Assert: no exception thrown + Assert.True(true); } } diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor new file mode 100644 index 0000000000..9323c1ebb3 --- /dev/null +++ b/tests/Core/Components/Toast/ToastInstanceTests.razor @@ -0,0 +1,102 @@ +@using System.Collections.Concurrent; +@using Bunit; +@using Microsoft.Extensions.DependencyInjection; +@using Xunit; + +@inherits FluentUITestContext + +@code { + + public ToastInstanceTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public async Task CancelAsync_CallsServiceCloseWithDismissedReason() + { + var service = new TestToastService(); + var instance = new ToastInstance(service, new ToastOptions()); + + await instance.CancelAsync(); + + Assert.Same(instance, service.LastClosedToast); + Assert.Equal(ToastCloseReason.Dismissed, service.LastCloseReason); + } + + [Fact] + public async Task CloseAsync_WithReason_CallsServiceCloseWithProvidedReason() + { + var service = new TestToastService(); + var instance = new ToastInstance(service, new ToastOptions()); + + await instance.CloseAsync(ToastCloseReason.QuickAction); + + Assert.Same(instance, service.LastClosedToast); + Assert.Equal(ToastCloseReason.QuickAction, service.LastCloseReason); + } + + [Fact] + public async Task CloseAsync_WithProgrammaticReason_CallsServiceCloseWithProgrammaticReason() + { + var service = new TestToastService(); + var instance = new ToastInstance(service, new ToastOptions()); + + await instance.CloseAsync(ToastCloseReason.Programmatic); + + Assert.Same(instance, service.LastClosedToast); + Assert.Equal(ToastCloseReason.Programmatic, service.LastCloseReason); + } + + [Fact] + public async Task CloseAsync_WithoutReason_CallsServiceCloseWithProgrammaticReason() + { + var service = new TestToastService(); + var instance = new ToastInstance(service, new ToastOptions()); + + await instance.CloseAsync(); + + Assert.Same(instance, service.LastClosedToast); + Assert.Equal(ToastCloseReason.Programmatic, service.LastCloseReason); + } + + private sealed class TestToastService : IToastService + { + public string? ProviderId { get; set; } + + public ConcurrentDictionary Items { get; } = new(); + + public Func OnUpdatedAsync { get; set; } = _ => Task.CompletedTask; + + public IToastInstance? LastClosedToast { get; private set; } + + public ToastCloseReason? LastCloseReason { get; private set; } + + public Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) + { + LastClosedToast = Toast; + LastCloseReason = reason; + return Task.CompletedTask; + } + + public Task DismissAsync(string toastId) + => Task.FromResult(false); + + public Task DismissAllAsync() + => Task.FromResult(0); + + public void Dispose() + { + } + + public Task ShowToastAsync(ToastOptions? options = null) + => Task.FromResult(ToastCloseReason.Programmatic); + + public Task ShowToastAsync(Action options) + => Task.FromResult(ToastCloseReason.Programmatic); + + public Task UpdateToastAsync(IToastInstance toast, Action update) + => Task.CompletedTask; + } +} diff --git a/tests/Core/Verify/FluentAssertOptions.cs b/tests/Core/Verify/FluentAssertOptions.cs index cf34d34657..4fbb161bfb 100644 --- a/tests/Core/Verify/FluentAssertOptions.cs +++ b/tests/Core/Verify/FluentAssertOptions.cs @@ -54,7 +54,9 @@ public string ScrubLinesWithReplace(string content) .ReplaceAttribute("blazor:onkeydown", "x") .ReplaceAttribute("blazor:onmousedown", "x") .ReplaceAttribute("blazor:onmouseup", "x") - .ReplaceAttribute("anchor", "xxx"); + .ReplaceAttribute("anchor", "xxx") + .ReplaceAttribute("aria-labelledby", "xxx") + .ReplaceAttribute("aria-describedby", "xxx"); } /// From c5b99d39323021727aa9965632b3d88c4755e423 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Wed, 25 Mar 2026 12:06:46 +0100 Subject: [PATCH 11/20] Add more tests, add xml comments, process Copilot review comments --- .../Toast/DebugPages/DebugToast.razor | 8 +- .../src/Components/Toast/FluentToast.ts | 14 +- .../Components/Toast/FluentToast.razor.cs | 146 +++++++++++-- .../Components/Toast/Services/ToastOptions.cs | 5 - .../Components/Toast/FluentToastTests.razor | 201 ++++++++++++++++++ .../Components/Toast/ToastInstanceTests.razor | 160 ++++++++++++++ 6 files changed, 513 insertions(+), 21 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index ae1f3e5a18..1a5e82ec32 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -83,7 +83,13 @@ options.ShowProgress = true; options.QuickAction1 = "Hide"; options.QuickAction2 = "Cancel"; - options.IsDismissable = false; + options.IsDismissable = true; + options.DismissAction = "Dismiss"; + options.DismissActionCallback = () => + { + Console.WriteLine("Toast dismissed."); + return Task.CompletedTask; + }; }); _lastResult = result.ToString() ?? string.Empty; diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index a55fd600af..a84ffe7725 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -483,15 +483,25 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { // Wait for the exit animation to complete await new Promise(resolve => { + let settled = false; const onAnimationEnd = (e: AnimationEvent) => { if (e.animationName === 'toast-collapse-spacing') { this.dialog.removeEventListener('animationend', onAnimationEnd); - resolve(true); + if (!settled) { + settled = true; + resolve(true); + } } }; this.dialog.addEventListener('animationend', onAnimationEnd); // Fallback in case animation doesn't fire - setTimeout(() => resolve(false), 650); + setTimeout(() => { + if (!settled) { + settled = true; + this.dialog.removeEventListener('animationend', onAnimationEnd); + resolve(false); + } + }, 650); }); this.dialog.hidePopover(); diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 271902d787..880718759c 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -7,8 +7,12 @@ namespace Microsoft.FluentUI.AspNetCore.Components; -#pragma warning disable CS1591, MA0051, MA0123, CA1822 - +/// +/// The FluentToast component represents a transient message that appears on the screen to provide feedback or +/// information to the user. It is typically used for displaying notifications, alerts, or status messages in a +/// non-intrusive manner. The FluentToast component can be customized with various options such as position, intent, +/// timeout duration, and actions, allowing developers to create engaging and informative user experiences. +/// public partial class FluentToast { /// @@ -17,42 +21,109 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) Id = Identifier.NewId(); } + [Inject] + private IToastService ToastService { get; set; } = default!; + + /// + /// Gets or sets the toast instance associated with this component. + /// [Parameter] public IToastInstance? Instance { get; set; } + /// + /// Gets or sets a value indicating whether the component is currently open. + /// [Parameter] public bool Opened { get; set; } + /// + /// Gets or sets the callback that is invoked when the open state changes. + /// + /// + /// Use this event to respond to changes in the component's open or closed state. The callback receives a value + /// indicating the new open state: if the component is open; otherwise, + /// . + /// [Parameter] public EventCallback OpenedChanged { get; set; } + /// + /// Gets or sets the duration in milliseconds before the toast automatically closes. Set this to less or equal to 0 + /// to disable automatic closing. + /// [Parameter] public int Timeout { get; set; } = 7000; + /// + /// Gets or sets the on the screen where the toast notification is displayed. + /// [Parameter] public ToastPosition? Position { get; set; } + /// + /// Gets or sets the vertical offset, in pixels, applied to the component's position. + /// [Parameter] public int VerticalOffset { get; set; } = 16; + /// + /// Gets or sets the horizontal offset, in pixels, applied to the component's content. + /// [Parameter] public int HorizontalOffset { get; set; } = 20; + /// + /// Gets or sets the intent of the toast notification, indicating its purpose or severity. + /// + /// + /// The intent determines the visual styling and icon used for the toast notification. Common intents include + /// informational, success, warning, and error. Setting the appropriate intent helps users quickly understand the + /// nature of the message. + /// [Parameter] public ToastIntent Intent { get; set; } = ToastIntent.Info; + /// + /// Gets or sets the level of notification politeness for assistive technologies. + /// + /// + /// Use this property to control how screen readers announce the toast notification. Setting an appropriate + /// politeness level can help ensure that important messages are delivered to users without unnecessary + /// interruption. + /// [Parameter] public ToastPoliteness? Politeness { get; set; } + /// + /// Gets or sets a value indicating whether the timeout countdown pauses when the user hovers over the component. + /// [Parameter] public bool PauseOnHover { get; set; } + /// + /// Gets or sets a value indicating whether the timeout countdown is paused when the browser window loses focus. + /// [Parameter] public bool PauseOnWindowBlur { get; set; } + /// + /// Gets or sets the callback that is invoked when the toggle state changes. + /// + /// + /// The callback receives a Boolean value indicating the new state of the toggle. Use this parameter to handle + /// toggle events in the parent component. + /// [Parameter] public EventCallback OnToggle { get; set; } + /// + /// Gets or sets the callback that is invoked when the toast status changes. + /// + /// + /// Use this property to handle status updates for the toast component, such as when it is shown, hidden, or + /// dismissed. The callback receives a instance containing details about the status + /// change. + /// [Parameter] public EventCallback OnStatusChange { get; set; } @@ -98,25 +169,48 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public string? DismissAction { get; set; } + /// + /// Gets or sets the current status value. + /// [Parameter] public string? Status { get; set; } + /// + /// Gets or sets a value indicating whether a progress indicator is displayed. + /// [Parameter] public bool ShowProgress { get; set; } + /// + /// Gets or sets the current value of the parameter. + /// [Parameter] public int? Value { get; set; } + /// + /// Gets or sets the maximum allowable value. + /// [Parameter] public int? Max { get; set; } = 100; + /// + /// Gets or sets the media content to render within the component. + /// + /// + /// Use this property to provide custom media elements, such as images, videos, or icons, that will be displayed as + /// part of the component's layout. + /// [Parameter] public RenderFragment? Media { get; set; } + /// + /// Gets or sets the content to be rendered as the main text of the toast. + /// [Parameter] public RenderFragment? ChildContent { get; set; } - internal Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); + // + internal static Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); internal Icon IntentIcon => Intent switch { @@ -130,15 +224,36 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) internal string? StyleValue => DefaultStyleBuilder.Build(); - [Inject] - private IToastService? ToastService { get; set; } - + /// + /// Raises the status change event asynchronously using the specified dialog toggle event arguments. + /// + /// The event data associated with the dialog toggle action. Cannot be null. + /// + /// A task that represents the asynchronous operation. The task result contains the event arguments for the toast + /// status change. + /// public Task RaiseOnStatusChangeAsync(DialogToggleEventArgs args) => RaiseOnStatusChangeAsync(new ToastEventArgs(this, args)); + /// + /// Raises the status change event for the specified toast instance asynchronously. + /// + /// + /// The toast instance for which the status change event is being raised. Cannot be null. + /// + /// The new status to associate with the toast instance. + /// + /// A task that represents the asynchronous operation. The task result contains the event arguments for the status + /// change. + /// public Task RaiseOnStatusChangeAsync(IToastInstance instance, ToastStatus status) => RaiseOnStatusChangeAsync(new ToastEventArgs(instance, status)); + /// + /// Raises the toggle event asynchronously using the specified dialog toggle event arguments. + /// + /// The event data associated with the dialog toggle action. Cannot be null. + /// A task that represents the asynchronous operation. public Task OnToggleAsync(DialogToggleEventArgs args) => HandleToggleAsync(args); @@ -153,16 +268,23 @@ internal Task RequestCloseAsync() return InvokeAsync(StateHasChanged); } - internal Task OnDismissActionClickedAsync() - => HandleActionClickedAsync(Instance?.Options.DismissActionCallback); + internal async Task OnDismissActionClickedAsync() + { + await Instance!.CloseAsync(ToastCloseReason.Dismissed); + + if (Instance?.Options.DismissActionCallback is not null) + { + await Instance.Options.DismissActionCallback(); + } + } internal Task OnQuickAction1ClickedAsync() - => HandleActionClickedAsync(Instance?.Options.QuickAction1Callback); + => HandleQuickActionClickedAsync(Instance?.Options.QuickAction1Callback); internal Task OnQuickAction2ClickedAsync() - => HandleActionClickedAsync(Instance?.Options.QuickAction2Callback); + => HandleQuickActionClickedAsync(Instance?.Options.QuickAction2Callback); - private async Task HandleActionClickedAsync(Func? callback) + private async Task HandleQuickActionClickedAsync(Func? callback) { await Instance!.CloseAsync(ToastCloseReason.QuickAction); @@ -262,5 +384,3 @@ private async Task RaiseOnStatusChangeAsync(ToastEventArgs args) return args; } } - -#pragma warning restore CS1591, MA0051, MA0123, CA1822 diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 6d221e2e26..013baa9523 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -182,11 +182,6 @@ public ToastOptions(Action implementationFactory) /// public RenderFragment? Media { get; set; } - /// - /// Gets or sets custom content rendered in the footer slot. - /// - public RenderFragment? FooterContent { get; set; } - /// /// Gets or sets custom content rendered in the default slot. /// diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index e49668cc61..47b0754a8e 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -115,6 +115,160 @@ Assert.Contains("fluent-button", cut.Markup); } + [Fact] + public async Task FluentToast_DismissButton_OnClick_DismissesToast() + { + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Dismiss button body"; + options.IsDismissable = true; + }); + + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + var toastInstance = Assert.IsType(toast.Instance.Instance); + + toast.Find("fluent-button").Click(); + + Assert.Equal(ToastCloseReason.Dismissed, toastInstance.PendingCloseReason); + + await toast.Instance.OnToggleAsync(new() + { + Id = toastInstance.Id, + Type = "beforetoggle", + OldState = "open", + NewState = "closed", + }); + await toast.Instance.OnToggleAsync(new() + { + Id = toastInstance.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + + var result = await toastTask; + + Assert.Equal(ToastCloseReason.Dismissed, result); + } + + [Fact] + public async Task FluentToast_DismissAction_InvokesDismissActionCallback() + { + var dismissActionCallbackInvoked = false; + + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Dismiss callback body"; + options.IsDismissable = true; + options.DismissAction = "Dismiss now"; + options.DismissActionCallback = () => + { + dismissActionCallbackInvoked = true; + return Task.CompletedTask; + }; + }); + + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + var toastInstance = Assert.IsType(toast.Instance.Instance); + + Assert.True(toast.Instance.IsDismissable); + Assert.Equal("Dismiss now", toast.Instance.DismissAction); + + toast.Find("fluent-link").Click(); + + Assert.True(dismissActionCallbackInvoked); + Assert.Equal(ToastCloseReason.Dismissed, toastInstance.PendingCloseReason); + + await toast.Instance.OnToggleAsync(new() + { + Id = toastInstance.Id, + Type = "beforetoggle", + OldState = "open", + NewState = "closed", + }); + await toast.Instance.OnToggleAsync(new() + { + Id = toastInstance.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + + var result = await toastTask; + + Assert.Equal(ToastCloseReason.Dismissed, result); + } + + [Fact] + public async Task FluentToast_OnToggle_CallbackInvoked() + { + bool? callbackValue = null; + + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "OnToggle body"; + }); + + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + var component = toast.Instance; + var toastId = component.Instance!.Id; + typeof(FluentToast).GetProperty(nameof(FluentToast.OnToggle))! + .SetValue(component, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, value => callbackValue = value)); + + await component.OnToggleAsync(new() + { + Id = toastId, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + + var result = await toastTask; + + Assert.False(callbackValue); + Assert.False(component.Opened); + Assert.Equal(ToastCloseReason.TimedOut, result); + } + + [Fact] + public async Task FluentToast_OpenedChanged_CallbackInvoked() + { + bool? callbackValue = null; + + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "OpenedChanged body"; + }); + + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + var component = toast.Instance; + var toastId = component.Instance!.Id; + typeof(FluentToast).GetProperty(nameof(FluentToast.OpenedChanged))! + .SetValue(component, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, value => callbackValue = value)); + + await component.OnToggleAsync(new() + { + Id = toastId, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + + var result = await toastTask; + + Assert.False(callbackValue); + Assert.False(component.Opened); + Assert.Equal(ToastCloseReason.TimedOut, result); + } + [Fact] public async Task FluentToast_OpenClose() { @@ -308,6 +462,32 @@ Assert.Contains("Toast Content", ToastProvider.Markup); } + [Fact] + public async Task FluentToast_HandleToggleAsync_WhenInstanceIsNotToastInstance_DoesNothing() + { + var onToggleInvoked = false; + var openedChangedInvoked = false; + var fakeInstance = new TestNonToastInstance("non-toast-instance"); + + var cut = Render(parameters => parameters + .Add(p => p.Instance, fakeInstance) + .Add(p => p.Opened, true) + .Add(p => p.OnToggle, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, _ => onToggleInvoked = true)) + .Add(p => p.OpenedChanged, Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this, _ => openedChangedInvoked = true))); + + await cut.Instance.OnToggleAsync(new() + { + Id = fakeInstance.Id, + Type = "toggle", + OldState = "open", + NewState = "closed", + }); + + Assert.True(cut.Instance.Opened); + Assert.False(onToggleInvoked); + Assert.False(openedChangedInvoked); + } + [Fact] public async Task FluentToast_StateChange_ViaClosed() { @@ -616,5 +796,26 @@ // Assert: no exception thrown Assert.True(true); } + + private sealed class TestNonToastInstance(string id) : IToastInstance + { + public string Id { get; } = id; + + public long Index => 0; + + public ToastOptions Options { get; } = new(); + + public Task Result => Task.FromResult(ToastCloseReason.Programmatic); + + public ToastStatus Status => ToastStatus.Visible; + + public Task CancelAsync() => Task.CompletedTask; + + public Task CloseAsync() => Task.CompletedTask; + + public Task CloseAsync(ToastCloseReason reason) => Task.CompletedTask; + + public Task UpdateAsync(Action update) => Task.CompletedTask; + } } diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor index 9323c1ebb3..970027cb9e 100644 --- a/tests/Core/Components/Toast/ToastInstanceTests.razor +++ b/tests/Core/Components/Toast/ToastInstanceTests.razor @@ -61,6 +61,121 @@ Assert.Equal(ToastCloseReason.Programmatic, service.LastCloseReason); } + [Fact] + public void ToastService_UsesProvidedLocalizer() + { + var localizer = new TestLocalizer(); + var service = new TestableToastService(Services, localizer); + + var value = service.GetLocalized("Toast_TestKey"); + + Assert.Equal("Custom localized value", value); + } + + [Fact] + public void ToastService_UsesDefaultLocalizer_WhenNoLocalizerProvided() + { + var service = new TestableToastService(Services, null); + + var value = service.GetLocalized("Fake_Hello", "Denis"); + + Assert.Equal("Hello Denis", value); + } + + [Fact] + public async Task ToastService_CloseAsync_WhenToastInstanceHasNoFluentToast_RemovesToastAndRaisesUnmounted() + { + var updates = new List(); + ToastEventArgs? statusChange = null; + var options = new ToastOptions + { + OnStatusChange = args => statusChange = args, + }; + var service = new TestableToastService(Services, null); + var instance = new ToastInstance(service, options); + + service.AddItem(instance); + service.SetOnUpdatedAsync(toast => + { + updates.Add(toast); + return Task.CompletedTask; + }); + + await service.CloseAsync(instance, ToastCloseReason.Programmatic); + + var result = await instance.Result; + + Assert.Equal(ToastStatus.Unmounted, instance.Status); + Assert.Equal(ToastCloseReason.Programmatic, result); + Assert.False(service.ContainsItem(instance.Id)); + Assert.Single(updates); + Assert.Same(instance, updates[0]); + Assert.NotNull(statusChange); + Assert.Equal(ToastStatus.Unmounted, statusChange.Status); + Assert.Same(instance, statusChange.Instance); + } + + [Fact] + public async Task ToastService_CloseAsync_WhenToastIsNotToastInstance_RemovesToastFromProvider() + { + var service = new TestableToastService(Services, null); + var instance = new TestNonToastInstance("non-toast"); + IToastInstance? updatedToast = null; + + service.AddItem(instance); + service.SetOnUpdatedAsync(toast => + { + updatedToast = toast; + return Task.CompletedTask; + }); + + await service.CloseAsync(instance, ToastCloseReason.Dismissed); + + Assert.False(service.ContainsItem(instance.Id)); + Assert.Same(instance, updatedToast); + } + + [Fact] + public async Task ToastService_DismissAsync_WhenToastIdIsNull_ReturnsFalse() + { + var service = new TestableToastService(Services, null); + + var result = await service.DismissAsync(null!); + + Assert.False(result); + } + + [Fact] + public async Task ToastService_UpdateToastAsync_WhenToastIsNotToastInstance_ThrowsArgumentException() + { + var service = new TestableToastService(Services, null); + var toast = new TestNonToastInstance("non-toast"); + + var exception = await Assert.ThrowsAsync(() => service.UpdateToastAsync(toast, options => options.Body = "Updated")); + + Assert.Equal("toast", exception.ParamName); + Assert.Contains("must be a ToastInstance", exception.Message); + } + + [Fact] + public async Task ToastService_ShowToastAsync_WhenProviderNotAvailable_ThrowsFluentServiceProviderException() + { + var service = new TestableToastService(Services, null); + + await Assert.ThrowsAsync>(() => service.ShowToastAsync()); + } + + [Fact] + public async Task ToastService_RemoveToastFromProviderAsync_WhenToastIdDoesNotExist_ThrowsInvalidOperationException() + { + var service = new TestableToastService(Services, null); + var toast = new TestNonToastInstance("missing-toast"); + + var exception = await Assert.ThrowsAsync(() => service.RemoveToastFromProviderAsync(toast)); + + Assert.Equal("Failed to remove Toast from ToastProvider: the ID 'missing-toast' doesn't exist in the ToastServiceProvider.", exception.Message); + } + private sealed class TestToastService : IToastService { public string? ProviderId { get; set; } @@ -99,4 +214,49 @@ public Task UpdateToastAsync(IToastInstance toast, Action update) => Task.CompletedTask; } + + private sealed class TestableToastService(IServiceProvider serviceProvider, IFluentLocalizer? localizer) + : ToastService(serviceProvider, localizer) + { + public string GetLocalized(string key, params object[] arguments) + => Localizer[key, arguments]; + + public void AddItem(IToastInstance toast) + => ServiceProvider.Items.TryAdd(toast.Id, toast); + + public bool ContainsItem(string id) + => ServiceProvider.Items.ContainsKey(id); + + public void SetOnUpdatedAsync(Func onUpdatedAsync) + => ServiceProvider.OnUpdatedAsync = onUpdatedAsync; + + public new Task RemoveToastFromProviderAsync(IToastInstance? toast) + => base.RemoveToastFromProviderAsync(toast); + } + + private sealed class TestLocalizer : IFluentLocalizer + { + public string this[string key, params object[] arguments] => "Custom localized value"; + } + + private sealed class TestNonToastInstance(string id) : IToastInstance + { + public string Id { get; } = id; + + public long Index => 0; + + public ToastOptions Options { get; } = new(); + + public Task Result => Task.FromResult(ToastCloseReason.Programmatic); + + public ToastStatus Status => ToastStatus.Visible; + + public Task CancelAsync() => Task.CompletedTask; + + public Task CloseAsync() => Task.CompletedTask; + + public Task CloseAsync(ToastCloseReason reason) => Task.CompletedTask; + + public Task UpdateAsync(Action update) => Task.CompletedTask; + } } From efda6fcdc6d1869366c495b65494a8964be423bb Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Wed, 25 Mar 2026 16:56:54 +0100 Subject: [PATCH 12/20] Rename and remove parameters, adjust tests --- .../Toast/DebugPages/DebugToast.razor | 15 +++-- .../Components/Toast/FluentProgressToast.cs | 14 ----- src/Core/Components/Toast/FluentToast.razor | 8 ++- .../Components/Toast/FluentToast.razor.cs | 51 +++++---------- .../Toast/FluentToastProvider.razor | 7 +-- .../Toast/FluentToastProvider.razor.cs | 10 +-- .../Toast/Services/IToastInstance.cs | 2 +- .../Toast/Services/ToastEventArgs.cs | 10 +-- .../Toast/Services/ToastInstance.cs | 4 +- .../Components/Toast/Services/ToastOptions.cs | 50 ++++++--------- .../Components/Toast/Services/ToastService.cs | 8 +-- ...ToastStatus.cs => ToastLifecycleStatus.cs} | 2 +- src/Core/Enums/ToastType.cs | 31 ++++++++++ .../Toast/FluentToastProviderTests.razor | 10 +-- .../Components/Toast/FluentToastTests.razor | 62 ++++++++++++++----- .../Components/Toast/ToastInstanceTests.razor | 6 +- 16 files changed, 154 insertions(+), 136 deletions(-) delete mode 100644 src/Core/Components/Toast/FluentProgressToast.cs rename src/Core/Enums/{ToastStatus.cs => ToastLifecycleStatus.cs} (95%) create mode 100644 src/Core/Enums/ToastType.cs diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor index 1a5e82ec32..b1acc327ef 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor @@ -73,14 +73,16 @@ private async Task OpenProgressToastAsync() { + RenderFragment childContent = @
Uploading 3 files...
; + var result = await ToastService.ShowToastAsync(options => { options.Timeout = 0; options.Intent = ToastIntent.Info; options.Title = "Uploading"; options.Body = "Please wait while your files are uploaded."; - options.Status = "Uploading 3 files..."; - options.ShowProgress = true; + options.Type = ToastType.IndeterminateProgress; + options.ChildContent = childContent; options.QuickAction1 = "Hide"; options.QuickAction2 = "Cancel"; options.IsDismissable = true; @@ -97,14 +99,19 @@ private async Task OpenProgressToast2Async() { + RenderFragment childContent = @
+
Uploading 3 files...
+ +
; + var result = await ToastService.ShowToastAsync(options => { options.Timeout = 0; options.Intent = ToastIntent.Info; options.Title = "Uploading"; options.Body = "Please wait while your files are uploaded."; - options.Status = "Uploading 3 files..."; - options.ShowProgress = true; + options.Type = ToastType.DeterminateProgress; + options.ChildContent = childContent; options.QuickAction1 = "Hide"; options.QuickAction2 = "Cancel"; options.IsDismissable = false; diff --git a/src/Core/Components/Toast/FluentProgressToast.cs b/src/Core/Components/Toast/FluentProgressToast.cs deleted file mode 100644 index 0c6a3b4fa0..0000000000 --- a/src/Core/Components/Toast/FluentProgressToast.cs +++ /dev/null @@ -1,14 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -namespace Microsoft.FluentUI.AspNetCore.Components; - -#pragma warning disable CS1591 - -[Obsolete($"Use {nameof(FluentToast)} with {nameof(FluentToast.ShowProgress)} instead.", error: false)] -public sealed class FluentProgressToast -{ -} - -#pragma warning restore CS1591 diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 67f65bab98..9fb855a70a 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -20,11 +20,13 @@ @attributes="@AdditionalAttributes" @ondialogtoggle="@OnToggleAsync" @ondialogbeforetoggle="@OnToggleAsync"> - @if (Media is not null) + @if (Icon is not null) { - @Media + } - else if (ShowProgress) + else if (Type == ToastType.IndeterminateProgress) {
diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index 880718759c..c419c6347b 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -72,6 +72,12 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public int HorizontalOffset { get; set; } = 20; + /// + /// Gets or sets the type of toast notification to display. + /// + [Parameter] + public ToastType Type { get; set; } = ToastType.Communication; + /// /// Gets or sets the intent of the toast notification, indicating its purpose or severity. /// @@ -170,41 +176,14 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) public string? DismissAction { get; set; } /// - /// Gets or sets the current status value. - /// - [Parameter] - public string? Status { get; set; } - - /// - /// Gets or sets a value indicating whether a progress indicator is displayed. - /// - [Parameter] - public bool ShowProgress { get; set; } - - /// - /// Gets or sets the current value of the parameter. - /// - [Parameter] - public int? Value { get; set; } - - /// - /// Gets or sets the maximum allowable value. + /// Gets or sets the icon rendered in the media slot of the toast. /// [Parameter] - public int? Max { get; set; } = 100; - - /// - /// Gets or sets the media content to render within the component. - /// - /// - /// Use this property to provide custom media elements, such as images, videos, or icons, that will be displayed as - /// part of the component's layout. - /// - [Parameter] - public RenderFragment? Media { get; set; } + public Icon? Icon { get; set; } /// - /// Gets or sets the content to be rendered as the main text of the toast. + /// Gets or sets custom content rendered in the toast body, such as progress content managed through + /// . /// [Parameter] public RenderFragment? ChildContent { get; set; } @@ -246,7 +225,7 @@ public Task RaiseOnStatusChangeAsync(DialogToggleEventArgs args) /// A task that represents the asynchronous operation. The task result contains the event arguments for the status /// change. /// - public Task RaiseOnStatusChangeAsync(IToastInstance instance, ToastStatus status) + public Task RaiseOnStatusChangeAsync(IToastInstance instance, ToastLifecycleStatus status) => RaiseOnStatusChangeAsync(new ToastEventArgs(instance, status)); /// @@ -336,9 +315,9 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) } var toastEventArgs = new ToastEventArgs(this, args); - if (toastEventArgs.Status == ToastStatus.Dismissed) + if (toastEventArgs.Status == ToastLifecycleStatus.Dismissed) { - toastInstance.Status = ToastStatus.Dismissed; + toastInstance.LifecycleStatus = ToastLifecycleStatus.Dismissed; await RaiseOnStatusChangeAsync(toastEventArgs); } @@ -363,14 +342,14 @@ private async Task HandleToggleAsync(DialogToggleEventArgs args) { toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingCloseReason ?? ToastCloseReason.TimedOut); toastInstance.PendingCloseReason = null; - toastInstance.Status = ToastStatus.Unmounted; + toastInstance.LifecycleStatus = ToastLifecycleStatus.Unmounted; if (ToastService is ToastService toastService) { await toastService.RemoveToastFromProviderAsync(Instance); } - await RaiseOnStatusChangeAsync(toastInstance, ToastStatus.Unmounted); + await RaiseOnStatusChangeAsync(toastInstance, ToastLifecycleStatus.Unmounted); } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 90b2905f5c..0544c7f3cb 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -18,6 +18,7 @@ Position="@GetPosition(toast)" VerticalOffset="@GetVerticalOffset(toast)" HorizontalOffset="@GetHorizontalOffset(toast)" + Type="@toast.Options.Type" Intent="@toast.Options.Intent" Politeness="@toast.Options.Politeness" PauseOnHover="@GetPauseOnHover(toast)" @@ -28,15 +29,11 @@ Title="@toast.Options.Title" Body="@toast.Options.Body" Subtitle="@toast.Options.Subtitle" - Status="@toast.Options.Status" QuickAction1="@toast.Options.QuickAction1" QuickAction2="@toast.Options.QuickAction2" IsDismissable="@toast.Options.IsDismissable" DismissAction="@toast.Options.DismissAction" - ShowProgress="@toast.Options.ShowProgress" - Value="@toast.Options.Value" - Max="@toast.Options.Max" - Media="@toast.Options.Media" + Icon="@toast.Options.Icon" ChildContent="@toast.Options.ChildContent" /> } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 10e7162597..fbf4cef758 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -127,7 +127,7 @@ private bool GetPauseOnWindowBlur(IToastInstance toast) private IEnumerable GetRenderedToasts() => ToastService?.Items.Values - .Where(toast => toast.Status is ToastStatus.Visible or ToastStatus.Dismissed) + .Where(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed) .OrderByDescending(toast => toast.Index) ?? Enumerable.Empty(); @@ -139,9 +139,9 @@ private void SynchronizeToastQueue() } var maxToastCount = MaxToastCount <= 0 ? _defaultMaxToastCount : MaxToastCount; - var activeCount = ToastService.Items.Values.Count(toast => toast.Status is ToastStatus.Visible or ToastStatus.Dismissed); + var activeCount = ToastService.Items.Values.Count(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed); var queuedToasts = ToastService.Items.Values - .Where(toast => toast.Status == ToastStatus.Queued) + .Where(toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued) .OrderBy(toast => toast.Index) .ToList(); @@ -154,8 +154,8 @@ private void SynchronizeToastQueue() if (toast is ToastInstance instance) { - instance.Status = ToastStatus.Visible; - toast.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastStatus.Visible)); + instance.LifecycleStatus = ToastLifecycleStatus.Visible; + toast.Options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Visible)); activeCount++; } } diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index 5c429d4ab8..8034bab6ef 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -33,7 +33,7 @@ public interface IToastInstance /// /// Gets the lifecycle status of the toast. /// - ToastStatus Status { get; } + ToastLifecycleStatus LifecycleStatus { get; } /// /// Closes the Toast as dismissed. diff --git a/src/Core/Components/Toast/Services/ToastEventArgs.cs b/src/Core/Components/Toast/Services/ToastEventArgs.cs index 31d0af0a95..744d395dc9 100644 --- a/src/Core/Components/Toast/Services/ToastEventArgs.cs +++ b/src/Core/Components/Toast/Services/ToastEventArgs.cs @@ -20,26 +20,26 @@ internal ToastEventArgs(FluentToast toast, string? id, string? eventType, string { Id = id ?? string.Empty; Instance = toast.Instance; - Status = ToastStatus.Queued; + Status = ToastLifecycleStatus.Queued; if (string.Equals(eventType, "toggle", StringComparison.OrdinalIgnoreCase)) { if (string.Equals(newState, "open", StringComparison.OrdinalIgnoreCase)) { - Status = ToastStatus.Visible; + Status = ToastLifecycleStatus.Visible; } } else if (string.Equals(eventType, "beforetoggle", StringComparison.OrdinalIgnoreCase)) { if (string.Equals(oldState, "open", StringComparison.OrdinalIgnoreCase)) { - Status = ToastStatus.Dismissed; + Status = ToastLifecycleStatus.Dismissed; } } } /// - internal ToastEventArgs(IToastInstance instance, ToastStatus status) + internal ToastEventArgs(IToastInstance instance, ToastLifecycleStatus status) { Id = instance.Id; Instance = instance; @@ -54,7 +54,7 @@ internal ToastEventArgs(IToastInstance instance, ToastStatus status) /// /// Gets the lifecycle status of the FluentToast component. /// - public ToastStatus Status { get; } + public ToastLifecycleStatus Status { get; } /// /// Gets the instance used by the . diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 732543927b..68dcf10505 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -38,8 +38,8 @@ internal ToastInstance(IToastService toastService, ToastOptions options) /// public Task Result => ResultCompletion.Task; - /// - public ToastStatus Status { get; internal set; } = ToastStatus.Queued; + /// + public ToastLifecycleStatus LifecycleStatus { get; internal set; } = ToastLifecycleStatus.Queued; /// " public string Id { get; } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 013baa9523..824134f11f 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -20,8 +20,7 @@ public ToastOptions() } /// - /// Initializes a new instance of the class - /// using the specified implementation factory. + /// Initializes a new instance of the class using the specified implementation factory. /// /// public ToastOptions(Action implementationFactory) @@ -35,25 +34,26 @@ public ToastOptions(Action implementationFactory) public string? Id { get; set; } /// - /// Gets or sets the CSS class names. - /// If given, these will be included in the class attribute of the `fluent-Toast` or `fluent-drawer` element. - /// To apply you styles to the `Toast` element, you need to create a class like `my-class::part(Toast) { ... }` + /// Gets or sets the CSS class names. If given, these will be included in the class attribute of the `fluent-Toast` + /// or `fluent-drawer` element. To apply you styles to the `Toast` element, you need to create a class like + /// `my-class::part(Toast) { ... }` /// public string? Class { get; set; } /// - /// Gets or sets the in-line styles. - /// If given, these will be included in the style attribute of the `Toast` element. + /// Gets or sets the in-line styles. If given, these will be included in the style attribute of the `Toast` element. /// public string? Style { get; set; } /// - /// Gets or sets the component CSS margin property. + /// Gets or sets the component CSS margin + /// property. /// public string? Margin { get; set; } /// - /// Gets or sets the component CSS padding property. + /// Gets or sets the component CSS padding + /// property. /// public string? Padding { get; set; } @@ -87,6 +87,11 @@ public ToastOptions(Action implementationFactory) /// public int? HorizontalOffset { get; set; } + /// + /// Gets or sets the toast type, which determines things like a default icon and styling of the toast. + /// + public ToastType Type { get; set; } + /// /// Gets or sets the toast intent. /// @@ -122,11 +127,6 @@ public ToastOptions(Action implementationFactory) /// public string? Subtitle { get; set; } - /// - /// Gets or sets the status text shown for progress toasts. - /// - public string? Status { get; set; } - /// /// Gets or sets the first quick action label. /// @@ -163,27 +163,13 @@ public ToastOptions(Action implementationFactory) public Func? DismissActionCallback { get; set; } /// - /// Gets or sets a value indicating whether progress visuals are shown. - /// - public bool ShowProgress { get; set; } - - /// - /// Gets or sets the current progress value. - /// - public int? Value { get; set; } - - /// - /// Gets or sets the maximum progress value. - /// - public int? Max { get; set; } = 100; - - /// - /// Gets or sets custom media rendered in the media slot. + /// Gets or sets the icon rendered in the media slot. /// - public RenderFragment? Media { get; set; } + public Icon? Icon { get; set; } /// - /// Gets or sets custom content rendered in the default slot. + /// Gets or sets custom content rendered in the default slot, such as progress content updated through + /// . /// public RenderFragment? ChildContent { get; set; } diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index 2181e62fc3..e450361466 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -44,7 +44,7 @@ public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) if (ToastInstance is not null) { - ToastInstance.Status = ToastStatus.Unmounted; + ToastInstance.LifecycleStatus = ToastLifecycleStatus.Unmounted; } // Remove the Toast from the ToastProvider @@ -53,10 +53,10 @@ public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) // Set the result of the Toast ToastInstance?.ResultCompletion.TrySetResult(reason); - // Raise the final ToastStatus.Unmounted event + // Raise the final ToastLifecycleStatus.Unmounted event if (ToastInstance is not null) { - ToastInstance.Options.OnStatusChange?.Invoke(new ToastEventArgs(ToastInstance, ToastStatus.Unmounted)); + ToastInstance.Options.OnStatusChange?.Invoke(new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Unmounted)); } } @@ -118,7 +118,7 @@ private async Task ShowToastCoreAsync(ToastOptions options) } var instance = new ToastInstance(this, options); - options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastStatus.Queued)); + options.OnStatusChange?.Invoke(new ToastEventArgs(instance, ToastLifecycleStatus.Queued)); // Add the Toast to the service, and render it. ServiceProvider.Items.TryAdd(instance?.Id ?? "", instance ?? throw new InvalidOperationException("Failed to create FluentToast.")); diff --git a/src/Core/Enums/ToastStatus.cs b/src/Core/Enums/ToastLifecycleStatus.cs similarity index 95% rename from src/Core/Enums/ToastStatus.cs rename to src/Core/Enums/ToastLifecycleStatus.cs index 89bd77c10c..48b9a260a8 100644 --- a/src/Core/Enums/ToastStatus.cs +++ b/src/Core/Enums/ToastLifecycleStatus.cs @@ -7,7 +7,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// /// Describes the current lifecycle status of a toast. /// -public enum ToastStatus +public enum ToastLifecycleStatus { /// /// The toast has been queued for display. diff --git a/src/Core/Enums/ToastType.cs b/src/Core/Enums/ToastType.cs new file mode 100644 index 0000000000..51150648db --- /dev/null +++ b/src/Core/Enums/ToastType.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Describes the type of toast. +/// +public enum ToastType +{ + /// + /// A confirmation toast. + /// + Confirmation, + + /// + /// A communication toast. + /// + Communication, + + /// + /// A determinate progress toast. + /// + DeterminateProgress, + + /// + /// An indeterminate progress toast. + /// + IndeterminateProgress, +} diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index 11e8e7f559..4b2db32c8f 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -100,7 +100,7 @@ public async Task FluentToast_QueuedUntilProviderHasRoom() { var provider = Render(parameters => parameters.Add(p => p.MaxToastCount, 1)); - var statuses = new List(); + var statuses = new List(); var firstToastTask = ToastService.ShowToastAsync(options => { @@ -117,7 +117,7 @@ Assert.Contains("First toast", provider.Markup); Assert.DoesNotContain("Second toast", provider.Markup); - Assert.Contains(ToastStatus.Queued, statuses); + Assert.Contains(ToastLifecycleStatus.Queued, statuses); var firstToast = provider.FindComponent(); await CloseToastAndWaitAsync(firstToast, ToastCloseReason.Programmatic); @@ -125,7 +125,7 @@ await Task.CompletedTask; Assert.Contains("Second toast", provider.Markup); - Assert.Contains(ToastStatus.Visible, statuses); + Assert.Contains(ToastLifecycleStatus.Visible, statuses); var secondToast = provider.FindComponent(); await CloseToastAndWaitAsync(secondToast, ToastCloseReason.Programmatic); @@ -169,8 +169,8 @@ var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; method.Invoke(provider.Instance, null); - Assert.Equal(ToastStatus.Visible, firstToast.Status); - Assert.Equal(ToastStatus.Queued, secondToast.Status); + Assert.Equal(ToastLifecycleStatus.Visible, firstToast.LifecycleStatus); + Assert.Equal(ToastLifecycleStatus.Queued, secondToast.LifecycleStatus); } private sealed class TestFluentToastProvider : FluentToastProvider diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 47b0754a8e..8be6323c39 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -73,22 +73,46 @@ } [Fact] - public void FluentToast_Media_RendersCustomContent() + public void FluentToast_Icon_RendersCustomIcon() { - RenderFragment media = @
Custom media
; - var cut = Render(@); + var icon = new CoreIcons.Regular.Size20.Dismiss(); + var cut = Render(parameters => parameters + .Add(p => p.Body, "Toast body") + .Add(p => p.Icon, icon)); - Assert.Contains("Custom media", cut.Markup); + Assert.Same(icon, cut.Instance.Icon); + Assert.Contains("slot=\"media\"", cut.Markup); + Assert.DoesNotContain("fluent-spinner", cut.Markup); } [Fact] - public void FluentToast_ShowProgress_RendersSpinner() + public void FluentToast_IndeterminateProgress_RendersSpinner() { - var cut = Render(@); + var cut = Render(parameters => parameters + .Add(p => p.Body, "Toast body") + .Add(p => p.Type, ToastType.IndeterminateProgress)); + Assert.Equal(ToastType.IndeterminateProgress, cut.Instance.Type); Assert.Contains("fluent-spinner", cut.Markup); } + [Fact] + public async Task FluentToast_ToastOptionsType_IsAppliedFromProvider() + { + _ = ToastService.ShowToastAsync(options => + { + options.Body = "Progress body"; + options.Type = ToastType.IndeterminateProgress; + }); + + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + + Assert.Equal(ToastType.IndeterminateProgress, toast.Instance.Type); + Assert.Contains("fluent-spinner", toast.Markup); + } + [Fact] public void FluentToast_Subtitle_RendersWhenNotEmpty() { @@ -400,9 +424,9 @@ } [Theory] - [InlineData(ToastStatus.Visible, "toggle", "any-old", "open")] - [InlineData(ToastStatus.Dismissed, "beforetoggle", "open", "any-new")] - public async Task FluentToast_Toggle_StatusChange(ToastStatus expectedStatus, string eventType, string oldState, string newState) + [InlineData(ToastLifecycleStatus.Visible, "toggle", "any-old", "open")] + [InlineData(ToastLifecycleStatus.Dismissed, "beforetoggle", "open", "any-new")] + public async Task FluentToast_Toggle_StatusChange(ToastLifecycleStatus expectedStatus, string eventType, string oldState, string newState) { ToastEventArgs? capturedArgs = null; @@ -491,7 +515,7 @@ [Fact] public async Task FluentToast_StateChange_ViaClosed() { - var statusChanges = new List(); + var statusChanges = new List(); // Act var toastTask = ToastService.ShowToastAsync(options => @@ -529,8 +553,8 @@ var result = await toastTask; // Assert - Assert.Contains(ToastStatus.Dismissed, statusChanges); - Assert.Contains(ToastStatus.Unmounted, statusChanges); + Assert.Contains(ToastLifecycleStatus.Dismissed, statusChanges); + Assert.Contains(ToastLifecycleStatus.Unmounted, statusChanges); Assert.Equal(ToastCloseReason.Programmatic, result); } @@ -555,7 +579,7 @@ }); // Assert: status correctly mapped, no exception thrown - Assert.Equal(ToastStatus.Visible, args.Status); + Assert.Equal(ToastLifecycleStatus.Visible, args.Status); } [Fact] @@ -580,12 +604,18 @@ public void FluentToast_Options_Ctor() { // Arrange - var options = new ToastOptions() { Data = "My data" }; - var optionsWithFactory = new ToastOptions(o => o.Id = "my-id"); + var options = new ToastOptions() { Data = "My data", Type = ToastType.Communication }; + var optionsWithFactory = new ToastOptions(o => + { + o.Id = "my-id"; + o.Type = ToastType.Confirmation; + }); // Assert Assert.Equal("My data", options.Data); + Assert.Equal(ToastType.Communication, options.Type); Assert.Equal("my-id", optionsWithFactory.Id); + Assert.Equal(ToastType.Confirmation, optionsWithFactory.Type); } [Theory] @@ -807,7 +837,7 @@ public Task Result => Task.FromResult(ToastCloseReason.Programmatic); - public ToastStatus Status => ToastStatus.Visible; + public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; public Task CancelAsync() => Task.CompletedTask; diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor index 970027cb9e..6a5387adfe 100644 --- a/tests/Core/Components/Toast/ToastInstanceTests.razor +++ b/tests/Core/Components/Toast/ToastInstanceTests.razor @@ -105,13 +105,13 @@ var result = await instance.Result; - Assert.Equal(ToastStatus.Unmounted, instance.Status); + Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); Assert.Equal(ToastCloseReason.Programmatic, result); Assert.False(service.ContainsItem(instance.Id)); Assert.Single(updates); Assert.Same(instance, updates[0]); Assert.NotNull(statusChange); - Assert.Equal(ToastStatus.Unmounted, statusChange.Status); + Assert.Equal(ToastLifecycleStatus.Unmounted, statusChange.Status); Assert.Same(instance, statusChange.Instance); } @@ -249,7 +249,7 @@ public Task Result => Task.FromResult(ToastCloseReason.Programmatic); - public ToastStatus Status => ToastStatus.Visible; + public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; public Task CancelAsync() => Task.CompletedTask; From c2b270e98084e6ad1b9419d2038a3f216ad9a9bd Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Wed, 25 Mar 2026 20:13:02 +0100 Subject: [PATCH 13/20] Process Copilot recommendation --- src/Core.Scripts/src/Components/Toast/FluentToast.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index a84ffe7725..64cebb260f 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -672,6 +672,9 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { // Centers need translate(-50%, 0) applied permanently if they are open if (position.includes('center')) { this.dialog.style.transform = enterTo; + } else { + // Ensure non-center positions do not retain stale transforms from a previous center position + this.dialog.style.transform = ''; } } From 9c658088956628fef44fcef3dc6e79a91c11772b Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 27 Mar 2026 16:58:08 +0100 Subject: [PATCH 14/20] Finalize work, add docs and examples --- .../Toast/DebugPages/DebugToast.razor | 122 ------------------ .../Examples/FluentToastCustomDismiss.razor | 33 +++++ .../Toast/Examples/FluentToastDefault.razor | 40 +++++- .../FluentToastDeterminateProgress.razor | 40 ++++++ .../FluentToastIndeterminateProgress.razor | 41 ++++++ .../Components/Toast/FluentToast.md | 110 +++++++++++++++- .../src/Components/Toast/FluentToast.ts | 1 - src/Core/Components/Toast/FluentToast.razor | 20 ++- .../Components/Toast/FluentToast.razor.cs | 4 +- .../Toast/FluentToastProvider.razor | 6 +- .../Toast/FluentToastProvider.razor.cs | 2 +- .../Toast/Services/IToastInstance.cs | 16 +-- .../Toast/Services/IToastService.cs | 19 +++ .../Toast/Services/ToastInstance.cs | 16 +-- .../Components/Toast/Services/ToastOptions.cs | 2 +- .../Components/Toast/Services/ToastService.cs | 27 +++- .../Services => Events}/ToastEventArgs.cs | 0 .../Toast/FluentToastProviderTests.razor | 32 +++++ .../Components/Toast/FluentToastTests.razor | 2 +- .../Components/Toast/ToastInstanceTests.razor | 67 +++++++++- 20 files changed, 429 insertions(+), 171 deletions(-) delete mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor rename src/Core/{Components/Toast/Services => Events}/ToastEventArgs.cs (100%) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor deleted file mode 100644 index b1acc327ef..0000000000 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ /dev/null @@ -1,122 +0,0 @@ -@page "/Toast/Debug/Service" -@inject IToastService ToastService - - - - - Open confirmation toast - - - Open communication toast - - - Open progress toast - - - Open determinate progress toast - - - -
- Last result: @_lastResult -
-
- -@code -{ - private string _lastResult = "(none)"; - - private async Task OpenDismissableToastAsync() - { - var result = await ToastService.ShowToastAsync(options => - { - options.Intent = ToastIntent.Warning; - options.Title = "Delete item?"; - options.Body = "This action can't be undone."; - options.QuickAction1 = "Delete"; - options.QuickAction1Callback = () => - { - Console.WriteLine("Delete action executed."); - return Task.CompletedTask; - }; - options.QuickAction2 = "Cancel"; - options.QuickAction2Callback = () => - { - Console.WriteLine("Cancel action executed."); - return Task.CompletedTask; - }; - options.IsDismissable = true; - options.OnStatusChange = (e) => - { - Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); - }; - }); - - _lastResult = result.ToString() ?? string.Empty; - } - - private async Task OpenToastAsync() - { - var result = await ToastService.ShowToastAsync(options => - { - options.Intent = ToastIntent.Info; - options.Title = "Email sent"; - options.Body = "Your message was delivered."; - options.Subtitle = "Just now"; - options.QuickAction1 = "Undo"; - options.QuickAction2 = "Dismiss"; - options.IsDismissable = false; - }); - - _lastResult = result.ToString() ?? string.Empty; - } - - private async Task OpenProgressToastAsync() - { - RenderFragment childContent = @
Uploading 3 files...
; - - var result = await ToastService.ShowToastAsync(options => - { - options.Timeout = 0; - options.Intent = ToastIntent.Info; - options.Title = "Uploading"; - options.Body = "Please wait while your files are uploaded."; - options.Type = ToastType.IndeterminateProgress; - options.ChildContent = childContent; - options.QuickAction1 = "Hide"; - options.QuickAction2 = "Cancel"; - options.IsDismissable = true; - options.DismissAction = "Dismiss"; - options.DismissActionCallback = () => - { - Console.WriteLine("Toast dismissed."); - return Task.CompletedTask; - }; - }); - - _lastResult = result.ToString() ?? string.Empty; - } - - private async Task OpenProgressToast2Async() - { - RenderFragment childContent = @
-
Uploading 3 files...
- -
; - - var result = await ToastService.ShowToastAsync(options => - { - options.Timeout = 0; - options.Intent = ToastIntent.Info; - options.Title = "Uploading"; - options.Body = "Please wait while your files are uploaded."; - options.Type = ToastType.DeterminateProgress; - options.ChildContent = childContent; - options.QuickAction1 = "Hide"; - options.QuickAction2 = "Cancel"; - options.IsDismissable = false; - }); - - _lastResult = result.ToString() ?? string.Empty; - } -} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor new file mode 100644 index 0000000000..0fd7e336a0 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor @@ -0,0 +1,33 @@ +@inject IToastService ToastService + + + Make toast + + +@code { + int clickCount = 0; + + private async Task OpenToastAsync() + { + var result = await ToastService.ShowToastAsync(options => + { + options.Intent = ToastIntent.Success; + options.Title = $"Toast title {++clickCount}"; + options.Body = "This toast has a custom dismss action."; + options.IsDismissable = true; + options.DismissAction = "Undo"; + options.DismissActionCallback = () => + { + Console.WriteLine("Undo action executed."); + return Task.CompletedTask; + }; + options.OnStatusChange = (e) => + { + Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); + }; + + }); + Console.WriteLine($"Toast result: {result}"); + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor index e02abfc9b0..7cf1304b11 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -1 +1,39 @@ - +@inject IToastService ToastService + + + Make toast + + +@code { + int clickCount = 0; + + private async Task OpenToastAsync() + { + var result = await ToastService.ShowToastAsync(options => + { + options.Title = $"Toast title {++clickCount}"; + options.Body = "Toasts are used to show brief messages to the user."; + options.Subtitle = "subtitle"; + options.QuickAction1 = "Action"; + options.QuickAction1Callback = () => + { + Console.WriteLine("Action 1 executed."); + return Task.CompletedTask; + }; + options.QuickAction2 = "Action"; + options.QuickAction2Callback = () => + { + Console.WriteLine("Action 2 executed."); + return Task.CompletedTask; + }; + options.IsDismissable = true; + options.OnStatusChange = (e) => + { + Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); + }; + + }); + Console.WriteLine($"Toast result: {result}"); + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor new file mode 100644 index 0000000000..cc8b9b5577 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDeterminateProgress.razor @@ -0,0 +1,40 @@ +@inject IToastService ToastService + + + Make toast + + +@code { + FluentButton openToastButton = default!; + + private static RenderFragment BuildProgressContent(int value) => + @
+ + @($"{value}% complete") +
; + + private async Task OpenToastAsync() + { + openToastButton.SetDisabled(true); + + var instance = await ToastService.ShowToastInstanceAsync(options => + { + options.Type = ToastType.DeterminateProgress; + options.Icon = new Icons.Regular.Size20.ArrowDownload(); + options.Title = "Downloading file"; + options.BodyContent = BuildProgressContent(0); + }); + + for (int i = 0; i <= 100; i += 10) + { + await Task.Delay(500); // Simulate work being done + await instance.UpdateAsync(options => + { + options.BodyContent = BuildProgressContent(i); + }); + } + + openToastButton.SetDisabled(false); + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor new file mode 100644 index 0000000000..b9a65753ae --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastIndeterminateProgress.razor @@ -0,0 +1,41 @@ +@inject IToastService ToastService + + + + Make toast + + + Finish process + + + +@code { + int clickCount = 0; + FluentButton openToastButton = default!; + + private async Task OpenToastAsync() + { + // Disable the button to prevent multiple toasts from being opened. + // In a real app, you would likely want to track the toast ID and only disable if that specific toast is open. + openToastButton.SetDisabled(true); + var result = await ToastService.ShowToastAsync(options => + { + options.Id = "indeterminate-toast"; + options.Timeout = 0; + options.Type = ToastType.IndeterminateProgress; + options.Intent = ToastIntent.Success; + options.Title = $"Toast title {++clickCount}"; + options.Body = "No idea when this will be finished..."; + }); + Console.WriteLine($"Toast result: {result}"); + } + + private async Task FinishProcessAsync() + { + // In a real app, you would likely keep track of the toast ID and update that specific toast. + await ToastService.DismissAsync("indeterminate-toast"); + // Enable the button again so a new toast can be opened. + openToastButton.SetDisabled(false); + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 4c459b5f90..ed8c3e055e 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -7,19 +7,119 @@ icon: FoodToast # Toast -The `FluentToast` component is a temporary notification that appears on the edge of the screen. +A toast communicates the status of an action someone is trying to take or that something happened elsewhere in the app. Toasts are temporary surfaces. +Use them for information that's useful and relevant, but not critical. -To display the `FluentToast` component, you need to set the `Opened` parameter to `true`. -This parameter is bindable, so you can control the visibility of the `FluentToast` from your code. +The library provides a `FluentToast` component that can be used to display these notifications. To display a toast, you **must** use the `ToastService`. You use +the `ToastOptions` class to configure the toast's content and behavior. -## Examples +## Types + +Toasts generally fall into three categories: confirmation, progress, and communication. The toast component has slots that can be turned on and off to best +help people achieve their goals. The ideal configuration and usage of each toast type is described below: + +### Confirmation toast + +Confirmation toasts are shown to someone as a direct result of their action. A confirmation toast’s state can be success, error, warning, +informational, or progress. + +### Progress toast + +Progress toasts inform someone about the status of an operation they initiated. + +### Communication toast + +Communication toasts inform someone of messages from the system or another person’s actions. These messages can include mentions, event reminders, replies, +and system updates. +They include a call to action directly linking to a solution or the content that they reference. They can be either temporary or persistent. They’re +dismissible only if there is another surface, like a notification center, where the customer can find this content again later. + +## Behavior + +### Dismissal + +Toasts can have timed, conditional, or express dismissals, dependent on their use case. + +#### Timed dismissal + +If there is no action to take, toast will time out after seven seconds. Timed dismissal is best when there is no further action to take, like for a successful +confirmation toast. + +People who navigate via mouse can pause the timer by hovering over the toast. However, toasts that don’t include actions won’t receive keyboard focus for +people who navigate primarily by keyboard. + +#### Conditional dismissal + +Use conditional dismissal for toasts that should persist until a condition is met, like a progress toast that dismisses once a task is complete. + +Don’t use toasts for necessary actions. If you need the encourage people to take an action before moving forward, try a more forceful surface like a message +bar or a dialog. + +#### Express dismissal + +Include the Close button to allow people to expressly dismiss toasts only if they can find that information again elsewhere, like in a notification center. + +>[!Note] We do not have a way yet to facilitate showing toast messages on other surfaces like a notification center, so use the express dismissal option with +caution. + +### Determinate and indeterminate progress + +Progress toasts can be either determinate or indeterminate, depending on the needs of your app and the capabilities of the technology you’re building on. +When the completion time can be predicted, show a determinate progress bar and percentage of completion. Determinate progress bars offer a reliable user +experience since they communicate status and assure people things are still working. + +If the completion time is unknown or its accuracy is unreliable, show an indeterminate spinner icon instead. + +Although a specific type of toast needs to be specified through the `ToastOptions`, the library does not prevent you from showing both a spinner icon and a +progress bar in the same toast, but we recommend strongly against doing this. + +## Accessibility + +By using the `Intent` property (from `ToastOptions`) semantic styles, icons and aria-live regions and roles used in the toast are automatically applied. + +All feedback states except info have an “assertive” aria-live and interrupt any other announcement a screen reader is making. Too many interruptions can disrupt someone’s flow, +so don’t overload people with too many assertive toasts. + +## Examples ### Default +This example shows a toast with the default configuration, which includes a title and a message. It also has the default intent of `Info`, which applies the corresponding icon. +It shows 2 action links in the footer, which is the maximum number of what is possible for a toast. + {{ FluentToastDefault }} -## API Documentation +### Custom dismissal + +This example shows a toast with a custom dismissal configuration. It uses an acton link (with a custom callback) instead of the standard dismiss icon to dismiss the toast. + +{{ FluentToastCustomDismiss }} + +### Indeterminate progress + +This example shows a toast with an indeterminate progress configuration. Timeout has been set to zero, so the toast will never close by itself. Use the 'Finish process' button to dismiss the toast. + +{{ FluentToastIndeterminateProgress }} + +### Determinate progress + +This example shows how a toast can be updated during a longer running process (with a predictable duration). + +{{ FluentToastDeterminateProgress }} + +## API ToastService + +{{ API Type=ToastService }} + +## API FluentToast {{ API Type=FluentToast }} +## API ToastOptions + +{{ API Type=ToastOptions Properties=All }} + +## API FluentToastProvider + +{{ API Type=FluentToastProvider }} diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 64cebb260f..9ebff74ce4 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -180,7 +180,6 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { .title { display: flex; - align-items: center; grid-column-end: 3; color: var(--colorNeutralForeground1); word-break: break-word; diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 9fb855a70a..da2ba62bd7 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -29,13 +29,13 @@ else if (Type == ToastType.IndeterminateProgress) {
- +
} else { } @@ -48,7 +48,7 @@ { @if (!string.IsNullOrEmpty(DismissAction)) { - + @DismissAction } @@ -59,17 +59,13 @@ IconStart="@DismissIcon" Title="Dismiss" slot="action" - OnClick="@(() => Instance!.CancelAsync())" /> + OnClick="@(() => Instance!.DismissAsync())" /> } } - @if (ChildContent is not null) + @if (!string.IsNullOrEmpty(Body) || BodyContent is not null) { -
@ChildContent
- } - else if (!string.IsNullOrEmpty(Body)) - { -
@Body
+
@Body@BodyContent
} @if (!string.IsNullOrEmpty(Subtitle)) @@ -82,14 +78,14 @@
@if (!string.IsNullOrEmpty(QuickAction1)) { - + @QuickAction1 } @if (!string.IsNullOrEmpty(QuickAction2)) { - + @QuickAction2 } diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index c419c6347b..c59d1690e2 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -186,7 +186,7 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) /// . ///
[Parameter] - public RenderFragment? ChildContent { get; set; } + public RenderFragment? BodyContent { get; set; } // internal static Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); @@ -249,7 +249,7 @@ internal Task RequestCloseAsync() internal async Task OnDismissActionClickedAsync() { - await Instance!.CloseAsync(ToastCloseReason.Dismissed); + await Instance!.DismissAsync(); if (Instance?.Options.DismissActionCallback is not null) { diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 0544c7f3cb..93179f776f 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -25,16 +25,16 @@ PauseOnWindowBlur="@GetPauseOnWindowBlur(toast)" Instance="@toast" OnStatusChange="@GetOnStatusChangeCallback(toast)" - AdditionalAttributes="@toast.Options.AdditionalAttributes" + Icon="@toast.Options.Icon" Title="@toast.Options.Title" Body="@toast.Options.Body" + BodyContent="@toast.Options.BodyContent" Subtitle="@toast.Options.Subtitle" QuickAction1="@toast.Options.QuickAction1" QuickAction2="@toast.Options.QuickAction2" IsDismissable="@toast.Options.IsDismissable" DismissAction="@toast.Options.DismissAction" - Icon="@toast.Options.Icon" - ChildContent="@toast.Options.ChildContent" /> + AdditionalAttributes="@toast.Options.AdditionalAttributes" /> } }
diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index fbf4cef758..071d22d6f2 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -142,7 +142,7 @@ private void SynchronizeToastQueue() var activeCount = ToastService.Items.Values.Count(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed); var queuedToasts = ToastService.Items.Values .Where(toast => toast.LifecycleStatus == ToastLifecycleStatus.Queued) - .OrderBy(toast => toast.Index) + .OrderByDescending(toast => toast.Index) .ToList(); foreach (var toast in queuedToasts) diff --git a/src/Core/Components/Toast/Services/IToastInstance.cs b/src/Core/Components/Toast/Services/IToastInstance.cs index 8034bab6ef..b3e572d82f 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -10,8 +10,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public interface IToastInstance { /// - /// Gets the unique identifier for the Toast. - /// If this value is not set in the , a new identifier is generated. + /// Gets the unique identifier for the Toast. If this value is not set in the , a new + /// identifier is generated. /// string Id { get; } @@ -35,12 +35,6 @@ public interface IToastInstance ///
ToastLifecycleStatus LifecycleStatus { get; } - /// - /// Closes the Toast as dismissed. - /// - /// - Task CancelAsync(); - /// /// Closes the Toast programmatically. /// @@ -54,6 +48,12 @@ public interface IToastInstance /// Task CloseAsync(ToastCloseReason reason); + /// + /// Dismisses the Toast. + /// + /// + Task DismissAsync(); + /// /// Updates the toast options while the toast is shown. /// diff --git a/src/Core/Components/Toast/Services/IToastService.cs b/src/Core/Components/Toast/Services/IToastService.cs index 61de9d6127..9eb0b46ed6 100644 --- a/src/Core/Components/Toast/Services/IToastService.cs +++ b/src/Core/Components/Toast/Services/IToastService.cs @@ -17,6 +17,13 @@ public partial interface IToastService : IFluentServiceBase /// Task CloseAsync(IToastInstance Toast, ToastCloseReason reason); + /// + /// Dismisses the specified toast instance. + /// + /// Instance of the toast to dismiss. + /// + Task DismissAsync(IToastInstance Toast); + /// /// Dismisses the toast with the specified identifier. /// @@ -42,6 +49,18 @@ public partial interface IToastService : IFluentServiceBase /// Action used to configure the toast. Task ShowToastAsync(Action options); + /// + /// Shows a toast using the supplied options and returns the live toast instance. + /// + /// Options to configure the toast. + Task ShowToastInstanceAsync(ToastOptions? options = null); + + /// + /// Shows a toast by configuring an options object and returns the live toast instance. + /// + /// Action used to configure the toast. + Task ShowToastInstanceAsync(Action options); + /// /// Updates a shown toast. /// diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 68dcf10505..25d534df1a 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -41,18 +41,12 @@ internal ToastInstance(IToastService toastService, ToastOptions options) /// public ToastLifecycleStatus LifecycleStatus { get; internal set; } = ToastLifecycleStatus.Queued; - /// " + /// public string Id { get; } - /// " + /// public long Index { get; } - /// - public Task CancelAsync() - { - return ToastService.CloseAsync(this, ToastCloseReason.Dismissed); - } - /// public Task CloseAsync() { @@ -65,6 +59,12 @@ public Task CloseAsync(ToastCloseReason reason) return ToastService.CloseAsync(this, reason); } + /// + public Task DismissAsync() + { + return ToastService.DismissAsync(this); + } + /// public Task UpdateAsync(Action update) { diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 824134f11f..6657b1d654 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -171,7 +171,7 @@ public ToastOptions(Action implementationFactory) /// Gets or sets custom content rendered in the default slot, such as progress content updated through /// . ///
- public RenderFragment? ChildContent { get; set; } + public RenderFragment? BodyContent { get; set; } /// /// Gets or sets the action raised when the toast lifecycle status changes. diff --git a/src/Core/Components/Toast/Services/ToastService.cs b/src/Core/Components/Toast/Services/ToastService.cs index e450361466..3a4fa4e5da 100644 --- a/src/Core/Components/Toast/Services/ToastService.cs +++ b/src/Core/Components/Toast/Services/ToastService.cs @@ -60,6 +60,12 @@ public async Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) } } + /// + public async Task DismissAsync(IToastInstance Toast) + { + await CloseAsync(Toast, ToastCloseReason.Dismissed); + } + /// public async Task DismissAsync(string toastId) { @@ -86,9 +92,10 @@ public async Task DismissAllAsync() } /// - public Task ShowToastAsync(ToastOptions? options = null) + public async Task ShowToastAsync(ToastOptions? options = null) { - return ShowToastCoreAsync(options ?? new ToastOptions()); + var instance = await ShowToastInstanceCoreAsync(options ?? new ToastOptions()); + return await instance.Result; } /// @@ -97,6 +104,18 @@ public Task ShowToastAsync(Action options) return ShowToastAsync(new ToastOptions(options)); } + /// + public async Task ShowToastInstanceAsync(ToastOptions? options = null) + { + return await ShowToastInstanceCoreAsync(options ?? new ToastOptions()); + } + + /// + public Task ShowToastInstanceAsync(Action options) + { + return ShowToastInstanceAsync(new ToastOptions(options)); + } + /// public async Task UpdateToastAsync(IToastInstance toast, Action update) { @@ -110,7 +129,7 @@ public async Task UpdateToastAsync(IToastInstance toast, Action up } /// - private async Task ShowToastCoreAsync(ToastOptions options) + private async Task ShowToastInstanceCoreAsync(ToastOptions options) { if (this.ProviderNotAvailable()) { @@ -124,7 +143,7 @@ private async Task ShowToastCoreAsync(ToastOptions options) ServiceProvider.Items.TryAdd(instance?.Id ?? "", instance ?? throw new InvalidOperationException("Failed to create FluentToast.")); await ServiceProvider.OnUpdatedAsync.Invoke(instance); - return await instance.Result; + return instance; } /// diff --git a/src/Core/Components/Toast/Services/ToastEventArgs.cs b/src/Core/Events/ToastEventArgs.cs similarity index 100% rename from src/Core/Components/Toast/Services/ToastEventArgs.cs rename to src/Core/Events/ToastEventArgs.cs diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index 4b2db32c8f..404197ed7b 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -197,6 +197,9 @@ public Task CloseAsync(IToastInstance Toast, ToastCloseReason reason) => Task.CompletedTask; + public Task DismissAsync(IToastInstance Toast) + => Task.CompletedTask; + public Task DismissAsync(string toastId) => Task.FromResult(false); @@ -213,7 +216,36 @@ public Task ShowToastAsync(Action options) => Task.FromResult(ToastCloseReason.Programmatic); + public Task ShowToastInstanceAsync(ToastOptions? options = null) + => Task.FromResult(new TestToastInstance()); + + public Task ShowToastInstanceAsync(Action options) + => Task.FromResult(new TestToastInstance()); + public Task UpdateToastAsync(IToastInstance toast, Action update) => Task.CompletedTask; + + private sealed class TestToastInstance : IToastInstance + { + public string Id { get; } = "toast"; + + public long Index => 0; + + public ToastOptions Options { get; } = new(); + + public Task Result => Task.FromResult(ToastCloseReason.Programmatic); + + public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; + + public Task CancelAsync() => Task.CompletedTask; + + public Task CloseAsync() => Task.CompletedTask; + + public Task CloseAsync(ToastCloseReason reason) => Task.CompletedTask; + + public Task DismissAsync() => Task.CompletedTask; + + public Task UpdateAsync(Action update) => Task.CompletedTask; + } } } diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 8be6323c39..ba711b77fc 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -839,7 +839,7 @@ public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; - public Task CancelAsync() => Task.CompletedTask; + public Task DismissAsync() => Task.CompletedTask; public Task CloseAsync() => Task.CompletedTask; diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor index 6a5387adfe..13dfcd4dcd 100644 --- a/tests/Core/Components/Toast/ToastInstanceTests.razor +++ b/tests/Core/Components/Toast/ToastInstanceTests.razor @@ -19,7 +19,7 @@ var service = new TestToastService(); var instance = new ToastInstance(service, new ToastOptions()); - await instance.CancelAsync(); + await instance.DismissAsync(); Assert.Same(instance, service.LastClosedToast); Assert.Equal(ToastCloseReason.Dismissed, service.LastCloseReason); @@ -140,11 +140,58 @@ { var service = new TestableToastService(Services, null); - var result = await service.DismissAsync(null!); + var result = await service.DismissAsync((string)null!); Assert.False(result); } + [Fact] + public async Task ToastService_DismissAsync_WithToastInstance_ClosesWithDismissedReason() + { + var service = new TestableToastService(Services, null); + var instance = new ToastInstance(service, new ToastOptions()); + + service.AddItem(instance); + + await service.DismissAsync(instance); + + var result = await instance.Result; + + Assert.Equal(ToastLifecycleStatus.Unmounted, instance.LifecycleStatus); + Assert.Equal(ToastCloseReason.Dismissed, result); + Assert.False(service.ContainsItem(instance.Id)); + } + + [Fact] + public async Task ToastService_ShowToastInstanceAsync_ReturnsLiveInstance() + { + var service = new TestableToastService(Services, null); + service.SetProviderId("provider"); + + var instance = await service.ShowToastInstanceAsync(new ToastOptions { Body = "Live body" }); + + Assert.NotNull(instance); + Assert.True(service.ContainsItem(instance.Id)); + Assert.Equal("Live body", instance.Options.Body); + Assert.Equal(ToastLifecycleStatus.Queued, instance.LifecycleStatus); + } + + [Fact] + public async Task ToastService_ShowToastInstanceAsync_ActionOverload_AppliesOptions() + { + var service = new TestableToastService(Services, null); + service.SetProviderId("provider"); + + var instance = await service.ShowToastInstanceAsync(options => + { + options.Body = "Configured body"; + options.Type = ToastType.Confirmation; + }); + + Assert.Equal("Configured body", instance.Options.Body); + Assert.Equal(ToastType.Confirmation, instance.Options.Type); + } + [Fact] public async Task ToastService_UpdateToastAsync_WhenToastIsNotToastInstance_ThrowsArgumentException() { @@ -195,6 +242,9 @@ return Task.CompletedTask; } + public Task DismissAsync(IToastInstance Toast) + => Task.CompletedTask; + public Task DismissAsync(string toastId) => Task.FromResult(false); @@ -211,6 +261,12 @@ public Task ShowToastAsync(Action options) => Task.FromResult(ToastCloseReason.Programmatic); + public Task ShowToastInstanceAsync(ToastOptions? options = null) + => Task.FromResult(new TestNonToastInstance("toast-instance")); + + public Task ShowToastInstanceAsync(Action options) + => Task.FromResult(new TestNonToastInstance("toast-instance")); + public Task UpdateToastAsync(IToastInstance toast, Action update) => Task.CompletedTask; } @@ -232,6 +288,11 @@ public new Task RemoveToastFromProviderAsync(IToastInstance? toast) => base.RemoveToastFromProviderAsync(toast); + + public void SetProviderId(string providerId) + => typeof(IFluentServiceBase) + .GetProperty(nameof(IFluentServiceBase.ProviderId))! + .SetValue(ServiceProvider, providerId); } private sealed class TestLocalizer : IFluentLocalizer @@ -253,6 +314,8 @@ public Task CancelAsync() => Task.CompletedTask; + public Task DismissAsync() => Task.CompletedTask; + public Task CloseAsync() => Task.CompletedTask; public Task CloseAsync(ToastCloseReason reason) => Task.CompletedTask; From 904ad4158ca8caf2ee3206ecaba7ca723b4a3cc1 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 27 Mar 2026 17:35:14 +0100 Subject: [PATCH 15/20] Update tests --- .../Toast/FluentToastProviderTests.razor | 3 ++- ...tTests.FluentToast_Render.verified.razor.html | 16 ---------------- .../Core/Components/Toast/FluentToastTests.razor | 5 ++--- .../Components/Toast/ToastInstanceTests.razor | 2 +- 4 files changed, 5 insertions(+), 21 deletions(-) delete mode 100644 tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index 404197ed7b..8770686bc4 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -159,7 +159,6 @@ var firstToast = new ToastInstance(service, new ToastOptions { Id = "first" }); var secondToast = new ToastInstance(service, new ToastOptions { Id = "second" }); - service.Items.TryAdd(secondToast.Id, secondToast); service.Items.TryAdd(firstToast.Id, firstToast); var provider = Render(parameters => parameters @@ -169,6 +168,8 @@ var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; method.Invoke(provider.Instance, null); + service.Items.TryAdd(secondToast.Id, secondToast); + Assert.Equal(ToastLifecycleStatus.Visible, firstToast.LifecycleStatus); Assert.Equal(ToastLifecycleStatus.Queued, secondToast.LifecycleStatus); } diff --git a/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html b/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html deleted file mode 100644 index 711f3dfbd8..0000000000 --- a/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html +++ /dev/null @@ -1,16 +0,0 @@ - - - -
- - -
Toast title
-
Toast Content - John
-
-
- - \ No newline at end of file diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index ba711b77fc..fd10ce8391 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -58,14 +58,13 @@ // Assert Assert.Contains("fluent-toast-provider", ToastProvider.Markup); Assert.Contains("Toast Content - John", ToastProvider.Markup); - ToastProvider.Verify(); } [Fact] - public void FluentToast_ChildContent() + public void FluentToast_BodyContent() { // Arrange & Act - var cut = Render(@Hello World); + var cut = Render(@Hello World); // Assert Assert.Contains("Hello World", cut.Markup); diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor index 13dfcd4dcd..867c0fab81 100644 --- a/tests/Core/Components/Toast/ToastInstanceTests.razor +++ b/tests/Core/Components/Toast/ToastInstanceTests.razor @@ -243,7 +243,7 @@ } public Task DismissAsync(IToastInstance Toast) - => Task.CompletedTask; + => CloseAsync(Toast, ToastCloseReason.Dismissed); public Task DismissAsync(string toastId) => Task.FromResult(false); From 934beb5f992c43cddc7d9fc58b367854c56e2aca Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 27 Mar 2026 17:41:16 +0100 Subject: [PATCH 16/20] Added 1 new test and removed 1 obsolete test --- .../Toast/FluentToastProviderTests.razor | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor index 8770686bc4..ff4787c240 100644 --- a/tests/Core/Components/Toast/FluentToastProviderTests.razor +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -153,25 +153,28 @@ } [Fact] - public void FluentToast_SynchronizeToastQueue_PromotesQueuedToastsInIndexOrder() + public void FluentToast_SynchronizeToastQueue_RendersNewestVisibleToastFirst() { var service = new TestToastService(); - var firstToast = new ToastInstance(service, new ToastOptions { Id = "first" }); - var secondToast = new ToastInstance(service, new ToastOptions { Id = "second" }); + var firstToast = new ToastInstance(service, new ToastOptions { Id = "first", Body = "First toast" }); + var secondToast = new ToastInstance(service, new ToastOptions { Id = "second", Body = "Second toast" }); service.Items.TryAdd(firstToast.Id, firstToast); + service.Items.TryAdd(secondToast.Id, secondToast); var provider = Render(parameters => parameters .Add(p => p.OverrideToastService, service) - .Add(p => p.MaxToastCount, 1)); + .Add(p => p.MaxToastCount, 2)); var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; method.Invoke(provider.Instance, null); + provider.Render(); - service.Items.TryAdd(secondToast.Id, secondToast); + var toasts = provider.FindComponents().ToList(); - Assert.Equal(ToastLifecycleStatus.Visible, firstToast.LifecycleStatus); - Assert.Equal(ToastLifecycleStatus.Queued, secondToast.LifecycleStatus); + Assert.Equal(2, toasts.Count); + Assert.Equal("second", toasts[0].Instance.Instance?.Id); + Assert.Equal("first", toasts[1].Instance.Instance?.Id); } private sealed class TestFluentToastProvider : FluentToastProvider From 1bb96d885d27c4ff36b66f8fe8819b4bf9b84654 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 27 Mar 2026 18:53:39 +0100 Subject: [PATCH 17/20] Process Copilot comments --- .../Toast/Examples/FluentToastCustomDismiss.razor | 2 +- .../Documentation/Components/Toast/FluentToast.md | 2 +- src/Core/Components/Toast/FluentToast.razor | 4 ++-- src/Core/Components/Toast/Services/ToastOptions.cs | 8 +++----- tests/Core/Components/Toast/ToastInstanceTests.razor | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor index 0fd7e336a0..278ed9c660 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastCustomDismiss.razor @@ -13,7 +13,7 @@ { options.Intent = ToastIntent.Success; options.Title = $"Toast title {++clickCount}"; - options.Body = "This toast has a custom dismss action."; + options.Body = "This toast has a custom dismiss action."; options.IsDismissable = true; options.DismissAction = "Undo"; options.DismissActionCallback = () => diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index ed8c3e055e..8edb6760e3 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -92,7 +92,7 @@ It shows 2 action links in the footer, which is the maximum number of what is po ### Custom dismissal -This example shows a toast with a custom dismissal configuration. It uses an acton link (with a custom callback) instead of the standard dismiss icon to dismiss the toast. +This example shows a toast with a custom dismissal configuration. It uses an action link (with a custom callback) instead of the standard dismiss icon to dismiss the toast. {{ FluentToastCustomDismiss }} diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index da2ba62bd7..a45f3e9e7a 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -15,8 +15,8 @@ vertical-offset="@VerticalOffset" horizontal-offset="@HorizontalOffset" role="listitem" - aria-labelledby="@($"{Id}-title")" - aria-describedby="@($"{Id}-body")" + aria-labelledby="@(string.IsNullOrEmpty(Title) ? null : $"{Id}-title")" + aria-describedby="@((string.IsNullOrEmpty(Body) && BodyContent is null) ? null : $"{Id}-body")" @attributes="@AdditionalAttributes" @ondialogtoggle="@OnToggleAsync" @ondialogbeforetoggle="@OnToggleAsync"> diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 6657b1d654..f443c23a1c 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -34,14 +34,12 @@ public ToastOptions(Action implementationFactory) public string? Id { get; set; } /// - /// Gets or sets the CSS class names. If given, these will be included in the class attribute of the `fluent-Toast` - /// or `fluent-drawer` element. To apply you styles to the `Toast` element, you need to create a class like - /// `my-class::part(Toast) { ... }` + /// Gets or sets the CSS class name. /// public string? Class { get; set; } /// - /// Gets or sets the in-line styles. If given, these will be included in the style attribute of the `Toast` element. + /// Gets or sets the in-line styles. /// public string? Style { get; set; } @@ -90,7 +88,7 @@ public ToastOptions(Action implementationFactory) /// /// Gets or sets the toast type, which determines things like a default icon and styling of the toast. /// - public ToastType Type { get; set; } + public ToastType Type { get; set; } = ToastType.Communication; /// /// Gets or sets the toast intent. diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor index 867c0fab81..33e5dc1898 100644 --- a/tests/Core/Components/Toast/ToastInstanceTests.razor +++ b/tests/Core/Components/Toast/ToastInstanceTests.razor @@ -14,7 +14,7 @@ } [Fact] - public async Task CancelAsync_CallsServiceCloseWithDismissedReason() + public async Task DismissAsync_CallsServiceCloseWithDismissedReason() { var service = new TestToastService(); var instance = new ToastInstance(service, new ToastOptions()); From 57171c85b664bac60c0a0ad495ea6a178b5cb51a Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 30 Mar 2026 14:38:46 +0200 Subject: [PATCH 18/20] - Add `Inverted` parameter wit example, tests and docs --- .../Toast/Examples/FluentToastInverted.razor | 35 +++++++++++++++++++ .../Components/Toast/FluentToast.md | 9 +++++ .../src/Components/Toast/FluentToast.ts | 10 +++++- src/Core.Scripts/src/FluentUIStyles.ts | 4 +++ src/Core/Components/Toast/FluentToast.razor | 7 ++-- .../Components/Toast/FluentToast.razor.cs | 20 ++++++++++- .../Toast/FluentToastProvider.razor | 1 + .../Components/Toast/Services/ToastOptions.cs | 5 +++ src/Core/Enums/Color.cs | 32 ++++++++++++----- .../Components/Toast/FluentToastTests.razor | 28 +++++++++++++++ 10 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor new file mode 100644 index 0000000000..2488fadcdf --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastInverted.razor @@ -0,0 +1,35 @@ +@inject IToastService ToastService + + + Make toast + + +@code { + int clickCount = 0; + + private async Task OpenToastAsync() + { + var result = await ToastService.ShowToastAsync(options => + { + options.Intent = ToastIntent.Info; + options.Title = $"Toast title {++clickCount}"; + options.Body = "Toasts are used to show brief messages to the user."; + options.Subtitle = "subtitle"; + options.QuickAction1 = "Action"; + options.QuickAction1Callback = () => + { + Console.WriteLine("Action 1 executed."); + return Task.CompletedTask; + }; + options.IsDismissable = true; + options.DismissAction = "Close"; + options.OnStatusChange = (e) => + { + Console.WriteLine($"Status changed: {e.Id} - {e.Status}"); + }; + options.Inverted = true; + }); + Console.WriteLine($"Toast result: {result}"); + } +} + diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md index 8edb6760e3..b55e62acbe 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -96,6 +96,15 @@ This example shows a toast with a custom dismissal configuration. It uses an act {{ FluentToastCustomDismiss }} +### Inverted toast + +You can use the `Inverted` property to show a toast with an inverted color scheme. This allows for showing a dark toast on a light background, or a light toast on a dark background. + +>[!Note] When setting `IsDismissable` to `true`, without setting a custom `DismissAction`, a toast will render a default dismiss button using the `FluentButton` component. +As a `FluentButton` has no notion of an `Inverted` property, you need to set an explicit `DismissAction` so a inverted aware link is rendered instead of the default button. + +{{ FluentToastInverted }} + ### Indeterminate progress This example shows a toast with an indeterminate progress configuration. Timeout has been set to zero, so the toast will never close by itself. Use the 'Finish process' button to dismiss the toast. diff --git a/src/Core.Scripts/src/Components/Toast/FluentToast.ts b/src/Core.Scripts/src/Components/Toast/FluentToast.ts index 9ebff74ce4..14eab3651f 100644 --- a/src/Core.Scripts/src/Components/Toast/FluentToast.ts +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -146,6 +146,10 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { color: var(--colorNeutralForegroundInverted); } + :host([inverted]) .media { + color: var(--colorNeutralForegroundInverted); + } + .media[data-intent="success"] { color: var(--colorStatusSuccessForeground1); } @@ -226,7 +230,7 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { } :host([inverted]) .subtitle { - color: var(--colorNeutralForegroundInverted2); + color: var(--colorNeutralForegroundInverted2); } .footer { @@ -238,6 +242,10 @@ export namespace Microsoft.FluentUI.Blazor.Components.Toast { padding-top: 16px; } + :host([inverted]) slot[name="footer"]::slotted(fluent-link[clickable]) { + color: var(--colorBrandForegroundInverted); + } + .footer ::slotted(*) { display: contents; } diff --git a/src/Core.Scripts/src/FluentUIStyles.ts b/src/Core.Scripts/src/FluentUIStyles.ts index 5ab2b1e143..fc0bd8d988 100644 --- a/src/Core.Scripts/src/FluentUIStyles.ts +++ b/src/Core.Scripts/src/FluentUIStyles.ts @@ -11,6 +11,10 @@ body:has(.prevent-scroll) { --warning: var(--colorStatusWarningForeground1); --error: var(--colorPaletteRedForeground1); --info: var(--colorNeutralForeground3); + --success-inverted: var(--colorStatusSuccessForegroundInverted); + --warning-inverted: var(--colorStatusWarningForegroundInverted); + --error-inverted: var(--colorPaletteRedForegroundInverted); + --info-inverted: var(--colorNeutralForegroundInverted2); --presence-available: var(--colorPaletteLightGreenForeground3); --presence-away: var(--colorPaletteMarigoldBackground3); --presence-busy: var(--colorPaletteRedBackground3); diff --git a/src/Core/Components/Toast/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index a45f3e9e7a..23d6e4d6d9 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -8,6 +8,7 @@ opened="@(Opened ? "true" : "false")" timeout="@Timeout" position="@Position.ToAttributeValue()" + inverted="@(Inverted ? "true" : null)" intent="@Intent.ToAttributeValue()" politeness="@Politeness.ToAttributeValue()" pause-on-hover="@(PauseOnHover ? "true" : null)" @@ -48,7 +49,7 @@ { @if (!string.IsNullOrEmpty(DismissAction)) { - + @DismissAction } @@ -78,14 +79,14 @@
@if (!string.IsNullOrEmpty(QuickAction1)) { - + @QuickAction1 } @if (!string.IsNullOrEmpty(QuickAction2)) { - + @QuickAction2 } diff --git a/src/Core/Components/Toast/FluentToast.razor.cs b/src/Core/Components/Toast/FluentToast.razor.cs index c59d1690e2..e9779976df 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -78,6 +78,12 @@ public FluentToast(LibraryConfiguration configuration) : base(configuration) [Parameter] public ToastType Type { get; set; } = ToastType.Communication; + /// + /// Gets or sets a value indicating whether the toast uses inverted colors. + /// + [Parameter] + public bool Inverted { get; set; } + /// /// Gets or sets the intent of the toast notification, indicating its purpose or severity. /// @@ -273,8 +279,20 @@ private async Task HandleQuickActionClickedAsync(Func? callback) } } - internal static Color GetIntentColor(ToastIntent intent) + internal Color GetIntentColor(ToastIntent intent) { + if (Inverted) + { + return intent switch + { + ToastIntent.Success => Color.SuccessInverted, + ToastIntent.Warning => Color.WarningInverted, + ToastIntent.Error => Color.ErrorInverted, + _ => Color.InfoInverted, + }; + + } + return intent switch { ToastIntent.Success => Color.Success, diff --git a/src/Core/Components/Toast/FluentToastProvider.razor b/src/Core/Components/Toast/FluentToastProvider.razor index 93179f776f..180214fb4a 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -19,6 +19,7 @@ VerticalOffset="@GetVerticalOffset(toast)" HorizontalOffset="@GetHorizontalOffset(toast)" Type="@toast.Options.Type" + Inverted="@toast.Options.Inverted" Intent="@toast.Options.Intent" Politeness="@toast.Options.Politeness" PauseOnHover="@GetPauseOnHover(toast)" diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index f443c23a1c..544298458f 100644 --- a/src/Core/Components/Toast/Services/ToastOptions.cs +++ b/src/Core/Components/Toast/Services/ToastOptions.cs @@ -90,6 +90,11 @@ public ToastOptions(Action implementationFactory) ///
public ToastType Type { get; set; } = ToastType.Communication; + /// + /// Gets or sets a value indicating whether the toast uses inverted colors. + /// + public bool Inverted { get; set; } + /// /// Gets or sets the toast intent. /// diff --git a/src/Core/Enums/Color.cs b/src/Core/Enums/Color.cs index a7b1f1550a..6f1f15bca2 100644 --- a/src/Core/Enums/Color.cs +++ b/src/Core/Enums/Color.cs @@ -40,36 +40,52 @@ public enum Color /// /// Use the '--warning' CSS variable color. - /// Note: This color is defined in the variables.css file. If this file is not being used, - /// a CSS variable with this name and appropriate value needs to be created. /// [Description("var(--warning)")] Warning, /// /// Use the '--info' CSS variable color. - /// Note: This color is defined in the variables.css file. If this file is not being used, - /// a CSS variable with this name and appropriate value needs to be created. /// [Description("var(--info)")] Info, /// /// Use the '--error' CSS variable color. - /// Note: This color is defined in the variables.css file. If this file is not being used, - /// a CSS variable with this name and appropriate value needs to be created. /// [Description("var(--error)")] Error, /// /// Use the '--success' CSS variable color. - /// Note: This color is defined in the variables.css file. If this file is not being used, - /// a CSS variable with this name and appropriate value needs to be created. /// [Description("var(--success)")] Success, + /// + /// Use the '--warning' CSS variable color. + /// + [Description("var(--warning-inverted)")] + WarningInverted, + + /// + /// Use the '--info' CSS variable color. + /// + [Description("var(--info-inverted)")] + InfoInverted, + + /// + /// Use the '--error' CSS variable color. + /// + [Description("var(--error-inverted)")] + ErrorInverted, + + /// + /// Use the '--success' CSS variable color. + /// + [Description("var(--success-inverted)")] + SuccessInverted, + /// /// Supply an HTML hex color string value (#rrggbb or #rgb) for the CustomColor parameter. /// diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index fd10ce8391..955966d924 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -84,6 +84,17 @@ Assert.DoesNotContain("fluent-spinner", cut.Markup); } + [Fact] + public void FluentToast_Inverted_RendersInvertedAttribute() + { + var cut = Render(parameters => parameters + .Add(p => p.Body, "Toast body") + .Add(p => p.Inverted, true)); + + Assert.True(cut.Instance.Inverted); + Assert.Contains("inverted=\"true\"", cut.Markup, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void FluentToast_IndeterminateProgress_RendersSpinner() { @@ -112,6 +123,23 @@ Assert.Contains("fluent-spinner", toast.Markup); } + [Fact] + public async Task FluentToast_ToastOptionsInverted_IsAppliedFromProvider() + { + _ = ToastService.ShowToastAsync(options => + { + options.Body = "Inverted body"; + options.Inverted = true; + }); + + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + + Assert.True(toast.Instance.Inverted); + Assert.Contains("inverted=\"true\"", toast.Markup, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void FluentToast_Subtitle_RendersWhenNotEmpty() { From 6da5e6624fc142fd73954498d627488b27cd8e85 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Mon, 30 Mar 2026 14:53:18 +0200 Subject: [PATCH 19/20] Add and fix test --- .../Components/Toast/FluentToastTests.razor | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/Core/Components/Toast/FluentToastTests.razor b/tests/Core/Components/Toast/FluentToastTests.razor index 955966d924..7bef35ff1e 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -652,7 +652,23 @@ [InlineData(ToastIntent.Error, Color.Error)] public void FluentToast_Intent_MapsToExpectedColor(ToastIntent intent, Color expectedColor) { - var color = FluentToast.GetIntentColor(intent); + var cut = Render(parameters => parameters.Add(p => p.Body, "test")); + var color = cut.Instance.GetIntentColor(intent); + + Assert.Equal(expectedColor, color); + } + + [Theory] + [InlineData(ToastIntent.Info, Color.InfoInverted)] + [InlineData(ToastIntent.Success, Color.SuccessInverted)] + [InlineData(ToastIntent.Warning, Color.WarningInverted)] + [InlineData(ToastIntent.Error, Color.ErrorInverted)] + public void FluentToast_Inverted_Intent_MapsToExpectedColor(ToastIntent intent, Color expectedColor) + { + var cut = Render(parameters => parameters + .Add(p => p.Body, "test") + .Add(p => p.Inverted, true)); + var color = cut.Instance.GetIntentColor(intent); Assert.Equal(expectedColor, color); } From affe63d481be24cc9ec3ca3c26132de570a6984b Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Fri, 3 Apr 2026 20:49:16 +0200 Subject: [PATCH 20/20] Delete AssemblyInfo.cs --- src/Core/Properties/AssemblyInfo.cs | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/Core/Properties/AssemblyInfo.cs diff --git a/src/Core/Properties/AssemblyInfo.cs b/src/Core/Properties/AssemblyInfo.cs deleted file mode 100644 index a0cf3d99fa..0000000000 --- a/src/Core/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -// ------------------------------------------------------------------------ -// This file is licensed to you under the MIT License. -// ------------------------------------------------------------------------ - -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("Microsoft.FluentUI.AspNetCore.Components.Tests")]