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 15a012be35..0000000000 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToast.razor +++ /dev/null @@ -1,32 +0,0 @@ -@page "/Toast/Debug/Service" -@inject IToastService ToastService - - - Open Toast - - -@code -{ - private async Task OpenToastAsync() - { - var result = await ToastService.ShowToastAsync(options => - { - options.Parameters.Add(nameof(DebugToastContent.Name), "John"); - - 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}"); - } - } -} 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 6987f1db9f..0000000000 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/DebugPages/DebugToastContent.razor +++ /dev/null @@ -1,34 +0,0 @@ -
- Toast Content -
-
- OK - Cancel -
- -@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/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..278ed9c660 --- /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 dismiss 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 new file mode 100644 index 0000000000..7cf1304b11 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/Examples/FluentToastDefault.razor @@ -0,0 +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/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 new file mode 100644 index 0000000000..b55e62acbe --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/Toast/FluentToast.md @@ -0,0 +1,134 @@ +--- +title: Toast +route: /Toast +category: 20|Components +icon: FoodToast +--- + +# Toast + +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. + +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. + +## 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 }} + +### Custom dismissal + +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 }} + +### 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. + +{{ 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/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentToast.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentToast.md index 3947c42952..d1175b2d5a 100644 --- a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentToast.md +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentToast.md @@ -4,49 +4,7 @@ route: /Migration/Toast hidden: true --- -- ### Entire toast system removed 💥 +## Removed from toast system 💥 - The full toast notification system has been **removed** in V5: - - `FluentToast` component - - `FluentToastProvider` component - - `IToastService` / `ToastService` - - `ToastInstance`, `ToastResult` - - Content components: `CommunicationToast`, `ConfirmationToast`, `ProgressToast` - - Content types: `CommunicationToastContent`, `ConfirmationToastContent`, `ProgressToastContent` - - `ToastParameters` - - Enums: `ToastIntent`, `ToastPosition`, `ToastTopCTAType` - -- ### V4 FluentToastProvider parameters (removed) - - | Parameter | Type | Default | - |-----------|------|---------| - | `Position` | `ToastPosition` | `TopRight` | - | `Timeout` | `int` | `7000` | - | `MaxToastCount` | `int` | `4` | - | `RemoveToastsOnNavigation` | `bool` | `true` | - -- ### Migration strategy - - Use `FluentMessageBar` for persistent notification messages, or implement a custom - toast/notification pattern using the V5 component set. - - ```csharp - // V4 - @inject IToastService ToastService - - ToastService.ShowCommunicationToast(new ToastParameters - { - Intent = ToastIntent.Success, - Title = "Saved!", - Content = new CommunicationToastContent { Subtitle = "Changes saved." } - }); - ``` - - ```xml - - - Changes saved. - - ``` +- Content components: `CommunicationToast`, `ConfirmationToast`, `ProgressToast` +- Content types: `CommunicationToastContent`, `ConfirmationToastContent`, `ProgressToastContent` 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..14eab3651f --- /dev/null +++ b/src/Core.Scripts/src/Components/Toast/FluentToast.ts @@ -0,0 +1,809 @@ +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 mediaRegion: HTMLDivElement; + private titleRegion: HTMLDivElement; + private actionRegion: HTMLDivElement; + private bodyRegion: HTMLDivElement; + private subtitleRegion: HTMLDivElement; + private footerRegion: HTMLDivElement; + private mediaSlot: HTMLSlotElement; + private titleSlot: HTMLSlotElement; + private actionSlot: 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; + private pauseReasons: Set = new Set(); + + // 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 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(); + const oldState = e?.oldState ?? (this.dialogIsOpen ? 'open' : 'closed'); + const newState = e?.newState ?? (oldState === 'open' ? 'closed' : 'open'); + 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.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'); + 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.actionRegion, + this.bodyRegion, + this.subtitleRegion, + this.footerRegion, + ); + + // 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] { + display: grid; + grid-template-columns: auto 1fr auto; + background: var(--colorNeutralBackground1); + 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; + 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); + } + + :host([inverted]) div[fuib][popover]{ + color: var(--colorNeutralForegroundInverted2); + background-color: var(--colorNeutralBackgroundInverted); + } + + .media { + display: flex; + grid-column-end: 2; + padding-top: 2px; + padding-inline-end: 8px; + font-size: var(--fontSizeBase400); + color: var(--colorNeutralForeground1); + } + + :host([inverted]) .media { + color: var(--colorNeutralForegroundInverted); + } + + :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; + grid-column-end: 3; + color: var(--colorNeutralForeground1); + word-break: break-word; + } + + :host([inverted]) .title { + color: var(--colorNeutralForegroundInverted2); + } + + .action { + display: flex; + align-items: start; + justify-content: end; + grid-column-end: -1; + padding-inline-start: 12px; + color: var(--colorBrandForeground1); + } + + :host([inverted]) .action { + color: var(--colorBrandForegroundInverted); + } + + .body { + grid-column: 2 / 3; + padding-top: 6px; + font-size: var(--fontSizeBase300); + line-height: var(--lineHeightBase300); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground1); + word-break: break-word; + } + + :host([inverted]) .body { + color: var(--colorNeutralForegroundInverted2); + } + + .subtitle { + grid-column: 2 / 3; + padding-top: 4px; + font-size: var(--fontSizeBase200); + line-height: var(--lineHeightBase200); + font-weight: var(--fontWeightRegular); + color: var(--colorNeutralForeground2); + } + + :host([inverted]) .subtitle { + color: var(--colorNeutralForegroundInverted2); + } + + .footer { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px; + grid-column: 2 / 3; + padding-top: 16px; + } + + :host([inverted]) slot[name="footer"]::slotted(fluent-link[clickable]) { + color: var(--colorBrandForegroundInverted); + } + + .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], + .action[hidden], + .body[hidden], + .subtitle[hidden], + .footer[hidden] { + display: none !important; + } + + /* Animations */ + :host div[fuib][popover]:popover-open { + opacity: 1; + animation: toast-enter 0.25s cubic-bezier(0.33, 0, 0, 1) forwards; + } + + :host div[fuib][popover].closing { + pointer-events: none; + overflow: hidden; + will-change: opacity, height, margin, padding; + animation: + 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 { + 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; + } + 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); + } + to { + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } + } + `); + this.shadowRoot!.adoptedStyleSheets = [ + ...(this.shadowRoot!.adoptedStyleSheets || []), + sheet + ]; + + 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.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')); + } + + 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.updateSlotStates(); + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => { + if (this.dialogIsOpen) { + this.updateToastStack(); + } + }); + this.resizeObserver.observe(this.dialog); + } + 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.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.clearTimeout(); + this.updateToastStack(); + } + + 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(); + } + }; + + 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', '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'); + } + } + } + + public showToast() { + if (!this.dialog) return; + + this.updateSlotStates(); + this.dialog.showPopover(); + this.updatePosition(); + this.updateToastStack(); + this.updateAccessibility(); + this.startTimeout(); + } + + 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'); + + // 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); + if (!settled) { + settled = true; + resolve(true); + } + } + }; + this.dialog.addEventListener('animationend', onAnimationEnd); + // Fallback in case animation doesn't fire + setTimeout(() => { + if (!settled) { + settled = true; + this.dialog.removeEventListener('animationend', onAnimationEnd); + resolve(false); + } + }, 650); + }); + + this.dialog.hidePopover(); + this.dialog.classList.remove('closing'); + this.classList.remove('animating'); + this.pauseReasons.clear(); + this.clearTimeout(); + this.updateToastStack(); + } + } + + private startTimeout() { + this.clearTimeout(); + 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; + } + } + + private clearTimeout() { + if (this.timeoutId !== null) { + window.clearTimeout(this.timeoutId); + this.timeoutId = null; + } + } + + 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-start' : 'bottom-end'); + 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'; + this.dialog.style.right = 'auto'; + this.dialog.style.bottom = 'auto'; + + let enterFrom = 'translateY(16px)'; + let enterTo = 'translateY(0)'; + + switch (position) { + case 'top-end': + this.dialog.style.top = `${verticalOffset}px`; + 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-start': + this.dialog.style.top = `${verticalOffset}px`; + 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-end': + this.dialog.style.bottom = `${verticalOffset}px`; + 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-start': + this.dialog.style.bottom = `${verticalOffset}px`; + 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': + 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; + } else { + // Ensure non-center positions do not retain stale transforms from a previous center position + this.dialog.style.transform = ''; + } + } + + 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, currentIndex) + .filter(toast => + toast.getToastPosition() === position && + toast.dialogIsOpen && + !toast.dialog.classList.contains('closing') + ); + + 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-start' : 'bottom-end'); + } + + 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, + 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'; + } + + private updateSlotStates() { + this.updateSlotState(this.mediaRegion, this.mediaSlot, 'has-media'); + this.updateSlotState(this.titleRegion, this.titleSlot, 'has-title'); + 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()) + ); + + region.hidden = !hasContent; + if (hasContent) { + this.setAttribute(hostAttribute, ''); + } else { + this.removeAttribute(hostAttribute); + } + + if (this.dialogIsOpen) { + this.updateToastStack(); + } + } + } + + 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/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.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts index 187c640373..dc958b62ae 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; @@ -58,7 +60,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/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/FluentToast.razor b/src/Core/Components/Toast/FluentToast.razor index 5e3ac5b247..23d6e4d6d9 100644 --- a/src/Core/Components/Toast/FluentToast.razor +++ b/src/Core/Components/Toast/FluentToast.razor @@ -1,25 +1,95 @@ @namespace Microsoft.FluentUI.AspNetCore.Components @inherits FluentComponentBase -@{ -#pragma warning disable IL2111 -} +@using Microsoft.FluentUI.AspNetCore.Components.Extensions -
- @if (Instance is not null) - { - - - + + @if (Icon is not null) + { + + } + else if (Type == ToastType.IndeterminateProgress) + { +
+ +
} else { - @ChildContent + + } + + @if (!string.IsNullOrEmpty(Title)) + { +
@Title
+ } + + @if (IsDismissable) + { + @if (!string.IsNullOrEmpty(DismissAction)) + { + + @DismissAction + + } + else + { + + } + } + + @if (!string.IsNullOrEmpty(Body) || BodyContent is not null) + { +
@Body@BodyContent
+ } + + @if (!string.IsNullOrEmpty(Subtitle)) + { +
@Subtitle
+ } + + @if (!string.IsNullOrEmpty(QuickAction1) || !string.IsNullOrEmpty(QuickAction2)) + { +
+ @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 9212ceef57..e9779976df 100644 --- a/src/Core/Components/Toast/FluentToast.razor.cs +++ b/src/Core/Components/Toast/FluentToast.razor.cs @@ -2,107 +2,380 @@ // This file is licensed to you under the MIT License. // ------------------------------------------------------------------------ -using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; namespace Microsoft.FluentUI.AspNetCore.Components; /// -/// The Toast component is a window overlaid on either the primary window or another Toast window. -/// Windows under a modal Toast are inert. +/// 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 : FluentComponentBase +public partial class FluentToast { /// - [DynamicDependency(nameof(OnToggleAsync))] - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(DialogToggleEventArgs))] public FluentToast(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; } + private IToastService ToastService { get; set; } = default!; /// - /// Gets or sets the instance used by the . + /// Gets or sets the toast instance associated with this component. /// [Parameter] public IToastInstance? Instance { get; set; } /// - /// Used when not calling the to show a Toast. + /// Gets or sets a value indicating whether the component is currently open. /// [Parameter] - public RenderFragment? ChildContent { get; set; } + public bool Opened { get; set; } /// - /// Command executed when the user clicks on the button. + /// 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 OnStateChange { get; set; } + public EventCallback OpenedChanged { get; set; } - /// - private bool LaunchedFromService => Instance is not null; + /// + /// 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; - /// - internal Task RaiseOnStateChangeAsync(DialogToggleEventArgs args) => RaiseOnStateChangeAsync(new ToastEventArgs(this, args)); + /// + /// Gets or sets the on the screen where the toast notification is displayed. + /// + [Parameter] + public ToastPosition? Position { get; set; } - /// - internal Task RaiseOnStateChangeAsync(IToastInstance instance, DialogState state) => RaiseOnStateChangeAsync(new ToastEventArgs(instance, state)); + /// + /// 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 type of toast notification to display. + /// + [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. + /// + /// + /// 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; } + + /// + /// 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 the toast is dismissable by the user. + /// + [Parameter] + public bool IsDismissable { get; set; } + + /// + /// Gets or sets the dismiss action label + /// + [Parameter] + public string? DismissAction { get; set; } + + /// + /// Gets or sets the icon rendered in the media slot of the toast. + /// + [Parameter] + public Icon? Icon { get; set; } + + /// + /// Gets or sets custom content rendered in the toast body, such as progress content managed through + /// . + /// + [Parameter] + public RenderFragment? BodyContent { get; set; } + + // + internal static Icon DismissIcon => new CoreIcons.Regular.Size20.Dismiss(); + + 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.Build(); + + /// + /// 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, ToastLifecycleStatus 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); + + internal Task RequestCloseAsync() + { + if (!Opened) + { + return Task.CompletedTask; + } + + Opened = false; + return InvokeAsync(StateHasChanged); + } + + internal async Task OnDismissActionClickedAsync() + { + await Instance!.DismissAsync(); + + if (Instance?.Options.DismissActionCallback is not null) + { + await Instance.Options.DismissActionCallback(); + } + } + + internal Task OnQuickAction1ClickedAsync() + => HandleQuickActionClickedAsync(Instance?.Options.QuickAction1Callback); + + internal Task OnQuickAction2ClickedAsync() + => HandleQuickActionClickedAsync(Instance?.Options.QuickAction2Callback); + + private async Task HandleQuickActionClickedAsync(Func? callback) + { + await Instance!.CloseAsync(ToastCloseReason.QuickAction); + + if (callback is not null) + { + await callback(); + } + } + + 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, + ToastIntent.Warning => Color.Warning, + ToastIntent.Error => Color.Error, + _ => Color.Info, + }; + } + + /// protected override Task OnAfterRenderAsync(bool firstRender) { - if (firstRender && LaunchedFromService) + if (firstRender && Instance is ToastInstance instance) { - var instance = Instance as ToastInstance; - if (instance is not null) + instance.FluentToast = this; + + if (!Opened) { - instance.FluentToast = this; + Opened = true; + return InvokeAsync(StateHasChanged); } - - // TODO - return Task.CompletedTask; // return ShowAsync(); } return Task.CompletedTask; } - /// - internal async Task OnToggleAsync(DialogToggleEventArgs args) + private async Task HandleToggleAsync(DialogToggleEventArgs args) { - if (string.CompareOrdinal(args.Id, Instance?.Id) != 0) + var expectedId = Instance?.Id ?? Id; + if (string.CompareOrdinal(args.Id, expectedId) != 0) + { + return; + } + + if (Instance is not ToastInstance toastInstance) { return; } - // TODO - await Task.CompletedTask; + var toastEventArgs = new ToastEventArgs(this, args); + if (toastEventArgs.Status == ToastLifecycleStatus.Dismissed) + { + toastInstance.LifecycleStatus = ToastLifecycleStatus.Dismissed; + await RaiseOnStatusChangeAsync(toastEventArgs); + } + + var toggled = string.Equals(args.NewState, "open", StringComparison.OrdinalIgnoreCase); + if (Opened != toggled) + { + Opened = toggled; + + if (OnToggle.HasDelegate) + { + await OnToggle.InvokeAsync(toggled); + } + + if (OpenedChanged.HasDelegate) + { + await OpenedChanged.InvokeAsync(toggled); + } + } + + if (string.Equals(args.Type, "toggle", StringComparison.OrdinalIgnoreCase) + && string.Equals(args.NewState, "closed", StringComparison.OrdinalIgnoreCase)) + { + toastInstance.ResultCompletion.TrySetResult(toastInstance.PendingCloseReason ?? ToastCloseReason.TimedOut); + toastInstance.PendingCloseReason = null; + toastInstance.LifecycleStatus = ToastLifecycleStatus.Unmounted; + + if (ToastService is ToastService toastService) + { + await toastService.RemoveToastFromProviderAsync(Instance); + } + + await RaiseOnStatusChangeAsync(toastInstance, ToastLifecycleStatus.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/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 dc72b414e7..180214fb4a 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor +++ b/src/Core/Components/Toast/FluentToastProvider.razor @@ -7,15 +7,35 @@ @attributes="AdditionalAttributes"> @if (ToastService != null) { - @foreach (var toast in ToastService.Items.Values.OrderBy(i => i.Index)) + @foreach (var toast in GetRenderedToasts()) { - + OnStatusChange="@GetOnStatusChangeCallback(toast)" + 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" + AdditionalAttributes="@toast.Options.AdditionalAttributes" /> } } diff --git a/src/Core/Components/Toast/FluentToastProvider.razor.cs b/src/Core/Components/Toast/FluentToastProvider.razor.cs index 2ab3e305a2..c92fd6fcaa 100644 --- a/src/Core/Components/Toast/FluentToastProvider.razor.cs +++ b/src/Core/Components/Toast/FluentToastProvider.razor.cs @@ -11,10 +11,13 @@ namespace Microsoft.FluentUI.AspNetCore.Components; /// public partial class FluentToastProvider : FluentComponentBase { + private readonly LibraryConfiguration configuration; + /// public FluentToastProvider(LibraryConfiguration configuration) : base(configuration) { Id = Identifier.NewId(); + this.configuration = configuration; } /// @@ -46,25 +49,71 @@ protected override void OnInitialized() ToastService.ProviderId = Id; ToastService.OnUpdatedAsync = async (item) => { + SynchronizeToastQueue(); await InvokeAsync(StateHasChanged); }; + + SynchronizeToastQueue(); } } /// - private static Action EmptyOnStateChange => (_) => { }; + internal static Action EmptyOnStatusChange => (_) => { }; - /// - /// Only for Unit Tests - /// - /// - internal void UpdateId(string? id) + private EventCallback GetOnStatusChangeCallback(IToastInstance toast) + => EventCallback.Factory.Create(this, toast.Options.OnStatusChange ?? EmptyOnStatusChange); + + private int GetTimeout(IToastInstance toast) + => toast.Options.Timeout ?? configuration.Toast.Timeout; + + private ToastPosition? GetPosition(IToastInstance toast) + => toast.Options.Position ?? configuration.Toast.Position; + + private int GetVerticalOffset(IToastInstance toast) + => toast.Options.VerticalOffset ?? configuration.Toast.VerticalOffset; + + private int GetHorizontalOffset(IToastInstance toast) + => toast.Options.HorizontalOffset ?? configuration.Toast.HorizontalOffset; + + private bool GetPauseOnHover(IToastInstance toast) + => toast.Options.PauseOnHover ?? configuration.Toast.PauseOnHover; + + private bool GetPauseOnWindowBlur(IToastInstance toast) + => toast.Options.PauseOnWindowBlur ?? configuration.Toast.PauseOnWindowBlur; + + private IEnumerable GetRenderedToasts() + => ToastService?.Items.Values + .Where(toast => toast.LifecycleStatus is ToastLifecycleStatus.Visible or ToastLifecycleStatus.Dismissed) + .OrderByDescending(toast => toast.Index) + ?? Enumerable.Empty(); + + private void SynchronizeToastQueue() { - Id = id; + if (ToastService is null) + { + return; + } - if (ToastService is not null) + var maxToastCount = configuration.Toast.MaxToastCount; + 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) + .OrderByDescending(toast => toast.Index) + .ToList(); + + foreach (var toast in queuedToasts) { - ToastService.ProviderId = id; + if (activeCount >= maxToastCount) + { + break; + } + + if (toast is ToastInstance instance) + { + 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 4ee9bcd2b3..b3e572d82f 100644 --- a/src/Core/Components/Toast/Services/IToastInstance.cs +++ b/src/Core/Components/Toast/Services/IToastInstance.cs @@ -10,13 +10,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public interface IToastInstance { /// - /// Gets the component type of the Toast. - /// - internal Type ComponentType { get; } - - /// - /// 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; } @@ -31,33 +26,38 @@ 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. + /// Gets the lifecycle status of the toast. /// - /// - Task CancelAsync(); + ToastLifecycleStatus LifecycleStatus { get; } /// - /// 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. + /// + /// Reason to close the Toast with. + /// + Task CloseAsync(ToastCloseReason reason); + + /// + /// Dismisses the Toast. /// - /// Result to close the Toast with. /// - Task CloseAsync(ToastResult result); + Task DismissAsync(); /// - /// Closes the Toast with the specified result. + /// Updates the toast options while the toast is shown. /// - /// Result to close the Toast with. + /// The action that mutates the current options. /// - Task CloseAsync(T result); + Task UpdateAsync(Action update); } diff --git a/src/Core/Components/Toast/Services/IToastService.cs b/src/Core/Components/Toast/Services/IToastService.cs index 6822346e27..9eb0b46ed6 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; /// @@ -13,26 +10,62 @@ 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, ToastCloseReason reason); + + /// + /// Dismisses the specified toast instance. + /// + /// Instance of the toast to dismiss. /// - Task CloseAsync(IToastInstance Toast, ToastResult result); + Task DismissAsync(IToastInstance Toast); + + /// + /// 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 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); + + /// + /// 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. + /// + /// 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/LibraryToastOptions.cs b/src/Core/Components/Toast/Services/LibraryToastOptions.cs new file mode 100644 index 0000000000..5a7fde9382 --- /dev/null +++ b/src/Core/Components/Toast/Services/LibraryToastOptions.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// This file is licensed to you under the MIT License. +// ------------------------------------------------------------------------ +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Options for the Fluent UI Blazor component library. +/// +public class LibraryToastOptions +{ + private const int _defaultMaxToastCount = 4; + private const int _defaultTimeout = 7000; + private const ToastPosition _defaultPosition = ToastPosition.BottomEnd; + private const int _defaultVerticalOffset = 16; + private const int _defaultHorizontalOffset = 20; + private const bool _defaultPauseOnHover = true; + private const bool _defaultPauseOnWindowBlur = true; + + /// + /// Initializes a new instance of the class. + /// + internal LibraryToastOptions() + { + } + + /// + /// Gets or sets the maximum number of toasts displayed at the same time. + /// + public int MaxToastCount { get; set; } = _defaultMaxToastCount; + + /// + /// Gets or sets the default timeout duration in milliseconds for visible toasts. + /// + public int Timeout { get; set; } = _defaultTimeout; + + /// + /// Gets or sets the default toast position. + /// + public ToastPosition? Position { get; set; } = _defaultPosition; + + /// + /// Gets or sets the default vertical offset in pixels. + /// + public int VerticalOffset { get; set; } = _defaultVerticalOffset; + + /// + /// Gets or sets the default horizontal offset in pixels. + /// + public int HorizontalOffset { get; set; } = _defaultHorizontalOffset; + + /// + /// Gets or sets a value indicating whether visible toasts pause timeout while hovered. + /// + public bool PauseOnHover { get; set; } = _defaultPauseOnHover; + + /// + /// Gets or sets a value indicating whether visible toasts pause timeout while the window is blurred. + /// + public bool PauseOnWindowBlur { get; set; } = _defaultPauseOnWindowBlur; +} diff --git a/src/Core/Components/Toast/Services/ToastInstance.cs b/src/Core/Components/Toast/Services/ToastInstance.cs index 05c0b2f6ac..25d534df1a 100644 --- a/src/Core/Components/Toast/Services/ToastInstance.cs +++ b/src/Core/Components/Toast/Services/ToastInstance.cs @@ -12,61 +12,62 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public class ToastInstance : IToastInstance { private static long _counter; - private readonly Type _componentType; - internal readonly TaskCompletionSource ResultCompletion = new(); + internal readonly TaskCompletionSource ResultCompletion = new(); /// - internal ToastInstance(IToastService toastService, 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); } - /// - Type IToastInstance.ComponentType => _componentType; - /// internal IToastService ToastService { get; } /// internal FluentToast? FluentToast { get; set; } + /// + internal ToastCloseReason? PendingCloseReason { get; set; } + /// public ToastOptions Options { get; internal set; } /// - public Task Result => ResultCompletion.Task; + public Task Result => ResultCompletion.Task; + + /// + public ToastLifecycleStatus LifecycleStatus { get; internal set; } = ToastLifecycleStatus.Queued; - /// " + /// public string Id { get; } - /// " + /// public long Index { get; } - /// - public Task CancelAsync() + /// + public Task CloseAsync() { - return ToastService.CloseAsync(this, ToastResult.Cancel()); + return ToastService.CloseAsync(this, ToastCloseReason.Programmatic); } - /// - public Task CloseAsync() + /// + public Task CloseAsync(ToastCloseReason reason) { - return ToastService.CloseAsync(this, ToastResult.Ok()); + return ToastService.CloseAsync(this, reason); } - /// - public Task CloseAsync(T result) + /// + public Task DismissAsync() { - return ToastService.CloseAsync(this, ToastResult.Ok(result)); + return ToastService.DismissAsync(this); } - /// - public Task CloseAsync(ToastResult result) + /// + public Task UpdateAsync(Action update) { - return ToastService.CloseAsync(this, result); + return ToastService.UpdateToastAsync(this, update); } } diff --git a/src/Core/Components/Toast/Services/ToastOptions.cs b/src/Core/Components/Toast/Services/ToastOptions.cs index 91eeb55ddf..544298458f 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; @@ -19,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) @@ -34,25 +34,24 @@ 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. /// 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; } @@ -67,15 +66,120 @@ public ToastOptions(Action implementationFactory) public IReadOnlyDictionary? AdditionalAttributes { get; set; } /// - /// Gets a list of Toast parameters. - /// Each parameter must correspond to a `[Parameter]` property defined in the component. + /// Gets or sets the timeout duration for the Toast in milliseconds. /// - public IDictionary Parameters { get; set; } = new Dictionary(StringComparer.Ordinal); + public int? Timeout { get; set; } /// - /// Gets or sets the action raised when the Toast is opened or closed. + /// Gets or sets the toast position on screen. /// - public Action? OnStateChange { get; set; } + public ToastPosition? Position { get; set; } + + /// + /// Gets or sets the vertical offset in pixels. + /// + public int? VerticalOffset { get; set; } + + /// + /// Gets or sets the horizontal offset in pixels. + /// + 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; } = 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. + /// + 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 first quick action label. + /// + 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 toast can be dismissed by the user. + /// + public bool IsDismissable { get; set; } + + /// + /// Gets or sets dismiss action label. + /// + public string? DismissAction { get; set; } + + /// + /// Gets or sets the callback invoked when the dismiss action is clicked. + /// + public Func? DismissActionCallback { get; set; } + + /// + /// Gets or sets the icon rendered in the media slot. + /// + public Icon? Icon { get; set; } + + /// + /// Gets or sets custom content rendered in the default slot, such as progress content updated through + /// . + /// + public RenderFragment? BodyContent { get; set; } + + /// + /// Gets or sets the action raised when the toast lifecycle status changes. + /// + public Action? OnStatusChange { get; set; } /// /// Gets the class, including the optional and values. 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 a1dc17d599..3a4fa4e5da 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; @@ -35,56 +30,120 @@ 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; - // Raise the ToastState.Closing event - ToastInstance?.FluentToast?.RaiseOnStateChangeAsync(Toast, DialogState.Closing); + if (ToastInstance?.FluentToast is FluentToast fluentToast) + { + ToastInstance.PendingCloseReason = reason; + await fluentToast.RequestCloseAsync(); + return; + } + + if (ToastInstance is not null) + { + ToastInstance.LifecycleStatus = ToastLifecycleStatus.Unmounted; + } // Remove the Toast from the ToastProvider 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); + // Raise the final ToastLifecycleStatus.Unmounted event + if (ToastInstance is not null) + { + ToastInstance.Options.OnStatusChange?.Invoke(new ToastEventArgs(ToastInstance, ToastLifecycleStatus.Unmounted)); + } } - /// - public Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(ToastOptions? options = null) where TToast : ComponentBase + /// + public async Task DismissAsync(IToastInstance Toast) { - return ShowToastAsync(typeof(TToast), options ?? new ToastOptions()); + await CloseAsync(Toast, ToastCloseReason.Dismissed); } - /// - public Task ShowToastAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TToast>(Action options) where TToast : ComponentBase + /// + public async Task DismissAsync(string toastId) { - return ShowToastAsync(typeof(TToast), new ToastOptions(options)); + if (string.IsNullOrWhiteSpace(toastId) || !ServiceProvider.Items.TryGetValue(toastId, out var toast)) + { + return false; + } + + await CloseAsync(toast, ToastCloseReason.Dismissed); + return true; } - /// - private async Task ShowToastAsync([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType, ToastOptions options) + /// + public async Task DismissAllAsync() + { + var toasts = ServiceProvider.Items.Values.ToList(); + + foreach (var toast in toasts) + { + await CloseAsync(toast, ToastCloseReason.Dismissed); + } + + return toasts.Count; + } + + /// + public async Task ShowToastAsync(ToastOptions? options = null) { - if (!componentType.IsSubclassOf(typeof(ComponentBase))) + var instance = await ShowToastInstanceCoreAsync(options ?? new ToastOptions()); + return await instance.Result; + } + + /// + 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) + { + 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)); } + update(instance.Options); + await ServiceProvider.OnUpdatedAsync.Invoke(instance); + } + + /// + private async Task ShowToastInstanceCoreAsync(ToastOptions options) + { if (this.ProviderNotAvailable()) { throw new FluentServiceProviderException(); } - var instance = new ToastInstance(this, componentType, options); + var instance = new ToastInstance(this, options); + 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.")); await ServiceProvider.OnUpdatedAsync.Invoke(instance); - return await instance.Result; + return instance; } /// 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/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/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/ToastLifecycleStatus.cs b/src/Core/Enums/ToastLifecycleStatus.cs new file mode 100644 index 0000000000..48b9a260a8 --- /dev/null +++ b/src/Core/Enums/ToastLifecycleStatus.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 ToastLifecycleStatus +{ + /// + /// 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/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/Enums/ToastPosition.cs b/src/Core/Enums/ToastPosition.cs new file mode 100644 index 0000000000..1a1d982c28 --- /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-end")] + TopEnd, + + /// + [Description("top-start")] + TopStart, + + /// + [Description("top-center")] + TopCenter, + + /// + [Description("bottom-end")] + BottomEnd, + + /// + [Description("bottom-start")] + BottomStart, + + /// + [Description("bottom-center")] + BottomCenter, +} 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/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/Components/Toast/Services/ToastEventArgs.cs b/src/Core/Events/ToastEventArgs.cs similarity index 69% rename from src/Core/Components/Toast/Services/ToastEventArgs.cs rename to src/Core/Events/ToastEventArgs.cs index dc16d0766b..744d395dc9 100644 --- a/src/Core/Components/Toast/Services/ToastEventArgs.cs +++ b/src/Core/Events/ToastEventArgs.cs @@ -20,41 +20,30 @@ internal ToastEventArgs(FluentToast toast, string? id, string? eventType, string { Id = id ?? string.Empty; Instance = toast.Instance; + Status = ToastLifecycleStatus.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 = ToastLifecycleStatus.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 = ToastLifecycleStatus.Dismissed; } } - else - { - State = DialogState.Closed; - } } /// - internal ToastEventArgs(IToastInstance instance, DialogState state) + internal ToastEventArgs(IToastInstance instance, ToastLifecycleStatus 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 ToastLifecycleStatus Status { get; } /// /// Gets the instance used by the . diff --git a/src/Core/Infrastructure/LibraryConfiguration.cs b/src/Core/Infrastructure/LibraryConfiguration.cs index bb0105211f..6e22a1743f 100644 --- a/src/Core/Infrastructure/LibraryConfiguration.cs +++ b/src/Core/Infrastructure/LibraryConfiguration.cs @@ -50,6 +50,11 @@ public class LibraryConfiguration /// public LibraryTooltipOptions Tooltip { get; } = new LibraryTooltipOptions(); + /// + /// Gets the options for the library toast. + /// + public LibraryToastOptions Toast { get; } = new LibraryToastOptions(); + /// /// Gets the sanitized markup string for safe rendering in HTML/Styles contexts. /// diff --git a/tests/Core/Components/Toast/FluentToastProviderTests.razor b/tests/Core/Components/Toast/FluentToastProviderTests.razor new file mode 100644 index 0000000000..ff4787c240 --- /dev/null +++ b/tests/Core/Components/Toast/FluentToastProviderTests.razor @@ -0,0 +1,255 @@ +@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(ToastLifecycleStatus.Queued, statuses); + + var firstToast = provider.FindComponent(); + await CloseToastAndWaitAsync(firstToast, ToastCloseReason.Programmatic); + await firstToastTask; + await Task.CompletedTask; + + Assert.Contains("Second toast", provider.Markup); + Assert.Contains(ToastLifecycleStatus.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_RendersNewestVisibleToastFirst() + { + var service = new TestToastService(); + 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, 2)); + + var method = typeof(FluentToastProvider).GetMethod("SynchronizeToastQueue", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; + method.Invoke(provider.Instance, null); + provider.Render(); + + var toasts = provider.FindComponents().ToList(); + + 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 + { + 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(IToastInstance Toast) + => 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 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.FluentToast_Render.verified.razor.html b/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html deleted file mode 100644 index ca803b07ca..0000000000 --- a/tests/Core/Components/Toast/FluentToastTests.FluentToast_Render.verified.razor.html +++ /dev/null @@ -1,10 +0,0 @@ - - - -
-
-
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 6b9127b8d4..7bef35ff1e 100644 --- a/tests/Core/Components/Toast/FluentToastTests.razor +++ b/tests/Core/Components/Toast/FluentToastTests.razor @@ -1,9 +1,7 @@ -@using Xunit; +@using Xunit; @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,208 +22,456 @@ ///
public IRenderedComponent ToastProvider { get; } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_Render() + private static async Task CloseToastAndWaitAsync(IRenderedComponent toast, ToastCloseReason reason) { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); + 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 - 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; // 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); - Assert.Contains("fuib", cut.Markup); + Assert.Contains("fluent-toast-b", cut.Markup); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_OpenClose() + [Fact] + public void FluentToast_Icon_RendersCustomIcon() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions() + var icon = new CoreIcons.Regular.Size20.Dismiss(); + var cut = Render(parameters => parameters + .Add(p => p.Body, "Toast body") + .Add(p => p.Icon, icon)); + + Assert.Same(icon, cut.Instance.Icon); + Assert.Contains("slot=\"media\"", cut.Markup); + 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() + { + 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 => { - AutoClose = true, - }; + 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 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() + { + var cut = Render(@); + + Assert.Contains("Toast subtitle", cut.Markup); + } + + [Fact] + public void FluentToast_IsDismissable_TrueWithDismissAction_RendersDismissLink() + { + var cut = Render(@); + + 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] + 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() + { // 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 = "Auto-close body"; + }); - // Wait for the toast to be closed (auto-closed on second render) + await Task.CompletedTask; + + var toast = ToastProvider.FindComponent(); + await CloseToastAndWaitAsync(toast, ToastCloseReason.Programmatic); + + // 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()); + Assert.Equal(ToastCloseReason.Programmatic, result); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_Instance() + [Fact] + public async Task FluentToast_RequestCloseAsync_WhenNotOpened_DoesNothing() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); + 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 - 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; - // 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 CloseToastAndWaitAsync(toast, 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)] + [Fact] 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; // 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; // Assert - Assert.True(result.Cancelled); + Assert.Equal(ToastCloseReason.Dismissed, result); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] 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; - // 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 CloseToastAndWaitAsync(toast, 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)] + [Fact] 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; // 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; // Assert - Assert.False(result.Cancelled); - Assert.Null(result.Value); + Assert.Equal(ToastCloseReason.Programmatic, result); } - [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) + [Theory] + [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) { - // 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.OnStatusChange = (args) => + { + capturedArgs = args; + }; + }); // 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() { @@ -234,28 +480,24 @@ 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); } - [Fact(Timeout = TEST_TIMEOUT)] + [Fact] 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; @@ -271,181 +513,181 @@ Assert.Contains("Toast Content", ToastProvider.Markup); } - [Fact(Timeout = TEST_TIMEOUT)] + [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() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); - var stateChanges = new List(); + var statusChanges = 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.Timeout = 1000; + options.OnStatusChange = (args) => + { + 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!, ToastResult.Ok("done")); + 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; // Assert - Assert.Contains(DialogState.Closing, stateChanges); - Assert.Contains(DialogState.Closed, stateChanges); - Assert.False(result.Cancelled); - Assert.Equal("done", result.GetValue()); + Assert.Contains(ToastLifecycleStatus.Dismissed, statusChanges); + Assert.Contains(ToastLifecycleStatus.Unmounted, statusChanges); + Assert.Equal(ToastCloseReason.Programmatic, result); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_OnStateChange_NoDelegate() + [Fact] + public async Task FluentToast_OnStatusChange_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; - // 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(ToastLifecycleStatus.Visible, args.Status); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_ComponentRule() + [Fact] + 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); - } - - [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.Parameters.Add(nameof(Templates.ToastRender.Name), "John"); - }); - }); + await Task.CompletedTask; - // 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); - } + var toast = ToastProvider.FindComponent(); + await toast.Instance.Instance!.UpdateAsync(options => options.Body = "Updated body"); - [Fact] - public void FluentToast_ToastResult() - { - // Arrange - var result = new ToastResult(content: "OK", cancelled: false); + ToastProvider.Render(); - // Assert - Assert.Equal("OK", result.Value); - Assert.Equal("OK", result.GetValue()); - Assert.Equal(0, result.GetValue()); - Assert.False(result.Cancelled); + Assert.Contains("Updated body", ToastProvider.Markup); } [Fact] - public void FluentToast_ToastResult_Ok() + public void FluentToast_Options_Ctor() { // Arrange - var result = ToastResult.Ok("My content"); + 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 content", result.Value); - Assert.False(result.Cancelled); + Assert.Equal("My data", options.Data); + Assert.Equal(ToastType.Communication, options.Type); + Assert.Equal("my-id", optionsWithFactory.Id); + Assert.Equal(ToastType.Confirmation, optionsWithFactory.Type); } - [Fact] - public void FluentToast_ToastResult_Ok_NoValue() + [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 = ToastResult.Ok(); + var cut = Render(parameters => parameters.Add(p => p.Body, "test")); + var color = cut.Instance.GetIntentColor(intent); - // Assert - Assert.Null(result.Value); - Assert.False(result.Cancelled); + Assert.Equal(expectedColor, color); } - [Fact] - public void FluentToast_ToastResult_Cancel() + [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) { - // Arrange - var result = ToastResult.Cancel("My content"); + var cut = Render(parameters => parameters + .Add(p => p.Body, "test") + .Add(p => p.Inverted, true)); + var color = cut.Instance.GetIntentColor(intent); - // Assert - Assert.Equal("My content", result.Value); - Assert.True(result.Cancelled); + Assert.Equal(expectedColor, color); } - [Fact] - public void FluentToast_ToastResult_Cancel_NoValue() + [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 = ToastResult.Cancel(); + var cut = Render(parameters => parameters.Add(p => p.Intent, intent)); + var intentIcon = cut.Instance.IntentIcon; - // Assert - Assert.Null(result.Value); - Assert.True(result.Cancelled); + Assert.NotNull(intentIcon); + Assert.Equal(expectedIconTypeName, intentIcon.GetType().Name); } [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 @@ -455,12 +697,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; @@ -473,36 +714,147 @@ Assert.Contains("color: red;", toastOptions.StyleValue); } - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_ProviderClassStyle() + [Fact] + public async Task FluentToast_AdditionalAttributes() { - // 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); + // Act + _ = 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(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_AdditionalAttributes() + [Fact] + public async Task FluentToast_QuickAction1Callback() { - // Arrange - var renderOptions = new Templates.ToastRenderOptions(); + var invoked = false; - // 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" } }; - }); + var toastTask = ToastService.ShowToastAsync(options => + { + options.Body = "Toast Content"; + options.QuickAction1 = "Undo"; + options.QuickAction1Callback = () => + { + invoked = true; + return 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("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_DismissById() + { + var toastTask = ToastService.ShowToastAsync(options => + { + options.Id = "dismiss-me"; + options.Body = "Dismiss by id"; + }); - // 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); + 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] + public async Task FluentToast_DismissAll() + { + var firstToastTask = ToastService.ShowToastAsync(options => + { + options.Id = "dismiss-all-1"; + options.Body = "First toast"; + }); + + var secondToastTask = ToastService.ShowToastAsync(options => + { + options.Id = "dismiss-all-2"; + options.Body = "Second toast"; + }); + + await Task.CompletedTask; + + 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); } [Fact] @@ -518,20 +870,25 @@ Assert.True(true); } - - [Fact(Timeout = TEST_TIMEOUT)] - public async Task FluentToast_WithInstance() + private sealed class TestNonToastInstance(string id) : IToastInstance { - // Act - var toastTask = ToastService.ShowToastAsync(); + public string Id { get; } = id; - // Don't wait for the toast to be closed - await Task.CompletedTask; + public long Index => 0; - ToastProvider.Render(); + public ToastOptions Options { get; } = new(); - // Assert - var x = ToastProvider.Markup; - ToastProvider.Verify(); + public Task Result => Task.FromResult(ToastCloseReason.Programmatic); + + public ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; + + public Task DismissAsync() => 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/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(); - } - } -} diff --git a/tests/Core/Components/Toast/ToastInstanceTests.razor b/tests/Core/Components/Toast/ToastInstanceTests.razor new file mode 100644 index 0000000000..33e5dc1898 --- /dev/null +++ b/tests/Core/Components/Toast/ToastInstanceTests.razor @@ -0,0 +1,325 @@ +@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 DismissAsync_CallsServiceCloseWithDismissedReason() + { + var service = new TestToastService(); + var instance = new ToastInstance(service, new ToastOptions()); + + await instance.DismissAsync(); + + 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); + } + + [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(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(ToastLifecycleStatus.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((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() + { + 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; } + + 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(IToastInstance Toast) + => CloseAsync(Toast, ToastCloseReason.Dismissed); + + 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 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; + } + + 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); + + public void SetProviderId(string providerId) + => typeof(IFluentServiceBase) + .GetProperty(nameof(IFluentServiceBase.ProviderId))! + .SetValue(ServiceProvider, providerId); + } + + 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 ToastLifecycleStatus LifecycleStatus => ToastLifecycleStatus.Visible; + + public Task CancelAsync() => Task.CompletedTask; + + public Task DismissAsync() => 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/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"); } ///