diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index fa9194c962b8..21a623a9ef38 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -80,8 +80,8 @@ body: id: bug-version attributes: label: Version (bug) - description: With which version are you experiencing the issue? Note that v6 is no longer actively maintained and most bug fixes will be reserved for v7. - placeholder: 7.x.x + description: With which version are you experiencing the issue? + placeholder: 8.x.x validations: required: true - type: input @@ -89,7 +89,7 @@ body: attributes: label: Version (working) description: Did it work on a previous version? If so, which version? - placeholder: 6.x.x + placeholder: 7.x.x - type: dropdown id: browsers attributes: diff --git a/README.md b/README.md index dddbdc151e79..efd05175c38c 100644 --- a/README.md +++ b/README.md @@ -22,21 +22,21 @@ MudBlazor is an ambitious Material Design component framework for Blazor with an emphasis on ease of use and clear structure. It is perfect for .NET developers who want to rapidly build web applications without having to struggle with CSS and Javascript. MudBlazor, being written entirely in C#, empowers you to adapt, fix or extend the framework. There are plenty of examples in the documentation, which makes understanding and learning MudBlazor very easy. -## Documentation & Demo -- [MudBlazor.com](https://mudblazor.com) -- [Try.MudBlazor.com](https://try.mudblazor.com/) - -## Why is MudBlazor so successful? -- Clean and aesthetic graphic design based on Material Design. -- Clear and easy to understand structure. -- Good documentation with many examples and source snippets. -- All components are written entirely in C#, no JavaScript allowed (except where absolutely necessary). -- Users can make beautiful apps without needing CSS (but they can of course use CSS too). -- No dependencies on other component libraries, 100% control over components and features. -- Stability! We strive for a complete test coverage. -- Releases often so developers can get their PRs and fixes in a timely fashion. - -## Prerequisites +## ๐Ÿ“˜ Documentation & Demo +- ๐ŸŒ [MudBlazor.com](https://mudblazor.com) โ€“ Full documentation +- โšก [Try.MudBlazor.com](https://try.mudblazor.com/) โ€“ Interactive playground + +## ๐Ÿ’Ž Why is MudBlazor so successful? +- Aesthetic design that follows Material Design principles. +- Intuitive, consistent component structure. +- Rich documentation with tons of examples and code snippets. +- Fully written in C# with minimal JavaScript. +- Build beautiful UIs without CSS (but fully customizable when needed). +- No third-party component dependencies โ€“ maximum flexibility. +- Strive for stability with extensive test coverage. +- Frequent releases so devs get their fixes and features fast. + +## โš™๏ธ Prerequisites | MudBlazor | .NET | Support | | :--- | :---: | :---: | | 1.x.x - 2.0.x | .NET 3.1 | Ended 03/2021 | @@ -46,58 +46,62 @@ MudBlazor is an ambitious Material Design component framework for Blazor with an | 8.x.x | [.NET 8](https://dotnet.microsoft.com/download/dotnet/8.0), [.NET 9](https://dotnet.microsoft.com/download/dotnet/9.0) | :heavy_check_mark: | > [!TIP] -> If you're updating to a newer version of MudBlazor, make sure to check out the [Migration Guide](https://github.com/MudBlazor/MudBlazor/blob/dev/MIGRATION.md) for detailed instructions on handling breaking changes and new features. +> Upgrading? Check our [Migration Guide](https://github.com/MudBlazor/MudBlazor/blob/dev/MIGRATION.md) for help with breaking changes. > [!WARNING] > 1. Static rendering is not supported - [Learn more](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes). -> 2. We cannot guarantee compatibility with browsers no longer maintained by their publishers. -For the best experience, use an up-to-date browser - See [Blazor supported platforms](https://learn.microsoft.com/aspnet/core/blazor/supported-platforms). +> 2. Older browsers may not be supported. Use a modern, up-to-date browser - [Blazor supported platforms](https://learn.microsoft.com/aspnet/core/blazor/supported-platforms). -## Stats +## ๐Ÿ“Š Repo Stats ![Alt](https://repobeats.axiom.co/api/embed/db53a44092e88fc34a4c0f37db12773b6787ec7e.svg "Repobeats analytics image") -## Contributing -๐Ÿ‘‹ Thanks for wanting to contribute! +## ๐Ÿค Contributing +Thanks for wanting to contribute! ๐Ÿ‘‹ Contributions from the community are what makes MudBlazor successful. -If you are familiar with technologies like C#, Blazor, JavaScript, or CSS, and wish to give something back, please consider submitting a pull request! -We try to merge all non-breaking bugfixes and will deliberate the value of new features for the community. -Please note there is no guarantee your PR will be merged, so if you want to be sure before investing the work, feel free to [contact the team](https://discord.gg/mudblazor) first. +If you're comfortable with C#, Blazor, JavaScript, or CSS, we'd love your help! +Whether it's fixing bugs, adding features, or improving documentation, every contribution counts. -Check out the [contribution guidelines](/CONTRIBUTING.md) to understand our goals and learn more about the internals of the project. +We aim to review and merge non-breaking pull requests quickly. +For larger features or changes, feel free to chat with us [on Discord](https://discord.gg/mudblazor) first to get feedback before diving in. -## Getting Started -We have premade templates available at the [MudBlazor.Templates](https://github.com/mudblazor/Templates) repository. +๐Ÿ“š Check out our [contribution guidelines](/CONTRIBUTING.md) to get started and learn more about how the project works. -Full installation instructions can be found [on our website](https://mudblazor.com/getting-started/installation). +## ๐Ÿš€ Getting Started +We have ready-to-go templates at the [MudBlazor.Templates](https://github.com/mudblazor/Templates) repository, or follow the quick install guide to set things up manually: -### Quick Installation Guide -Install Package +### ๐Ÿ› ๏ธ Quick Install +Install Package: ``` dotnet add package MudBlazor ``` -Add the following to `_Imports.razor` + +Add to `_Imports.razor`: ```razor @using MudBlazor ``` -Add the following to the `MainLayout.razor` or `App.razor` + +Add to the `MainLayout.razor` or `App.razor`: ```razor ``` -Add the following to your HTML `head` section, it's either `index.html` or `_Layout.cshtml`/`_Host.cshtml`/`App.razor` depending on whether you're running WebAssembly or Server + +Add to your HTML `head` section: ```razor ``` -Next, add the following to the default Blazor script at the end of the `body` +It's either `index.html` or `_Layout.cshtml`/`_Host.cshtml`/`App.razor` depending on whether you're running WebAssembly or Server. + +Next, add to the default Blazor script at the end of the `body`: ```razor ``` -Add the following to the relevant sections of `Program.cs` +Add to the relevant sections of `Program.cs`: ```c# using MudBlazor.Services; ``` @@ -105,7 +109,10 @@ using MudBlazor.Services; builder.Services.AddMudServices(); ``` -### Usage +### ๐Ÿ”— Full Setup Guide +For more details, see the [complete installation guide](https://mudblazor.com/getting-started/installation) on our website. + +### ๐Ÿ’ป Example Usage ```razor MudBlazor is @Text diff --git a/ROADMAP.md b/ROADMAP.md index 341c1b968be1..2673dbe9e986 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,48 +1,44 @@ # MudBlazor Roadmap -MudBlazor continues to thrive with millions of downloads on [NuGet](https://www.nuget.org/packages/MudBlazor/) and thousands of members on [Discord](https://discord.gg/mudblazor). +MudBlazor continues to grow with millions of downloads on [NuGet](https://www.nuget.org/packages/MudBlazor/) and a thriving community on [Discord](https://discord.gg/mudblazor). -With the recent release of v7, and now v8, we've made significant progress in ensuring the library remains healthy and robust. -Not only have we introduced countless new features and bug fixes, but we've built a more stable foundation for future development, allowing us to continue moving the project forward to meet the needs of the community. +With the release of v7 and now v8, we've introduced many new features, fixed long-standing issues, and built a stronger foundation for the future. The focus has been on stability, performance, and evolving with the needs of the community. -## Migration +## ๐Ÿ” Migration ### .NET Lifecycle -Builds are no longer provided for .NET 6 or .NET 7. -This change aligns with Microsoft's [.NET support policies](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core) and lets us focus on providing the best support for newer editions. +We no longer provide updates for .NET 6 or .NET 7. +This change aligns with Microsoft's [.NET support policies](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core) and lets us focus on newer versions. -Update your SDK to .NET 8 or .NET 9 to get the latest packages. +To stay current, update your SDK to .NET 8 or .NET 9. -### MudBlazor Lifecycle +### MudBlazor Version Support -**v8:** -This is the current recommended version and all new features and development effort will be directed here. +- **v8**: The current recommended version. All new features and improvements go here. +- **v7**: We'll continue to accept bug fixes from the community, but no new features. +- **v6**: Official support ended in January 2025. No further updates will be released. -**v7:** -We will continue to accept bugfixes from the community for v7 but no new features will be introduced and we won't accept any requests. +### Migrating from Older Versions -**v6:** -Support for v6 ended 01/2025 and no new patches will be released. +v7 introduced necessary breaking changes. v8 builds on that with an aim for smoother transitions. -### Migrating from a Previous Version +For help with upgrading, check out: -The release of v7 addressed several long-standing needs for breaking changes and our goal for v8 is to build on that success while ensuring a smoother transition. +- [v7 Migration Guide](https://github.com/MudBlazor/MudBlazor/issues/8447) +- [v8 Migration Guide](https://github.com/MudBlazor/MudBlazor/issues/9953) +- [Built-in Analyzer](https://mudblazor.com/features/analyzers) โ€“ flags removed or renamed parameters -For help with migrating to a new version, please refer to the [v7.0.0 Migration Guide](https://github.com/MudBlazor/MudBlazor/issues/8447) or [v8.0.0 Migration Guide](https://github.com/MudBlazor/MudBlazor/issues/9953). -In addition we've created a [built-in analyzer](https://mudblazor.com/features/analyzers) that identifies parameters that have been removed or renamed. +## ๐Ÿ”ฎ Looking Ahead -## Looking Ahead +We're committed to improving MudBlazor, but since it's a community-driven project, we can't promise exact release timelines or specific features. -We are passionate about improving the library but can't make firm promises about release timelines or specific features as the MudBlazor team is made up of volunteers who contribute to the project in their personal time. +Our focus is to keep the library stable, performant, and modern. The pace of development depends on volunteer availability, and we appreciate your patience and support as we continue to evolve. -Our goal is to keep MudBlazor stable, performant, and evolving with thoughtful enhancements, but the pace of development depends on the availability of contributors. -We appreciate your support as we continue to work on keeping MudBlazor the premiere UI library for Blazor. +## ๐Ÿค Get Involved -## Get Involved +MudBlazor wouldn't be what it is without our amazing community. +Whether you work with C#, Blazor, JavaScript, CSS, testing, or docs, your contributions are always welcome! -MudBlazor thrives thanks to our amazing community. Whether you specialize in C#, Blazor, JavaScript, CSS, documentation, testing, or design, your help is always welcome. - -If youโ€™re interested in contributing, please check out [our contribution guidelines](https://github.com/MudBlazor/MudBlazor/blob/dev/CONTRIBUTING.md) to get started. - -You can also open an issue, start a discussion, or tag a member of the team in relevant GitHub issues to collaborate with us. +Read our [contribution guide](https://github.com/MudBlazor/MudBlazor/blob/dev/CONTRIBUTING.md) to get started. +Submit a pull request, open an issue, start a discussion, or tag us on GitHub to collaborate, or hop into [Discord](https://discord.gg/mudblazor) and say hi! diff --git a/src/MudBlazor.Docs/Pages/Components/Autocomplete/Examples/AutocompletePlaygroundExample.razor b/src/MudBlazor.Docs/Pages/Components/Autocomplete/Examples/AutocompletePlaygroundExample.razor index e6b687cf8308..c4fcc1d54f16 100644 --- a/src/MudBlazor.Docs/Pages/Components/Autocomplete/Examples/AutocompletePlaygroundExample.razor +++ b/src/MudBlazor.Docs/Pages/Components/Autocomplete/Examples/AutocompletePlaygroundExample.razor @@ -16,7 +16,8 @@ Placeholder="@(_placeholder ? "Placeholder" : null)" HelperText="@(_helperText ? "Helper Text" : null)" HelperTextOnFocus="_helperTextOnFocus" - Clearable="_clearable" /> + Clearable="_clearable" + Modal="_modal" /> } @@ -35,19 +36,21 @@ + @code { - string _value; - Margin _margin; - bool _dense; - bool _disabled; - bool _readonly; - bool _placeholder; - bool _helperText; - bool _helperTextOnFocus; - bool _clearable; + private string _value; + private Margin _margin; + private bool _dense; + private bool _disabled; + private bool _readonly; + private bool _placeholder; + private bool _helperText; + private bool _helperTextOnFocus; + private bool _clearable; + private bool _modal = true; private string[] _states = { @@ -70,7 +73,9 @@ // if text is null or empty, show complete list if (string.IsNullOrEmpty(value)) + { return _states; + } return _states.Where(x => x.Contains(value, StringComparison.InvariantCultureIgnoreCase)); } diff --git a/src/MudBlazor.Docs/Pages/Components/ChatBubble/ChatPage.razor b/src/MudBlazor.Docs/Pages/Components/ChatBubble/ChatPage.razor index 23e4187e5993..1ef9addb3203 100644 --- a/src/MudBlazor.Docs/Pages/Components/ChatBubble/ChatPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/ChatBubble/ChatPage.razor @@ -1,20 +1,8 @@ -@page "/components/chat" +๏ปฟ@page "/components/chat" @page "/components/MudChat" - - - - - Warning: This component is currently under development. -
-
- Breaking changes such as updates to the API, look and feel, or CSS classes, may occur even in minor patch releases. - Please use it only if you are prepared to adapt your code accordingly and provide feedback or contribute code. -
-
-
-
+ diff --git a/src/MudBlazor.Docs/Pages/Components/ColorPicker/ColorPickerPage.razor b/src/MudBlazor.Docs/Pages/Components/ColorPicker/ColorPickerPage.razor index b08640e32634..cfe17c6497d7 100644 --- a/src/MudBlazor.Docs/Pages/Components/ColorPicker/ColorPickerPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/ColorPicker/ColorPickerPage.razor @@ -85,6 +85,18 @@ + + + + By default, the color picker is modal, preventing interaction with other elements while it is open. + To allow interactions with other elements, set Modal="false". + + + + + + + @@ -124,4 +136,4 @@ -
\ No newline at end of file + diff --git a/src/MudBlazor.Docs/Pages/Components/ColorPicker/Examples/ColorPickerInlineModelessExample.razor b/src/MudBlazor.Docs/Pages/Components/ColorPicker/Examples/ColorPickerInlineModelessExample.razor new file mode 100644 index 000000000000..2a1f90c07bab --- /dev/null +++ b/src/MudBlazor.Docs/Pages/Components/ColorPicker/Examples/ColorPickerInlineModelessExample.razor @@ -0,0 +1,3 @@ +๏ปฟ@namespace MudBlazor.Docs.Examples + + diff --git a/src/MudBlazor.Docs/Pages/Components/DataGrid/DataGridPage.razor b/src/MudBlazor.Docs/Pages/Components/DataGrid/DataGridPage.razor index 9125f5c337cf..39604c05a081 100644 --- a/src/MudBlazor.Docs/Pages/Components/DataGrid/DataGridPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/DataGrid/DataGridPage.razor @@ -96,17 +96,22 @@ - The <MudDataGrid> allows you to group data by column. Setting the Grouping property - to true will turn on grouping which will add a menu item in the column options to toggle grouping of that column. - To disable grouping on a column, set its Groupable property to false. + The <MudDataGrid> allows you to group data by columns. Setting the Grouping property + to true enables grouping, adding a menu item in the column options to toggle grouping for that column. + To disable grouping for a specific column, set its Groupable property to false. + You can group multiple Column elements at the same time. Options for configuring column grouping include the two-way bindable + properties Grouping, GroupExpanded, and GroupByOrder. + + The GroupBy property allows you to define a custom function for grouping a column. - - + + + @@ -182,7 +187,8 @@ The <MudDataGrid> allows you to create hierarchical layouts. To do that the HierarchyColumn has to be added in the Columns definitions.

- You can customize the icon to toggle the RowDetail, disable the toggle button and also initially expand the RowDetail. + You can customize the icon to toggle the RowDetail, disable the toggle button and also initially expand the RowDetail. In addition most customization options are available + including EnableHeaderToggle that puts a Toggle Icon in the header row to Expand/Collapse all.
diff --git a/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridDetailRowExample.razor b/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridDetailRowExample.razor index c957d492fe1e..af055bf87817 100644 --- a/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridDetailRowExample.razor +++ b/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridDetailRowExample.razor @@ -3,10 +3,9 @@ @namespace MudBlazor.Docs.Examples @inject HttpClient httpClient - + - + @@ -33,16 +32,32 @@
Read Only + Expand All + Collapse All + +
+@code { -@code { + private bool _isReadOnly = true; + private bool _expandSingleRow = false; + private bool _enableHeaderToggle = false; + private MudDataGrid _dataGrid = null!; private IEnumerable Elements = new List(); protected override async Task OnInitializedAsync() { Elements = await httpClient.GetFromJsonAsync>("webapi/periodictable"); } - - private bool _isReadOnly = true; -} \ No newline at end of file + + private Task ExpandAll() + { + return _dataGrid.ExpandAllHierarchy(); + } + + private Task CollapseAll() + { + return _dataGrid.CollapseAllHierarchy(); + } +} diff --git a/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridGroupingExample.razor b/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridGroupingExample.razor index d2198635b4ad..7a743cc20421 100644 --- a/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridGroupingExample.razor +++ b/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridGroupingExample.razor @@ -1,9 +1,9 @@ ๏ปฟ@using System.Net.Http.Json @using MudBlazor.Examples.Data.Models @namespace MudBlazor.Docs.Examples -@inject HttpClient httpClient +@inject HttpClient HttpClient - Periodic Elements @@ -36,24 +36,25 @@
Customize Group Template Customize Group By - Expand All - Collapse All + Expand All + Collapse All
@code { - IEnumerable Elements = new List(); - MudDataGrid dataGrid; - bool _customizeGroupTemplate; - static bool _customizeGroupBy; - static string[] _nonmetals = new string[] { "H", "He","N", "O", "F", "Ne", "Cl", "Ar", "Kr", "Xe", "Rn", "Br", "C", "P", "Se", "Se", "I" }; - Func _groupBy = x => + private IEnumerable _elements = new List(); + private MudDataGrid _dataGrid = null!; + private bool _customizeGroupTemplate; + private static bool _customizeGroupBy; + private static readonly string[] _nonmetals = ["H", "He","N", "O", "F", "Ne", "Cl", "Ar", "Kr", "Xe", "Rn", "Br", "C", "P", "Se", "Se", "I"]; + + private readonly Func _groupBy = x => { if (_customizeGroupBy) return _nonmetals.Contains(x.Sign) ? "Nonmetal": "Metal"; return x.Group; }; - private string GroupClassFunc(GroupDefinition item) + private static string GroupClassFunc(GroupDefinition item) { return item.Grouping.Key?.ToString() == "Nonmetal" || item.Grouping.Key?.ToString() == "Other" ? "mud-theme-warning" @@ -62,22 +63,22 @@ protected override async Task OnInitializedAsync() { - Elements = await httpClient.GetFromJsonAsync>("webapi/periodictable"); + _elements = await HttpClient.GetFromJsonAsync>("webapi/periodictable"); } - void ExpandAllGroups() + private Task ExpandAllGroupsAsync() { - dataGrid?.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - void CollapseAllGroups() + private Task CollapseAllGroupsAsync() { - dataGrid?.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } - void CustomizeByGroupChanged(bool isChecked) + private void CustomizeByGroupChanged(bool isChecked) { _customizeGroupBy = isChecked; - dataGrid.GroupItems(); + _dataGrid.GroupItems(); } } diff --git a/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridGroupingMultiLevelExample.razor b/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridGroupingMultiLevelExample.razor new file mode 100644 index 000000000000..fb75f668d0f1 --- /dev/null +++ b/src/MudBlazor.Docs/Pages/Components/DataGrid/Examples/DataGridGroupingMultiLevelExample.razor @@ -0,0 +1,394 @@ +๏ปฟ@using System.Collections.Generic +@using MudBlazor.Utilities +@namespace MudBlazor.Docs.Examples + + + + US States Information2025 + + + + + + + + + + @if (_customizeGroupTemplate) + { + var color = context.Grouping.Key?.ToString() switch + { + "Healthcare" => Color.Primary, + "Tech" => Color.Secondary, + "Tourism" => Color.Info, + _ => Color.Dark + }; +
+ + @context.Grouping.Key + + @context.Grouping.Count() states + +
+ } + else + { + @context.Title: @(context.Grouping.Key) + } +
+
+ + + @if (_customizeGroupTemplate) + { + var (icon, color) = context.Grouping.Key?.ToString() switch + { + "Northeast" or "Southeast" => (Icons.Material.Filled.East, Color.Warning), + "West" or "Pacific" => (Icons.Material.Filled.West, Color.Success), + "Midwest" => (Icons.Material.Filled.LocationOn, Color.Info), + "Southwest" => (Icons.Material.Filled.South, Color.Error), + _ => (Icons.Material.Filled.Public, Color.Default) + }; +
+ + @context.Grouping.Key + + @context.Grouping.Count() states + +
+ } + else + { + @context.Title: @(context.Grouping.Key) + } +
+
+ + + @if (_customizeGroupTemplate) + { + var (icon, color) = context.Grouping.Key?.ToString() switch + { + "Continental" => (Icons.Material.Filled.AcUnit, Color.Primary), + "Humid Subtropical" => (Icons.Material.Filled.WbSunny, Color.Warning), + "Mediterranean" or "Tropical" => (Icons.Material.Filled.WaterDrop, Color.Info), + "Desert" or "Semi-arid" or "Arid" => (Icons.Material.Filled.Landscape, Color.Error), + _ => (Icons.Material.Filled.Cloud, Color.Default) + }; +
+ + @context.Grouping.Key + + @context.Grouping.Count() states + +
+ } + else + { + @context.Title: @(context.Grouping.Key) + } +
+
+ + + @if (_customizeGroupTemplate) + { + Year Inducted: @context.Grouping.Key total @context.Grouping.Count() + } + else + { + @context.Title: @(context.Grouping.Key) + } + + +
+
+ +
+ Customize Group Template + Customize Group By Industry + Customize Group Style + Expand All + Collapse All +
+ +@code { +#nullable enable + public static string __description__ = "Multi Level Grouping within DataGrid"; + // For the Grid + private MudDataGrid _dataGrid = null!; + private string? _searchString = string.Empty; + // Bound Properties @bind- + private bool _customizeGroupTemplate; + private bool _customizeGroupBy; + private bool _customizeGroupStyle; + private bool _primaryIndustryGrouping = true; + private bool _regionGrouping; + private bool _climateGrouping = true; + private int _primaryIndustryOrder ; + private int _climateOrder = 1; + private int _regionOrder = 2; + // Display Options + private bool _primaryIndustryExpanded = true; + private bool _regionExpanded = true; + private bool _climateExpanded = true; + + // Primary grouping by industry type + private Func? _groupBy1; + + protected override void OnInitialized() + { + _groupBy1 = x => + { + if (_customizeGroupBy) + { + return x.PrimaryIndustry switch + { + "Healthcare" => "Health", + "Technology" => "Tech", + "Tourism" => "Vacay", + _ => "Other" + }; + } + + return x.PrimaryIndustry; + }; + } + + protected override void OnAfterRender(bool firstRender) + { + base.OnAfterRender(firstRender); + + if (firstRender) + { + // Check if the dataGrid is grouped and update the state accordingly (for IsGrouped) + if (_dataGrid?.IsGrouped == true) + { + StateHasChanged(); + } + } + } + + private async Task> ServerReload(GridState state) + { + // call API + var data = GetAllStates(_searchString, state.SortDefinitions); + // simulate some wait time + await Task.Delay(150); + + var totalItems = data.Count; + var pagedData = data.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArray(); + return new() + { + TotalItems = totalItems, + Items = pagedData + }; + } + + private static string GroupClassFunc(GroupDefinition item) + { + var key = item.Grouping.Key?.ToString(); + + return item.Title switch + { + "Primary Industry" => key switch + { + "Healthcare" => "mud-theme-primary", + "Tech" => "mud-theme-secondary", + "Tourism" => "mud-theme-info", + _ => "mud-theme-dark" + }, + "Region" => key switch + { + "Northeast" or "Southeast" => "mud-theme-warning", + "West" or "Pacific" => "mud-theme-success", + "Midwest" => "mud-theme-info", + "Southwest" => "mud-theme-error", + _ => string.Empty + }, + _ => key switch + { + "Continental" => "mud-theme-primary", + "Humid Subtropical" => "mud-theme-warning", + "Mediterranean" or "Tropical" => "mud-theme-info", + "Desert" or "Semi-arid" or "Arid" => "mud-theme-error", + _ => string.Empty + } + }; + } + + private static string GroupStyleFunc(GroupDefinition item) + { + var indent = item.Level * 16; // 16px per level + var borderWidth = Math.Max(1, 4 - item.Level); // Decrease border width with depth + + var style = new StyleBuilder() + .AddStyle("padding-left", $"{indent}px") + .AddStyle("border-left", $"{borderWidth}px solid") + .AddStyle("border-color", GetBorderColor(item)) + .AddStyle("opacity", $"{1 - (item.Level - 1) * 0.2}") // Fade out deeper levels slightly + .Build(); + + return style; + } + + private static string GetBorderColor(GroupDefinition item) + { + var key = item.Grouping.Key?.ToString(); + + return item.Title switch + { + // Primary level (Industry) + "Primary Industry" => key switch + { + "Healthcare" => "var(--mud-palette-primary)", + "Tech" => "var(--mud-palette-secondary)", + "Tourism" => "var(--mud-palette-info)", + _ => "var(--mud-palette-dark)" + }, + "Region" => key switch + { + "Northeast" or "Southeast" => "var(--mud-palette-warning)", + "West" or "Pacific" => "var(--mud-palette-success)", + "Midwest" => "var(--mud-palette-info)", + "Southwest" => "var(--mud-palette-error)", + _ => "var(--mud-palette-dark)" + }, + "Climate" => key switch + { + "Continental" => "var(--mud-palette-primary)", + "Humid Subtropical" => "var(--mud-palette-warning)", + "Mediterranean" or "Tropical" => "var(--mud-palette-info)", + "Desert" or "Semi-arid" or "Arid" => "var(--mud-palette-error)", + _ => "var(--mud-palette-dark)" + }, + _ => "var(--mud-palette-dark)" + }; + } + + private Task ExpandAllGroupsAsync() + { + return _dataGrid.ExpandAllGroupsAsync(); + } + + private Task CollapseAllGroupsAsync() + { + return _dataGrid.CollapseAllGroupsAsync(); + } + + private void CustomizeByGroupChanged(bool isChecked) + { + _customizeGroupBy = isChecked; + _dataGrid.GroupItems(); + } + + private static List GetAllStates(string? searchString, ICollection>? sortDefinitions) + { + List data = + [ + new USState(Id: 1, State: "Alabama", Counties: 67, Population: 5024279, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1819), + new USState(Id: 2, State: "Alaska", Counties: 30, Population: 733406, PrimaryIndustry: "Oil and Gas", Region: "Pacific", Climate: "Subarctic", YearInducted: 1959), + new USState(Id: 3, State: "Arizona", Counties: 15, Population: 7151502, PrimaryIndustry: "Healthcare", Region: "Southwest", Climate: "Desert", YearInducted: 1912), + new USState(Id: 4, State: "Arkansas", Counties: 75, Population: 3011524, PrimaryIndustry: "Agriculture", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1836), + new USState(Id: 5, State: "California", Counties: 58, Population: 39538223, PrimaryIndustry: "Technology", Region: "West", Climate: "Mediterranean", YearInducted: 1850), + new USState(Id: 6, State: "Colorado", Counties: 64, Population: 5773714, PrimaryIndustry: "Tourism", Region: "West", Climate: "Semi-arid", YearInducted: 1876), + new USState(Id: 7, State: "Connecticut", Counties: 8, Population: 3605944, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 8, State: "Delaware", Counties: 3, Population: 989948, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1787), + new USState(Id: 9, State: "Florida", Counties: 67, Population: 21538187, PrimaryIndustry: "Tourism", Region: "Southeast", Climate: "Tropical", YearInducted: 1845), + new USState(Id: 10, State: "Georgia", Counties: 159, Population: 10711908, PrimaryIndustry: "Agriculture", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1788), + new USState(Id: 11, State: "Hawaii", Counties: 5, Population: 1455271, PrimaryIndustry: "Tourism", Region: "Pacific", Climate: "Tropical", YearInducted: 1959), + new USState(Id: 12, State: "Idaho", Counties: 44, Population: 1839106, PrimaryIndustry: "Agriculture", Region: "West", Climate: "Continental", YearInducted: 1890), + new USState(Id: 13, State: "Illinois", Counties: 102, Population: 12812508, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1818), + new USState(Id: 14, State: "Indiana", Counties: 92, Population: 6785528, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1816), + new USState(Id: 15, State: "Iowa", Counties: 99, Population: 3190369, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1846), + new USState(Id: 16, State: "Kansas", Counties: 105, Population: 2937880, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1861), + new USState(Id: 17, State: "Kentucky", Counties: 120, Population: 4505836, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Continental", YearInducted: 1792), + new USState(Id: 18, State: "Louisiana", Counties: 64, Population: 4657757, PrimaryIndustry: "Oil and Gas", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1812), + new USState(Id: 19, State: "Maine", Counties: 16, Population: 1362359, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1820), + new USState(Id: 20, State: "Maryland", Counties: 24, Population: 6177224, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 21, State: "Massachusetts", Counties: 14, Population: 7029917, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 22, State: "Michigan", Counties: 83, Population: 10077331, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1837), + new USState(Id: 23, State: "Minnesota", Counties: 87, Population: 5706494, PrimaryIndustry: "Healthcare", Region: "Midwest", Climate: "Continental", YearInducted: 1858), + new USState(Id: 24, State: "Mississippi", Counties: 82, Population: 2961279, PrimaryIndustry: "Agriculture", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1817), + new USState(Id: 25, State: "Missouri", Counties: 115, Population: 6154913, PrimaryIndustry: "Healthcare", Region: "Midwest", Climate: "Continental", YearInducted: 1821), + new USState(Id: 26, State: "Montana", Counties: 56, Population: 1084225, PrimaryIndustry: "Agriculture", Region: "West", Climate: "Continental", YearInducted: 1889), + new USState(Id: 27, State: "Nebraska", Counties: 93, Population: 1961504, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1867), + new USState(Id: 28, State: "Nevada", Counties: 17, Population: 3104614, PrimaryIndustry: "Tourism", Region: "West", Climate: "Desert", YearInducted: 1864), + new USState(Id: 29, State: "New Hampshire", Counties: 10, Population: 1377529, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 30, State: "New Jersey", Counties: 21, Population: 9288994, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1787), + new USState(Id: 31, State: "New Mexico", Counties: 33, Population: 2117522, PrimaryIndustry: "Oil and Gas", Region: "Southwest", Climate: "Arid", YearInducted: 1912), + new USState(Id: 32, State: "New York", Counties: 62, Population: 20201249, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 33, State: "North Carolina", Counties: 100, Population: 10439388, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1789), + new USState(Id: 34, State: "North Dakota", Counties: 53, Population: 779094, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1889), + new USState(Id: 35, State: "Ohio", Counties: 88, Population: 11799448, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1803), + new USState(Id: 36, State: "Oklahoma", Counties: 77, Population: 3959353, PrimaryIndustry: "Oil and Gas", Region: "Southwest", Climate: "Continental", YearInducted: 1907), + new USState(Id: 37, State: "Oregon", Counties: 36, Population: 4237256, PrimaryIndustry: "Technology", Region: "West", Climate: "Mediterranean", YearInducted: 1859), + new USState(Id: 38, State: "Pennsylvania", Counties: 67, Population: 13002700, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1787), + new USState(Id: 39, State: "Rhode Island", Counties: 5, Population: 1097379, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1790), + new USState(Id: 40, State: "South Carolina", Counties: 46, Population: 5118425, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1788), + new USState(Id: 41, State: "South Dakota", Counties: 66, Population: 886667, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1889), + new USState(Id: 42, State: "Tennessee", Counties: 95, Population: 6910840, PrimaryIndustry: "Healthcare", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1796), + new USState(Id: 43, State: "Texas", Counties: 254, Population: 29145505, PrimaryIndustry: "Oil and Gas", Region: "Southwest", Climate: "Subtropical", YearInducted: 1845), + new USState(Id: 44, State: "Utah", Counties: 29, Population: 3271616, PrimaryIndustry: "Healthcare", Region: "West", Climate: "Semi-arid", YearInducted: 1896), + new USState(Id: 45, State: "Vermont", Counties: 14, Population: 643077, PrimaryIndustry: "Tourism", Region: "Northeast", Climate: "Continental", YearInducted: 1791), + new USState(Id: 46, State: "Virginia", Counties: 133, Population: 8631393, PrimaryIndustry: "Technology", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1788), + new USState(Id: 47, State: "Washington", Counties: 39, Population: 7705281, PrimaryIndustry: "Technology", Region: "West", Climate: "Oceanic", YearInducted: 1889), + new USState(Id: 48, State: "West Virginia", Counties: 55, Population: 1793716, PrimaryIndustry: "Healthcare", Region: "Southeast", Climate: "Continental", YearInducted: 1863), + new USState(Id: 49, State: "Wisconsin", Counties: 72, Population: 5893718, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1848), + new USState(Id: 50, State: "Wyoming", Counties: 23, Population: 576851, PrimaryIndustry: "Mining", Region: "West", Climate: "Semi-arid", YearInducted: 1890) + ]; + + if (sortDefinitions != null && sortDefinitions.Any()) + { + // on server or actual API would be IQueryable likely + IOrderedEnumerable? orderedQuery = null; + foreach (var sort in sortDefinitions) + { + orderedQuery = data.OrderByDirection(sort.Descending ? SortDirection.Descending : SortDirection.Ascending, + obj => GetPropertyValue(obj, sort.SortBy)); + } + data = orderedQuery!.ToList(); + } + + return data.Where(x => MatchesSearch(x, searchString)).ToList(); + } + + private static bool MatchesSearch(USState obj, string? searchString) + { + if (string.IsNullOrEmpty(searchString)) + { + return true; + } + + return obj.State.Contains(searchString, StringComparison.OrdinalIgnoreCase) || + obj.PrimaryIndustry.Contains(searchString, StringComparison.OrdinalIgnoreCase) || + obj.YearInducted.ToString().Contains(searchString) || + obj.Climate.Contains(searchString, StringComparison.OrdinalIgnoreCase) || + obj.Region.Contains(searchString, StringComparison.OrdinalIgnoreCase); + } + + private static object? GetPropertyValue(TT obj, string propertyName) + { + return typeof(TT).GetProperty(propertyName)?.GetValue(obj); + } + + private record USState(int Id, string State, int Counties, int Population, string PrimaryIndustry, string Region, string Climate, int YearInducted); +} diff --git a/src/MudBlazor.Docs/Pages/Components/DatePicker/DatePickerPage.razor b/src/MudBlazor.Docs/Pages/Components/DatePicker/DatePickerPage.razor index 0f505f9b21dd..8142bca364fa 100644 --- a/src/MudBlazor.Docs/Pages/Components/DatePicker/DatePickerPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/DatePicker/DatePickerPage.razor @@ -89,6 +89,18 @@
+ + + + By default, the date picker is modal, preventing interaction with other elements while it is open. + To allow interactions with other elements, set Modal="false". + + + + + + + diff --git a/src/MudBlazor.Docs/Pages/Components/DatePicker/Examples/DatePickerInlineModelessExample.razor b/src/MudBlazor.Docs/Pages/Components/DatePicker/Examples/DatePickerInlineModelessExample.razor new file mode 100644 index 000000000000..bdea61b68551 --- /dev/null +++ b/src/MudBlazor.Docs/Pages/Components/DatePicker/Examples/DatePickerInlineModelessExample.razor @@ -0,0 +1,5 @@ +๏ปฟ@namespace MudBlazor.Docs.Examples + + + + diff --git a/src/MudBlazor.Docs/Pages/Components/DateRangePicker/DateRangePickerPage.razor b/src/MudBlazor.Docs/Pages/Components/DateRangePicker/DateRangePickerPage.razor index aa8eba137383..6051ccef3ac9 100644 --- a/src/MudBlazor.Docs/Pages/Components/DateRangePicker/DateRangePickerPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/DateRangePicker/DateRangePickerPage.razor @@ -11,6 +11,18 @@ + + + + By default, the date range picker is modal, preventing interaction with other elements while it is open. + To allow interactions with other elements, set Modal="false". + + + + + + + diff --git a/src/MudBlazor.Docs/Pages/Components/DateRangePicker/Examples/DateRangePickerInlineModelessExample.razor b/src/MudBlazor.Docs/Pages/Components/DateRangePicker/Examples/DateRangePickerInlineModelessExample.razor new file mode 100644 index 000000000000..ad320d4d9de3 --- /dev/null +++ b/src/MudBlazor.Docs/Pages/Components/DateRangePicker/Examples/DateRangePickerInlineModelessExample.razor @@ -0,0 +1,5 @@ +๏ปฟ@namespace MudBlazor.Docs.Examples + + + + diff --git a/src/MudBlazor.Docs/Pages/Components/Dialog/DialogPage.razor b/src/MudBlazor.Docs/Pages/Components/Dialog/DialogPage.razor index 8e59fe9f9a04..96d52fad33f1 100644 --- a/src/MudBlazor.Docs/Pages/Components/Dialog/DialogPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/Dialog/DialogPage.razor @@ -6,7 +6,7 @@ Note - The Dialog is dependant on IDialogService and MudDialogProvider + The Dialog is dependent on IDialogService and MudDialogProvider Check the Installation page for instructions regarding default setup. diff --git a/src/MudBlazor.Docs/Pages/Components/Menu/Examples/MenuModalExample.razor b/src/MudBlazor.Docs/Pages/Components/Menu/Examples/MenuModalExample.razor new file mode 100644 index 000000000000..98ce412a02fb --- /dev/null +++ b/src/MudBlazor.Docs/Pages/Components/Menu/Examples/MenuModalExample.razor @@ -0,0 +1,23 @@ +๏ปฟ@namespace MudBlazor.Docs.Examples + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MudBlazor.Docs/Pages/Components/Menu/MenuPage.razor b/src/MudBlazor.Docs/Pages/Components/Menu/MenuPage.razor index b72c41306b38..1407da0d68f1 100644 --- a/src/MudBlazor.Docs/Pages/Components/Menu/MenuPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/Menu/MenuPage.razor @@ -191,6 +191,18 @@ + + + + By default, the menu is modal, meaning that you cannot interact with other elements while it is open. + To allow interactions with other elements, set the Modal property to false. + + + + + + + diff --git a/src/MudBlazor.Docs/Pages/Components/Select/Examples/SelectPlaygroundExample.razor b/src/MudBlazor.Docs/Pages/Components/Select/Examples/SelectPlaygroundExample.razor index 044cb79150db..b2d825addfe8 100644 --- a/src/MudBlazor.Docs/Pages/Components/Select/Examples/SelectPlaygroundExample.razor +++ b/src/MudBlazor.Docs/Pages/Components/Select/Examples/SelectPlaygroundExample.razor @@ -1,4 +1,4 @@ -๏ปฟ@namespace MudBlazor.Docs.Examples +@namespace MudBlazor.Docs.Examples @using MudBlazor @@ -16,7 +16,8 @@ Placeholder="@(_placeholder ? "Placeholder" : null)" HelperText="@(_helperText ? "Helper Text" : null)" HelperTextOnFocus="_helperTextOnFocus" - Clearable="_clearable"> + Clearable="_clearable" + Modal="_modal"> @foreach (var state in _states) { @state @@ -41,6 +42,7 @@ + @@ -55,6 +57,7 @@ private bool _helperTextOnFocus; private bool _clearable; private bool _fitContent; + private bool _modal = true; private readonly string[] _states = { diff --git a/src/MudBlazor.Docs/Pages/Components/TimePicker/Examples/TimePickerInlineModelessExample.razor b/src/MudBlazor.Docs/Pages/Components/TimePicker/Examples/TimePickerInlineModelessExample.razor new file mode 100644 index 000000000000..93279866a382 --- /dev/null +++ b/src/MudBlazor.Docs/Pages/Components/TimePicker/Examples/TimePickerInlineModelessExample.razor @@ -0,0 +1,8 @@ +๏ปฟ@namespace MudBlazor.Docs.Examples + + + + +@code{ + private TimeSpan? _time = new TimeSpan(00, 45, 00); +} diff --git a/src/MudBlazor.Docs/Pages/Components/TimePicker/TimePickerPage.razor b/src/MudBlazor.Docs/Pages/Components/TimePicker/TimePickerPage.razor index 434cc1640e22..e5fe3fd56559 100644 --- a/src/MudBlazor.Docs/Pages/Components/TimePicker/TimePickerPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/TimePicker/TimePickerPage.razor @@ -44,6 +44,18 @@ + + + + By default, the time picker is modal, preventing interaction with other elements while it is open. + To allow interactions with other elements, set Modal="false". + + + + + + + @@ -119,4 +131,4 @@ - \ No newline at end of file + diff --git a/src/MudBlazor.Docs/Pages/Customization/DefaultTheme.razor b/src/MudBlazor.Docs/Pages/Customization/DefaultTheme.razor index 7b2e7fd57de4..5528d6f4e812 100644 --- a/src/MudBlazor.Docs/Pages/Customization/DefaultTheme.razor +++ b/src/MudBlazor.Docs/Pages/Customization/DefaultTheme.razor @@ -28,7 +28,7 @@ @Row.Name @Row.Type -
+
@Row.Default
@@ -37,7 +37,7 @@ @if (!Row.Default.Equals(Row.Dark)) { -
+
@Row.Dark
@@ -351,4 +351,4 @@ public string CSSVariable { get; set; } public bool IsHeader { get; set; } } -} \ No newline at end of file +} diff --git a/src/MudBlazor.Docs/Pages/Customization/Globals/Examples/ButtonGlobalsExample.razor b/src/MudBlazor.Docs/Pages/Customization/Globals/Examples/ButtonGlobalsExample.razor deleted file mode 100644 index 3bdd94bcd4c6..000000000000 --- a/src/MudBlazor.Docs/Pages/Customization/Globals/Examples/ButtonGlobalsExample.razor +++ /dev/null @@ -1,3 +0,0 @@ -๏ปฟ@namespace MudBlazor.Docs.Examples - -Button with new defaults diff --git a/src/MudBlazor.Docs/Pages/Customization/Globals/Examples/ButtonGlobalsStartupExample.razor b/src/MudBlazor.Docs/Pages/Customization/Globals/Examples/ButtonGlobalsStartupExample.razor deleted file mode 100644 index 88ffbfcea811..000000000000 --- a/src/MudBlazor.Docs/Pages/Customization/Globals/Examples/ButtonGlobalsStartupExample.razor +++ /dev/null @@ -1,9 +0,0 @@ -๏ปฟ@namespace MudBlazor.Docs.Examples - -// Add MudBlazor services -Services.AddMudServices(); - -// Change the default appearance of all MudButton components -MudGlobal.ButtonDefaults.Color = Color.Secondary; -MudGlobal.ButtonDefaults.Size = Size.Large; -MudGlobal.ButtonDefaults.Variant = Variant.Filled; diff --git a/src/MudBlazor.Docs/Pages/Customization/Globals/Globals.razor b/src/MudBlazor.Docs/Pages/Customization/Globals/Globals.razor index 303428b369e9..db251c8e3faa 100644 --- a/src/MudBlazor.Docs/Pages/Customization/Globals/Globals.razor +++ b/src/MudBlazor.Docs/Pages/Customization/Globals/Globals.razor @@ -1,15 +1,14 @@ ๏ปฟ@page "/customization/globals" - + - - Warning: This feature is currently under development. -
-
- Breaking changes to the API may occur between releases. - Please only use MudGlobal if you are prepared to adapt your code and provide feedback or contribute code . + + EXPERIMENTAL: + This feature is under development and may not be ready for general use. + Breaking changes will occur between releases. + Use only if you are prepared to adapt your code and provide feedback or contribute to its development.
@@ -18,29 +17,26 @@ - The static class contains default settings which can override the default settings for MudBlazor components. -
-
- The following example changes all components to use a large size, secondary color, and filled variant as their defaults. -
-
- Note: Defaults should be set when your application initializes, such as when adding MudBlazor services. + You can find default setting overrides of some MudBlazor features in the static class. + These defaults should be set when your application initializes, such as when adding MudBlazor services. + If they are set after the app has started, they may not take effect until the next page load, or may not take effect at all. + + + Our theming system is recommended for changing the looks and style of your application. + To find more ways to customize your app, refer to our other documentation in the sidebar. +
- - -
- A global exception handler is available via the MudGlobal.UnhandledExceptionHandler property. + You can handle MudBlazor component exceptions using MudGlobal.UnhandledExceptionHandler. -
diff --git a/src/MudBlazor.Docs/Shared/Appbar.razor b/src/MudBlazor.Docs/Shared/Appbar.razor index cb4462e9f895..a0638b45cbd8 100644 --- a/src/MudBlazor.Docs/Shared/Appbar.razor +++ b/src/MudBlazor.Docs/Shared/Appbar.razor @@ -92,7 +92,8 @@ + ValueChanged="OnSearchResult" OpenChanged="o => _searchDialogAutocompleteOpen = o" ReturnedItemsCountChanged="c => _searchDialogReturnedItemsCount = c" + Modal="false"> @result.Title @result.SubTitle diff --git a/src/MudBlazor.UnitTests.Docs/Generated/ApiDocsTests.cs b/src/MudBlazor.UnitTests.Docs/Generated/ApiDocsTests.cs index 5ba1f3c0e353..ab78863413a4 100644 --- a/src/MudBlazor.UnitTests.Docs/Generated/ApiDocsTests.cs +++ b/src/MudBlazor.UnitTests.Docs/Generated/ApiDocsTests.cs @@ -38,6 +38,7 @@ public void Setup() ctx.Services.AddSingleton(); ctx.Services.AddTransient(); ctx.Services.AddScoped(); + ctx.Services.AddScoped(); ctx.Services.AddTransient(); ctx.Services.AddTransient(); ctx.Services.AddTransient(); diff --git a/src/MudBlazor.UnitTests.Docs/Generated/ExampleDocsTests.cs b/src/MudBlazor.UnitTests.Docs/Generated/ExampleDocsTests.cs index 81a85deb4698..b5fafbb7c3c9 100644 --- a/src/MudBlazor.UnitTests.Docs/Generated/ExampleDocsTests.cs +++ b/src/MudBlazor.UnitTests.Docs/Generated/ExampleDocsTests.cs @@ -35,6 +35,7 @@ public void Setup() ctx.Services.AddTransient(); ctx.Services.AddSingleton(); ctx.Services.AddScoped(); + ctx.Services.AddScoped(); ctx.Services.AddTransient(); ctx.Services.AddTransient(); ctx.Services.AddTransient(); diff --git a/src/MudBlazor.UnitTests.Shared/Mocks/MockPointerEventsNoneService.cs b/src/MudBlazor.UnitTests.Shared/Mocks/MockPointerEventsNoneService.cs new file mode 100644 index 000000000000..ffff14be9ee9 --- /dev/null +++ b/src/MudBlazor.UnitTests.Shared/Mocks/MockPointerEventsNoneService.cs @@ -0,0 +1,20 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor.UnitTests.Shared.Mocks; + +#nullable enable + +public class MockPointerEventsNoneService : IPointerEventsNoneService +{ + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public Task SubscribeAsync(IPointerEventsNoneObserver observer, PointerEventsNoneOptions options) => Task.CompletedTask; + + public Task SubscribeAsync(string elementId, PointerEventsNoneOptions options, IPointerDownObserver? pointerDown = null, IPointerUpObserver? pointerUp = null) => Task.CompletedTask; + + public Task UnsubscribeAsync(IPointerEventsNoneObserver observer) => Task.CompletedTask; + + public Task UnsubscribeAsync(string elementId) => Task.CompletedTask; +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridColumnGroupingTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridColumnGroupingTest.razor index 4a36fae30175..4e6ba3996f66 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridColumnGroupingTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridColumnGroupingTest.razor @@ -1,7 +1,7 @@ ๏ปฟ - + @@ -17,6 +17,8 @@ ? "(None)" // Customize group name for null values otherwise would be empty group name : x.Profession; + public bool IsNameGrouped { get; private set; } = true; + public bool IsGenderGrouped { get; private set; } public bool IsAgeGrouped { get; private set; } @@ -31,11 +33,29 @@ new("Alice", 32, "Female", "Cook") ]; - private void GroupByGender(MouseEventArgs args) => IsGenderGrouped = true; - - private void GroupByAge(MouseEventArgs args) => IsAgeGrouped = true; - - private void GroupByProfession(MouseEventArgs args) => IsProfessionGrouped = true; + private void GroupByGender(MouseEventArgs args) + { + IsNameGrouped = false; + IsProfessionGrouped = false; + IsAgeGrouped = false; + IsGenderGrouped = true; + } + + private void GroupByAge(MouseEventArgs args) + { + IsNameGrouped = false; + IsProfessionGrouped = false; + IsAgeGrouped = true; + IsGenderGrouped = false; + } + + private void GroupByProfession(MouseEventArgs args) + { + IsNameGrouped = false; + IsProfessionGrouped = true; + IsAgeGrouped = false; + IsGenderGrouped = false; + } public record Model(string Name, int Age, string Gender, string? Profession); } diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupCollapseAllTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupCollapseAllTest.razor index 41ec96f0b0a5..21110d147005 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupCollapseAllTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupCollapseAllTest.razor @@ -1,4 +1,4 @@ - new TestObject(x.Name, x.Category)).ToList(); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } public record TestObject(string Name, string Category); -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandAllCollapseAllTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandAllCollapseAllTest.razor index ef9f81346164..d91faf9d49d3 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandAllCollapseAllTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandAllCollapseAllTest.razor @@ -1,4 +1,4 @@ - new Element(x.Group, x.Position, x.Name)).ToList(); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } public class Element(string group, int position, string name) @@ -139,4 +139,4 @@ public string Group { get; set; } = group; } -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedAsyncTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedAsyncTest.razor index 375a8c413972..90644b3ce048 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedAsyncTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedAsyncTest.razor @@ -1,4 +1,4 @@ - Fruits @@ -39,15 +39,15 @@ _fruits.Add(new Fruit("Banana", 5, "Musa")); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } public record Fruit(string Name, int Count, string Category); -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseAsyncTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseAsyncTest.razor index 8aab110089a7..f0d16995aaac 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseAsyncTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseAsyncTest.razor @@ -40,15 +40,15 @@ _fruits.Add(new Fruit("Banana", 5, "Musa")); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } public record Fruit(string Name, int Count, string Category); -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseServerDataTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseServerDataTest.razor index 196ca80fc9d6..aa7e4e2bd9e2 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseServerDataTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseServerDataTest.razor @@ -46,15 +46,15 @@ _fruits.Add(new Fruit("Banana", 5, "Musa")); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } public record Fruit(string Name, int Count, string Category); -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseTest.razor index cd7ed6c7d192..0e6e447c0638 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedFalseTest.razor @@ -37,15 +37,15 @@ _fruits.Add(new Fruit("Banana", 5, "Musa")); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } public record Fruit(string Name, int Count, string Category); -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedServerDataTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedServerDataTest.razor index 0a251fea0714..a55530f6412e 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedServerDataTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedServerDataTest.razor @@ -1,4 +1,4 @@ - Fruits @@ -48,15 +48,15 @@ _fruits.Add(new Fruit("Banana", 5, "Musa")); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } public record Fruit(string Name, int Count, string Category); -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedTest.razor index 24a404458fbb..e96e1dfbd56b 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupExpandedTest.razor @@ -1,4 +1,4 @@ - Fruits @@ -38,14 +38,14 @@ GroupExpanded="true" RowContextMenuClick="@OnRowContextMenuClick"> _fruits.Add(new Fruit("Banana", 5, "Musa")); } - public void ExpandAllGroups() + public Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - public void CollapseAllGroups() + public Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } private void OnRowContextMenuClick(DataGridRowClickEventArgs args) @@ -61,4 +61,4 @@ GroupExpanded="true" RowContextMenuClick="@OnRowContextMenuClick"> public string Category { get; set; } = category; } -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupingMultiLevelTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupingMultiLevelTest.razor new file mode 100644 index 000000000000..eb88375c9738 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridGroupingMultiLevelTest.razor @@ -0,0 +1,392 @@ +๏ปฟ@using MudBlazor.Utilities + + + + US States Information2025 + + + + + + + + + + @if (_customizeGroupTemplate) + { + var color = context.Grouping.Key?.ToString() switch + { + "Healthcare" => Color.Primary, + "Tech" => Color.Secondary, + "Tourism" => Color.Info, + _ => Color.Dark + }; +
+ + @context.Grouping.Key + + @context.Grouping.Count() states + +
+ } + else + { + @context.Title: @(context.Grouping.Key) + } +
+
+ + + @if (_customizeGroupTemplate) + { + var (icon, color) = context.Grouping.Key?.ToString() switch + { + "Northeast" or "Southeast" => (Icons.Material.Filled.East, Color.Warning), + "West" or "Pacific" => (Icons.Material.Filled.West, Color.Success), + "Midwest" => (Icons.Material.Filled.LocationOn, Color.Info), + "Southwest" => (Icons.Material.Filled.South, Color.Error), + _ => (Icons.Material.Filled.Public, Color.Default) + }; +
+ + @context.Grouping.Key + + @context.Grouping.Count() states + +
+ } + else + { + @context.Title: @(context.Grouping.Key) + } +
+
+ + + @if (_customizeGroupTemplate) + { + var (icon, color) = context.Grouping.Key?.ToString() switch + { + "Continental" => (Icons.Material.Filled.AcUnit, Color.Primary), + "Humid Subtropical" => (Icons.Material.Filled.WbSunny, Color.Warning), + "Mediterranean" or "Tropical" => (Icons.Material.Filled.WaterDrop, Color.Info), + "Desert" or "Semi-arid" or "Arid" => (Icons.Material.Filled.Landscape, Color.Error), + _ => (Icons.Material.Filled.Cloud, Color.Default) + }; +
+ + @context.Grouping.Key + + @context.Grouping.Count() states + +
+ } + else + { + @context.Title: @(context.Grouping.Key) + } +
+
+ + + @if (_customizeGroupTemplate) + { + Year Inducted: @context.Grouping.Key total @context.Grouping.Count() + } + else + { + @context.Title: @(context.Grouping.Key) + } + + +
+
+ +
+ Customize Group Template + Customize Group By Industry + Customize Group Style + Expand All + Collapse All +
+ +@code { +#nullable enable + public static string __description__ = "Multi Level Grouping within DataGrid"; + // For the Grid + private MudDataGrid _dataGrid = null!; + private string? _searchString = string.Empty; + // Bound Properties @bind- + private bool _customizeGroupTemplate; + private bool _customizeGroupBy; + private bool _customizeGroupStyle; + private bool _primaryIndustryGrouping = true; + private bool _regionGrouping; + private bool _climateGrouping = true; + private int _primaryIndustryOrder; + private int _climateOrder = 1; + private int _regionOrder = 2; + // Display Options + private bool _primaryIndustryExpanded = true; + private bool _regionExpanded = true; + private bool _climateExpanded = true; + + // Primary grouping by industry type + private Func? _groupBy1; + + protected override void OnInitialized() + { + _groupBy1 = x => + { + if (_customizeGroupBy) + { + return x.PrimaryIndustry switch + { + "Healthcare" => "Health", + "Technology" => "Tech", + "Tourism" => "Vacay", + _ => "Other" + }; + } + + return x.PrimaryIndustry; + }; + } + + protected override void OnAfterRender(bool firstRender) + { + base.OnAfterRender(firstRender); + + if (firstRender) + { + // Check if the dataGrid is grouped and update the state accordingly (for IsGrouped) + if (_dataGrid.IsGrouped == true) + { + StateHasChanged(); + } + } + } + + private async Task> ServerReload(GridState state) + { + // call API + var data = GetAllStates(_searchString, state.SortDefinitions); + // simulate some wait time + await Task.Delay(150); + + var totalItems = data.Count; + var pagedData = data.Skip(state.Page * state.PageSize).Take(state.PageSize).ToArray(); + return new() + { + TotalItems = totalItems, + Items = pagedData + }; + } + + private static string GroupClassFunc(GroupDefinition item) + { + var key = item.Grouping.Key?.ToString(); + + return item.Title switch + { + "Primary Industry" => key switch + { + "Healthcare" => "mud-theme-primary", + "Tech" => "mud-theme-secondary", + "Tourism" => "mud-theme-info", + _ => "mud-theme-dark" + }, + "Region" => key switch + { + "Northeast" or "Southeast" => "mud-theme-warning", + "West" or "Pacific" => "mud-theme-success", + "Midwest" => "mud-theme-info", + "Southwest" => "mud-theme-error", + _ => string.Empty + }, + _ => key switch + { + "Continental" => "mud-theme-primary", + "Humid Subtropical" => "mud-theme-warning", + "Mediterranean" or "Tropical" => "mud-theme-info", + "Desert" or "Semi-arid" or "Arid" => "mud-theme-error", + _ => string.Empty + } + }; + } + + private static string GroupStyleFunc(GroupDefinition item) + { + var indent = item.Level * 16; // 16px per level + var borderWidth = Math.Max(1, 4 - item.Level); // Decrease border width with depth + + var style = new StyleBuilder() + .AddStyle("padding-left", $"{indent}px") + .AddStyle("border-left", $"{borderWidth}px solid") + .AddStyle("border-color", GetBorderColor(item)) + .AddStyle("opacity", $"{1 - (item.Level - 1) * 0.2}") // Fade out deeper levels slightly + .Build(); + + return style; + } + + private static string GetBorderColor(GroupDefinition item) + { + var key = item.Grouping.Key?.ToString(); + + return item.Title switch + { + // Primary level (Industry) + "Primary Industry" => key switch + { + "Healthcare" => "var(--mud-palette-primary)", + "Tech" => "var(--mud-palette-secondary)", + "Tourism" => "var(--mud-palette-info)", + _ => "var(--mud-palette-dark)" + }, + "Region" => key switch + { + "Northeast" or "Southeast" => "var(--mud-palette-warning)", + "West" or "Pacific" => "var(--mud-palette-success)", + "Midwest" => "var(--mud-palette-info)", + "Southwest" => "var(--mud-palette-error)", + _ => "var(--mud-palette-dark)" + }, + "Climate" => key switch + { + "Continental" => "var(--mud-palette-primary)", + "Humid Subtropical" => "var(--mud-palette-warning)", + "Mediterranean" or "Tropical" => "var(--mud-palette-info)", + "Desert" or "Semi-arid" or "Arid" => "var(--mud-palette-error)", + _ => "var(--mud-palette-dark)" + }, + _ => "var(--mud-palette-dark)" + }; + } + + private Task ExpandAllGroupsAsync() + { + return _dataGrid.ExpandAllGroupsAsync(); + } + + private Task CollapseAllGroupsAsync() + { + return _dataGrid.CollapseAllGroupsAsync(); + } + + private void CustomizeByGroupChanged(bool isChecked) + { + _customizeGroupBy = isChecked; + _dataGrid.GroupItems(); + } + + private static List GetAllStates(string? searchString, ICollection>? sortDefinitions) + { + List data = + [ + new USState(Id: 1, State: "Alabama", Counties: 67, Population: 5024279, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1819), + new USState(Id: 2, State: "Alaska", Counties: 30, Population: 733406, PrimaryIndustry: "Oil and Gas", Region: "Pacific", Climate: "Subarctic", YearInducted: 1959), + new USState(Id: 3, State: "Arizona", Counties: 15, Population: 7151502, PrimaryIndustry: "Healthcare", Region: "Southwest", Climate: "Desert", YearInducted: 1912), + new USState(Id: 4, State: "Arkansas", Counties: 75, Population: 3011524, PrimaryIndustry: "Agriculture", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1836), + new USState(Id: 5, State: "California", Counties: 58, Population: 39538223, PrimaryIndustry: "Technology", Region: "West", Climate: "Mediterranean", YearInducted: 1850), + new USState(Id: 6, State: "Colorado", Counties: 64, Population: 5773714, PrimaryIndustry: "Tourism", Region: "West", Climate: "Semi-arid", YearInducted: 1876), + new USState(Id: 7, State: "Connecticut", Counties: 8, Population: 3605944, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 8, State: "Delaware", Counties: 3, Population: 989948, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1787), + new USState(Id: 9, State: "Florida", Counties: 67, Population: 21538187, PrimaryIndustry: "Tourism", Region: "Southeast", Climate: "Tropical", YearInducted: 1845), + new USState(Id: 10, State: "Georgia", Counties: 159, Population: 10711908, PrimaryIndustry: "Agriculture", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1788), + new USState(Id: 11, State: "Hawaii", Counties: 5, Population: 1455271, PrimaryIndustry: "Tourism", Region: "Pacific", Climate: "Tropical", YearInducted: 1959), + new USState(Id: 12, State: "Idaho", Counties: 44, Population: 1839106, PrimaryIndustry: "Agriculture", Region: "West", Climate: "Continental", YearInducted: 1890), + new USState(Id: 13, State: "Illinois", Counties: 102, Population: 12812508, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1818), + new USState(Id: 14, State: "Indiana", Counties: 92, Population: 6785528, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1816), + new USState(Id: 15, State: "Iowa", Counties: 99, Population: 3190369, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1846), + new USState(Id: 16, State: "Kansas", Counties: 105, Population: 2937880, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1861), + new USState(Id: 17, State: "Kentucky", Counties: 120, Population: 4505836, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Continental", YearInducted: 1792), + new USState(Id: 18, State: "Louisiana", Counties: 64, Population: 4657757, PrimaryIndustry: "Oil and Gas", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1812), + new USState(Id: 19, State: "Maine", Counties: 16, Population: 1362359, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1820), + new USState(Id: 20, State: "Maryland", Counties: 24, Population: 6177224, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 21, State: "Massachusetts", Counties: 14, Population: 7029917, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 22, State: "Michigan", Counties: 83, Population: 10077331, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1837), + new USState(Id: 23, State: "Minnesota", Counties: 87, Population: 5706494, PrimaryIndustry: "Healthcare", Region: "Midwest", Climate: "Continental", YearInducted: 1858), + new USState(Id: 24, State: "Mississippi", Counties: 82, Population: 2961279, PrimaryIndustry: "Agriculture", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1817), + new USState(Id: 25, State: "Missouri", Counties: 115, Population: 6154913, PrimaryIndustry: "Healthcare", Region: "Midwest", Climate: "Continental", YearInducted: 1821), + new USState(Id: 26, State: "Montana", Counties: 56, Population: 1084225, PrimaryIndustry: "Agriculture", Region: "West", Climate: "Continental", YearInducted: 1889), + new USState(Id: 27, State: "Nebraska", Counties: 93, Population: 1961504, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1867), + new USState(Id: 28, State: "Nevada", Counties: 17, Population: 3104614, PrimaryIndustry: "Tourism", Region: "West", Climate: "Desert", YearInducted: 1864), + new USState(Id: 29, State: "New Hampshire", Counties: 10, Population: 1377529, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 30, State: "New Jersey", Counties: 21, Population: 9288994, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1787), + new USState(Id: 31, State: "New Mexico", Counties: 33, Population: 2117522, PrimaryIndustry: "Oil and Gas", Region: "Southwest", Climate: "Arid", YearInducted: 1912), + new USState(Id: 32, State: "New York", Counties: 62, Population: 20201249, PrimaryIndustry: "Finance and Insurance", Region: "Northeast", Climate: "Continental", YearInducted: 1788), + new USState(Id: 33, State: "North Carolina", Counties: 100, Population: 10439388, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1789), + new USState(Id: 34, State: "North Dakota", Counties: 53, Population: 779094, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1889), + new USState(Id: 35, State: "Ohio", Counties: 88, Population: 11799448, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1803), + new USState(Id: 36, State: "Oklahoma", Counties: 77, Population: 3959353, PrimaryIndustry: "Oil and Gas", Region: "Southwest", Climate: "Continental", YearInducted: 1907), + new USState(Id: 37, State: "Oregon", Counties: 36, Population: 4237256, PrimaryIndustry: "Technology", Region: "West", Climate: "Mediterranean", YearInducted: 1859), + new USState(Id: 38, State: "Pennsylvania", Counties: 67, Population: 13002700, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1787), + new USState(Id: 39, State: "Rhode Island", Counties: 5, Population: 1097379, PrimaryIndustry: "Healthcare", Region: "Northeast", Climate: "Continental", YearInducted: 1790), + new USState(Id: 40, State: "South Carolina", Counties: 46, Population: 5118425, PrimaryIndustry: "Manufacturing", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1788), + new USState(Id: 41, State: "South Dakota", Counties: 66, Population: 886667, PrimaryIndustry: "Agriculture", Region: "Midwest", Climate: "Continental", YearInducted: 1889), + new USState(Id: 42, State: "Tennessee", Counties: 95, Population: 6910840, PrimaryIndustry: "Healthcare", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1796), + new USState(Id: 43, State: "Texas", Counties: 254, Population: 29145505, PrimaryIndustry: "Oil and Gas", Region: "Southwest", Climate: "Subtropical", YearInducted: 1845), + new USState(Id: 44, State: "Utah", Counties: 29, Population: 3271616, PrimaryIndustry: "Healthcare", Region: "West", Climate: "Semi-arid", YearInducted: 1896), + new USState(Id: 45, State: "Vermont", Counties: 14, Population: 643077, PrimaryIndustry: "Tourism", Region: "Northeast", Climate: "Continental", YearInducted: 1791), + new USState(Id: 46, State: "Virginia", Counties: 133, Population: 8631393, PrimaryIndustry: "Technology", Region: "Southeast", Climate: "Humid Subtropical", YearInducted: 1788), + new USState(Id: 47, State: "Washington", Counties: 39, Population: 7705281, PrimaryIndustry: "Technology", Region: "West", Climate: "Oceanic", YearInducted: 1889), + new USState(Id: 48, State: "West Virginia", Counties: 55, Population: 1793716, PrimaryIndustry: "Healthcare", Region: "Southeast", Climate: "Continental", YearInducted: 1863), + new USState(Id: 49, State: "Wisconsin", Counties: 72, Population: 5893718, PrimaryIndustry: "Manufacturing", Region: "Midwest", Climate: "Continental", YearInducted: 1848), + new USState(Id: 50, State: "Wyoming", Counties: 23, Population: 576851, PrimaryIndustry: "Mining", Region: "West", Climate: "Semi-arid", YearInducted: 1890) + ]; + + if (sortDefinitions != null && sortDefinitions.Any()) + { + // on server or actual API would be IQueryable likely + IOrderedEnumerable? orderedQuery = null; + foreach (var sort in sortDefinitions) + { + orderedQuery = data.OrderByDirection(sort.Descending ? SortDirection.Descending : SortDirection.Ascending, + obj => GetPropertyValue(obj, sort.SortBy)); + } + data = orderedQuery!.ToList(); + } + + return data.Where(x => MatchesSearch(x, searchString)).ToList(); + } + + private static bool MatchesSearch(USState obj, string? searchString) + { + if (string.IsNullOrEmpty(searchString)) + { + return true; + } + + return obj.State.Contains(searchString, StringComparison.OrdinalIgnoreCase) || + obj.PrimaryIndustry.Contains(searchString, StringComparison.OrdinalIgnoreCase) || + obj.YearInducted.ToString().Contains(searchString) || + obj.Climate.Contains(searchString, StringComparison.OrdinalIgnoreCase) || + obj.Region.Contains(searchString, StringComparison.OrdinalIgnoreCase); + } + + private static object? GetPropertyValue(TT obj, string propertyName) + { + return typeof(TT).GetProperty(propertyName)?.GetValue(obj); + } + + public record USState(int Id, string State, int Counties, int Population, string PrimaryIndustry, string Region, string Climate, int YearInducted); +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor index c9a0ac2cd696..1e4146ce1b9b 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor @@ -1,21 +1,34 @@ -๏ปฟ +๏ปฟ + + + + + + + + + + @($"uid = {context.Item.Name}|{context.Item.Age}|{context.Item.Status}|{Guid.NewGuid()}") + + + + +Expand All +Collapse All + + +@code { + [Parameter] + public bool RightToLeft { get; set; } - - - - - - - - - - @($"uid = {context.Item.Name}|{context.Item.Age}|{context.Item.Status}|{Guid.NewGuid()}") - - - + [Parameter] + public bool EnableHeaderToggle { get; set;} -@code { - private IEnumerable _items = new List() + [Parameter] + public bool ExpandSingleRow { get; set; } + + private MudDataGrid _dataGrid = null!; + private readonly IEnumerable _items = new List { new Model("Sam", 56, Severity.Normal), new Model("Alicia", 54, Severity.Info), @@ -24,5 +37,15 @@ new Model("Anders", 24, Severity.Error) }; + private Task ExpandAll() + { + return _dataGrid.ExpandAllHierarchy(); + } + + private Task CollapseAll() + { + return _dataGrid.CollapseAllHierarchy(); + } + public record Model (string Name, int Age, Severity Status); } diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridServerDataColumnGroupingTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridServerDataColumnGroupingTest.razor index de1562e608bb..3514cc7e54c4 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridServerDataColumnGroupingTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridServerDataColumnGroupingTest.razor @@ -70,14 +70,14 @@ _elements = await _periodicTableService.GetElements(); } - private void ExpandAllGroups() + private Task ExpandAllGroups() { - _dataGrid.ExpandAllGroups(); + return _dataGrid.ExpandAllGroupsAsync(); } - private void CollapseAllGroups() + private Task CollapseAllGroups() { - _dataGrid.CollapseAllGroups(); + return _dataGrid.CollapseAllGroupsAsync(); } private void CustomizeByGroupChanged(bool isChecked) diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridUniqueRowKeyTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridUniqueRowKeyTest.razor index bb8a8b680ff4..dd80cf08b7f0 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridUniqueRowKeyTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridUniqueRowKeyTest.razor @@ -1,16 +1,17 @@ ๏ปฟ @* The input element will be tested to see if it gets recreated when row order changes. *@ - + - + - + @code { + [Parameter] public bool Group { get; set; } diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Input/InputHeapLockedTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Input/InputHeapLockedTest.razor new file mode 100644 index 000000000000..fffb51b5e5be --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Input/InputHeapLockedTest.razor @@ -0,0 +1,45 @@ +๏ปฟ
+ + +@code { + private bool _isLoading = false; + + private readonly Dictionary _userAttributes = new Dictionary + { + { "autocomplete", "off" }, + { "data-textfieldref", "ChatbotMessageTextField" } + }; + + private Task SendMessage(KeyboardEventArgs e) => Task.CompletedTask; + + private void HandleKeyDown(KeyboardEventArgs e) + { + switch (e.Key) + { + case "Enter": + _isLoading = true; + break; + default: + if (e.Key.Length == 1) // Single character + { + StateHasChanged(); + } + break; + } + } +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Menu/MenuEdgeCasesTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Menu/MenuEdgeCasesTest.razor new file mode 100644 index 000000000000..174ec0d05568 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Menu/MenuEdgeCasesTest.razor @@ -0,0 +1,179 @@ +๏ปฟ + + Test Application + + + @{ + var priority = "1"; + switch (priority) + { + case "1": + Priority 1 + break; + case "2": + Priority 2 + break; + case "3": + Priority 3 + break; + case "4": + Priority 4 + break; + case "5": + Priority 5 + break; + case null: + None + break; + } + } + + +
+ Priority 1 + Priority 2 + Priority 3 + Priority 4 + Priority 5 + None +
+
+
+ + + @{ + var priority = "1"; + switch (priority) + { + case "1": + Priority 1 + break; + case "2": + Priority 2 + break; + case "3": + Priority 3 + break; + case "4": + Priority 4 + break; + case "5": + Priority 5 + break; + case null: + None + break; + } + } + + +
+ Priority 1 + Priority 2 + Priority 3 + Priority 4 + Priority 5 + None +
+
+
+
+ + @* On initial load these don't respect the mouseover leave event only activating and not deactivating *@ + + @foreach (var menu in _menuList) + { + + + @($"Menu {menu}") + + + + + + + + + + } + + +
+ Click + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + @{ + var priority = "1"; + switch (priority) + { + case "1": + Priority 1 + break; + case "2": + Priority 2 + break; + case "3": + Priority 3 + break; + case "4": + Priority 4 + break; + case "5": + Priority 5 + break; + case null: + None + break; + } + } + + +
+ Priority 1 + Priority 2 + Priority 3 + Priority 4 + Priority 5 + None +
+
+
+
+
+ +@code { + public static string __description__ = "4 menu edge cases for visual testing, initial load, in appbar, nested, and in dialog."; + private readonly string[] _menuList = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]; + private bool _visible; +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverFlipDirectionTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverFlipDirectionTest.razor new file mode 100644 index 000000000000..d4efc91780ba --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverFlipDirectionTest.razor @@ -0,0 +1,115 @@ +๏ปฟ + + @(_expanded ? "Collapse" : "Expand") + @(_expanded ? "Collapse" : "Expand") for 5 s + Expand list in 5 s + + +
+ + + + Top Left + Top Center + Top Right + Bottom Left + Bottom Center + Bottom Right + Center Left + Center Center + Center Right + + + Top Left + Top Center + Top Right + Bottom Left + Bottom Center + Bottom Right + Center Left + Center Center + Center Right + +
+
+ + + + + + + + + + @if (_expandList) + { + + + + + + + + + + + + + + + + + + + } + + @if (!_hideBottom) + { + @(_expanded ? "Collapse" : "Expand") + @(_expanded ? "Collapse" : "Expand") for 5 s + + +
+ +
+
+ } +
+@code { + public static string __description__ = "Popover should take the best available space"; + + private bool _hideBottom; + private bool _expandList; + private bool _expanded = true; + private int _height = 600; + private int _heightBottom = 400; + private Origin _anchor = Origin.BottomLeft; + private Origin _transform = Origin.TopLeft; + + private readonly DropdownSettings _dropdownSettings = new() + { + OverflowBehavior = OverflowBehavior.FlipAlways + }; + + private void OnExpandCollapseClick() => _expanded = !_expanded; + + private async Task OnExpandCollapseFor5sClickAsync() + { + var expanded = _expanded; + _expanded = !expanded; + await Task.Delay(5000); + _expanded = expanded; + } + + private async Task OnExpandListIn5sAsync() + { + _expandList = false; + await Task.Delay(5000); + _expandList = true; + } +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Tooltip/TooltipNotRemovedTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tooltip/TooltipNotRemovedTest.razor new file mode 100644 index 000000000000..a675c8aaf52a --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tooltip/TooltipNotRemovedTest.razor @@ -0,0 +1,35 @@ +๏ปฟ + @for (var i = 0; i < 200; i++) + { +
+ @{ + var current = i; + } + + BTN @current + +
+ } +
+ +@code +{ + private Guid _key; + + protected override Task OnInitializedAsync() + { + var cts = new CancellationTokenSource(); + RunThingAsync(cts.Token).CatchAndLog(); + return base.OnInitializedAsync(); + } + + private async Task RunThingAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + _key = Guid.NewGuid(); + InvokeAsync(StateHasChanged).CatchAndLog(); + await Task.Delay(100, cancellationToken); + } + } +} diff --git a/src/MudBlazor.UnitTests/Components/AutocompleteTests.cs b/src/MudBlazor.UnitTests/Components/AutocompleteTests.cs index f55ad21931b6..eca2b66399b0 100644 --- a/src/MudBlazor.UnitTests/Components/AutocompleteTests.cs +++ b/src/MudBlazor.UnitTests/Components/AutocompleteTests.cs @@ -1853,6 +1853,75 @@ public void RequiredAndAriaRequiredAutocompleteAttributes_Should_BeDynamic() comp.Find("input").GetAttribute("aria-required").Should().Be("true"); } + /// + /// Ensure selecting an option does not reopen the list. + /// + [Test] + public void Autocomplete_SelectingOption_ShouldNot_ReopenList() + { + var comp = Context.RenderComponent(); + var autocompleteComponent = comp.FindComponent>(); + var autocomplete = autocompleteComponent.Instance; + + // Open the menu + autocompleteComponent.Find("div.mud-input-control").Focus(); + comp.WaitForAssertion(() => comp.Find("div.mud-popover").ClassList.Should().Contain("mud-popover-open")); + + // Select an option + comp.Find("div.mud-list-item").Click(); + + // Assert: Menu should remain closed + comp.WaitForAssertion(() => comp.Find("div.mud-popover").ClassList.Should().NotContain("mud-popover-open")); + } + + /// + /// Ensure the menu does not open in read-only mode. + /// + [Test] + public void Autocomplete_User_ShouldNot_OpenMenu_InReadOnlyMode() + { + var comp = Context.RenderComponent>(parameters => parameters + .Add(p => p.ReadOnly, true) + .Add(p => p.OpenOnFocus, true)); + var autocomplete = comp.Instance; + + // Attempt to open the menu via focus + comp.Find("div.mud-input-control").Focus(); + + // Assert: Menu should not open + comp.WaitForAssertion(() => autocomplete.Open.Should().BeFalse()); + + // Attempt to open the menu via click + comp.Find("div.mud-input-control").MouseDown(); + + // Assert: Menu should not open + comp.WaitForAssertion(() => autocomplete.Open.Should().BeFalse()); + } + + /// + /// Ensure the menu does not open in disabled mode. + /// + [Test] + public void Autocomplete_User_ShouldNot_OpenMenu_InDisabledMode() + { + var comp = Context.RenderComponent>(parameters => parameters + .Add(p => p.Disabled, true) + .Add(p => p.OpenOnFocus, true)); + var autocomplete = comp.Instance; + + // Attempt to open the menu via focus + comp.Find("div.mud-input-control").Focus(); + + // Assert: Menu should not open + comp.WaitForAssertion(() => autocomplete.Open.Should().BeFalse()); + + // Attempt to open the menu via click + comp.Find("div.mud-input-control").MouseDown(); + + // Assert: Menu should not open + comp.WaitForAssertion(() => autocomplete.Open.Should().BeFalse()); + } + /// /// Ensure that the ItemDisabledTemplate and ItemSelectedTemplate both can display when ItemTemplate isn't provided (null) /// diff --git a/src/MudBlazor.UnitTests/Components/ChartTests.cs b/src/MudBlazor.UnitTests/Components/ChartTests.cs index f83331f8ac57..4b9b1411367a 100644 --- a/src/MudBlazor.UnitTests/Components/ChartTests.cs +++ b/src/MudBlazor.UnitTests/Components/ChartTests.cs @@ -63,9 +63,18 @@ public void BarChartSelectionTest() var comp = Context.RenderComponent(); // print the generated html comp.Find("h6").InnerHtml.Trim().Should().Be("Selected portion of the chart: -1"); + + //check tooltip + comp.FindAll("path.mud-chart-bar")[0].MouseOver(); + comp.Find("tspan").InnerHtml.Trim().Should().Be("40"); + // now click something and see that the selected index changes: comp.FindAll("path.mud-chart-bar")[0].Click(); comp.Find("h6").InnerHtml.Trim().Should().Be("Selected portion of the chart: 0"); + + comp.FindAll("path.mud-chart-bar")[10].MouseOver(); + comp.Find("tspan").InnerHtml.Trim().Should().Be("24"); + comp.FindAll("path.mud-chart-bar")[10].Click(); comp.Find("h6").InnerHtml.Trim().Should().Be("Selected portion of the chart: 1"); } diff --git a/src/MudBlazor.UnitTests/Components/DataGridGroupingTests.cs b/src/MudBlazor.UnitTests/Components/DataGridGroupingTests.cs new file mode 100644 index 000000000000..d9613c4de469 --- /dev/null +++ b/src/MudBlazor.UnitTests/Components/DataGridGroupingTests.cs @@ -0,0 +1,544 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using AngleSharp.Dom; +using Bunit; +using FluentAssertions; +using MudBlazor.UnitTests.TestComponents.DataGrid; +using NUnit.Framework; + +#nullable enable +namespace MudBlazor.UnitTests.Components +{ + public class DataGridGroupingTests : BunitTest + { + [Test] + public async Task DataGridGroupExpandedTrueTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + // until a change happens this bool tracks whether GroupExpanded is applied. + dataGrid.Instance._groupInitialExpanded = true; + comp.FindAll("tbody .mud-table-row").Count.Should().Be(7); + await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroupsAsync()); + // collapsing group rows counts + dataGrid.Instance._groupInitialExpanded = false; + dataGrid.Render(); + // after all groups are collapsed + comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); + await comp.InvokeAsync(() => + comp.Instance.AddFruit()); + // datagrid should not be expanded with the new category since CollapseAll collapsed it (Even if it was empty) + dataGrid.Render(); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(3); + } + + [Test] + public async Task DataGridGroupExpandedTrueAsyncTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); + await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroupsAsync()); + dataGrid.Render(); + // after all groups are collapsed + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); + await comp.InvokeAsync(() => + comp.Instance.AddFruit()); + // datagrid should not be expanded with the new category since CollapseAll collapsed it (Even if it was empty) + dataGrid.Render(); + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(3)); + } + + [Test] + public async Task DataGridGroupExpandedTrueServerDataTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); + await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroupsAsync()); + dataGrid.Render(); + // after all groups are collapsed + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); + await comp.InvokeAsync(() => comp.Instance.AddFruit()); + // datagrid should not be expanded with the new category since CollapseAll collapsed it (Even if it was empty) + dataGrid.Render(); + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(3)); + } + + [Test] + public async Task DataGridGroupExpandedFalseTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); + dataGrid.Render(); + // after all groups are expanded + comp.FindAll("tbody .mud-table-row").Count.Should().Be(7); + await comp.InvokeAsync(() => + comp.Instance.AddFruit()); + // datagrid should not be collapsed with the new category since ExpandAll expanded it (Even if it was empty) + dataGrid.Render(); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(10); + } + + [Test] + public async Task DataGridGroupExpandedFalseAsyncTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); + dataGrid.Render(); + // after all groups are expanded + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); + await comp.InvokeAsync(() => + comp.Instance.AddFruit()); + // datagrid should not be collapsed with the new category since ExpandAll expanded it (Even if it was empty) + dataGrid.Render(); + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(10)); + } + + [Test] + public async Task DataGridGroupExpandedFalseServerDataTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); + dataGrid.Render(); + // after all groups are expanded + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); + await comp.InvokeAsync(() => comp.Instance.AddFruit()); + // datagrid should not be collapsed with the new category since ExpandAll expanded it (Even if it was empty) + dataGrid.Render(); + comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(10)); + } + + [Test] + public async Task DataGridGroupCollapseAllTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.FindAll("tbody .mud-table-row").Count.Should().Be(3); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); + comp.Render(); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(15); + await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroupsAsync()); + comp.Render(); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(3); + comp.Instance.RefreshList(); + comp.Render(); + // after all groups are expanded + comp.FindAll("tbody .mud-table-row").Count.Should().Be(3); + } + + [Test] + public async Task DataGridGroupExpandAllCollapseAllTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); + comp.Render(); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(14); + await dataGrid.InvokeAsync(() => dataGrid.Instance.NavigateTo(Page.First)); + await dataGrid.InvokeAsync(() => dataGrid.Instance.NavigateTo(Page.Next)); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(18); + await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroupsAsync()); + await dataGrid.InvokeAsync(() => dataGrid.Instance.NavigateTo(Page.First)); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); + comp.Instance.RefreshList(); + comp.Render(); + comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); + } + + + [Test] + public void ShouldSetIsGenderGroupedToTrueWhenGroupingIsApplied() + { + // Render the DataGridGroupingTest component for testing. + var comp = Context.RenderComponent(); + + // Attempt to find the MudPopoverProvider component within the rendered component. + // MudPopoverProvider is used to manage popovers in the component, including the grouping popover. + var popoverProvider = comp.FindComponent(); + + // Assert that initially, before any user interaction, IsGenderGrouped should be false. + comp.Instance.IsGenderGrouped.Should().Be(false); + + // Find the button within the 'th' element with class 'gender' that triggers the popover for grouping. + var genderHeaderOption = comp.Find("th.gender .mud-menu button"); + + // Simulate a click on the gender header group button to open the popover with grouping options. + genderHeaderOption.Click(); + + // Find all MudListItem components within the popoverProvider. + // These list items represent the individual options within the grouping popover. + var listItems = popoverProvider.FindComponents(); + + // Assert that there are exactly 2 list items (options) available in the popover. + listItems.Count.Should().Be(2); + + // From the list items found, select the second one which is expected to be the clickable option for grouping. + var clickablePopover = listItems[1].Find(".mud-menu-item"); + + // click on the grouping option to apply grouping to the data grid. + clickablePopover.Click(); + + // After clicking the grouping option, assert that IsGenderGrouped is now true, indicating that + // the action of applying grouping has successfully updated the component's state. + comp.Instance.IsGenderGrouped.Should().Be(true); + } + + [Test] + [SetCulture("en-US")] + public void DataGridServerGroupUngroupingTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + var popoverProvider = comp.FindComponent(); + + //ungroup Category + var headerCategory = comp.Find("th.group .mud-menu button"); + headerCategory.Click(); + var catListItems = popoverProvider.FindComponents(); + catListItems.Count.Should().Be(4); + var clickableCategoryUngroup = catListItems[3].Find(".mud-menu-item"); + clickableCategoryUngroup.Click(); + + //click name grouping in grid + var headerOption = comp.Find("th.name .mud-menu button"); + headerOption.Click(); + var listItems = popoverProvider.FindComponents(); + listItems.Count.Should().Be(4); + var clickablePopover = listItems[3].Find(".mud-menu-item"); + clickablePopover.Click(); + var cells = dataGrid.FindAll("td"); + + //checking cell content is the most reliable way to verify grouping + cells[0].TextContent.Should().Be("Name: Hydrogen"); + cells[1].TextContent.Should().Be("Name: Helium"); + cells[2].TextContent.Should().Be("Name: Lithium"); + cells[3].TextContent.Should().Be("Name: Beryllium"); + cells[4].TextContent.Should().Be("Name: Boron"); + cells[5].TextContent.Should().Be("Name: Carbon"); + cells[6].TextContent.Should().Be("Name: Nitrogen"); + cells[7].TextContent.Should().Be("Name: Oxygen"); + cells[8].TextContent.Should().Be("Name: Fluorine"); + cells[9].TextContent.Should().Be("Name: Neon"); + dataGrid.Instance.IsGrouped.Should().BeTrue(); + + //click name ungrouping in grid + headerOption = comp.Find("th.name .mud-menu button"); + headerOption.Click(); + listItems = popoverProvider.FindComponents(); + listItems.Count.Should().Be(4); + clickablePopover = listItems[3].Find(".mud-menu-item"); + clickablePopover.Click(); + cells = dataGrid.FindAll("td"); + // We do not need check all 10 rows as it's clear that it's ungrouped if first row pass + cells[0].TextContent.Should().Be("1"); + cells[1].TextContent.Should().Be("H"); + cells[2].TextContent.Should().Be("Hydrogen"); + cells[3].TextContent.Should().Be("0"); + cells[4].TextContent.Should().Be("1.00794"); + cells[5].TextContent.Should().Be("Other"); + dataGrid.Instance.IsGrouped.Should().BeFalse(); + } + + [Test] + public void DataGridGroupingTestBoundAndUnboundScenarios() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + var popoverProvider = comp.FindComponent(); + + IRefreshableElementCollection Rows() => dataGrid.FindAll("tr"); + IRefreshableElementCollection Cells() => dataGrid.FindAll("td"); + + // Assert that initially, before any user interaction, IsGenderGrouped and IsAgeGrouped should be false + // The default grouping is by name + comp.Instance.IsGenderGrouped.Should().Be(false); + comp.Instance.IsAgeGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(false); + + var nameCells = Cells(); + nameCells.Count.Should().Be(4, because: "4 data rows"); + nameCells[0].TextContent.Should().Be("Name: John"); + nameCells[1].TextContent.Should().Be("Name: Johanna"); + nameCells[2].TextContent.Should().Be("Name: Steve"); + nameCells[3].TextContent.Should().Be("Name: Alice"); + Rows().Count.Should().Be(6, because: "1 header row + 4 data rows + 1 footer row"); + + var ageGrouping = comp.Find(".GroupByAge"); + ageGrouping.Click(); + comp.Instance.IsAgeGrouped.Should().Be(true); + comp.Instance.IsGenderGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(false); + var ageCells = Cells(); + ageCells.Count.Should().Be(3, because: "3 data rows"); + ageCells[0].TextContent.Should().Be("Age: 45"); + ageCells[1].TextContent.Should().Be("Age: 23"); + ageCells[2].TextContent.Should().Be("Age: 32"); + Rows().Count.Should().Be(5, because: "1 header row + 3 data rows + 1 footer row"); + + var genderGrouping = comp.Find(".GroupByGender"); + genderGrouping.Click(); + comp.Instance.IsGenderGrouped.Should().Be(true); + comp.Instance.IsAgeGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(false); + var genderCells = Cells(); + genderCells.Count.Should().Be(2, because: "2 data rows"); + genderCells[0].TextContent.Should().Be("Gender: Male"); + genderCells[1].TextContent.Should().Be("Gender: Female"); + Rows().Count.Should().Be(4, because: "1 header row + 2 data rows + 1 footer row"); + + var professionGrouping = comp.Find(".GroupByProfession"); + professionGrouping.Click(); + comp.Instance.IsGenderGrouped.Should().Be(false); + comp.Instance.IsAgeGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(true); + var professionCells = Cells(); + professionCells.Count.Should().Be(2, because: "2 data rows"); + professionCells[0].TextContent.Should().Be("Profession: Cook"); + professionCells[1].TextContent.Should().Be("Profession: (None)"); + Rows().Count.Should().Be(4, because: "1 header row + 2 data rows + 1 footer row"); + + //click age grouping in grid + var headerOption = comp.Find("th.age .mud-menu button"); + headerOption.Click(); + var listItems = popoverProvider.FindComponents(); + listItems.Count.Should().Be(2); + var clickablePopover = listItems[1].Find(".mud-menu-item"); + clickablePopover.Click(); + comp.Instance.IsAgeGrouped.Should().Be(false); + comp.Instance.IsGenderGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(true); + Rows().Count.Should().Be(5, because: "1 header row + 3 data rows + 1 footer row"); + + //click gender grouping in grid + headerOption = comp.Find("th.gender .mud-menu button"); + headerOption.Click(); + listItems = popoverProvider.FindComponents(); + listItems.Count.Should().Be(2); + clickablePopover = listItems[1].Find(".mud-menu-item"); + clickablePopover.Click(); + comp.Instance.IsGenderGrouped.Should().Be(true); + comp.Instance.IsAgeGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(true); + Rows().Count.Should().Be(5, because: "1 header row + 3 data rows + 1 footer row"); + + //click Name grouping in grid + headerOption = comp.Find("th.name .mud-menu button"); + headerOption.Click(); + listItems = popoverProvider.FindComponents(); + listItems.Count.Should().Be(2); + clickablePopover = listItems[1].Find(".mud-menu-item"); + clickablePopover.Click(); + comp.Instance.IsGenderGrouped.Should().Be(true); + comp.Instance.IsAgeGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(true); + Rows().Count.Should().Be(6, because: "1 header row + 4 data rows + 1 footer row"); + + //click profession grouping in grid + headerOption = comp.Find("th.profession .mud-menu button"); + headerOption.Click(); + listItems = popoverProvider.FindComponents(); + listItems.Count.Should().Be(2); + clickablePopover = listItems[1].Find(".mud-menu-item"); + clickablePopover.Click(); + comp.Instance.IsGenderGrouped.Should().Be(true); + comp.Instance.IsAgeGrouped.Should().Be(false); + comp.Instance.IsProfessionGrouped.Should().Be(false); + Rows().Count.Should().Be(6, because: "1 header row + 4 data rows + 1 footer row"); + } + + + [Test] + public async Task DataGridGroupedWithServerDataPaginationTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + var rows = dataGrid.FindAll("tr"); + rows.Count.Should().Be(12, because: "1 header row + 10 data rows + 1 footer row"); + var cells = dataGrid.FindAll("td"); + cells.Count.Should().Be(10, because: "We have 10 data rows with one group collapsed"); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); + rows = dataGrid.FindAll("tr"); + rows.Count.Should().Be(32, because: "1 header row + 10 data rows + 1 footer row + 10 group rows + 10 footer group rows"); + cells = dataGrid.FindAll("td"); + cells.Count.Should().Be(30, because: "We have 10 data rows with one group + 10*2 cells inside groups"); + //check cells + cells[0].TextContent.Should().Be("Number: 1"); + cells[1].TextContent.Should().Be("Hydrogen"); cells[2].TextContent.Should().Be("1"); + cells[3].TextContent.Should().Be("Number: 2"); + cells[4].TextContent.Should().Be("Helium"); cells[5].TextContent.Should().Be("2"); + cells[6].TextContent.Should().Be("Number: 3"); + cells[7].TextContent.Should().Be("Lithium"); cells[8].TextContent.Should().Be("3"); + cells[9].TextContent.Should().Be("Number: 4"); + cells[10].TextContent.Should().Be("Beryllium"); cells[11].TextContent.Should().Be("4"); + cells[12].TextContent.Should().Be("Number: 5"); + cells[13].TextContent.Should().Be("Boron"); cells[14].TextContent.Should().Be("5"); + cells[15].TextContent.Should().Be("Number: 6"); + cells[16].TextContent.Should().Be("Carbon"); cells[17].TextContent.Should().Be("6"); + cells[18].TextContent.Should().Be("Number: 7"); + cells[19].TextContent.Should().Be("Nitrogen"); cells[20].TextContent.Should().Be("7"); + cells[21].TextContent.Should().Be("Number: 8"); + cells[22].TextContent.Should().Be("Oxygen"); cells[23].TextContent.Should().Be("8"); + cells[24].TextContent.Should().Be("Number: 9"); + cells[25].TextContent.Should().Be("Fluorine"); cells[26].TextContent.Should().Be("9"); + cells[27].TextContent.Should().Be("Number: 10"); + cells[28].TextContent.Should().Be("Neon"); cells[29].TextContent.Should().Be("10"); + //get next page + await comp.InvokeAsync(() => dataGrid.Instance.NavigateTo(Page.Next)); + comp.Render(); + await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroupsAsync()); + cells = dataGrid.FindAll("td"); + cells.Count.Should().Be(10, because: "We have 10 data rows with one group collapsed from next page"); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); + cells = dataGrid.FindAll("td"); + cells.Count.Should().Be(30, because: "We have next 10 data rows with one group + 10*2 cells inside groups"); + //cells should have data from next page + cells[0].TextContent.Should().Be("Number: 11"); + cells[1].TextContent.Should().Be("Sodium"); cells[2].TextContent.Should().Be("11"); + cells[3].TextContent.Should().Be("Number: 12"); + cells[4].TextContent.Should().Be("Magnesium"); cells[5].TextContent.Should().Be("12"); + cells[6].TextContent.Should().Be("Number: 13"); + cells[7].TextContent.Should().Be("Aluminium"); cells[8].TextContent.Should().Be("13"); + cells[9].TextContent.Should().Be("Number: 14"); + cells[10].TextContent.Should().Be("Silicon"); cells[11].TextContent.Should().Be("14"); + cells[12].TextContent.Should().Be("Number: 15"); + cells[13].TextContent.Should().Be("Phosphorus"); cells[14].TextContent.Should().Be("15"); + cells[15].TextContent.Should().Be("Number: 16"); + cells[16].TextContent.Should().Be("Sulfur"); cells[17].TextContent.Should().Be("16"); + cells[18].TextContent.Should().Be("Number: 17"); + cells[19].TextContent.Should().Be("Chlorine"); cells[20].TextContent.Should().Be("17"); + cells[21].TextContent.Should().Be("Number: 18"); + cells[22].TextContent.Should().Be("Argon"); cells[23].TextContent.Should().Be("18"); + cells[24].TextContent.Should().Be("Number: 19"); + cells[25].TextContent.Should().Be("Potassium"); cells[26].TextContent.Should().Be("19"); + cells[27].TextContent.Should().Be("Number: 20"); + cells[28].TextContent.Should().Be("Calcium"); cells[29].TextContent.Should().Be("20"); + } + + [Test] + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void TestRtlGroupIconMethod(bool isRightToLeft, bool isExpanded) + { + var test = new MudDataGrid(); + if (isExpanded) + { + test.GetGroupIcon(isExpanded, isRightToLeft).Should().Be(Icons.Material.Filled.ExpandMore); + } + else + { + test.GetGroupIcon(isExpanded, isRightToLeft).Should().Be(isRightToLeft ? Icons.Material.Filled.ChevronLeft : Icons.Material.Filled.ChevronRight); + } + } + + [Test] + public async Task GroupExpandClick_ShouldToggleExpandedState() + { + // Arrange + var component = Context.RenderComponent(); + + var dataGrid = component.FindComponent>(); + await component.InvokeAsync(() => dataGrid.Instance.ReloadServerData()); + + var rows = component.FindComponents>(); + rows.Count.Should().Be(15); + var row = rows[0]; + // Test the method + // Act + void GetCount(bool currExpanded) + { + var defaultExpanded = row.Instance.GroupDefinition.Expanded; + // Whatever the expanded state is if it differs from the default it should be in the dictionary + dataGrid.Instance._groupExpansionsDict.Count.Should().Be(currExpanded != defaultExpanded ? 1 : 0); + } + + // Test the UI + var expandButton = () => row.Find(".mud-datagrid-group-button"); + expandButton.Should().NotBeNull(); + expandButton().Click(); + + row.WaitForAssertion(() => row.Instance._expanded.Should().BeFalse()); + row.WaitForAssertion(() => GetCount(false)); + expandButton().Click(); + + row.WaitForAssertion(() => row.Instance._expanded.Should().BeTrue()); + row.WaitForAssertion(() => GetCount(true)); + } + + [Test] + public async Task DataGrid_Grouping_TestGroupableSets() + { + var component = Context.RenderComponent(); + + var dataGrid = component.FindComponent>(); + // by default has a groupdefinition + dataGrid.WaitForAssertion(() => dataGrid.Instance._groupDefinition.Should().NotBeNull()); + // turn off grouping for the whole grid + dataGrid.SetParam(x => x.Groupable, false); + dataGrid.Render(); + await component.InvokeAsync(() => dataGrid.Instance.ReloadServerData()); + + // grouping shouldn't exist + dataGrid.Instance._groupDefinition.Should().BeNull(); + foreach (var column in dataGrid.Instance.RenderedColumns) + { + column.GroupingState.Value.Should().Be(false); + } + + // no grouping rows + var rows = component.FindComponents>(); + rows.Count.Should().Be(0); + } + + [Test] + public async Task DataGrid_Grouping_GroupDefinition() + { + var component = Context.RenderComponent(); + + var dataGrid = component.FindComponent>(); + await component.InvokeAsync(() => dataGrid.Instance.ReloadServerData()); + // grouping is already setup make sure group definition is not null and it's first inner definition is not null + dataGrid.WaitForAssertion(() => dataGrid.Instance._groupDefinition.Should().NotBeNull()); + dataGrid.Instance._groupDefinition.InnerGroup.Should().NotBeNull(); + dataGrid.Instance._groupDefinition.Grouping.Should().BeNullOrEmpty(); + // _groupDefinition is the definition for all the groups but isn't combined into the items until display so we need to + // check the final definitions from within the DataGridGroupRow + + var rows = component.FindComponents>(); + rows.Count.Should().Be(15); + var row = rows[0]; + + // Only One Manufacturing Primary Industry + row.Instance.GroupDefinition.Title.Should().Be("Primary Industry"); + row.Instance.GroupDefinition.Grouping.Key.Should().Be("Manufacturing"); + row.Instance.Items.Should().NotBeNull(); + row.Instance.Items.Count().Should().Be(1); + // Agriculture should have 2 items + row = rows[6]; + row.Instance.Items.Should().NotBeNull(); + row.Instance.Items.Count().Should().Be(2); + // the next row is a sub group of Agriculture and should also have 2 items + row = rows[7]; + row.Instance.Items.Should().NotBeNull(); + row.Instance.Items.Count().Should().Be(2); + } + } +} diff --git a/src/MudBlazor.UnitTests/Components/DataGridTests.cs b/src/MudBlazor.UnitTests/Components/DataGridTests.cs index 3e07b0a7750d..02f5082b1da5 100644 --- a/src/MudBlazor.UnitTests/Components/DataGridTests.cs +++ b/src/MudBlazor.UnitTests/Components/DataGridTests.cs @@ -2939,6 +2939,44 @@ public void DataGridRowDetailClosedTest() dataGrid.FindAll("td").SingleOrDefault(x => x.TextContent.Trim().StartsWith("uid = Sam|56|Normal|")).Should().BeNull(); } + [Test] + public async Task DataGrid_RowDetail_ExpandCollapseAllTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + dataGrid.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(2)); + await dataGrid.InvokeAsync(() => dataGrid.Instance.CollapseAllHierarchy()); + dataGrid.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(0)); + await dataGrid.InvokeAsync(() => dataGrid.Instance.ExpandAllHierarchy()); + dataGrid.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(5)); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void DataGrid_RowDetail_RTL_GroupIcon(bool rightToLeft) + { + var comp = Context.RenderComponent(param => param + .Add(p => p.RightToLeft, rightToLeft) + ); + var dataGrid = comp.FindComponent>(); + var svg = dataGrid.Find(".mud-table-body .mud-table-row .mud-table-cell .mud-icon-root"); + + if (!rightToLeft) + { + // ChevronRight by Default + svg.InnerHtml.Should().Contain("(); - var dataGrid = comp.FindComponent>(); - - comp.FindAll("tbody .mud-table-row").Count.Should().Be(7); - await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroups()); - dataGrid.Render(); - // after all groups are collapsed - comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); - await comp.InvokeAsync(() => - comp.Instance.AddFruit()); - // datagrid should be expanded with the new category - dataGrid.Render(); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(5); - } - - [Test] - public async Task DataGridGroupExpandedTrueAsyncTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); - await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroups()); - dataGrid.Render(); - // after all groups are collapsed - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); - await comp.InvokeAsync(() => - comp.Instance.AddFruit()); - // datagrid should be expanded with the new category - dataGrid.Render(); - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(5)); - } - - [Test] - public async Task DataGridGroupExpandedTrueServerDataTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); - await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroups()); - dataGrid.Render(); - // after all groups are collapsed - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); - await comp.InvokeAsync(() => comp.Instance.AddFruit()); - // datagrid should be expanded with the new category - dataGrid.Render(); - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(5)); - } - - [Test] - public async Task DataGridGroupExpandedFalseTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); - dataGrid.Render(); - // after all groups are expanded - comp.FindAll("tbody .mud-table-row").Count.Should().Be(7); - await comp.InvokeAsync(() => - comp.Instance.AddFruit()); - // datagrid should be collapsed with the new category - dataGrid.Render(); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(8); - } - - [Test] - public async Task DataGridGroupExpandedFalseAsyncTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); - dataGrid.Render(); - // after all groups are expanded - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); - await comp.InvokeAsync(() => - comp.Instance.AddFruit()); - // datagrid should be collapsed with the new category - dataGrid.Render(); - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(8)); - } - - [Test] - public async Task DataGridGroupExpandedFalseServerDataTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(2)); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); - dataGrid.Render(); - // after all groups are expanded - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(7)); - await comp.InvokeAsync(() => comp.Instance.AddFruit()); - // datagrid should be collapsed with the new category - dataGrid.Render(); - comp.WaitForAssertion(() => comp.FindAll("tbody .mud-table-row").Count.Should().Be(8)); - } - - [Test] - public async Task DataGridGroupCollapseAllTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - comp.FindAll("tbody .mud-table-row").Count.Should().Be(3); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); - comp.Render(); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(15); - await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroups()); - comp.Render(); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(3); - comp.Instance.RefreshList(); - comp.Render(); - // after all groups are expanded - comp.FindAll("tbody .mud-table-row").Count.Should().Be(3); - } - - [Test] - public async Task DataGridGroupExpandAllCollapseAllTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); - comp.Render(); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(14); - await dataGrid.InvokeAsync(() => dataGrid.Instance.NavigateTo(Page.First)); - await dataGrid.InvokeAsync(() => dataGrid.Instance.NavigateTo(Page.Next)); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(18); - await comp.InvokeAsync(() => dataGrid.Instance.CollapseAllGroups()); - await dataGrid.InvokeAsync(() => dataGrid.Instance.NavigateTo(Page.First)); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); - comp.Instance.RefreshList(); - comp.Render(); - comp.FindAll("tbody .mud-table-row").Count.Should().Be(2); - } - [Test] public async Task DataGridPropertyColumnFormatTest() { @@ -4491,43 +4383,6 @@ public void DataGridRedundantMenuTest() columnOptionsSpan.TextContent.Trim().Should().BeEmpty(); } - [Test] - public void ShouldSetIsGenderGroupedToTrueWhenGroupingIsApplied() - { - // Render the DataGridGroupingTest component for testing. - var comp = Context.RenderComponent(); - - // Attempt to find the MudPopoverProvider component within the rendered component. - // MudPopoverProvider is used to manage popovers in the component, including the grouping popover. - var popoverProvider = comp.FindComponent(); - - // Assert that initially, before any user interaction, IsGenderGrouped should be false. - comp.Instance.IsGenderGrouped.Should().Be(false); - - // Find the button within the 'th' element with class 'gender' that triggers the popover for grouping. - var genderHeaderOption = comp.Find("th.gender .mud-menu button"); - - // Simulate a click on the gender header group button to open the popover with grouping options. - genderHeaderOption.Click(); - - // Find all MudListItem components within the popoverProvider. - // These list items represent the individual options within the grouping popover. - var listItems = popoverProvider.FindComponents(); - - // Assert that there are exactly 2 list items (options) available in the popover. - listItems.Count.Should().Be(2); - - // From the list items found, select the second one which is expected to be the clickable option for grouping. - var clickablePopover = listItems[1].Find(".mud-menu-item"); - - // click on the grouping option to apply grouping to the data grid. - clickablePopover.Click(); - - // After clicking the grouping option, assert that IsGenderGrouped is now true, indicating that - // the action of applying grouping has successfully updated the component's state. - comp.Instance.IsGenderGrouped.Should().Be(true); - } - [Test] public void DataGridDynamicColumnsTest() { @@ -4580,161 +4435,6 @@ public void DataGridSelectColumnTest() selectAllCheckboxes[1].Instance.Value.Should().BeFalse(); } - [Test] - [SetCulture("en-US")] - public void DataGridServerGroupUngroupingTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - var popoverProvider = comp.FindComponent(); - - //click name grouping in grid - var headerOption = comp.Find("th.name .mud-menu button"); - headerOption.Click(); - var listItems = popoverProvider.FindComponents(); - listItems.Count.Should().Be(4); - var clickablePopover = listItems[3].Find(".mud-menu-item"); - clickablePopover.Click(); - var cells = dataGrid.FindAll("td"); - - //checking cell content is the most reliable way to verify grouping - cells[0].TextContent.Should().Be("Name: Hydrogen"); - cells[1].TextContent.Should().Be("Name: Helium"); - cells[2].TextContent.Should().Be("Name: Lithium"); - cells[3].TextContent.Should().Be("Name: Beryllium"); - cells[4].TextContent.Should().Be("Name: Boron"); - cells[5].TextContent.Should().Be("Name: Carbon"); - cells[6].TextContent.Should().Be("Name: Nitrogen"); - cells[7].TextContent.Should().Be("Name: Oxygen"); - cells[8].TextContent.Should().Be("Name: Fluorine"); - cells[9].TextContent.Should().Be("Name: Neon"); - dataGrid.Instance.GroupedColumn.Should().NotBeNull(); - - //click name ungrouping in grid - headerOption = comp.Find("th.name .mud-menu button"); - headerOption.Click(); - listItems = popoverProvider.FindComponents(); - listItems.Count.Should().Be(4); - clickablePopover = listItems[3].Find(".mud-menu-item"); - clickablePopover.Click(); - cells = dataGrid.FindAll("td"); - // We do not need check all 10 rows as it's clear that it's ungrouped if first row pass - cells[0].TextContent.Should().Be("1"); - cells[1].TextContent.Should().Be("H"); - cells[2].TextContent.Should().Be("Hydrogen"); - cells[3].TextContent.Should().Be("0"); - cells[4].TextContent.Should().Be("1.00794"); - cells[5].TextContent.Should().Be("Other"); - dataGrid.Instance.GroupedColumn.Should().BeNull(); - } - - [Test] - public void DataGridGroupingTestBoundAndUnboundScenarios() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - var popoverProvider = comp.FindComponent(); - - IRefreshableElementCollection Rows() => dataGrid.FindAll("tr"); - IRefreshableElementCollection Cells() => dataGrid.FindAll("td"); - - // Assert that initially, before any user interaction, IsGenderGrouped and IsAgeGrouped should be false - // The default grouping is by name - comp.Instance.IsGenderGrouped.Should().Be(false); - comp.Instance.IsAgeGrouped.Should().Be(false); - comp.Instance.IsProfessionGrouped.Should().Be(false); - - var nameCells = Cells(); - nameCells.Count.Should().Be(4, because: "4 data rows"); - nameCells[0].TextContent.Should().Be("Name: John"); - nameCells[1].TextContent.Should().Be("Name: Johanna"); - nameCells[2].TextContent.Should().Be("Name: Steve"); - nameCells[3].TextContent.Should().Be("Name: Alice"); - Rows().Count.Should().Be(6, because: "1 header row + 4 data rows + 1 footer row"); - - var ageGrouping = comp.Find(".GroupByAge"); - ageGrouping.Click(); - comp.Instance.IsAgeGrouped.Should().Be(true); - comp.Instance.IsGenderGrouped.Should().Be(false); - comp.Instance.IsProfessionGrouped.Should().Be(false); - var ageCells = Cells(); - ageCells.Count.Should().Be(3, because: "3 data rows"); - ageCells[0].TextContent.Should().Be("Age: 45"); - ageCells[1].TextContent.Should().Be("Age: 23"); - ageCells[2].TextContent.Should().Be("Age: 32"); - Rows().Count.Should().Be(5, because: "1 header row + 3 data rows + 1 footer row"); - - var genderGrouping = comp.Find(".GroupByGender"); - genderGrouping.Click(); - comp.Instance.IsGenderGrouped.Should().Be(true); - comp.Instance.IsAgeGrouped.Should().Be(true, because: "Age is not bound"); - comp.Instance.IsProfessionGrouped.Should().Be(false); - var genderCells = Cells(); - genderCells.Count.Should().Be(2, because: "2 data rows"); - genderCells[0].TextContent.Should().Be("Gender: Male"); - genderCells[1].TextContent.Should().Be("Gender: Female"); - Rows().Count.Should().Be(4, because: "1 header row + 2 data rows + 1 footer row"); - - var professionGrouping = comp.Find(".GroupByProfession"); - professionGrouping.Click(); - comp.Instance.IsGenderGrouped.Should().Be(false); - comp.Instance.IsAgeGrouped.Should().Be(true, because: "Age is not bound"); - comp.Instance.IsProfessionGrouped.Should().Be(true); - var professionCells = Cells(); - professionCells.Count.Should().Be(2, because: "2 data rows"); - professionCells[0].TextContent.Should().Be("Profession: Cook"); - professionCells[1].TextContent.Should().Be("Profession: (None)"); - Rows().Count.Should().Be(4, because: "1 header row + 2 data rows + 1 footer row"); - - //click age grouping in grid - var headerOption = comp.Find("th.age .mud-menu button"); - headerOption.Click(); - var listItems = popoverProvider.FindComponents(); - listItems.Count.Should().Be(2); - var clickablePopover = listItems[1].Find(".mud-menu-item"); - clickablePopover.Click(); - comp.Instance.IsAgeGrouped.Should().Be(true); - comp.Instance.IsGenderGrouped.Should().Be(false); - comp.Instance.IsProfessionGrouped.Should().Be(false); - Rows().Count.Should().Be(5, because: "1 header row + 3 data rows + 1 footer row"); - - //click gender grouping in grid - headerOption = comp.Find("th.gender .mud-menu button"); - headerOption.Click(); - listItems = popoverProvider.FindComponents(); - listItems.Count.Should().Be(2); - clickablePopover = listItems[1].Find(".mud-menu-item"); - clickablePopover.Click(); - comp.Instance.IsGenderGrouped.Should().Be(true); - comp.Instance.IsAgeGrouped.Should().Be(true, because: "Age is not bound"); - comp.Instance.IsProfessionGrouped.Should().Be(false); - Rows().Count.Should().Be(4, because: "1 header row + 2 data rows + 1 footer row"); - - //click Name grouping in grid - headerOption = comp.Find("th.name .mud-menu button"); - headerOption.Click(); - listItems = popoverProvider.FindComponents(); - listItems.Count.Should().Be(2); - clickablePopover = listItems[1].Find(".mud-menu-item"); - clickablePopover.Click(); - comp.Instance.IsGenderGrouped.Should().Be(false); - comp.Instance.IsAgeGrouped.Should().Be(true, because: "Age is not bound"); - comp.Instance.IsProfessionGrouped.Should().Be(false); - Rows().Count.Should().Be(6, because: "1 header row + 4 data rows + 1 footer row"); - - //click profession grouping in grid - headerOption = comp.Find("th.profession .mud-menu button"); - headerOption.Click(); - listItems = popoverProvider.FindComponents(); - listItems.Count.Should().Be(2); - clickablePopover = listItems[1].Find(".mud-menu-item"); - clickablePopover.Click(); - comp.Instance.IsGenderGrouped.Should().Be(false); - comp.Instance.IsAgeGrouped.Should().Be(true, because: "Age is not bound"); - comp.Instance.IsProfessionGrouped.Should().Be(true); - Rows().Count.Should().Be(4, because: "1 header row + 2 data rows + 1 footer row"); - } - [Test] public async Task FilterDefinitionTestHasFilterProperty() { @@ -4760,72 +4460,6 @@ await comp.InvokeAsync(() => dataGrid.Instance.AddFilterAsync(new FilterDefiniti statusHeaderCell.Instance.hasFilter.Should().BeFalse(); } - [Test] - public async Task DataGridGroupedWithServerDataPaginationTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - var rows = dataGrid.FindAll("tr"); - rows.Count.Should().Be(12, because: "1 header row + 10 data rows + 1 footer row"); - var cells = dataGrid.FindAll("td"); - cells.Count.Should().Be(10, because: "We have 10 data rows with one group collapsed"); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); - rows = dataGrid.FindAll("tr"); - rows.Count.Should().Be(32, because: "1 header row + 10 data rows + 1 footer row + 10 group rows + 10 footer group rows"); - cells = dataGrid.FindAll("td"); - cells.Count.Should().Be(30, because: "We have 10 data rows with one group + 10*2 cells inside groups"); - //check cells - cells[0].TextContent.Should().Be("Number: 1"); - cells[1].TextContent.Should().Be("Hydrogen"); cells[2].TextContent.Should().Be("1"); - cells[3].TextContent.Should().Be("Number: 2"); - cells[4].TextContent.Should().Be("Helium"); cells[5].TextContent.Should().Be("2"); - cells[6].TextContent.Should().Be("Number: 3"); - cells[7].TextContent.Should().Be("Lithium"); cells[8].TextContent.Should().Be("3"); - cells[9].TextContent.Should().Be("Number: 4"); - cells[10].TextContent.Should().Be("Beryllium"); cells[11].TextContent.Should().Be("4"); - cells[12].TextContent.Should().Be("Number: 5"); - cells[13].TextContent.Should().Be("Boron"); cells[14].TextContent.Should().Be("5"); - cells[15].TextContent.Should().Be("Number: 6"); - cells[16].TextContent.Should().Be("Carbon"); cells[17].TextContent.Should().Be("6"); - cells[18].TextContent.Should().Be("Number: 7"); - cells[19].TextContent.Should().Be("Nitrogen"); cells[20].TextContent.Should().Be("7"); - cells[21].TextContent.Should().Be("Number: 8"); - cells[22].TextContent.Should().Be("Oxygen"); cells[23].TextContent.Should().Be("8"); - cells[24].TextContent.Should().Be("Number: 9"); - cells[25].TextContent.Should().Be("Fluorine"); cells[26].TextContent.Should().Be("9"); - cells[27].TextContent.Should().Be("Number: 10"); - cells[28].TextContent.Should().Be("Neon"); cells[29].TextContent.Should().Be("10"); - //get next page - dataGrid.Instance.CurrentPage = 1; - await comp.InvokeAsync(async () => await dataGrid.Instance.ReloadServerData()); - cells = dataGrid.FindAll("td"); - cells.Count.Should().Be(10, because: "We have 10 data rows with one group collapsed from next page"); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); - cells = dataGrid.FindAll("td"); - cells.Count.Should().Be(30, because: "We have next 10 data rows with one group + 10*2 cells inside groups"); - //cells should have data from next page - cells[0].TextContent.Should().Be("Number: 11"); - cells[1].TextContent.Should().Be("Sodium"); cells[2].TextContent.Should().Be("11"); - cells[3].TextContent.Should().Be("Number: 12"); - cells[4].TextContent.Should().Be("Magnesium"); cells[5].TextContent.Should().Be("12"); - cells[6].TextContent.Should().Be("Number: 13"); - cells[7].TextContent.Should().Be("Aluminium"); cells[8].TextContent.Should().Be("13"); - cells[9].TextContent.Should().Be("Number: 14"); - cells[10].TextContent.Should().Be("Silicon"); cells[11].TextContent.Should().Be("14"); - cells[12].TextContent.Should().Be("Number: 15"); - cells[13].TextContent.Should().Be("Phosphorus"); cells[14].TextContent.Should().Be("15"); - cells[15].TextContent.Should().Be("Number: 16"); - cells[16].TextContent.Should().Be("Sulfur"); cells[17].TextContent.Should().Be("16"); - cells[18].TextContent.Should().Be("Number: 17"); - cells[19].TextContent.Should().Be("Chlorine"); cells[20].TextContent.Should().Be("17"); - cells[21].TextContent.Should().Be("Number: 18"); - cells[22].TextContent.Should().Be("Argon"); cells[23].TextContent.Should().Be("18"); - cells[24].TextContent.Should().Be("Number: 19"); - cells[25].TextContent.Should().Be("Potassium"); cells[26].TextContent.Should().Be("19"); - cells[27].TextContent.Should().Be("Number: 20"); - cells[28].TextContent.Should().Be("Calcium"); cells[29].TextContent.Should().Be("20"); - } - /// /// Reproduce the bug from https://github.com/MudBlazor/MudBlazor/issues/9585 /// When a column is hidden by the menu and the precedent column is resized, then the app crash @@ -5016,24 +4650,6 @@ public async Task TestCurrentPageParameterTwoWayBinding() comp.WaitForAssertion(() => comp.Find(".mud-table-body .mud-table-row .mud-table-cell").TextContent.Should().Be("3")); } - [Test] - [TestCase(true, true)] - [TestCase(true, false)] - [TestCase(false, true)] - [TestCase(false, false)] - public void TestRtlGroupIconMethod(bool isRightToLeft, bool isExpanded) - { - var test = new MudDataGrid(); - if (isExpanded) - { - test.GetGroupIcon(isExpanded, isRightToLeft).Should().Be(Icons.Material.Filled.ExpandMore); - } - else - { - test.GetGroupIcon(isExpanded, isRightToLeft).Should().Be(isRightToLeft ? Icons.Material.Filled.ChevronLeft : Icons.Material.Filled.ChevronRight); - } - } - /// /// Verifies data grid does not reuse row child components for different items (the @key for the row is set to the user supplied item). /// @@ -5053,10 +4669,9 @@ public async Task DataGridUniqueRowKey() before.Should().NotBeSameAs(after, because: "If the @key is correctly set to the row item, child components will be recreated on row reordering."); - //Test the expanded group case comp.SetParametersAndRender(parameters => parameters.Add(p => p.Group, true)); - await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroups()); + await comp.InvokeAsync(() => dataGrid.Instance.ExpandAllGroupsAsync()); await comp.InvokeAsync(() => dataGrid.Instance.SetSortAsync(sortByColumnName, SortDirection.Ascending, x => x)); before = dataGrid.FindComponent>(); @@ -5176,5 +4791,150 @@ public async Task DataGridSelectedItemEventsTest() comp.Instance.SelectedItemChanged = false; comp.Instance.SelectedItemsChanged = false; } + + [Test] + public void DataGridHeaderToggleHierarchyTest() + { + // Render with EnableHeaderToggle = true to enable header toggle functionality + var comp = Context.RenderComponent(parameters => + parameters.Add(p => p.EnableHeaderToggle, true)); + var dataGrid = comp.FindComponent>(); + + // Find the header cell that should include hierarchy toggle + var headerCell = dataGrid.FindComponents>().First(); + + var headerElement = comp.Find("th.mud-header-togglehierarchy"); + headerElement.Should().NotBeNull("Header should have mud-header-togglehierarchy class when EnableHeaderToggle is true"); + headerCell.Instance.IncludeHierarchyToggle.Should().BeTrue(); + + // Check that the HierarchyToggle button exists in the header + var toggleButton = headerElement.QuerySelector(".mud-hierarchy-toggle-button"); + toggleButton.Should().NotBeNull("HierarchyToggle button should be rendered in header"); + + // The initial state should be expanded (Anders and Ira items are initially expanded) + dataGrid.Instance._openHierarchies.Count.Should().Be(2); + + // Click the toggle button to collapse all hierarchies + toggleButton.Click(); + comp.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(0)); + + // Click again to expand all + toggleButton = headerElement.QuerySelector(".mud-hierarchy-toggle-button"); + toggleButton.Click(); + comp.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(5)); + } + + [Test] + [TestCase(true)] + [TestCase(false)] + public void DataGridHeaderToggleIconTest(bool rightToLeft) + { + // Render with EnableHeaderToggle = true and set RTL mode + var comp = Context.RenderComponent(parameters => + { + parameters.Add(p => p.EnableHeaderToggle, true); + parameters.Add(p => p.RightToLeft, rightToLeft); + }); + var dataGrid = comp.FindComponent>(); + + // Find the header with toggle + var headerElement = comp.Find("th.mud-header-togglehierarchy"); + + // Find the toggle button in header + var toggleButton = headerElement.QuerySelector(".mud-hierarchy-toggle-button"); + var icon = toggleButton.QuerySelector(".mud-icon-root"); + + // Initial state should show expanded icon (ExpandMore) + var iconPath = icon.InnerHtml; + iconPath.Should().Contain("M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z", + "Icon should be ExpandMore when hierarchies are expanded"); + + // Click to collapse all + toggleButton.Click(); + + // Now the icon should change based on RTL mode + icon = headerElement.QuerySelector(".mud-hierarchy-toggle-button .mud-icon-root"); + iconPath = icon.InnerHtml; + + if (rightToLeft) + { + iconPath.Should().Contain("M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z", + "Icon should be ChevronLeft in RTL mode when hierarchies are collapsed"); + } + else + { + iconPath.Should().Contain("M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z", + "Icon should be ChevronRight in LTR mode when hierarchies are collapsed"); + } + } + + [Test] + public async Task DataGridToggleHierarchyMethodTest() + { + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + var headerCell = dataGrid.FindComponents>().First(); + + // Initially, there should be 2 expanded items + dataGrid.Instance._openHierarchies.Count.Should().Be(2); + var accessor = headerCell.Instance; + await accessor.ToggleHierarchyAsync(); + + // After calling ToggleHierarchy when some hierarchies are open, all should be collapsed + dataGrid.Instance._openHierarchies.Count.Should().Be(0); + + // Call ToggleHierarchy again + await accessor.ToggleHierarchyAsync(); + + // Now all hierarchies should be expanded + dataGrid.Instance._openHierarchies.Count.Should().Be(5); + } + + [Test] + public async Task DataGridGetHierarchyGroupIconTest() + { + // Create a test component + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + // Get a reference to a HeaderCell to test GetGroupIcon method + var headerCell = dataGrid.FindComponents>().First(); + + // Create a PrivateAccessor to invoke the GetGroupIcon method + var accessor = headerCell.Instance; + + // When expanded (RTL doesn't matter in this case) + var expandedIcon = accessor.GetGroupIcon(); + expandedIcon.Should().Be(Icons.Material.Filled.ExpandMore); + + await accessor.ToggleHierarchyAsync(); // collapse all + + // When collapsed + LTR + var collapsedIcon = accessor.GetGroupIcon(); + comp.WaitForAssertion(() => collapsedIcon.Should().Be(Icons.Material.Filled.ChevronRight)); + + comp.SetParametersAndRender(parameters => parameters.Add(p => p.RightToLeft, true)); + // When collapsed + RTL + comp.WaitForAssertion(() => accessor.GetGroupIcon().Should().Be(Icons.Material.Filled.ChevronLeft)); + } + + [Test] + public void DataGrid_HierarchyExpandSingleRowTest() + { + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.ExpandSingleRow, false)); + var dataGrid = comp.FindComponent>(); + + dataGrid.Instance._openHierarchies.Count.Should().Be(2); + var item = dataGrid.Instance._openHierarchies.First(); + item.Should().NotBeNull(); + + comp.SetParametersAndRender(p => p.Add(p => p.ExpandSingleRow, true)); + + dataGrid.Instance._openHierarchies.Count.Should().Be(1); + + dataGrid.Instance._openHierarchies.First().Should().Be(item); + } } } diff --git a/src/MudBlazor.UnitTests/Components/DateRangePickerTests.cs b/src/MudBlazor.UnitTests/Components/DateRangePickerTests.cs index 1baeadf8075f..fd4eef1567fb 100644 --- a/src/MudBlazor.UnitTests/Components/DateRangePickerTests.cs +++ b/src/MudBlazor.UnitTests/Components/DateRangePickerTests.cs @@ -255,10 +255,8 @@ public void Open_SelectEndDateLowerThanStart_CheckClosed() { var comp = OpenPicker(); // clicking a day buttons to select a range and close - comp.FindAll("button.mud-picker-calendar-day") - .Where(x => x.TrimmedText().Equals("10")).First().Click(); - comp.FindAll("button.mud-picker-calendar-day") - .Where(x => x.TrimmedText().Equals("8")).First().Click(); + comp.SelectDate("10"); + comp.SelectDate("8"); comp.FindAll("div.mud-picker-open").Count.Should().Be(1); comp.WaitForAssertion(() => comp.FindAll("div.mud-picker-open").Count.Should().Be(0), TimeSpan.FromSeconds(5)); comp.Instance.DateRange.Should().NotBeNull(); @@ -1203,5 +1201,55 @@ await comp.FindAll("button.mud-picker-calendar-day") comp.Instance.DateRange.Start.Should().Be(comp.Instance.DateRange.End); } + + [Test] + public async Task DateRangePicker_BlurAsync() + { + var comp = Context.RenderComponent(parameters => parameters + .Add(picker => picker.ReadOnly, false) + .Add(picker => picker.Editable, true)); + + var input = comp.Find("input"); + + await comp.Instance.FocusStartAsync(); + + // the input is actually never focused because the test is run in a headless browser + //comp.Find("input").IsFocused.Should().BeTrue(); + + await comp.Instance.BlurAsync(); + + comp.Find("input").IsFocused.Should().BeFalse(); + } + } + + public static class DatePickerRenderedFragmentExtensions + { + public static void SelectDate(this IRenderedFragment comp, string day, bool firstOccurrence = true) + { + comp.ValidateSelection(day, firstOccurrence).Click(); + } + + public static async Task SelectDateAsync(this IRenderedFragment comp, string day, bool firstOccurrence = true) + { + await comp.ValidateSelection(day, firstOccurrence).ClickAsync(new MouseEventArgs()); + } + + private static IElement ValidateSelection(this IRenderedFragment comp, string day, bool firstOccurrence) + { + var matchingDays = comp.FindAll("button.mud-picker-calendar-day") + .Where(x => !x.ClassList.Contains("mud-hidden") && x.TrimmedText().Equals(day)) + .ToList(); + + Assert.That(matchingDays.Count != 0, $"Invalid day ({day}) selected"); + + if (!firstOccurrence) + Assert.That(matchingDays.Count == 2, $"Only one instance of date ({day}) found"); + + var selectedDate = matchingDays[firstOccurrence ? 0 : 1]; + + Assert.That(!selectedDate.IsDisabled(), $"Selected date ({day}) is disabled"); + + return selectedDate; + } } } diff --git a/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs b/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs index a4f5b55d9c64..6069573b0287 100644 --- a/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs +++ b/src/MudBlazor.UnitTests/Components/DynamicTabsTests.cs @@ -104,12 +104,11 @@ public async Task BasicParameters_WithToolTips() actual.Should().BeEquivalentTo(expected); var parent = (IHtmlElement)item.Parent; - parent.Children.Should().HaveCount(1, because: "the button and no empty popover hint since it's not active"); + parent.Children.Should().HaveCount(2, because: "the button and the empty popover hint since it's not active"); await item.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); - var popover = comp.Find("div.mud-popover"); - var popoverId = popover.Id.Substring(15); + var popoverId = parent.Children[1].Id.Substring(8); var toolTip = comp.Find($"#popovercontent-{popoverId}"); @@ -134,12 +133,11 @@ public async Task BasicParameters_WithToolTips() actual.Should().BeEquivalentTo(expected); var parent = (IHtmlElement)item.Parent; - parent.Children.Should().HaveCount(1, because: "the button and no popover hint"); ; + parent.Children.Should().HaveCount(2, because: "the button and the empty popover hint"); ; await item.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); - var popover = comp.Find("div.mud-popover"); - var popoverId = popover.Id.Substring(15); + var popoverId = parent.Children[1].Id.Substring(8); var toolTip = comp.Find($"#popovercontent-{popoverId}"); diff --git a/src/MudBlazor.UnitTests/Components/MenuTests.cs b/src/MudBlazor.UnitTests/Components/MenuTests.cs index 5d55a744abe6..6b98f3fb4e5f 100644 --- a/src/MudBlazor.UnitTests/Components/MenuTests.cs +++ b/src/MudBlazor.UnitTests/Components/MenuTests.cs @@ -688,11 +688,11 @@ public async Task Menu_PointerEvents_ShowHide_WithDebounce() // Immediately after leave, submenu should still be visible (hide debounce in effect) comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "Submenu should remain open immediately after pointer leave"); - // Wait less than the hide delay (which is 2x show delay) - await Task.Delay(hoverDelay + 50); + // Wait less than the delay + await Task.Delay(hoverDelay / 2); comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "Submenu should still be open before hide delay completes"); - // After the full hide delay (2x hover delay), submenu should close + // After the full delay, submenu should close await Task.Delay(hoverDelay + 50); comp.FindAll("div.mud-popover-open").Count.Should().Be(1, "Submenu should close after full hide delay (2x hover delay)"); } @@ -735,11 +735,11 @@ public async Task Menu_PointerEvents_Cancellation() // Start leave sequence, but re-enter before hide completes menu.PointerLeaveAsync(new PointerEventArgs()).CatchAndLog(); - await Task.Delay(hoverDelay); + await Task.Delay(hoverDelay / 2); menu._hideDebouncer.Cancel(); // Wait for what would have been the full hide delay - await Task.Delay((hoverDelay * 2) + 100); + await Task.Delay(hoverDelay + 100); comp.FindAll("div.mud-popover-open").Count.Should().Be(2, "Submenu should still be open because hide debounce was cancelled by re-entering"); } diff --git a/src/MudBlazor.UnitTests/Components/OverlayTests.cs b/src/MudBlazor.UnitTests/Components/OverlayTests.cs index b04d97ebc61f..f94dfa7ff483 100644 --- a/src/MudBlazor.UnitTests/Components/OverlayTests.cs +++ b/src/MudBlazor.UnitTests/Components/OverlayTests.cs @@ -1,6 +1,8 @@ ๏ปฟusing AngleSharp.Dom; using Bunit; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Moq; using MudBlazor.UnitTests.TestComponents.Overlay; using NUnit.Framework; @@ -150,6 +152,37 @@ public void ShouldApplyAbsoluteClass(bool absolute) } } + [Test] + [TestCase(true)] + [TestCase(false)] + public void ShouldApplyCorrectPointerEvents(bool modal) + { + var providerComp = Context.RenderComponent(); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Visible, true) + .Add(p => p.Modal, modal) + ); + + if (modal) + { + providerComp.Find("div.mud-overlay").Attributes["style"].Value.Should().NotContain("pointer-events:none"); + } + else + { + providerComp.Find("div.mud-overlay").Attributes["style"].Value.Should().Contain("pointer-events:none"); + } + } + + [Test] + public void ShouldHaveId() + { + var providerComp = Context.RenderComponent(); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Visible, true) + ); + providerComp.Find("div.mud-overlay").Attributes["id"].Value.Should().NotBeNullOrEmpty(); + } + [Test] [TestCase(true, "", false, 0)] // Absolute is true [TestCase(false, "mud-skip-overlay-section", false, 1)] // Dialog @@ -222,4 +255,138 @@ public void ShouldRenderChildContent() comp.Find("div.child-content").TextContent.Should().Be("Hello World"); } + + [Test] + [TestCase(true, true, false, true)] + [TestCase(true, false, false, false)] + [TestCase(true, false, true, false)] + [TestCase(true, true, true, false)] + [TestCase(false, true, false, false)] + [TestCase(false, false, false, false)] + [TestCase(false, false, true, false)] + [TestCase(false, true, true, false)] + public void CallsSubscribeAsyncOnPointerEventsNoneServiceWhenExpected(bool visible, bool autoClose, bool modal, bool callsStart) + { + Context.Services.Remove(ServiceDescriptor.Scoped()); + var serviceMock = new Mock(); + serviceMock + .Setup(s => s.SubscribeAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + Context.Services.AddScoped(_ => serviceMock.Object); + + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Visible, visible) + .Add(p => p.AutoClose, autoClose) + .Add(p => p.Modal, modal) + ); + + serviceMock.Verify(s => s.SubscribeAsync(It.IsAny(), It.IsAny()), callsStart ? Times.Once() : Times.Never()); + } + + [Test] + public void Overlay_ShouldHaveElementId_AndMatchRenderedDivId() + { + // Arrange + var providerComp = Context.RenderComponent(); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Visible, true) + ); + + // Act + var elementId = ((IPointerEventsNoneObserver)comp.Instance).ElementId; + var overlayDiv = providerComp.Find("div.mud-overlay"); + + // Assert + elementId.Should().NotBeNullOrWhiteSpace(); + overlayDiv.Id.Should().Be(elementId); + } + + [Test] + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public async Task Overlay_HandleLockScrollChanges(bool absolute, bool lockscroll) + { + var scrollManagerMock = new Mock(); + Context.Services.AddSingleton(scrollManagerMock.Object); + var providerComp = Context.RenderComponent(); + + var visible = true; + + // === Initial: Visible = true, should lock scroll if conditions match === + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Absolute, absolute) + .Bind(p => p.Visible, visible, p => visible = p) + .Add(p => p.LockScroll, lockscroll) + ); + + var mudOverlay = comp.Instance; + + // Initial unlock state + scrollManagerMock.Verify(s => s.UnlockScrollAsync(It.IsAny(), It.IsAny()), Times.Never()); + + if (!absolute && lockscroll) + { + scrollManagerMock.Verify(s => s.LockScrollAsync("body", mudOverlay.LockScrollClass), Times.Once()); + } + else + { + scrollManagerMock.Verify(s => s.LockScrollAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + // === Manually re-trigger HandleLockScrollChange (should not change counts) === + await mudOverlay.HandleLockScrollChange(); + + if (!absolute && lockscroll) + { + scrollManagerMock.Verify(s => s.LockScrollAsync("body", mudOverlay.LockScrollClass), Times.Once()); + } + else + { + scrollManagerMock.Verify(s => s.LockScrollAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + // === Toggle visible to false, expect unlock === + visible = false; + comp.SetParametersAndRender(p => p.Add(p => p.Visible, visible)); + + if (!absolute && lockscroll) + { + scrollManagerMock.Verify(s => s.UnlockScrollAsync("body", mudOverlay.LockScrollClass), Times.Once()); + } + else + { + scrollManagerMock.Verify(s => s.UnlockScrollAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + // open it + visible = true; + comp.SetParametersAndRender(p => p.Add(p => p.Visible, visible)); + + // close it by method + await mudOverlay.CloseOverlayAsync(); + + if (!absolute && lockscroll) + { + scrollManagerMock.Verify(s => s.UnlockScrollAsync("body", mudOverlay.LockScrollClass), Times.Exactly(2)); + } + else + { + scrollManagerMock.Verify(s => s.UnlockScrollAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + + // === Dispose component === + await mudOverlay.DisposeAsync(); + + if (!absolute && lockscroll) + { + scrollManagerMock.Verify(s => s.UnlockScrollAsync("body", mudOverlay.LockScrollClass), Times.AtLeast(2)); + } + else + { + scrollManagerMock.Verify(s => s.UnlockScrollAsync(It.IsAny(), It.IsAny()), Times.AtMostOnce()); + } + } } diff --git a/src/MudBlazor.UnitTests/Components/PopoverTests.cs b/src/MudBlazor.UnitTests/Components/PopoverTests.cs index a7d9e238eab5..07e94aace1e5 100644 --- a/src/MudBlazor.UnitTests/Components/PopoverTests.cs +++ b/src/MudBlazor.UnitTests/Components/PopoverTests.cs @@ -14,7 +14,8 @@ public void PopoverOptions_Defaults() { var options = new PopoverOptions(); - options.ContainerClass.Should().Be("mudblazor-main-content"); + options.OverflowPadding.Should().Be(24); + options.ContainerClass.Should().Be("mud-popover-provider"); options.FlipMargin.Should().Be(0); options.ThrowOnDuplicateProvider.Should().Be(true); } diff --git a/src/MudBlazor.UnitTests/Components/ThemeProviderTests.cs b/src/MudBlazor.UnitTests/Components/ThemeProviderTests.cs index 7da11c5e3e32..19495f9af4cc 100644 --- a/src/MudBlazor.UnitTests/Components/ThemeProviderTests.cs +++ b/src/MudBlazor.UnitTests/Components/ThemeProviderTests.cs @@ -153,85 +153,85 @@ public void DifferentCultures(string cultureString) "--mud-drawer-width-mini-left: 56px;", "--mud-drawer-width-mini-right: 56px;", "--mud-appbar-height: 64px;", - "--mud-typography-default-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-default-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-default-size: .875rem;", "--mud-typography-default-weight: 400;", "--mud-typography-default-lineheight: 1.43;", "--mud-typography-default-letterspacing: .01071em;", "--mud-typography-default-text-transform: none;", - "--mud-typography-h1-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-h1-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-h1-size: 6rem;", "--mud-typography-h1-weight: 300;", "--mud-typography-h1-lineheight: 1.167;", "--mud-typography-h1-letterspacing: -.01562em;", "--mud-typography-h1-text-transform: none;", - "--mud-typography-h2-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-h2-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-h2-size: 3.75rem;", "--mud-typography-h2-weight: 300;", "--mud-typography-h2-lineheight: 1.2;", "--mud-typography-h2-letterspacing: -.00833em;", "--mud-typography-h2-text-transform: none;", - "--mud-typography-h3-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-h3-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-h3-size: 3rem;", "--mud-typography-h3-weight: 400;", "--mud-typography-h3-lineheight: 1.167;", "--mud-typography-h3-letterspacing: 0;", "--mud-typography-h3-text-transform: none;", - "--mud-typography-h4-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-h4-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-h4-size: 2.125rem;", "--mud-typography-h4-weight: 400;", "--mud-typography-h4-lineheight: 1.235;", "--mud-typography-h4-letterspacing: .00735em;", "--mud-typography-h4-text-transform: none;", - "--mud-typography-h5-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-h5-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-h5-size: 1.5rem;", "--mud-typography-h5-weight: 400;", "--mud-typography-h5-lineheight: 1.334;", "--mud-typography-h5-letterspacing: 0;", "--mud-typography-h5-text-transform: none;", - "--mud-typography-h6-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-h6-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-h6-size: 1.25rem;", "--mud-typography-h6-weight: 500;", "--mud-typography-h6-lineheight: 1.6;", "--mud-typography-h6-letterspacing: .0075em;", "--mud-typography-h6-text-transform: none;", - "--mud-typography-subtitle1-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-subtitle1-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-subtitle1-size: 1rem;", "--mud-typography-subtitle1-weight: 400;", "--mud-typography-subtitle1-lineheight: 1.75;", "--mud-typography-subtitle1-letterspacing: .00938em;", "--mud-typography-subtitle1-text-transform: none;", - "--mud-typography-subtitle2-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-subtitle2-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-subtitle2-size: .875rem;", "--mud-typography-subtitle2-weight: 500;", "--mud-typography-subtitle2-lineheight: 1.57;", "--mud-typography-subtitle2-letterspacing: .00714em;", "--mud-typography-subtitle2-text-transform: none;", - "--mud-typography-body1-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-body1-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-body1-size: 1rem;", "--mud-typography-body1-weight: 400;", "--mud-typography-body1-lineheight: 1.5;", "--mud-typography-body1-letterspacing: .00938em;", "--mud-typography-body1-text-transform: none;", - "--mud-typography-body2-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-body2-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-body2-size: .875rem;", "--mud-typography-body2-weight: 400;", "--mud-typography-body2-lineheight: 1.43;", "--mud-typography-body2-letterspacing: .01071em;", "--mud-typography-body2-text-transform: none;", - "--mud-typography-button-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-button-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-button-size: .875rem;", "--mud-typography-button-weight: 500;", "--mud-typography-button-lineheight: 1.75;", "--mud-typography-button-letterspacing: .02857em;", "--mud-typography-button-text-transform: uppercase;", - "--mud-typography-caption-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-caption-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-caption-size: .75rem;", "--mud-typography-caption-weight: 400;", "--mud-typography-caption-lineheight: 1.66;", "--mud-typography-caption-letterspacing: .03333em;", "--mud-typography-caption-text-transform: none;", - "--mud-typography-overline-family: 'Roboto','Helvetica','Arial','sans-serif';", + "--mud-typography-overline-family: Roboto, Helvetica, Arial, sans-serif;", "--mud-typography-overline-size: .75rem;", "--mud-typography-overline-weight: 400;", "--mud-typography-overline-lineheight: 2.66;", diff --git a/src/MudBlazor.UnitTests/Components/ToolTipTests.cs b/src/MudBlazor.UnitTests/Components/ToolTipTests.cs index fb112497bba3..8a48dacff932 100644 --- a/src/MudBlazor.UnitTests/Components/ToolTipTests.cs +++ b/src/MudBlazor.UnitTests/Components/ToolTipTests.cs @@ -42,23 +42,25 @@ public async Task RenderContent(bool usingFocusout) button.ParentElement.ClassList.Should().Contain("mud-tooltip-root"); - //the button [0] and the popover node doesn't exist yet - button.ParentElement.Children.Should().HaveCount(1); + //the button [0] and [1] the popover npde + button.ParentElement.Children.Should().HaveCount(2); + + var popoverNode = button.ParentElement.Children[1]; + popoverNode.Id.Should().StartWith("popover-"); + + var popoverContentNode = () => comp.Find($"#popovercontent-{popoverNode.Id.Substring(8)}"); + + //no content for the popover node + popoverContentNode().Children.Should().BeEmpty(); //not visible by default tooltipComp.GetState(x => x.Visible).Should().BeFalse(); //trigger pointerover - await button.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); - //content should be visible - var popoverNode = button.ParentElement.Children[1]; - popoverNode.Id.Should().StartWith("popover-"); - - var popoverContentNode = comp.Find($"#popovercontent-{popoverNode.Id.Substring(8)}"); - popoverContentNode.TextContent.Should().Be("my tooltip content text"); - popoverContentNode.ClassList.Should().Contain("d-flex"); + popoverContentNode().TextContent.Should().Be("my tooltip content text"); + popoverContentNode().ClassList.Should().Contain("d-flex"); tooltipComp.GetState(x => x.Visible).Should().BeTrue(); @@ -72,9 +74,7 @@ public async Task RenderContent(bool usingFocusout) button.ParentElement.FocusOut(); } //no content should be visible - comp.Markup.Should().NotContain("my tooltip content text"); - //the button [0] and the popover node doesn't exist again - button.ParentElement.Children.Should().HaveCount(1); + popoverContentNode().Children.Should().BeEmpty(); tooltipComp.GetState(x => x.Visible).Should().BeFalse(); } @@ -110,17 +110,20 @@ public async Task RenderTooltipFragment(bool usingFocusout) button.ParentElement.ClassList.Should().Contain("mud-tooltip-root"); //the button [0] and [1] the popover node - button.ParentElement.Children.Should().HaveCount(1); - - //trigger pointerover - - await button.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); + button.ParentElement.Children.Should().HaveCount(2); var popoverNode = button.ParentElement.Children[1]; popoverNode.Id.Should().StartWith("popover-"); var popoverContentNode = comp.Find($"#popovercontent-{popoverNode.Id.Substring(8)}"); + + //no content for the popover node + popoverContentNode.Children.Should().BeEmpty(); + + //trigger pointerover + await button.ParentElement.TriggerEventAsync("onpointerenter", new PointerEventArgs()); + //content should be visible popoverContentNode.ClassList.Should().Contain("mud-tooltip"); popoverContentNode.ClassList.Should().Contain("d-flex"); @@ -137,9 +140,7 @@ public async Task RenderTooltipFragment(bool usingFocusout) button.ParentElement.FocusOut(); } //no content should be visible - comp.Markup.Should().NotContain("My content"); - //the button [0] and the popover node doesn't exist again - button.ParentElement.Children.Should().HaveCount(1); + popoverContentNode.Children.Should().BeEmpty(); } [Test] @@ -357,81 +358,39 @@ public async Task Tooltip_Disabled_Button_OnPointerEnter_NoPopover() comp.FindAll("div.mud-popover-open").Count.Should().Be(0); } - [TestCase(0, 0)] - [TestCase(500, 500)] - [Test] - public void Tooltip_Debouncer_Initials(double duration, double delay) - { - var comp = Context.RenderComponent(p => - { - p.Add(x => x.Delay, delay); - p.Add(x => x.Duration, duration); - }); - - var tooltipComp = comp.FindComponent().Instance; - tooltipComp.Delay.Should().Be(delay); - tooltipComp.Duration.Should().Be(duration); - tooltipComp._previousDelay.Should().Be(delay); - tooltipComp._previousDuration.Should().Be(duration); - var button = comp.Find("button"); - button.Should().NotBeNull(); - } - - [TestCase(0, 0)] - [TestCase(500, 500)] [Test] - public async Task Tooltip_Debouncer_Duration_and_Delay(double duration, double delay) + [TestCase(true)] + [TestCase(false)] + public async Task Tooltip_Handle_Pointer_Events(bool showOnHover) { - var comp = Context.RenderComponent(p => - { - p.Add(x => x.Delay, delay); - p.Add(x => x.Duration, duration); - }); - - var tooltipComp = comp.FindComponent().Instance; - - // cannot await or it waits until the debounce happens - var eventTask = tooltipComp.HandlePointerEnterAsync(); - if (delay > 0) - tooltipComp.ShowToolTip().Should().BeFalse(); - - await Task.Delay((int)delay + 50); - tooltipComp.ShowToolTip().Should().BeTrue(); + var comp = Context.RenderComponent(parameters => parameters + .Add(x => x.ShowOnHover, showOnHover) + .Add(x => x.ShowOnClick, true) + .Add(x => x.Text, "tooltip text") + ); - await eventTask; // ensure all completed + var div = comp.Find(".mud-tooltip-root"); + div.Should().NotBeNull(); - // cannot await or it waits until the debounce happens - eventTask = tooltipComp.HandlePointerLeaveAsync(); - if (duration > 0) - tooltipComp.ShowToolTip().Should().BeTrue(); + var tooltip = comp.Instance; + tooltip.Should().NotBeNull(); - await Task.Delay((int)(duration + delay) + 50); - tooltipComp.ShowToolTip().Should().BeFalse(); + await tooltip.HandlePointerEnterAsync(); + tooltip.GetState(x => x.Visible).Should().Be(showOnHover); - await eventTask; - } - - [TestCase(true)] - [TestCase(false)] - [Test] - public async Task Tooltip_ShowOnHover(bool showOnHover) - { - var comp = Context.RenderComponent(p => + if (showOnHover) { - p.Add(x => x.ShowOnHover, showOnHover); - }); - // we don't need to await Task.Delay to account for Delay/Duration since the await Handle takes care of it. - var tooltipComp = comp.FindComponent().Instance; - tooltipComp.ShowOnHover.Should().Be(showOnHover); - if (!showOnHover) - { - await tooltipComp.HandlePointerEnterAsync(); - tooltipComp.ShowToolTip().Should().BeFalse(); + await tooltip.HandlePointerLeaveAsync(); + tooltip.GetState(x => x.Visible).Should().Be(!showOnHover); } - else + + await div.PointerEnterAsync(new PointerEventArgs()); + tooltip.GetState(x => x.Visible).Should().Be(showOnHover); + + if (showOnHover) { - await tooltipComp.HandlePointerEnterAsync(); - tooltipComp.ShowToolTip().Should().BeTrue(); + await div.PointerLeaveAsync(new PointerEventArgs()); + tooltip.GetState(x => x.Visible).Should().Be(!showOnHover); } } } diff --git a/src/MudBlazor.UnitTests/Components/UserAttributes/UserAttributesTests.cs b/src/MudBlazor.UnitTests/Components/UserAttributes/UserAttributesTests.cs index 67309a85b955..5b3c8d35a755 100644 --- a/src/MudBlazor.UnitTests/Components/UserAttributes/UserAttributesTests.cs +++ b/src/MudBlazor.UnitTests/Components/UserAttributes/UserAttributesTests.cs @@ -23,6 +23,8 @@ static UserAttributesTests() Exclude(typeof(MudPicker<>)); // Internal component, skip Exclude(typeof(MudRadioGroup<>)); // Wrapping component, skip Exclude(typeof(MudOverlay)); // Sectioned component, skip + Exclude(typeof(DataGridGroupRow<>)); // Internal component, skip + Exclude(typeof(DataGridVirtualizeRow<>)); // Internal component, skip } [Test] diff --git a/src/MudBlazor.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/src/MudBlazor.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index 495c4a19bdd5..e8bf381a047f 100644 --- a/src/MudBlazor.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/src/MudBlazor.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -377,6 +377,23 @@ public void AddMudEventManager_ShouldRegisterServices() eventListenerFactory.Should().NotBeNull(); } + [Test] + public void AddMudBlazorPointerEventsNoneService_ShouldRegisterServices() + { + // Arrange + var services = new ServiceCollection() + .AddLogging() + .AddSingleton(); + + // Act + services.AddMudBlazorPointerEventsNoneService(); + var serviceProvider = services.BuildServiceProvider(); + var pointerEventsNoneService = serviceProvider.GetService(); + + // Assert + pointerEventsNoneService.Should().NotBeNull(); + } + [Test] public void AddMudLocalization_ShouldRegisterServices() { diff --git a/src/MudBlazor.UnitTests/Services/PointerEvents/Mocks/PointerEventsNoneObserverMock.cs b/src/MudBlazor.UnitTests/Services/PointerEvents/Mocks/PointerEventsNoneObserverMock.cs new file mode 100644 index 000000000000..a7d1298b6853 --- /dev/null +++ b/src/MudBlazor.UnitTests/Services/PointerEvents/Mocks/PointerEventsNoneObserverMock.cs @@ -0,0 +1,29 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor.UnitTests.Services.PointerEvents.Mocks; + +public class PointerEventsNoneObserverMock : IPointerEventsNoneObserver +{ + public string ElementId { get; } + + public List<(string elemendId, EventArgs eventArgs)> Notifications { get; } = new(); + + public PointerEventsNoneObserverMock(string elementId) + { + ElementId = elementId; + } + + public Task NotifyOnPointerDownAsync(EventArgs args) + { + Notifications.Add((ElementId, args)); + return Task.CompletedTask; + } + + public Task NotifyOnPointerUpAsync(EventArgs args) + { + Notifications.Add((ElementId, args)); + return Task.CompletedTask; + } +} diff --git a/src/MudBlazor.UnitTests/Services/PointerEvents/PointerEventsNoneObserverTests.cs b/src/MudBlazor.UnitTests/Services/PointerEvents/PointerEventsNoneObserverTests.cs new file mode 100644 index 000000000000..6e2cf2f77b90 --- /dev/null +++ b/src/MudBlazor.UnitTests/Services/PointerEvents/PointerEventsNoneObserverTests.cs @@ -0,0 +1,139 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Moq; +using NUnit.Framework; + +namespace MudBlazor.UnitTests.Services.PointerEvents; + +[TestFixture] +public class PointerEventsNoneObserverTests +{ + [Test] + public void Constructor_WhenCalled_DoesNotInvokePointerObservers() + { + // Arrange + var pointerDownMock = new Mock(); + pointerDownMock + .Setup(x => x.NotifyOnPointerDownAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + var pointerUpMock = new Mock(); + pointerUpMock + .Setup(x => x.NotifyOnPointerUpAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + // Act + var observer = new PointerEventsNoneObserver("observer1", pointerDownMock.Object, pointerUpMock.Object); + + // Assert + pointerDownMock.Verify(x => x.NotifyOnPointerDownAsync(It.IsAny()), Times.Never); + pointerUpMock.Verify(x => x.NotifyOnPointerUpAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task NotifyOnPointerDownAsync_WhenCalled_InvokesPointerDownObserver() + { + // Arrange + var pointerDownMock = new Mock(); + pointerDownMock + .Setup(x => x.NotifyOnPointerDownAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + IPointerEventsNoneObserver observer = new PointerEventsNoneObserver("observer1", pointerDownMock.Object, null); + + // Act + await observer.NotifyOnPointerDownAsync(EventArgs.Empty); + + // Assert + pointerDownMock.Verify(x => x.NotifyOnPointerDownAsync(It.IsAny()), Times.Once); + } + + [Test] + public async Task NotifyOnPointerDownAsync_WhenCalled_DoesNotInvokePointerUpObserver() + { + // Arrange + var pointerUpMock = new Mock(); + pointerUpMock + .Setup(x => x.NotifyOnPointerUpAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + IPointerEventsNoneObserver observer = new PointerEventsNoneObserver("observer1", null, pointerUpMock.Object); + + // Act + await observer.NotifyOnPointerDownAsync(EventArgs.Empty); + + // Assert + pointerUpMock.Verify(x => x.NotifyOnPointerUpAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task NotifyOnPointerUpAsync_WhenCalled_InvokesPointerUpObserver() + { + // Arrange + var pointerUpMock = new Mock(); + pointerUpMock + .Setup(x => x.NotifyOnPointerUpAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + IPointerEventsNoneObserver observer = new PointerEventsNoneObserver("observer1", null, pointerUpMock.Object); + + // Act + await observer.NotifyOnPointerUpAsync(EventArgs.Empty); + + // Assert + pointerUpMock.Verify(x => x.NotifyOnPointerUpAsync(It.IsAny()), Times.Once); + } + + [Test] + public async Task NotifyOnPointerUpAsync_WhenCalled_DoesNotInvokePointerDownObserver() + { + // Arrange + var pointerDownMock = new Mock(); + pointerDownMock + .Setup(x => x.NotifyOnPointerDownAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + IPointerEventsNoneObserver observer = new PointerEventsNoneObserver("observer1", pointerDownMock.Object, null); + + // Act + await observer.NotifyOnPointerUpAsync(EventArgs.Empty); + + // Assert + pointerDownMock.Verify(x => x.NotifyOnPointerDownAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task NotifyOnPointerDownAndUpAsync_WhenCalled_InvokesBothObservers() + { + // Arrange + var pointerDownMock = new Mock(); + pointerDownMock + .Setup(x => x.NotifyOnPointerDownAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + var pointerUpMock = new Mock(); + pointerUpMock + .Setup(x => x.NotifyOnPointerUpAsync(It.IsAny())) + .Returns(Task.CompletedTask) + .Verifiable(); + + IPointerEventsNoneObserver observer = new PointerEventsNoneObserver("observer1", pointerDownMock.Object, pointerUpMock.Object); + + // Act + await observer.NotifyOnPointerDownAsync(EventArgs.Empty); + await observer.NotifyOnPointerUpAsync(EventArgs.Empty); + + // Assert + pointerDownMock.Verify(x => x.NotifyOnPointerDownAsync(It.IsAny()), Times.Once); + pointerUpMock.Verify(x => x.NotifyOnPointerUpAsync(It.IsAny()), Times.Once); + } +} diff --git a/src/MudBlazor.UnitTests/Services/PointerEvents/PointerEventsNoneServiceTests.cs b/src/MudBlazor.UnitTests/Services/PointerEvents/PointerEventsNoneServiceTests.cs new file mode 100644 index 000000000000..0ea42e15b6f1 --- /dev/null +++ b/src/MudBlazor.UnitTests/Services/PointerEvents/PointerEventsNoneServiceTests.cs @@ -0,0 +1,172 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.JSInterop; +using Microsoft.JSInterop.Infrastructure; +using Moq; +using MudBlazor.UnitTests.Services.PointerEvents.Mocks; +using NUnit.Framework; + +namespace MudBlazor.UnitTests.Services.PointerEvents; + +#nullable enable + +[TestFixture] +public class PointerEventsNoneServiceTests +{ + [Test] + public async Task SubscribeAsync_WithObserver_ShouldSubscribe() + { + // Arrange + var jsRuntimeMock = new Mock(); + var observer = new PointerEventsNoneObserverMock("observer1"); + var options = new PointerEventsNoneOptions(); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + + // Act + await service.SubscribeAsync(observer, options); + + // Assert + observer.Notifications.Count.Should().Be(0); + service.ObserversCount.Should().Be(1); + jsRuntimeMock.Verify(x => x.InvokeAsync("mudPointerEventsNone.listenForPointerEvents", It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task SubscribeAsync_ReSubscribeWithSameObserverIdentifier() + { + // Arrange + var jsRuntimeMock = new Mock(); + var observer = new PointerEventsNoneObserverMock("observer1"); + var options = new PointerEventsNoneOptions(); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + + // Act + await service.SubscribeAsync(observer, options); + await service.SubscribeAsync(observer, options); + await service.SubscribeAsync(observer, options); + + // Assert + observer.Notifications.Count.Should().Be(0); + service.ObserversCount.Should().Be(1); + jsRuntimeMock.Verify(x => x.InvokeAsync("mudPointerEventsNone.listenForPointerEvents", It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task SubscribeAsync_MultipleObservers_ShouldNotifyCorrectObserver() + { + // Arrange + var jsRuntimeMock = new Mock(); + var observer1 = new PointerEventsNoneObserverMock("observer1"); + var observer2 = new PointerEventsNoneObserverMock("observer2"); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + + await service.SubscribeAsync(observer1, new()); + await service.SubscribeAsync(observer2, new()); + + // Act + await service.RaiseOnPointerDown([observer2.ElementId]); + + // Assert + service.ObserversCount.Should().Be(2); + observer1.Notifications.Count.Should().Be(0); + observer2.Notifications.Count.Should().Be(1); + observer2.Notifications.Should().ContainSingle().Which.Should().BeEquivalentTo((observer2.ElementId, EventArgs.Empty)); + } + + [Test] + public async Task SubscribeAsync_Overloads() + { + // Arrange + var jsRuntimeMock = new Mock(); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + + // Act + await service.SubscribeAsync("observer1", new()); + await service.SubscribeAsync("observer2", new(), PointerEventsNoneObserver.PointerDownIgnore(), PointerEventsNoneObserver.PointerUpIgnore()); + + // Assert + service.ObserversCount.Should().Be(2); + } + + [Test] + public async Task RaiseOnPointerDown_ShouldNotifyObservers() + { + // Arrange + var jsRuntimeMock = new Mock(); + var observer = new PointerEventsNoneObserverMock("observer1"); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + await service.SubscribeAsync(observer, new()); + + // Act + await service.RaiseOnPointerDown([observer.ElementId]); + + // Assert + service.ObserversCount.Should().Be(1); + observer.Notifications.Count.Should().Be(1); + observer.Notifications.Should().ContainSingle().Which.Should().BeEquivalentTo((observer.ElementId, EventArgs.Empty)); + } + + [Test] + public async Task RaiseOnPointerUp_ShouldNotifyObservers() + { + // Arrange + var jsRuntimeMock = new Mock(); + var observer = new PointerEventsNoneObserverMock("observer1"); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + await service.SubscribeAsync(observer, new()); + + // Act + await service.RaiseOnPointerUp([observer.ElementId]); + + // Assert + service.ObserversCount.Should().Be(1); + observer.Notifications.Count.Should().Be(1); + observer.Notifications.Should().ContainSingle().Which.Should().BeEquivalentTo((observer.ElementId, EventArgs.Empty)); + } + + [Test] + public async Task UnsubscribeAsync_UnsubscribeObserver() + { + // Arrange + var jsRuntimeMock = new Mock(); + var observer = new PointerEventsNoneObserverMock("observer1"); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + await service.SubscribeAsync(observer, new()); + + // Act + await service.UnsubscribeAsync(observer); + + // Assert + service.ObserversCount.Should().Be(0); + jsRuntimeMock.Verify(x => x.InvokeAsync("mudPointerEventsNone.listenForPointerEvents", It.IsAny(), It.IsAny()), Times.Once); + jsRuntimeMock.Verify(x => x.InvokeAsync("mudPointerEventsNone.cancelListener", It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task DisposeAsync_ShouldClearAllObservers() + { + // Arrange + var jsRuntimeMock = new Mock(); + var service = new PointerEventsNoneService(NullLogger.Instance, jsRuntimeMock.Object); + await service.SubscribeAsync("observer1", new()); + await service.SubscribeAsync("observer2", new()); + await service.SubscribeAsync("observer3", new()); + await service.SubscribeAsync("observer4", new()); + await service.SubscribeAsync("observer5", new()); + var beforeObserversCount = service.ObserversCount; + + // Act + await service.DisposeAsync(); + var afterObserversCount = service.ObserversCount; + + // Assert + beforeObserversCount.Should().Be(5); + afterObserversCount.Should().Be(0); + jsRuntimeMock.Verify(x => x.InvokeAsync("mudPointerEventsNone.listenForPointerEvents", It.IsAny(), It.IsAny()), Times.Exactly(5)); + jsRuntimeMock.Verify(x => x.InvokeAsync("mudPointerEventsNone.dispose", It.IsAny(), It.IsAny()), Times.Once); + } +} diff --git a/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs b/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs index fe3d6f0ac746..f8ab51148fc1 100644 --- a/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs +++ b/src/MudBlazor.UnitTests/Services/Popover/PopoverServiceTests.cs @@ -2,11 +2,6 @@ // MudBlazor licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using FluentAssertions; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging.Abstractions; @@ -545,7 +540,7 @@ public async Task CreatePopoverAsync_UpdatePopoverAsync_DestroyPopoverAsync_Shou .Callback(signalEvent.Set); jsRuntimeMock.Setup(x => x.InvokeAsync("mudPopover.initialize", It.IsAny(), - It.Is(y => y.Length == 2))) + It.Is(y => y.Length == 3))) .ReturnsAsync(Mock.Of()) .Verifiable(); diff --git a/src/MudBlazor/Attributes/CategoryAttribute.cs b/src/MudBlazor/Attributes/CategoryAttribute.cs index 27bc6c65e719..b3bf3d031e71 100644 --- a/src/MudBlazor/Attributes/CategoryAttribute.cs +++ b/src/MudBlazor/Attributes/CategoryAttribute.cs @@ -1,7 +1,4 @@ -๏ปฟusing System; -using System.Collections.Generic; - -namespace MudBlazor +๏ปฟnamespace MudBlazor { /// @@ -251,6 +248,22 @@ public static class Container public const string Behavior = "Behavior"; } + public static class DataGrid + { + public const string Data = "Data"; + public const string Behavior = "Behavior"; + public const string Header = "Header"; + public const string Rows = "Rows"; + public const string Footer = "Footer"; + public const string Filtering = "Filtering"; + public const string Grouping = "Grouping"; + public const string Sorting = "Sorting"; + public const string Pagination = "Pagination"; + public const string Selecting = "Selecting"; + public const string Editing = "Editing"; + public const string Appearance = "Appearance"; + } + public static class Dialog { public const string Behavior = "Behavior"; diff --git a/src/MudBlazor/Base/MudBaseInput.cs b/src/MudBlazor/Base/MudBaseInput.cs index 7e0a9c5e19ae..c37118d9de41 100644 --- a/src/MudBlazor/Base/MudBaseInput.cs +++ b/src/MudBlazor/Base/MudBaseInput.cs @@ -493,6 +493,8 @@ protected virtual Task UpdateTextPropertyAsync(bool updateValue) protected internal virtual async Task OnBlurredAsync(FocusEventArgs obj) { + _isFocused = false; + if (ReadOnly) { return; @@ -501,8 +503,6 @@ protected internal virtual async Task OnBlurredAsync(FocusEventArgs obj) // all the OnBlur parents (TextField, MudMask, NumericField, DateRange, etc) currently point to this method // which causes this method to be fired repeatedly, we can use the obj.Type of FocusedEventArgs to track it - _isFocused = false; - if (!OnlyValidateIfDirty || _isDirty) { Touched = true; diff --git a/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor b/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor index 6c0db0442c1c..7330195c3ab3 100644 --- a/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor +++ b/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor @@ -143,4 +143,8 @@
- + diff --git a/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor.cs b/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor.cs index e209e808db20..82b2da1ccb1f 100644 --- a/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor.cs +++ b/src/MudBlazor/Components/Autocomplete/MudAutocomplete.razor.cs @@ -32,6 +32,7 @@ public partial class MudAutocomplete : MudBaseInput private T[]? _items; private List _enabledItemIndices = []; private Func? _toStringFunc; + private bool _handleNextFocus; [Inject] private IScrollManager ScrollManager { get; set; } = null!; @@ -366,6 +367,16 @@ protected string GetListItemClassname(bool isSelected) => [Category(CategoryTypes.FormComponent.ListBehavior)] public RenderFragment? ProgressIndicatorInPopoverTemplate { get; set; } + /// + /// Prevents interaction with background elements while this list is open. + /// + /// + /// Defaults to true. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListBehavior)] + public bool Modal { get; set; } = true; + /// /// Determines the width of this Popover dropdown in relation to the parent container. /// @@ -551,12 +562,8 @@ public async Task SelectOptionAsync(T value) await _elementReference.SetText(optionText); } - await FocusAsync(); - // We want focus with a closed popover Open = false; - // And update StateHasChanged(); - } finally { @@ -957,20 +964,53 @@ internal async Task OnEnterKeyAsync() } } - private Task OnInputClickedAsync() => OnInputActivationAsync(true); + private Task OnInputClickedAsync() + { + if (GetDisabledState()) + { + return Task.CompletedTask; + } - private Task OnInputFocusedAsync() => OnInputActivationAsync(OpenOnFocus); + return OnInputActivatedAsync(true); + } - private async Task OnInputActivationAsync(bool openMenu) + private async Task OnInputFocusedAsync() { + if (GetDisabledState()) + { + // This shouldn't be possible through the UI, but could be triggered in code. + return; + } + + if (GetReadOnlyState()) + { + // A readonly input doesn't trigger onblur later correctly, so we have to disable focus features for it. + return; + } + + var wasFocused = _isFocused; _isFocused = true; - if (SelectOnActivation && !GetDisabledState() && !GetReadOnlyState()) + // Skip features that are not meant for internal focus events. + if (_handleNextFocus) + { + _handleNextFocus = false; + return; + } + + // Select the input text unless we're already focused or it will interfere with cursor selection. + if (!wasFocused && SelectOnActivation) { await SelectAsync(); } - if (openMenu && !Open && !_opening) + await OnInputActivatedAsync(OpenOnFocus); + } + + private async Task OnInputActivatedAsync(bool openMenu) + { + // The click event also triggers the focus event so we don't want to unnecessarily handle both. + if (openMenu && !Open && !_opening && !GetReadOnlyState()) { await OpenMenuAsync(); } @@ -980,7 +1020,6 @@ internal async Task HandleClearButtonAsync(MouseEventArgs e) { // clear button clicked, let's make sure text is cleared and the menu has focus Open = true; - _isFocused = true; await SetValueAsync(default, false); await SetTextAsync(default, false); _selectedListItemIndex = default; @@ -1006,6 +1045,7 @@ internal async Task AdornmentClickHandlerAsync() private Task OnInputBlurredAsync(FocusEventArgs args) { _isFocused = false; + _handleNextFocus = false; // When Immediate is enabled, then the CoerceValue is set by TextChanged // So only coerce the value on blur when Immediate is disabled @@ -1090,6 +1130,7 @@ protected override async ValueTask DisposeAsyncCore() /// public override ValueTask FocusAsync() { + _handleNextFocus = true; // Let the event handler know it was not triggered by the user. return _elementReference.FocusAsync(); } @@ -1130,6 +1171,10 @@ private async Task OnTextChangedAsync(string? text) await SetTextAsync(text, true); } - private Task ListItemOnClickAsync(T item) => SelectOptionAsync(item); + private async Task ListItemOnClickAsync(T item) + { + await SelectOptionAsync(item); + await FocusAsync(); + } } } diff --git a/src/MudBlazor/Components/Carousel/MudCarousel.razor.cs b/src/MudBlazor/Components/Carousel/MudCarousel.razor.cs index d8534713c9f3..c492e0462ce3 100644 --- a/src/MudBlazor/Components/Carousel/MudCarousel.razor.cs +++ b/src/MudBlazor/Components/Carousel/MudCarousel.razor.cs @@ -15,6 +15,7 @@ namespace MudBlazor public partial class MudCarousel : MudBaseBindableItemsControl, IAsyncDisposable { private Timer? _timer; + private bool _disposing; private bool _autoCycle = true; private Color _currentColor = Color.Inherit; private TimeSpan _cycleTimeout = TimeSpan.FromSeconds(5); @@ -292,7 +293,7 @@ private void OnSwipeEnd(SwipeEventArgs e) /// private ValueTask StartTimerAsync() { - if (AutoCycle) + if (AutoCycle && !_disposing) { _timer?.Change(AutoCycleTime, TimeSpan.Zero); } @@ -334,6 +335,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { + // Prevent timer creation after or while disposal, which would result in a memory leak. + if (_disposing) return; _timer = new Timer(TimerElapsed, null, AutoCycle ? AutoCycleTime : Timeout.InfiniteTimeSpan, AutoCycleTime); } } @@ -347,6 +350,10 @@ public async ValueTask DisposeAsync() protected virtual async ValueTask DisposeAsyncCore() { + // Immediately sets disposing to true, + // so that timer creation on OnAfterRenderAsync does not happen after disposal. + _disposing = true; + await StopTimerAsync(); var timer = _timer; diff --git a/src/MudBlazor/Components/Chart/Charts/Bar.razor.cs b/src/MudBlazor/Components/Chart/Charts/Bar.razor.cs index 08969cb6ac03..f9add5108404 100644 --- a/src/MudBlazor/Components/Chart/Charts/Bar.razor.cs +++ b/src/MudBlazor/Components/Chart/Charts/Bar.razor.cs @@ -153,10 +153,11 @@ private void GenerateBars(int lowestHorizontalLine, double gridYUnits, double ho for (var j = 0; j < data.Length; j++) { + var dataValue = data[j]; var gridValueX = HorizontalStartSpace + (BarStroke / 2) + (i * BarGap) + (j * horizontalSpace); var gridValueY = _boundHeight - VerticalStartSpace + (lowestHorizontalLine * verticalSpace); - var dataValue = ((data[j] / gridYUnits) - lowestHorizontalLine) * verticalSpace; - var gridValue = _boundHeight - VerticalStartSpace - dataValue; + var barHeight = ((dataValue / gridYUnits) - lowestHorizontalLine) * verticalSpace; + var gridValue = _boundHeight - VerticalStartSpace - barHeight; var bar = new SvgPath() { diff --git a/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor b/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor index 352f7e3761a2..6ae772b17579 100644 --- a/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor +++ b/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor @@ -99,24 +99,28 @@ @* Render the tooltip as an SVG group when a bar is hovered *@ @if (_hoveredDataPoint is not null && _hoverDataPointChartLine is not null && MudChartParent?.ChartOptions.ShowToolTips == true) { - var seriesIndex = _chartDataPoints.First(x => x.Value.Contains(_hoveredDataPoint)).Key; + var dataPoint = _chartDataPoints.FirstOrDefault(x => x.Value.Contains(_hoveredDataPoint)); - var color = MudChartParent.ChartOptions.ChartPalette.GetValue(seriesIndex % MudChartParent.ChartOptions.ChartPalette.Length)?.ToString() ?? string.Empty; - var series = _series[seriesIndex]; - - if (!string.IsNullOrWhiteSpace(series.DataMarkerTooltipTitleFormat)) + if (dataPoint.Value is not null) { - var tooltipTitle = series.DataMarkerTooltipTitleFormat - .Replace("{{SERIES_NAME}}", series.Name) - .Replace("{{X_VALUE}}", _hoveredDataPoint.LabelXValue) - .Replace("{{Y_VALUE}}", _hoveredDataPoint.LabelYValue); + var seriesIndex = dataPoint.Key; + var color = MudChartParent.ChartOptions.ChartPalette.GetValue(seriesIndex % MudChartParent.ChartOptions.ChartPalette.Length)?.ToString() ?? string.Empty; + var series = _series[seriesIndex]; + + if (!string.IsNullOrWhiteSpace(series.DataMarkerTooltipTitleFormat)) + { + var tooltipTitle = series.DataMarkerTooltipTitleFormat + .Replace("{{SERIES_NAME}}", series.Name) + .Replace("{{X_VALUE}}", _hoveredDataPoint.LabelXValue) + .Replace("{{Y_VALUE}}", _hoveredDataPoint.LabelYValue); - var tooltipSubtitle = series.DataMarkerTooltipSubtitleFormat? - .Replace("{{SERIES_NAME}}", series.Name) - .Replace("{{X_VALUE}}", _hoveredDataPoint.LabelXValue) - .Replace("{{Y_VALUE}}", _hoveredDataPoint.LabelYValue) ?? string.Empty; + var tooltipSubtitle = series.DataMarkerTooltipSubtitleFormat? + .Replace("{{SERIES_NAME}}", series.Name) + .Replace("{{X_VALUE}}", _hoveredDataPoint.LabelXValue) + .Replace("{{Y_VALUE}}", _hoveredDataPoint.LabelYValue) ?? string.Empty; - + + } } } diff --git a/src/MudBlazor/Components/DataGrid/CellContext.cs b/src/MudBlazor/Components/DataGrid/CellContext.cs index 03126d1fed48..01fd974d79de 100644 --- a/src/MudBlazor/Components/DataGrid/CellContext.cs +++ b/src/MudBlazor/Components/DataGrid/CellContext.cs @@ -53,6 +53,7 @@ public CellContext(MudDataGrid dataGrid, T item) StartEditingItemAsync = () => dataGrid.SetEditingItemAsync(item), CancelEditingItemAsync = () => dataGrid.CancelEditingItemAsync(), ToggleHierarchyVisibilityForItemAsync = () => dataGrid.ToggleHierarchyVisibilityAsync(item), + GetGroupIcon = (expanded, rightToLeft) => dataGrid.GetGroupIcon(expanded, rightToLeft), }; } @@ -80,6 +81,11 @@ public class CellActions /// The function which toggles hierarchy visibility. /// public required Func ToggleHierarchyVisibilityForItemAsync { get; init; } + + /// + /// The function which retrieves the GroupIcon. + /// + public Func? GetGroupIcon { get; init; } } } } diff --git a/src/MudBlazor/Components/DataGrid/Column.razor.cs b/src/MudBlazor/Components/DataGrid/Column.razor.cs index 05fad1ef2ca3..a66d257d6740 100644 --- a/src/MudBlazor/Components/DataGrid/Column.razor.cs +++ b/src/MudBlazor/Components/DataGrid/Column.razor.cs @@ -22,6 +22,8 @@ public abstract partial class Column<[DynamicallyAccessedMembers(DynamicallyAcce private static readonly RenderFragment> EmptyChildContent = _ => builder => { }; internal ParameterState HiddenState { get; } internal ParameterState GroupingState { get; } + internal ParameterState _groupExpandedState; + internal ParameterState _groupByOrderState; /// /// The data grid which owns this column. @@ -115,6 +117,42 @@ public abstract partial class Column<[DynamicallyAccessedMembers(DynamicallyAcce [Parameter] public Func GroupBy { get; set; } + /// + /// The order in which values are grouped when there are more than one group + /// + /// + /// Defaults to 0. + /// + [Parameter] + public int GroupByOrder { get; set; } + + /// + /// Occurs when the property has changed. + /// + [Parameter] + public EventCallback GroupByOrderChanged { get; set; } + + /// + /// Whether the column is indented 48px beyond it's parent when grouped. + /// + [Parameter] + public bool GroupIndented { get; set; } = true; + + /// + /// Whether groups created from this column are expanded. Toggling the value will Toggle all grouped rows of this column. + /// + /// + /// Defaults to false. + /// + [Parameter] + public bool GroupExpanded { get; set; } + + /// + /// Occurs when the property has changed. + /// + [Parameter] + public EventCallback GroupExpandedChanged { get; set; } + /// /// Requires a value to be set. /// @@ -547,16 +585,38 @@ protected Column() .WithParameter(() => Grouping) .WithEventCallback(() => GroupingChanged) .WithChangeHandler(OnGroupingParameterChangedAsync); + _groupExpandedState = registerScope.RegisterParameter(nameof(GroupExpanded)) + .WithParameter(() => GroupExpanded) + .WithChangeHandler(OnGroupExpandedChangedAsync); + _groupByOrderState = registerScope.RegisterParameter(nameof(GroupByOrder)) + .WithParameter(() => GroupByOrder) + .WithChangeHandler(OnGroupByOrderChangedAsync); } private async Task OnGroupingParameterChangedAsync() { - if (GroupingState.Value) + // Regroup DataGrid + if (DataGrid is not null) { - if (DataGrid is not null) - { - await DataGrid.ChangedGrouping(this); - } + await DataGrid.ChangedGrouping(this); + } + } + + private async Task OnGroupExpandedChangedAsync() + { + // Regroup DataGrid + if (DataGrid is not null) + { + await DataGrid.ChangedGrouping(); + } + } + + private async Task OnGroupByOrderChangedAsync() + { + // Regroup DataGrid + if (DataGrid is not null) + { + await DataGrid.ChangedGrouping(); } } @@ -652,11 +712,6 @@ internal void CompileGroupBy() internal async Task SetGroupingAsync(bool group) { await GroupingState.SetValueAsync(group); - - if (DataGrid is not null) - { - await DataGrid.ChangedGrouping(this); - } } /// diff --git a/src/MudBlazor/Components/DataGrid/DataGridGroupRow.razor b/src/MudBlazor/Components/DataGrid/DataGridGroupRow.razor new file mode 100644 index 000000000000..aa6a53ed0b05 --- /dev/null +++ b/src/MudBlazor/Components/DataGrid/DataGridGroupRow.razor @@ -0,0 +1,57 @@ +๏ปฟ@namespace MudBlazor +@inherits MudComponentBase +@typeparam T + + + +
+ + @if (GroupDefinition?.GroupTemplate == null) + { + @GroupDefinition?.Title: @(Items?.Key ?? "null") + } + else + { + @GroupDefinition.GroupTemplate(GroupDefinition) + } +
+ + + +@if (_expanded) +{ + + @if (GroupDefinition.InnerGroup != null) + { + var innerGroupItems = DataGrid?.GetItemsOfGroup(GroupDefinition.InnerGroup, Items); + var groupDefinitions = DataGrid.GetGroupDefinitions(GroupDefinition.InnerGroup, innerGroupItems); + @foreach (var group in groupDefinitions) + { + + } + } + else + { + @if (Items != null) + { + + + + @DataGrid.FooterCells(Items) + + } + } +} diff --git a/src/MudBlazor/Components/DataGrid/DataGridGroupRow.razor.cs b/src/MudBlazor/Components/DataGrid/DataGridGroupRow.razor.cs new file mode 100644 index 000000000000..f33d2bc2904a --- /dev/null +++ b/src/MudBlazor/Components/DataGrid/DataGridGroupRow.razor.cs @@ -0,0 +1,107 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using MudBlazor.Utilities; + +#nullable enable + +namespace MudBlazor +{ + public partial class DataGridGroupRow<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> : MudComponentBase + { + internal bool _expanded; + + protected string GroupClassname => new CssBuilder("mud-table-cell") + .AddClass("mud-datagrid-group") + .AddClass($"mud-row-group-indented-{(GroupDefinition.Indentation ? Math.Min(GroupDefinition.Level, 5) : 0)}") + .AddClass(GroupClassFunc?.Invoke(GroupDefinition)) + .AddClass(GroupClass) + .Build(); + + protected string GroupStylename => new StyleBuilder() + .AddStyle(GroupStyle) + .AddStyle(GroupStyleFunc?.Invoke(GroupDefinition)) + .Build(); + + [Parameter, EditorRequired] + [Category(CategoryTypes.DataGrid.Grouping)] + public MudDataGrid DataGrid { get; set; } = null!; + + [Parameter] + [Category(CategoryTypes.DataGrid.Selecting)] + public EventCallback<(MouseEventArgs args, T item, int index)> RowClick { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Selecting)] + public EventCallback<(MouseEventArgs args, T item, int index)> ContextRowClick { get; set; } + + /// + /// The definition for this grouping level + /// + [Parameter, EditorRequired] + [Category(CategoryTypes.DataGrid.Grouping)] + public GroupDefinition GroupDefinition { get; set; } = null!; + + /// + /// The groups and items within this grouping. + /// + [Parameter] + [Category(CategoryTypes.DataGrid.Grouping)] + public IGrouping? Items { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Appearance)] + public string? GroupClass { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Appearance)] + public string? GroupStyle { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Appearance)] + public Func, string>? GroupClassFunc { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Appearance)] + public Func, string>? GroupStyleFunc { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Appearance)] + public string? StyleClass { get; set; } + + protected override void OnParametersSet() + { + _expanded = GroupDefinition.Expanded; + base.OnParametersSet(); + } + + internal void GroupExpandClick() + { + _expanded = !_expanded; + // update the expansion state for _groupExpansionsDict + // if it has a key we see if it differs from the definition Expanded State and update accordingly + // if it doesn't we add it if the new state doesn't match the definition + if (Items != null) + { + var key = new { GroupDefinition.Title, Items.Key }; + if (DataGrid._groupExpansionsDict.ContainsKey(key)) + { + if (_expanded == GroupDefinition.Expanded) + DataGrid._groupExpansionsDict.Remove(key); + else + DataGrid._groupExpansionsDict[key] = _expanded; + } + else + { + if (_expanded != GroupDefinition.Expanded) + DataGrid._groupExpansionsDict.TryAdd(key, _expanded); + } + } + DataGrid._groupInitialExpanded = false; + } + } +} diff --git a/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor b/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor new file mode 100644 index 000000000000..0aaa5a3b4aa7 --- /dev/null +++ b/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor @@ -0,0 +1,87 @@ +๏ปฟ@namespace MudBlazor +@inherits MudComponentBase +@typeparam T +@using MudBlazor.Utilities +@using MudBlazor.Resources +@inject InternalMudLocalizer Localizer + +@{ + var resolvedItems = new List>(0); + if (DataGrid.IsGrouped) + { + if (GroupedItems is not null) + { + resolvedItems = GroupedItems.Select((item, index) => new IndexBag(index, item)).ToList(); + } + } + else if (!DataGrid.Virtualize || DataGrid.VirtualizeServerData == null || DataGrid.HasFooter) + { + resolvedItems = DataGrid.CurrentPageItems.Select((items, index) => new IndexBag(index, items)) + .ToList(); + } + else + { + resolvedItems = DataGrid.CurrentPageItems.Select((item, index) => new IndexBag(index, item)).ToList(); + } +} + + + + @if (DataGrid.RowLoadingContent != null) + { + @DataGrid.RowLoadingContent + } + else + { + + + @Localizer[LanguageResource.MudDataGrid_Loading] + + + } + + + + @{ + var rowClass = new CssBuilder(DataGrid.RowClass).AddClass(DataGrid.RowClassFunc?.Invoke(itemBag.Item, itemBag.Index)).Build(); + var rowStyle = new StyleBuilder().AddStyle(DataGrid.RowStyle).AddStyle(DataGrid.RowStyleFunc?.Invoke(itemBag.Item, itemBag.Index)).Build(); + } + + + @foreach (var column in DataGrid.RenderedColumns) + { + if (!column.HiddenState.Value) + { + @DataGrid.Cell(column, itemBag.Item) + } + } + + + @if (DataGrid.ChildRowContent != null && (DataGrid._openHierarchies.Contains(itemBag.Item) || !DataGrid.HasHierarchyColumn)) + { + + + @DataGrid.ChildRowContent(new CellContext(DataGrid, itemBag.Item)) + + + } + + + + +
+ @DataGrid.NoRecordsContent +
+ + +
+
diff --git a/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor.cs b/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor.cs new file mode 100644 index 000000000000..65efed9fe67e --- /dev/null +++ b/src/MudBlazor/Components/DataGrid/DataGridVirtualizeRow.razor.cs @@ -0,0 +1,30 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +#nullable enable +namespace MudBlazor +{ + public partial class DataGridVirtualizeRow<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> : MudComponentBase + { + [Parameter, EditorRequired] + [Category(CategoryTypes.DataGrid.Data)] + public MudDataGrid DataGrid { get; set; } = null!; + + [Parameter] + [Category(CategoryTypes.DataGrid.Grouping)] + public IGrouping? GroupedItems { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Selecting)] + public EventCallback<(MouseEventArgs args, T item, int index)> RowClick { get; set; } + + [Parameter] + [Category(CategoryTypes.DataGrid.Selecting)] + public EventCallback<(MouseEventArgs args, T item, int index)> ContextRowClick { get; set; } + } +} diff --git a/src/MudBlazor/Components/DataGrid/GroupDefinition.cs b/src/MudBlazor/Components/DataGrid/GroupDefinition.cs index 48b1e6ef9325..31bf117ac7f2 100644 --- a/src/MudBlazor/Components/DataGrid/GroupDefinition.cs +++ b/src/MudBlazor/Components/DataGrid/GroupDefinition.cs @@ -2,6 +2,8 @@ // MudBlazor licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using Microsoft.AspNetCore.Components; + namespace MudBlazor; #nullable enable @@ -11,10 +13,20 @@ namespace MudBlazor; /// public class GroupDefinition { + private GroupDefinition? _innerGroup; + /// /// The LINQ definition of the grouping. /// - public IGrouping Grouping { get; set; } + public required IGrouping Grouping { get; set; } + + /// + /// The function which selects items for this group. + /// + /// + /// Typically used during a LINQ GroupBy() call to group items. + /// + public Func Selector { get; set; } = default!; /// /// Expands this group. @@ -25,13 +37,61 @@ public class GroupDefinition public bool Expanded { get; set; } /// - /// Creates a new instance. + /// The template for the grouped column. /// - /// The LINQ definition of the grouping. - /// Expands this group. - public GroupDefinition(IGrouping grouping, bool expanded) + public RenderFragment>? GroupTemplate { get; set; } + + /// + /// The title of the grouped column + /// + public string Title { get; set; } = string.Empty; + + /// + /// The group definition within this definition. + /// + public GroupDefinition? InnerGroup { - Grouping = grouping; - Expanded = expanded; + get => _innerGroup; + set + { + if (_innerGroup is not null) + { + _innerGroup.Parent = null; + } + + _innerGroup = value; + + if (_innerGroup is not null) + { + _innerGroup.Parent = this; + _innerGroup.Indentation = Indentation; + } + } + } + + /// + /// Indents the each Group beyond the first by 48 px. + /// + public bool Indentation { get; set; } = true; + + /// + /// The parent group definition. + /// + internal GroupDefinition? Parent { get; set; } + + /// + /// Gets the nesting level of this group. + /// + public int Level + { + get + { + if (Parent is null) + { + return 1; + } + + return Parent.Level + 1; + } } } diff --git a/src/MudBlazor/Components/DataGrid/HeaderCell.razor b/src/MudBlazor/Components/DataGrid/HeaderCell.razor index 3585d6b3cfcd..4856e826eaa9 100644 --- a/src/MudBlazor/Components/DataGrid/HeaderCell.razor +++ b/src/MudBlazor/Components/DataGrid/HeaderCell.razor @@ -32,7 +32,7 @@ else if (Column != null && !Column.HiddenState.Value) } -@code{ +@code { internal RenderFragment TableHeader() { @@ -46,6 +46,10 @@ else if (Column != null && !Column.HiddenState.Value) { @Column.HeaderTemplate(Column.headerContext) } + else if (IncludeHierarchyToggle) + { + @HierarchyToggle + } else { @computedTitle @@ -58,6 +62,10 @@ else if (Column != null && !Column.HiddenState.Value) { @Column.HeaderTemplate(Column.headerContext) } + else if (IncludeHierarchyToggle) + { + @HierarchyToggle + } else { @computedTitle @@ -155,4 +163,13 @@ else if (Column != null && !Column.HiddenState.Value) } ; } + + private RenderFragment HierarchyToggle => + @ + + ; + } diff --git a/src/MudBlazor/Components/DataGrid/HeaderCell.razor.cs b/src/MudBlazor/Components/DataGrid/HeaderCell.razor.cs index ced148ee1d38..add581867239 100644 --- a/src/MudBlazor/Components/DataGrid/HeaderCell.razor.cs +++ b/src/MudBlazor/Components/DataGrid/HeaderCell.razor.cs @@ -30,6 +30,12 @@ public partial class HeaderCell<[DynamicallyAccessedMembers(DynamicallyAccessedM [CascadingParameter] public MudDataGrid DataGrid { get; set; } + /// + /// Whether the display should be right to left + /// + [CascadingParameter(Name = "RightToLeft")] + public bool RightToLeft { get; set; } + /// /// Shows this cell only in the header area. /// @@ -110,6 +116,10 @@ public partial class HeaderCell<[DynamicallyAccessedMembers(DynamicallyAccessedM #region Computed Properties and Functions + private bool Expanded => Column?.DataGrid._openHierarchies.Count > 1; + + internal bool IncludeHierarchyToggle => Column?.HeaderClass?.Contains("mud-header-togglehierarchy") ?? false; + private string computedTitle { get @@ -243,6 +253,28 @@ protected override async Task OnInitializedAsync() #region Events + internal async Task ToggleHierarchyAsync() + { + if (DataGrid is null) + { + return; + } + + if (Expanded) + { + await DataGrid.CollapseAllHierarchy(); + } + else + { + await DataGrid.ExpandAllHierarchy(); + } + } + + internal string GetGroupIcon() + { + return DataGrid?.GetGroupIcon(Expanded, RightToLeft) ?? string.Empty; + } + /// /// This is triggered by the DataGrid when a sort is applied /// e.g. from another HeaderCell. @@ -506,8 +538,12 @@ internal async Task GroupColumnAsync() if (Column is not null) { await Column.SetGroupingAsync(true); + await DataGrid.ChangedGrouping(Column); + } + else + { + await DataGrid.ChangedGrouping(); } - DataGrid.DropContainerHasChanged(); } @@ -517,7 +553,7 @@ internal async Task UngroupColumnAsync() { await Column.SetGroupingAsync(false); } - + await DataGrid.ChangedGrouping(); DataGrid.DropContainerHasChanged(); } diff --git a/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor b/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor index f8ed159a85c5..287ae64e4af8 100644 --- a/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor +++ b/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor @@ -2,19 +2,27 @@ @inherits MudComponentBase @typeparam T -
diff --git a/src/MudBlazor/Components/Menu/MudMenu.razor.cs b/src/MudBlazor/Components/Menu/MudMenu.razor.cs index 3f3c95404c10..abe4e32e0f2f 100644 --- a/src/MudBlazor/Components/Menu/MudMenu.razor.cs +++ b/src/MudBlazor/Components/Menu/MudMenu.razor.cs @@ -29,8 +29,7 @@ public partial class MudMenu : MudComponentBase, IActivatable, IDisposable public MudMenu() { _showDebouncer = new DebounceDispatcher(MudGlobal.MenuDefaults.HoverDelay); - // double the delay for hiding a menu - _hideDebouncer = new DebounceDispatcher(MudGlobal.MenuDefaults.HoverDelay * 2); + _hideDebouncer = new DebounceDispatcher(MudGlobal.MenuDefaults.HoverDelay); using var registerScope = CreateRegisterScope(); _openState = registerScope.RegisterParameter(nameof(Open)) @@ -54,6 +53,7 @@ public MudMenu() protected string PopoverClassname => new CssBuilder() .AddClass(PopoverClass) + .AddClass("mud-popover-nested", ParentMenu is not null) .AddClass("mud-popover-position-override", PositionAtCursor) .Build(); @@ -329,6 +329,16 @@ public MudMenu() [Category(CategoryTypes.Menu.Appearance)] public bool DropShadow { get; set; } = true; + /// + /// Prevents interaction with background elements while this menu is open. + /// + /// + /// Defaults to true. + /// + [Parameter] + [Category(CategoryTypes.Menu.PopupBehavior)] + public bool Modal { get; set; } = true; + /// /// The components within this menu. /// diff --git a/src/MudBlazor/Components/Overlay/MudOverlay.razor b/src/MudBlazor/Components/Overlay/MudOverlay.razor index 6383cf722813..b666bab2751f 100644 --- a/src/MudBlazor/Components/Overlay/MudOverlay.razor +++ b/src/MudBlazor/Components/Overlay/MudOverlay.razor @@ -18,6 +18,7 @@ @code { private RenderFragment OverlayFragment => @
/// A layer which darkens a window, often as part of showing a . /// -public partial class MudOverlay : MudComponentBase, IAsyncDisposable +public partial class MudOverlay : MudComponentBase, IPointerEventsNoneObserver, IAsyncDisposable { + private int _lockCount; + private bool _previousAbsolute; + private bool _previousLockScroll; private readonly ParameterState _visibleState; + private readonly string _elementId = Identifier.Create("overlay"); protected string Classname => new CssBuilder("mud-overlay") @@ -33,6 +37,7 @@ public partial class MudOverlay : MudComponentBase, IAsyncDisposable protected string Styles => new StyleBuilder() .AddStyle("z-index", $"{ZIndex}", ZIndex != 5) + .AddStyle("pointer-events", "none", !Modal) .AddStyle(Style) .Build(); @@ -42,6 +47,12 @@ public partial class MudOverlay : MudComponentBase, IAsyncDisposable [Inject] public IScrollManager ScrollManager { get; set; } = null!; + /// + /// Pointer events none service when pointer events are set to none. + /// + [Inject] + private IPointerEventsNoneService PointerEventsNoneService { get; set; } = null!; + /// /// Child content of the component. /// @@ -82,6 +93,14 @@ public partial class MudOverlay : MudComponentBase, IAsyncDisposable [Category(CategoryTypes.Overlay.ClickAction)] public bool AutoClose { get; set; } + /// + /// Occurs when changes. + /// + /// + /// This event is triggered when the auto-close behavior of the overlay changes. + /// + public EventCallback AutoCloseChanged { get; set; } + /// /// Prevents the Document.body element from scrolling. /// @@ -102,6 +121,16 @@ public partial class MudOverlay : MudComponentBase, IAsyncDisposable [Category(CategoryTypes.Overlay.Behavior)] public string LockScrollClass { get; set; } = "scroll-locked"; + /// + /// Prevents interaction with background elements. + /// + /// + /// Defaults to true. + /// + [Parameter] + [Category(CategoryTypes.Overlay.Behavior)] + public bool Modal { get; set; } = true; + /// /// Applies the theme's dark overlay color. /// @@ -171,47 +200,99 @@ public partial class MudOverlay : MudComponentBase, IAsyncDisposable (Class?.Contains("mud-skip-overlay-section") ?? false) || ChildContent != null; + string IPointerEventsNoneObserver.ElementId => _elementId; + public MudOverlay() { using var registerScope = CreateRegisterScope(); _visibleState = registerScope.RegisterParameter(nameof(Visible)) .WithParameter(() => Visible) - .WithEventCallback(() => VisibleChanged); + .WithEventCallback(() => VisibleChanged) + .WithChangeHandler(HandleVisibleChanged); } protected override async Task OnAfterRenderAsync(bool firstTime) { - if (!LockScroll || Absolute) + // If the overlay is initially visible and modeless auto-close is enabled, + // then start tracking pointer down events. + if (firstTime && Visible && !Modal && AutoClose) + { + await StartModelessAutoCloseTrackingAsync(); + } + } + + protected override async Task OnParametersSetAsync() + { + if (_previousLockScroll != LockScroll || _previousAbsolute != Absolute) + { + // handle lock scroll change when user changes LockScroll parameter + _previousLockScroll = LockScroll; + _previousAbsolute = Absolute; + await HandleLockScrollChange(); + } + + if (Modal || !AutoClose) { return; } if (Visible) { - await BlockScrollAsync(); + await StartModelessAutoCloseTrackingAsync(); } else { - await UnblockScrollAsync(); + await StopModelessAutoCloseTrackingAsync(); + } + } + + internal async Task HandleLockScrollChange() + { + if (LockScroll && !Absolute) + { + if (_visibleState.Value) + { + await BlockScrollAsync(); + } + else + { + await UnblockScrollAsync(); + } } } + // change lockscroll value when user toggles visible state + private Task HandleVisibleChanged(ParameterChangedEventArgs args) => HandleLockScrollChange(); + protected internal async Task OnClickHandlerAsync(MouseEventArgs ev) { if (AutoClose) { - await _visibleState.SetValueAsync(false); - await OnClosed.InvokeAsync(); + await CloseOverlayAsync(); } await OnClick.InvokeAsync(ev); } + internal async Task CloseOverlayAsync() + { + await _visibleState.SetValueAsync(false); + await OnClosed.InvokeAsync(); + await HandleLockScrollChange(); + } + /// /// Locks the scroll by attaching a CSS class to the specified element, in this case the body. /// private ValueTask BlockScrollAsync() { + // we only want to lock scroll once + if (_lockCount > 0) + { + return ValueTask.CompletedTask; + } + + _lockCount++; return ScrollManager.LockScrollAsync("body", LockScrollClass); } @@ -220,16 +301,47 @@ private ValueTask BlockScrollAsync() /// private ValueTask UnblockScrollAsync() { + _lockCount = Math.Max(0, _lockCount - 1); return ScrollManager.UnlockScrollAsync("body", LockScrollClass); } - public ValueTask DisposeAsync() + /// + /// Subscribes to pointer down events to close the overlay when the user clicks outside of it. + /// + private async Task StartModelessAutoCloseTrackingAsync() + { + if (IsJSRuntimeAvailable) + { + await PointerEventsNoneService.SubscribeAsync(this, new() { SubscribeDown = true }); + } + } + + /// + /// Unsubscribes from pointer down events. + /// + private async Task StopModelessAutoCloseTrackingAsync() { if (IsJSRuntimeAvailable) { - return UnblockScrollAsync(); + await PointerEventsNoneService.UnsubscribeAsync(this); + } + } + + Task IPointerDownObserver.NotifyOnPointerDownAsync(EventArgs args) => CloseOverlayAsync(); + + /// + public async ValueTask DisposeAsync() + { + if (!IsJSRuntimeAvailable) + { + return; + } + + if (_lockCount > 0) + { + await UnblockScrollAsync(); } - return ValueTask.CompletedTask; + await StopModelessAutoCloseTrackingAsync(); } } diff --git a/src/MudBlazor/Components/Picker/MudPicker.razor b/src/MudBlazor/Components/Picker/MudPicker.razor index 1e36d545f4c9..29a027fdd673 100644 --- a/src/MudBlazor/Components/Picker/MudPicker.razor +++ b/src/MudBlazor/Components/Picker/MudPicker.razor @@ -35,6 +35,7 @@ ErrorText="@ErrorText" Clearable="@(Clearable && !GetReadOnlyState())" OnClearButtonClick="@(async () => await ClearAsync())" + TextUpdateSuppression="@(Editable && !GetReadOnlyState())" @onclick="OnClickAsync" />; #nullable enable @@ -57,27 +58,27 @@ } } - @if (PickerVariant == PickerVariant.Inline) - { - -
- -
- @if (PickerContent != null) - { - @PickerContent - } -
- @if (PickerActions != null) + @if (PickerVariant == PickerVariant.Inline) + { + +
+ +
+ @if (PickerContent != null) { -
- @PickerActions(this) -
+ @PickerContent } - -
- - } +
+ @if (PickerActions != null) + { +
+ @PickerActions(this) +
+ } +
+
+
+ } else if (PickerVariant == PickerVariant.Static) { @@ -117,7 +118,11 @@
@if (PickerVariant == PickerVariant.Inline) { - + } ; } diff --git a/src/MudBlazor/Components/Picker/MudPicker.razor.cs b/src/MudBlazor/Components/Picker/MudPicker.razor.cs index cf832272b6b6..833f5577131a 100644 --- a/src/MudBlazor/Components/Picker/MudPicker.razor.cs +++ b/src/MudBlazor/Components/Picker/MudPicker.razor.cs @@ -408,6 +408,17 @@ public IMask? Mask set => _mask = value; } + /// + /// Prevents interaction with background elements while the picker is open. + /// + /// + /// Defaults to true. + /// Only possible to set to false when is . + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Behavior)] + public bool Modal { get; set; } = true; + /// /// The location the popover opens, relative to its container. /// diff --git a/src/MudBlazor/Components/Popover/MudPopoverProvider.razor b/src/MudBlazor/Components/Popover/MudPopoverProvider.razor index ed3a4d69a787..009ebf662e1e 100644 --- a/src/MudBlazor/Components/Popover/MudPopoverProvider.razor +++ b/src/MudBlazor/Components/Popover/MudPopoverProvider.razor @@ -2,7 +2,7 @@ @if (Enabled) { -
+
@foreach (var handler in PopoverService.ActivePopovers) { diff --git a/src/MudBlazor/Components/Select/MudSelect.razor b/src/MudBlazor/Components/Select/MudSelect.razor index 865e66b85c82..a7d59a5f5a18 100644 --- a/src/MudBlazor/Components/Select/MudSelect.razor +++ b/src/MudBlazor/Components/Select/MudSelect.razor @@ -1,4 +1,4 @@ -๏ปฟ@namespace MudBlazor +@namespace MudBlazor @typeparam T @inherits MudBaseInput @@ -53,7 +53,8 @@ ShrinkLabel="@ShrinkLabel" InputId="@InputElementId" Required="@Required"> - @if (CanRenderValue) { + @if (CanRenderValue) + { @GetSelectedValuePresenter() } @@ -103,7 +104,8 @@ Class="@ListClass" Dense="@Dense" @bind-SelectedValue="_activeItemId"> - @if (MultiSelection && SelectAll) { + @if (MultiSelection && SelectAll) + { - - + + \ No newline at end of file diff --git a/src/MudBlazor/Components/Select/MudSelect.razor.cs b/src/MudBlazor/Components/Select/MudSelect.razor.cs index 5ccc0430a147..67e30cf05c1c 100644 --- a/src/MudBlazor/Components/Select/MudSelect.razor.cs +++ b/src/MudBlazor/Components/Select/MudSelect.razor.cs @@ -279,6 +279,16 @@ private async Task SelectLastItem() [Parameter] public EventCallback OnClose { get; set; } + /// + /// Prevents interaction with background elements while this list is open. + /// + /// + /// Defaults to true. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.ListBehavior)] + public bool Modal { get; set; } = true; + /// /// The content within this component, typically a list of components. /// diff --git a/src/MudBlazor/Components/ThemeProvider/MudThemeProvider.razor.cs b/src/MudBlazor/Components/ThemeProvider/MudThemeProvider.razor.cs index 2f243b2b2d7b..c8a66d8ca250 100644 --- a/src/MudBlazor/Components/ThemeProvider/MudThemeProvider.razor.cs +++ b/src/MudBlazor/Components/ThemeProvider/MudThemeProvider.razor.cs @@ -388,7 +388,7 @@ protected virtual void GenerateTheme(StringBuilder theme) //Typography theme.AppendLine( - $"--{Typography}-default-family: '{string.Join("','", _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-default-family: {FormatFontFamily(_theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-default-size: {_theme.Typography.Default.FontSize};"); theme.AppendLine($"--{Typography}-default-weight: {_theme.Typography.Default.FontWeight};"); theme.AppendLine( @@ -397,7 +397,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-default-text-transform: {_theme.Typography.Default.TextTransform};"); theme.AppendLine( - $"--{Typography}-h1-family: '{string.Join("','", _theme.Typography.H1.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-h1-family: {FormatFontFamily(_theme.Typography.H1.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-h1-size: {_theme.Typography.H1.FontSize};"); theme.AppendLine($"--{Typography}-h1-weight: {_theme.Typography.H1.FontWeight};"); theme.AppendLine( @@ -406,7 +406,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-h1-text-transform: {_theme.Typography.H1.TextTransform};"); theme.AppendLine( - $"--{Typography}-h2-family: '{string.Join("','", _theme.Typography.H2.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-h2-family: {FormatFontFamily(_theme.Typography.H2.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-h2-size: {_theme.Typography.H2.FontSize};"); theme.AppendLine($"--{Typography}-h2-weight: {_theme.Typography.H2.FontWeight};"); theme.AppendLine( @@ -415,7 +415,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-h2-text-transform: {_theme.Typography.H2.TextTransform};"); theme.AppendLine( - $"--{Typography}-h3-family: '{string.Join("','", _theme.Typography.H3.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-h3-family: {FormatFontFamily(_theme.Typography.H3.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-h3-size: {_theme.Typography.H3.FontSize};"); theme.AppendLine($"--{Typography}-h3-weight: {_theme.Typography.H3.FontWeight};"); theme.AppendLine( @@ -424,7 +424,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-h3-text-transform: {_theme.Typography.H3.TextTransform};"); theme.AppendLine( - $"--{Typography}-h4-family: '{string.Join("','", _theme.Typography.H4.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-h4-family: {FormatFontFamily(_theme.Typography.H4.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-h4-size: {_theme.Typography.H4.FontSize};"); theme.AppendLine($"--{Typography}-h4-weight: {_theme.Typography.H4.FontWeight};"); theme.AppendLine( @@ -433,7 +433,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-h4-text-transform: {_theme.Typography.H4.TextTransform};"); theme.AppendLine( - $"--{Typography}-h5-family: '{string.Join("','", _theme.Typography.H5.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-h5-family: {FormatFontFamily(_theme.Typography.H5.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-h5-size: {_theme.Typography.H5.FontSize};"); theme.AppendLine($"--{Typography}-h5-weight: {_theme.Typography.H5.FontWeight};"); theme.AppendLine( @@ -442,7 +442,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-h5-text-transform: {_theme.Typography.H5.TextTransform};"); theme.AppendLine( - $"--{Typography}-h6-family: '{string.Join("','", _theme.Typography.H6.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-h6-family: {FormatFontFamily(_theme.Typography.H6.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-h6-size: {_theme.Typography.H6.FontSize};"); theme.AppendLine($"--{Typography}-h6-weight: {_theme.Typography.H6.FontWeight};"); theme.AppendLine( @@ -451,7 +451,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-h6-text-transform: {_theme.Typography.H6.TextTransform};"); theme.AppendLine( - $"--{Typography}-subtitle1-family: '{string.Join("','", _theme.Typography.Subtitle1.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-subtitle1-family: {FormatFontFamily(_theme.Typography.Subtitle1.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-subtitle1-size: {_theme.Typography.Subtitle1.FontSize};"); theme.AppendLine($"--{Typography}-subtitle1-weight: {_theme.Typography.Subtitle1.FontWeight};"); theme.AppendLine( @@ -460,7 +460,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-subtitle1-text-transform: {_theme.Typography.Subtitle1.TextTransform};"); theme.AppendLine( - $"--{Typography}-subtitle2-family: '{string.Join("','", _theme.Typography.Subtitle2.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-subtitle2-family: {FormatFontFamily(_theme.Typography.Subtitle2.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-subtitle2-size: {_theme.Typography.Subtitle2.FontSize};"); theme.AppendLine($"--{Typography}-subtitle2-weight: {_theme.Typography.Subtitle2.FontWeight};"); theme.AppendLine( @@ -469,7 +469,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-subtitle2-text-transform: {_theme.Typography.Subtitle2.TextTransform};"); theme.AppendLine( - $"--{Typography}-body1-family: '{string.Join("','", _theme.Typography.Body1.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-body1-family: {FormatFontFamily(_theme.Typography.Body1.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-body1-size: {_theme.Typography.Body1.FontSize};"); theme.AppendLine($"--{Typography}-body1-weight: {_theme.Typography.Body1.FontWeight};"); theme.AppendLine( @@ -478,7 +478,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-body1-text-transform: {_theme.Typography.Body1.TextTransform};"); theme.AppendLine( - $"--{Typography}-body2-family: '{string.Join("','", _theme.Typography.Body2.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-body2-family: {FormatFontFamily(_theme.Typography.Body2.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-body2-size: {_theme.Typography.Body2.FontSize};"); theme.AppendLine($"--{Typography}-body2-weight: {_theme.Typography.Body2.FontWeight};"); theme.AppendLine( @@ -487,7 +487,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-body2-text-transform: {_theme.Typography.Body2.TextTransform};"); theme.AppendLine( - $"--{Typography}-button-family: '{string.Join("','", _theme.Typography.Button.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-button-family: {FormatFontFamily(_theme.Typography.Button.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-button-size: {_theme.Typography.Button.FontSize};"); theme.AppendLine($"--{Typography}-button-weight: {_theme.Typography.Button.FontWeight};"); theme.AppendLine( @@ -496,7 +496,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-button-text-transform: {_theme.Typography.Button.TextTransform};"); theme.AppendLine( - $"--{Typography}-caption-family: '{string.Join("','", _theme.Typography.Caption.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-caption-family: {FormatFontFamily(_theme.Typography.Caption.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-caption-size: {_theme.Typography.Caption.FontSize};"); theme.AppendLine($"--{Typography}-caption-weight: {_theme.Typography.Caption.FontWeight};"); theme.AppendLine( @@ -505,7 +505,7 @@ protected virtual void GenerateTheme(StringBuilder theme) theme.AppendLine($"--{Typography}-caption-text-transform: {_theme.Typography.Caption.TextTransform};"); theme.AppendLine( - $"--{Typography}-overline-family: '{string.Join("','", _theme.Typography.Overline.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())}';"); + $"--{Typography}-overline-family: {FormatFontFamily(_theme.Typography.Overline.FontFamily ?? _theme.Typography.Default.FontFamily ?? Array.Empty())};"); theme.AppendLine($"--{Typography}-overline-size: {_theme.Typography.Overline.FontSize};"); theme.AppendLine($"--{Typography}-overline-weight: {_theme.Typography.Overline.FontWeight};"); theme.AppendLine( @@ -572,4 +572,9 @@ private async Task OnObserveSystemThemeChangeChanged(ParameterChangedEventArgs JsRuntime.InvokeVoidAsyncIgnoreErrors("stopWatchingDarkThemeMedia"); private DotNetObjectReference CreateDotNetObjectReference() => DotNetObjectReference.Create(this); + + private static string FormatFontFamily(string[] fontFamilies) + { + return string.Join(", ", fontFamilies.Select(font => font.Contains(' ') ? $"'{font}'" : font)); + } } diff --git a/src/MudBlazor/Components/Tooltip/MudTooltip.razor b/src/MudBlazor/Components/Tooltip/MudTooltip.razor index 3a9f2a6a2449..b51243294822 100644 --- a/src/MudBlazor/Components/Tooltip/MudTooltip.razor +++ b/src/MudBlazor/Components/Tooltip/MudTooltip.razor @@ -5,7 +5,7 @@ @ChildContent @if (ShowToolTip()) { - + @if (TooltipContent is not null) {
diff --git a/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs b/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs index 56f438cee950..d71defc4e02b 100644 --- a/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs +++ b/src/MudBlazor/Components/Tooltip/MudTooltip.razor.cs @@ -1,7 +1,6 @@ ๏ปฟusing Microsoft.AspNetCore.Components; using MudBlazor.State; using MudBlazor.Utilities; -using MudBlazor.Utilities.Debounce; namespace MudBlazor { @@ -11,16 +10,8 @@ public partial class MudTooltip : MudComponentBase private readonly ParameterState _visibleState; private Origin _anchorOrigin; private Origin _transformOrigin; - internal DebounceDispatcher _showDebouncer; - internal DebounceDispatcher _hideDebouncer; - internal double _previousDelay; - internal double _previousDuration; public MudTooltip() { - _previousDelay = Delay; - _showDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Delay)); - _previousDuration = Duration; - _hideDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Duration)); using var registerScope = CreateRegisterScope(); _visibleState = registerScope.RegisterParameter(nameof(Visible)) .WithParameter(() => Visible) @@ -172,49 +163,19 @@ public MudTooltip() /// internal bool ShowToolTip() { - if (_anchorOrigin == Origin.TopLeft || _transformOrigin == Origin.TopLeft) - ConvertPlacement(); - return !Disabled && _visibleState.Value && (TooltipContent is not null || !string.IsNullOrEmpty(Text)); + return !Disabled && (TooltipContent is not null || !string.IsNullOrEmpty(Text)); } protected override void OnParametersSet() { base.OnParametersSet(); - if (Math.Abs(_previousDelay - Delay) > .001) - { - _showDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Delay)); - _previousDelay = Delay; - } - - if (Math.Abs(_previousDuration - Duration) > .001) - { - _hideDebouncer = new DebounceDispatcher(TimeSpan.FromMilliseconds(Duration)); - _previousDuration = Duration; - } + ConvertPlacement(); } - internal Task HandlePointerEnterAsync() - { - if (!ShowOnHover) - { - return Task.CompletedTask; - } + internal Task HandlePointerEnterAsync() => ShowOnHover ? _visibleState.SetValueAsync(true) : Task.CompletedTask; - _hideDebouncer.Cancel(); - return _showDebouncer.DebounceAsync(() => _visibleState.SetValueAsync(true)); - } - - internal Task HandlePointerLeaveAsync() - { - if (!ShowOnHover) - { - return Task.CompletedTask; - } - - _showDebouncer.Cancel(); - return _hideDebouncer.DebounceAsync(() => _visibleState.SetValueAsync(false)); - } + internal Task HandlePointerLeaveAsync() => ShowOnHover ? _visibleState.SetValueAsync(false) : Task.CompletedTask; private Task HandleFocusInAsync() { diff --git a/src/MudBlazor/Extensions/ServiceCollectionExtensions.cs b/src/MudBlazor/Extensions/ServiceCollectionExtensions.cs index bc3a84dcdbd6..2a7ad1d2000a 100644 --- a/src/MudBlazor/Extensions/ServiceCollectionExtensions.cs +++ b/src/MudBlazor/Extensions/ServiceCollectionExtensions.cs @@ -235,10 +235,22 @@ public static IServiceCollection AddMudEventManager(this IServiceCollection serv return services; } + /// + /// Adds IPointerEventsNoneService as a scoped dependency. + /// + /// IServiceCollection + public static IServiceCollection AddMudBlazorPointerEventsNoneService(this IServiceCollection services) + { + services.TryAddScoped(); + + return services; + } + /// /// Adds the services required for translations. /// /// IServiceCollection + /// Continues the IServiceCollection chain. public static IServiceCollection AddMudLocalization(this IServiceCollection services) { services.TryAddTransient(); @@ -324,6 +336,7 @@ public static IServiceCollection AddMudServices(this IServiceCollection services .AddMudBlazorScrollSpy() .AddMudPopoverService() .AddMudEventManager() + .AddMudBlazorPointerEventsNoneService() .AddMudLocalization(); } @@ -399,6 +412,7 @@ public static IServiceCollection AddMudServices(this IServiceCollection services }) .AddMudBlazorScrollSpy() .AddMudEventManager() + .AddMudBlazorPointerEventsNoneService() .AddMudLocalization(); } diff --git a/src/MudBlazor/Interop/PointerEventsNoneInterop.cs b/src/MudBlazor/Interop/PointerEventsNoneInterop.cs new file mode 100644 index 000000000000..45d4307aa634 --- /dev/null +++ b/src/MudBlazor/Interop/PointerEventsNoneInterop.cs @@ -0,0 +1,39 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.JSInterop; + +namespace MudBlazor.Interop; + +#nullable enable + +internal class PointerEventsNoneInterop +{ + private readonly IJSRuntime _jsRuntime; + + public PointerEventsNoneInterop(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public ValueTask ListenForPointerEventsAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] T>( + DotNetObjectReference dotNetObjectReference, + string elementId, + PointerEventsNoneOptions options, + CancellationToken cancellationToken = default) where T : class + { + return _jsRuntime.InvokeVoidAsyncWithErrorHandling("mudPointerEventsNone.listenForPointerEvents", cancellationToken, dotNetObjectReference, elementId, options); + } + + public ValueTask CancelListenerAsync(string elementId, CancellationToken cancellationToken = default) + { + return _jsRuntime.InvokeVoidAsyncWithErrorHandling("mudPointerEventsNone.cancelListener", cancellationToken, elementId); + } + + public ValueTask DisposeAsync(CancellationToken cancellationToken = default) + { + return _jsRuntime.InvokeVoidAsyncIgnoreErrors("mudPointerEventsNone.dispose", cancellationToken); + } +} diff --git a/src/MudBlazor/Interop/PopoverJsInterop.cs b/src/MudBlazor/Interop/PopoverJsInterop.cs index ea5b6a6d6efd..cc42803a0f0e 100644 --- a/src/MudBlazor/Interop/PopoverJsInterop.cs +++ b/src/MudBlazor/Interop/PopoverJsInterop.cs @@ -2,9 +2,6 @@ // MudBlazor licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.JSInterop; namespace MudBlazor.Interop; @@ -19,9 +16,9 @@ public PopoverJsInterop(IJSRuntime jsRuntime) _jsRuntime = jsRuntime; } - public ValueTask Initialize(string containerClass, int flipMargin, CancellationToken cancellationToken = default) + public ValueTask Initialize(string containerClass, int flipMargin, int overflowPadding, CancellationToken cancellationToken = default) { - return _jsRuntime.InvokeVoidAsyncWithErrorHandling("mudPopover.initialize", cancellationToken, containerClass, flipMargin); + return _jsRuntime.InvokeVoidAsyncWithErrorHandling("mudPopover.initialize", cancellationToken, containerClass, flipMargin, overflowPadding); } public ValueTask Connect(Guid id, CancellationToken cancellationToken = default) diff --git a/src/MudBlazor/MudBlazor.csproj b/src/MudBlazor/MudBlazor.csproj index 4dca93224bf0..97fd77e3d1de 100644 --- a/src/MudBlazor/MudBlazor.csproj +++ b/src/MudBlazor/MudBlazor.csproj @@ -89,7 +89,7 @@ all - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/MudBlazor/Services/MudGlobal.cs b/src/MudBlazor/Services/MudGlobal.cs index aa301c1ef521..8317df1077a1 100644 --- a/src/MudBlazor/Services/MudGlobal.cs +++ b/src/MudBlazor/Services/MudGlobal.cs @@ -5,10 +5,21 @@ namespace MudBlazor; /// -/// A collection of settings that let you control the default behavior or appearance of MudBlazor components. +/// +/// Static properties that let you control the default behavior of some parts of MudBlazor. +/// +/// +/// Warning: This feature is under development and breaking changes to the API will occur between releases. +/// See our website for more info including our support policy. +/// /// public static class MudGlobal { + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class ButtonDefaults { /// @@ -22,6 +33,11 @@ public static class ButtonDefaults public static Variant Variant { get; set; } = Variant.Text; } + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class DialogDefaults { /// @@ -30,6 +46,11 @@ public static class DialogDefaults public static DefaultFocus DefaultFocus { get; set; } = DefaultFocus.Element; } + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class GridDefaults { /// @@ -38,6 +59,11 @@ public static class GridDefaults public static int Spacing { set; get; } = 6; } + /// + /// Default settings for MudBlazor input components. + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class InputDefaults { /// @@ -56,6 +82,11 @@ public static class InputDefaults public static Margin Margin { get; set; } = Margin.None; } + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class LinkDefaults { /// @@ -74,15 +105,24 @@ public static class LinkDefaults public static Underline Underline { get; set; } = Underline.Hover; } + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class MenuDefaults { /// - /// The time in milliseconds before a is activated by the cursor hovering over it - /// or 2x that time before it is hidden after the cursor leaves the menu. + /// The delay in milliseconds before a is shown when hovered, or hidden after the cursor moves away. /// public static int HoverDelay { get; set; } = 300; } + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class PopoverDefaults { /// @@ -91,6 +131,11 @@ public static class PopoverDefaults public static int Elevation { get; set; } = 8; } + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class StackDefaults { /// @@ -99,6 +144,11 @@ public static class StackDefaults public static int Spacing { get; set; } = 3; } + /// + /// Default settings for . + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class TooltipDefaults { /// @@ -112,6 +162,11 @@ public static class TooltipDefaults public static TimeSpan Duration { get; set; } = TransitionDefaults.Duration; } + /// + /// Default settings for transitions in MudBlazor components. + ///
+ /// Warning: This feature is under development and breaking changes to the API will occur between releases. + ///
public static class TransitionDefaults { /// @@ -126,7 +181,7 @@ public static class TransitionDefaults } /// - /// Applies regular rounding by default; additional rounding if set to true; or squares them if set to false for MudBlazor components. + /// Applies regular rounding to components by default; additional rounding if set to true; or squares them if set to false for MudBlazor components. /// public static bool? Rounded { get; set; } diff --git a/src/MudBlazor/Services/PointerEvents/IPointerDownObserver.cs b/src/MudBlazor/Services/PointerEvents/IPointerDownObserver.cs new file mode 100644 index 000000000000..ae0cf84d0f2b --- /dev/null +++ b/src/MudBlazor/Services/PointerEvents/IPointerDownObserver.cs @@ -0,0 +1,20 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor; + +#nullable enable + +/// +/// Represents an observer that observes and responds to pointer down events. +/// +public interface IPointerDownObserver +{ + /// + /// Notifies the observer of a pointer down event. + /// + /// The event arguments associated with the pointer down event. + /// A task that represents the asynchronous operation. + Task NotifyOnPointerDownAsync(EventArgs args) => Task.CompletedTask; +} diff --git a/src/MudBlazor/Services/PointerEvents/IPointerEventsNoneObserver.cs b/src/MudBlazor/Services/PointerEvents/IPointerEventsNoneObserver.cs new file mode 100644 index 000000000000..b9315195369e --- /dev/null +++ b/src/MudBlazor/Services/PointerEvents/IPointerEventsNoneObserver.cs @@ -0,0 +1,24 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor; + +#nullable enable + +/// +/// Represents an observer that listens for pointer down and pointer up events +/// on a specific HTML element with pointer-events: none. +/// +/// +/// This observer is associated with a unique HTML element ID and is used by the +/// to relay pointer interactions from JavaScript +/// to .NET, even though the element itself does not natively receive pointer events. +/// +public interface IPointerEventsNoneObserver : IPointerDownObserver, IPointerUpObserver +{ + /// + /// Gets the unique ID of the HTML element associated with this observer. + /// + string ElementId { get; } +} diff --git a/src/MudBlazor/Services/PointerEvents/IPointerEventsNoneService.cs b/src/MudBlazor/Services/PointerEvents/IPointerEventsNoneService.cs new file mode 100644 index 000000000000..3d33ab748ed3 --- /dev/null +++ b/src/MudBlazor/Services/PointerEvents/IPointerEventsNoneService.cs @@ -0,0 +1,46 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor; + +#nullable enable + +/// +/// Represents a service that enables C# components to receive pointer event notifications for HTML elements +/// with pointer-events: none, which normally do not receive any pointer interactions. +/// +internal interface IPointerEventsNoneService : IAsyncDisposable +{ + /// + /// Subscribes an observer to pointer events for a specified element. + /// + /// The observer that will receive pointer event notifications. + /// Options for configuring the pointer event listener behavior. + /// A task that represents the asynchronous operation. + Task SubscribeAsync(IPointerEventsNoneObserver observer, PointerEventsNoneOptions options); + + /// + /// Subscribes to pointer events for a specified element by its ID and optionally provides callbacks for pointer down and up events. + /// + /// The unique ID of the HTML element to observe. + /// Options for configuring the pointer event listener behavior. + /// Optional observer that handles pointer down events. + /// Optional observer that handles pointer up events. + /// A task that represents the asynchronous operation. + Task SubscribeAsync(string elementId, PointerEventsNoneOptions options, IPointerDownObserver? pointerDown = null, IPointerUpObserver? pointerUp = null); + + /// + /// Unsubscribes a previously registered observer from pointer events. + /// + /// The observer to unsubscribe. + /// A task that represents the asynchronous operation. + Task UnsubscribeAsync(IPointerEventsNoneObserver observer); + + /// + /// Unsubscribes from pointer events for a specified element by its ID. + /// + /// The unique ID of the HTML element to stop observing. + /// A task that represents the asynchronous operation. + Task UnsubscribeAsync(string elementId); +} diff --git a/src/MudBlazor/Services/PointerEvents/IPointerUpObserver.cs b/src/MudBlazor/Services/PointerEvents/IPointerUpObserver.cs new file mode 100644 index 000000000000..ae1ff8f1d8c9 --- /dev/null +++ b/src/MudBlazor/Services/PointerEvents/IPointerUpObserver.cs @@ -0,0 +1,20 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor; + +#nullable enable + +/// +/// Represents an observer that observes and responds to pointer up events. +/// +public interface IPointerUpObserver +{ + /// + /// Notifies the observer of a pointer up event. + /// + /// The event arguments associated with the pointer up event. + /// A task that represents the asynchronous operation. + Task NotifyOnPointerUpAsync(EventArgs args) => Task.CompletedTask; +} diff --git a/src/MudBlazor/Services/PointerEvents/PointerEventsNoneObserver.cs b/src/MudBlazor/Services/PointerEvents/PointerEventsNoneObserver.cs new file mode 100644 index 000000000000..3db595a6e42a --- /dev/null +++ b/src/MudBlazor/Services/PointerEvents/PointerEventsNoneObserver.cs @@ -0,0 +1,54 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor; + +#nullable enable + +/// +/// Represents a pointer events observer that handles pointer down and pointer up events for a specific HTML element. +/// +public class PointerEventsNoneObserver : IPointerEventsNoneObserver +{ + private readonly string _elementId; + private readonly IPointerDownObserver _pointerDownObserver; + private readonly IPointerUpObserver _pointerUpObserver; + private static readonly PointerEventsObserverIgnore _ignore = new(); + + /// + /// Initializes a new instance of the class + /// + /// The unique identifier of the HTML element to observe. + /// The observer for pointer down events. + /// The observer for pointer up events. + internal PointerEventsNoneObserver(string elementId, IPointerDownObserver? pointerDownObserver, IPointerUpObserver? pointerUpObserver) + { + _elementId = elementId; + _pointerDownObserver = pointerDownObserver ?? _ignore; + _pointerUpObserver = pointerUpObserver ?? _ignore; + } + + /// + string IPointerEventsNoneObserver.ElementId => _elementId; + + /// + Task IPointerDownObserver.NotifyOnPointerDownAsync(EventArgs args) => _pointerDownObserver.NotifyOnPointerDownAsync(args); + + /// + Task IPointerUpObserver.NotifyOnPointerUpAsync(EventArgs args) => _pointerUpObserver.NotifyOnPointerUpAsync(args); + + /// + /// Gets a that ignores pointer down events. + /// + /// An instance of that ignores pointer down events. + public static IPointerDownObserver PointerDownIgnore() => _ignore; + + /// + /// Gets a that ignores pointer up events. + /// + /// An instance of that ignores pointer down events. + public static IPointerUpObserver PointerUpIgnore() => _ignore; + + private sealed class PointerEventsObserverIgnore : IPointerDownObserver, IPointerUpObserver; +} diff --git a/src/MudBlazor/Services/PointerEvents/PointerEventsNoneOptions.cs b/src/MudBlazor/Services/PointerEvents/PointerEventsNoneOptions.cs new file mode 100644 index 000000000000..d3512f6cffbe --- /dev/null +++ b/src/MudBlazor/Services/PointerEvents/PointerEventsNoneOptions.cs @@ -0,0 +1,26 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace MudBlazor; + +/// +/// Represents options for . +/// +public class PointerEventsNoneOptions +{ + /// + /// Output event and debug information to the browser's console. + /// + public bool EnableLogging { get; init; } + + /// + /// Subscribe to pointer down events. + /// + public bool SubscribeDown { get; init; } + + /// + /// Subscribe to pointer up events. + /// + public bool SubscribeUp { get; init; } +} diff --git a/src/MudBlazor/Services/PointerEvents/PointerEventsNoneService.cs b/src/MudBlazor/Services/PointerEvents/PointerEventsNoneService.cs new file mode 100644 index 000000000000..9fd8a12b3a79 --- /dev/null +++ b/src/MudBlazor/Services/PointerEvents/PointerEventsNoneService.cs @@ -0,0 +1,156 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; +using MudBlazor.Interop; +using MudBlazor.Utilities.ObserverManager; + +namespace MudBlazor; + +#nullable enable + +/// +/// +/// This implementation uses JavaScript interop to globally listen for pointer events such as pointer down and up. +/// Since elements with pointer-events: none do not receive events normally, the interop captures these events +/// and checks if they occurred over any registered element IDs. Matching observers are then notified in C#. +/// +/// This allows you to make elements with disabled pointer interaction still participate in interaction logic, +/// such as overlays or custom render layers. +/// +internal sealed class PointerEventsNoneService : IPointerEventsNoneService +{ + private bool _disposed; + private readonly CancellationTokenSource _cancellationTokenSource; + private readonly CancellationToken _cancellationToken; + private readonly PointerEventsNoneInterop _pointerEventsNoneInterop; + private readonly ObserverManager _observerManager; + private readonly Lazy> _dotNetObjectReference; + + /// + /// Gets the number of observers. + /// + /// + /// This property is not exposed in the public API of the interface and is intended for internal use only. + /// + internal int ObserversCount => _observerManager.Count; + + [DynamicDependency(nameof(RaiseOnPointerDown))] + [DynamicDependency(nameof(RaiseOnPointerUp))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PointerEventsNoneOptions))] + public PointerEventsNoneService(ILogger logger, IJSRuntime jSRuntime) + { + _cancellationTokenSource = new(); + _cancellationToken = _cancellationTokenSource.Token; + _pointerEventsNoneInterop = new(jSRuntime); + _observerManager = new(logger); + _dotNetObjectReference = new(() => DotNetObjectReference.Create(this)); + } + + /// + public async Task SubscribeAsync(IPointerEventsNoneObserver observer, PointerEventsNoneOptions options) + { + ArgumentNullException.ThrowIfNull(observer); + + if (!_observerManager.TryGetOrAddSubscription(observer.ElementId, observer, out var newObserver)) + { + await _pointerEventsNoneInterop.ListenForPointerEventsAsync(_dotNetObjectReference.Value, newObserver.ElementId, options, _cancellationToken); + } + } + + /// + public Task SubscribeAsync(string elementId, PointerEventsNoneOptions options, IPointerDownObserver? pointerDown = null, IPointerUpObserver? pointerUp = null) + { + return SubscribeAsync(new PointerEventsNoneObserver(elementId, pointerDown, pointerUp), options); + } + + /// + public Task UnsubscribeAsync(IPointerEventsNoneObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + + if (_disposed) + { + return Task.CompletedTask; + } + + return UnsubscribeAsync(observer.ElementId); + } + + /// + public async Task UnsubscribeAsync(string elementId) + { + ArgumentNullException.ThrowIfNull(elementId); + + if (_disposed) + { + return; + } + + _observerManager.Unsubscribe(elementId); + + await _pointerEventsNoneInterop.CancelListenerAsync(elementId, _cancellationToken); + } + + /// + /// Notifies observers when a pointer down event occurs over one or more of the specified HTML elements. + /// This method is invoked from JavaScript via interop. + /// + /// An array of element IDs for which the pointer down event was detected. + /// A task representing the asynchronous notification operation. + /// + /// This method is not exposed in the public API of the interface and is intended for internal use only. + /// + [JSInvokable] + public Task RaiseOnPointerDown(string[] elementIds) + { + return _observerManager + .NotifyAsync( + notification: observer => observer.NotifyOnPointerDownAsync(EventArgs.Empty), + predicate: (id, _) => elementIds.Contains(id)); + } + + /// + /// Notifies observers when a pointer up event occurs on any of the specified HTML elements. + /// This method is invoked from JavaScript via interop. + /// + /// An array of HTML element IDs that received the pointer up event. + /// A task representing the asynchronous notification operation. + /// + /// This method is not exposed in the public API of the interface and is intended for internal use only. + /// It is called by the JavaScript layer when a pointer up event is detected globally over elements with pointer-events: none. + /// + [JSInvokable] + public Task RaiseOnPointerUp(string[] elementIds) + { + return _observerManager + .NotifyAsync( + notification: observer => observer.NotifyOnPointerUpAsync(EventArgs.Empty), + predicate: (id, _) => elementIds.Contains(id)); + } + + /// + public async ValueTask DisposeAsync() + { + if (!_disposed) + { + _disposed = true; + + await _cancellationTokenSource.CancelAsync(); + + _observerManager.Clear(); + + if (_dotNetObjectReference.IsValueCreated) + { + _dotNetObjectReference.Value.Dispose(); + } + + await _pointerEventsNoneInterop.DisposeAsync(CancellationToken.None); + + _cancellationTokenSource.Dispose(); + } + } +} diff --git a/src/MudBlazor/Services/Popover/PopoverOptions.cs b/src/MudBlazor/Services/Popover/PopoverOptions.cs index aeb767338f7e..52a8fdfb5754 100644 --- a/src/MudBlazor/Services/Popover/PopoverOptions.cs +++ b/src/MudBlazor/Services/Popover/PopoverOptions.cs @@ -20,7 +20,7 @@ public class PopoverOptions /// Gets or sets the CSS class of the popover container. /// The default value is mudblazor-main-content. /// - public string ContainerClass { get; set; } = "mudblazor-main-content"; + public string ContainerClass { get; set; } = "mud-popover-provider"; /// /// Gets or sets the FlipMargin for the popover. @@ -34,6 +34,13 @@ public class PopoverOptions /// public TimeSpan QueueDelay { get; set; } = TimeSpan.FromSeconds(0.5); + /// + /// Gets or sets the overflow padding for the popover. This is used when adjusting popovers that go off screen at the top or left. + /// It is also used to create max-height for popovers containing a list that will go off screen. + /// The default value is 24 rougly equal to the 8dp margin of material design. + /// + public int OverflowPadding { get; set; } = 24; + /// /// Gets or sets a value indicating whether to throw an exception when a duplicate is encountered. /// The default value is true. diff --git a/src/MudBlazor/Services/Popover/PopoverService.cs b/src/MudBlazor/Services/Popover/PopoverService.cs index 9cfa8af1fdbf..22946678f65a 100644 --- a/src/MudBlazor/Services/Popover/PopoverService.cs +++ b/src/MudBlazor/Services/Popover/PopoverService.cs @@ -327,7 +327,7 @@ private async Task InitializeServiceIfNeededAsync() return; } - await _popoverJsInterop.Initialize(PopoverOptions.ContainerClass, PopoverOptions.FlipMargin, _cancellationToken); + await _popoverJsInterop.Initialize(PopoverOptions.ContainerClass, PopoverOptions.FlipMargin, PopoverOptions.OverflowPadding, _cancellationToken); // Starts in background await _batchExecutor.StartAsync(_cancellationToken); IsInitialized = true; diff --git a/src/MudBlazor/Services/Scroll/ScrollManager.cs b/src/MudBlazor/Services/Scroll/ScrollManager.cs index d436e333b421..198e3c5afc04 100644 --- a/src/MudBlazor/Services/Scroll/ScrollManager.cs +++ b/src/MudBlazor/Services/Scroll/ScrollManager.cs @@ -47,6 +47,8 @@ public ValueTask ScrollToYearAsync(string elementId) => public ValueTask ScrollToListItemAsync(string elementId) => _jSRuntime.InvokeVoidAsync("mudScrollManager.scrollToListItem", elementId); + // lockScroll and unlockScroll use a counter system in javascript so we can lock/unlock without limit + // and maintain the proper lock. IF YOU CHANGE THIS, CHANGE THE JAVASCRIPT AS WELL /// public ValueTask LockScrollAsync(string selector = "body", string cssClass = "scroll-locked") => _jSRuntime.InvokeVoidAsync("mudScrollManager.lockScroll", selector, cssClass); diff --git a/src/MudBlazor/Styles/components/_chat.scss b/src/MudBlazor/Styles/components/_chat.scss index 0342e8635093..3612b10599ac 100644 --- a/src/MudBlazor/Styles/components/_chat.scss +++ b/src/MudBlazor/Styles/components/_chat.scss @@ -41,13 +41,27 @@ $default-foreground: var(--mud-palette-text-primary); top: 0; } - &.mud-chat-start { + &.mud-chat-start, + &.mud-chat-end.mud-chat-rtl { + .mud-chat-bubble:before { + transform: none; + } + } + + &.mud-chat-end, + &.mud-chat-start.mud-chat-rtl { + .mud-chat-bubble:before { + transform: scaleX(-1); + } + } + + &.mud-chat-start, &.mud-chat-end.mud-chat-rtl { .mud-chat-bubble { border-top-left-radius: 0; } } - &.mud-chat-end { + &.mud-chat-end, &.mud-chat-start.mud-chat-rtl { .mud-chat-bubble { border-top-right-radius: 0; } @@ -61,13 +75,27 @@ $default-foreground: var(--mud-palette-text-primary); bottom: 0; } - &.mud-chat-start { + &.mud-chat-start, + &.mud-chat-end.mud-chat-rtl { + .mud-chat-bubble:before { + transform: none; + } + } + + &.mud-chat-end, + &.mud-chat-start.mud-chat-rtl { + .mud-chat-bubble:before { + transform: scaleX(-1); + } + } + + &.mud-chat-start, &.mud-chat-end.mud-chat-rtl { .mud-chat-bubble { border-bottom-left-radius: 0; } } - &.mud-chat-end { + &.mud-chat-end, &.mud-chat-start.mud-chat-rtl { .mud-chat-bubble { border-bottom-right-radius: 0; } diff --git a/src/MudBlazor/Styles/components/_datagrid.scss b/src/MudBlazor/Styles/components/_datagrid.scss index f5f39ad385e4..6ea9a6523465 100644 --- a/src/MudBlazor/Styles/components/_datagrid.scss +++ b/src/MudBlazor/Styles/components/_datagrid.scss @@ -153,6 +153,16 @@ .mud-resizing { border-right: 2px solid var(--mud-palette-primary); } + + &.mud-datagrid-group { + background-color: var(--mud-palette-background-gray); + } + + @for $i from 2 through 5 { + &.mud-row-group-indented-#{$i} { + padding-left: #{$i * 48 - 48}px !important; + } + } } } diff --git a/src/MudBlazor/Styles/components/_popover.scss b/src/MudBlazor/Styles/components/_popover.scss index 8294cff79cea..0975151cdc1f 100644 --- a/src/MudBlazor/Styles/components/_popover.scss +++ b/src/MudBlazor/Styles/components/_popover.scss @@ -3,6 +3,8 @@ z-index: calc(var(--mud-zindex-popover) + 1); position: absolute; opacity: 0; + top: -9999px; + left: -9999px; &.mud-popover-fixed { position: fixed; diff --git a/src/MudBlazor/Styles/components/_table.scss b/src/MudBlazor/Styles/components/_table.scss index 24613149afb3..84a6d0d36a53 100644 --- a/src/MudBlazor/Styles/components/_table.scss +++ b/src/MudBlazor/Styles/components/_table.scss @@ -522,6 +522,10 @@ } } +.mud-header-togglehierarchy .mud-table-row-expander { + padding: 6px; +} + .mud-table-row-expander { margin-top: -12px; margin-bottom: -12px; diff --git a/src/MudBlazor/TScripts/mudElementReference.js b/src/MudBlazor/TScripts/mudElementReference.js index a810cde4813e..a2363d0ce998 100644 --- a/src/MudBlazor/TScripts/mudElementReference.js +++ b/src/MudBlazor/TScripts/mudElementReference.js @@ -155,13 +155,16 @@ class MudElementReference { e.preventDefault(); element.blur(); if (dotNetReference) { - dotNetReference.invokeMethodAsync('CallOnBlurredAsync'); + // make sure blur events only happen when heap is unlocked + requestAnimationFrame(() => { + dotNetReference.invokeMethodAsync('CallOnBlurredAsync'); + }); } else { console.error("No dotNetReference found for iosKeyboardFocus"); } } - element.addEventListener('blur', element._mudBlurHandler); + if (element) element.addEventListener('blur', element._mudBlurHandler); } // dispose event removeOnBlurEvent(element, dotnetRef) { diff --git a/src/MudBlazor/TScripts/mudPointerEventsNone.js b/src/MudBlazor/TScripts/mudPointerEventsNone.js new file mode 100644 index 000000000000..144e1c44b92b --- /dev/null +++ b/src/MudBlazor/TScripts/mudPointerEventsNone.js @@ -0,0 +1,194 @@ +๏ปฟ// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +class MudPointerEventsNone { + constructor() { + this.dotnet = null; + this.logger = (msg, ...args) => { }; + this.pointerDownHandlerRef = null; + this.pointerUpHandlerRef = null; + this.pointerDownMap = new Map(); + this.pointerUpMap = new Map(); + } + + listenForPointerEvents(dotNetReference, elementId, options) { + if (!options) { + this.logger("options object is required but was not provided"); + return; + } + + if (options.enableLogging) { + this.logger = (msg, ...args) => console.log("[MudBlazor | PointerEventsNone]", msg, ...args); + } else { + this.logger = (msg, ...args) => { }; + } + + this.logger("Called listenForPointerEvents", { dotNetReference, elementId, options }); + + if (!dotNetReference) { + this.logger("dotNetReference is required but was not provided"); + return; + } + + if (!elementId) { + this.logger("elementId is required but was not provided"); + return; + } + + if (!options.subscribeDown && !options.subscribeUp) { + this.logger("No subscriptions added: both subscribeDown and subscribeUp are false"); + return; + } + + if (!this.dotnet) { + this.dotnet = dotNetReference; + } + + if (options.subscribeDown) { + this.logger("Subscribing to 'pointerdown' for element:", elementId); + this.pointerDownMap.set(elementId, options); + + if (!this.pointerDownHandlerRef) { + this.logger("Registering global 'pointerdown' event listener"); + this.pointerDownHandlerRef = this.pointerDownHandler.bind(this); + document.addEventListener("pointerdown", this.pointerDownHandlerRef, false); + } + } + + if (options.subscribeUp) { + this.logger("Subscribing to 'pointerup' events for element:", elementId); + this.pointerUpMap.set(elementId, options); + + if (!this.pointerUpHandlerRef) { + this.logger("Registering global 'pointerup' event listener"); + this.pointerUpHandlerRef = this.pointerUpHandler.bind(this); + document.addEventListener("pointerup", this.pointerUpHandlerRef, false); + } + } + } + + pointerDownHandler(event) { + this._handlePointerEvent(event, this.pointerDownMap, "RaiseOnPointerDown"); + } + + pointerUpHandler(event) { + this._handlePointerEvent(event, this.pointerUpMap, "RaiseOnPointerUp"); + } + + _handlePointerEvent(event, map, raiseMethod) { + if (map.size === 0) { + this.logger("No elements registered for", raiseMethod); + return; + } + + const elements = []; + for (const id of map.keys()) { + const element = document.getElementById(id); + if (element) { + elements.push(element); + } else { + this.logger("Element", id, "not found in DOM"); + } + } + + if (elements.length === 0) { + this.logger("None of the registered elements were found in the DOM for", raiseMethod); + return; + } + + // Set the pointer events of each element to auto so they are returned in the elementsFromPoint + elements.forEach(x => x.style.pointerEvents = "auto"); + + // Get the elements directly under the event + const elementsFromPoint = document.elementsFromPoint(event.clientX, event.clientY); + + // Reset the pointer events to none + elements.forEach(x => x.style.pointerEvents = "none"); + + const matchingIds = []; + + // Start checking the topmost element and work our way down + for (const element of elementsFromPoint) { + // If the element is not in the map then it should be treated + // as a blocking element, so we break the loop. + if (!element.id || !map.has(element.id)) { + break; + } + + matchingIds.push(element.id); + } + + if (matchingIds.length === 0) { + this.logger("No matching registered elements found under pointer for", raiseMethod); + return; + } + + this.logger("Raising", raiseMethod, "for matching element(s):", matchingIds); + this.dotnet.invokeMethodAsync(raiseMethod, matchingIds); + } + + cancelListener(elementId) { + if (!elementId) { + this.logger("cancelListener called with invalid elementId"); + return; + } + + const hadDown = this.pointerDownMap.delete(elementId); + const hadUp = this.pointerUpMap.delete(elementId); + + if (hadDown || hadUp) { + this.logger("Cancelled listener for element", elementId); + } else { + this.logger("No active listener found for element", elementId); + } + + if (this.pointerDownMap.size === 0 && this.pointerDownHandlerRef) { + this.logger("No more elements listening for 'pointerdown' โ€” removing global event listener"); + document.removeEventListener("pointerdown", this.pointerDownHandlerRef); + this.pointerDownHandlerRef = null; + } + + if (this.pointerUpMap.size === 0 && this.pointerUpHandlerRef) { + this.logger("No more elements listening for 'pointerup' โ€” removing global event listener"); + document.removeEventListener("pointerup", this.pointerUpHandlerRef); + this.pointerUpHandlerRef = null; + } + } + + dispose() { + if (!this.dotnet && !this.pointerDownHandlerRef && !this.pointerUpHandlerRef) { + this.logger("dispose() called but instance was already cleaned up"); + return; + } + + this.logger("Disposing"); + + if (this.pointerDownHandlerRef) { + this.logger("Removing global 'pointerdown' event listener"); + document.removeEventListener("pointerdown", this.pointerDownHandlerRef); + this.pointerDownHandlerRef = null; + } + if (this.pointerUpHandlerRef) { + this.logger("Removing global 'pointerup' event listener"); + document.removeEventListener("pointerup", this.pointerUpHandlerRef); + this.pointerUpHandlerRef = null; + } + + const downCount = this.pointerDownMap.size; + const upCount = this.pointerUpMap.size; + + if (downCount > 0) { + this.logger("Clearing", downCount, "element(s) from pointerDownMap"); + } + if (upCount > 0) { + this.logger("Clearing", upCount, "element(s) from pointerUpMap"); + } + + this.pointerDownMap.clear(); + this.pointerUpMap.clear(); + this.dotnet = null; + } +} + +window.mudPointerEventsNone = new MudPointerEventsNone(); \ No newline at end of file diff --git a/src/MudBlazor/TScripts/mudPopover.js b/src/MudBlazor/TScripts/mudPopover.js index 318c46e43e0c..23f84837ba22 100644 --- a/src/MudBlazor/TScripts/mudPopover.js +++ b/src/MudBlazor/TScripts/mudPopover.js @@ -3,7 +3,12 @@ // See the LICENSE file in the project root for more information. window.mudpopoverHelper = { + // set by the class MudPopover in initialize + mainContainerClass: null, + overflowPadding: 24, + flipMargin: 0, + // used for setting a debounce debounce: function (func, wait) { let timeout; return function executedFunction(...args) { @@ -16,19 +21,54 @@ window.mudpopoverHelper = { }; }, - rafThrottle: function (func) { - let ticking = false; - return function (...args) { - if (!ticking) { - window.requestAnimationFrame(() => { - func.apply(this, args); - ticking = false; - }); - ticking = true; - } - }; + basePopoverZIndex: parseInt(getComputedStyle(document.documentElement) + .getPropertyValue('--mud-zindex-popover')) || 1200, + + baseTooltipZIndex: parseInt(getComputedStyle(document.documentElement) + .getPropertyValue('--mud-zindex-tooltip')) || 1600, + + // static set of replacement values + flipClassReplacements: { + 'top': { + 'mud-popover-top-left': 'mud-popover-bottom-left', + 'mud-popover-top-center': 'mud-popover-bottom-center', + 'mud-popover-anchor-bottom-center': 'mud-popover-anchor-top-center', + 'mud-popover-top-right': 'mud-popover-bottom-right', + }, + 'left': { + 'mud-popover-top-left': 'mud-popover-top-right', + 'mud-popover-center-left': 'mud-popover-center-right', + 'mud-popover-anchor-center-right': 'mud-popover-anchor-center-left', + 'mud-popover-bottom-left': 'mud-popover-bottom-right', + }, + 'right': { + 'mud-popover-top-right': 'mud-popover-top-left', + 'mud-popover-center-right': 'mud-popover-center-left', + 'mud-popover-anchor-center-left': 'mud-popover-anchor-center-right', + 'mud-popover-bottom-right': 'mud-popover-bottom-left', + }, + 'bottom': { + 'mud-popover-bottom-left': 'mud-popover-top-left', + 'mud-popover-bottom-center': 'mud-popover-top-center', + 'mud-popover-anchor-top-center': 'mud-popover-anchor-bottom-center', + 'mud-popover-bottom-right': 'mud-popover-top-right', + }, + 'top-and-left': { + 'mud-popover-top-left': 'mud-popover-bottom-right', + }, + 'top-and-right': { + 'mud-popover-top-right': 'mud-popover-bottom-left', + }, + 'bottom-and-left': { + 'mud-popover-bottom-left': 'mud-popover-top-right', + }, + 'bottom-and-right': { + 'mud-popover-bottom-right': 'mud-popover-top-left', + }, + }, + // used to calculate the position of the popover calculatePopoverPosition: function (list, boundingRect, selfRect) { let top = 0; let left = 0; @@ -100,74 +140,30 @@ window.mudpopoverHelper = { } return { - top: top, left: left, offsetX: offsetX, offsetY: offsetY + top: top, left: left, offsetX: offsetX, offsetY: offsetY, anchorY: top, anchorX: left }; }, - flipClassReplacements: { - 'top': { - 'mud-popover-top-left': 'mud-popover-bottom-left', - 'mud-popover-top-center': 'mud-popover-bottom-center', - 'mud-popover-anchor-bottom-center': 'mud-popover-anchor-top-center', - 'mud-popover-top-right': 'mud-popover-bottom-right', - }, - 'left': { - 'mud-popover-top-left': 'mud-popover-top-right', - 'mud-popover-center-left': 'mud-popover-center-right', - 'mud-popover-anchor-center-right': 'mud-popover-anchor-center-left', - 'mud-popover-bottom-left': 'mud-popover-bottom-right', - }, - 'right': { - 'mud-popover-top-right': 'mud-popover-top-left', - 'mud-popover-center-right': 'mud-popover-center-left', - 'mud-popover-anchor-center-left': 'mud-popover-anchor-center-right', - 'mud-popover-bottom-right': 'mud-popover-bottom-left', - }, - 'bottom': { - 'mud-popover-bottom-left': 'mud-popover-top-left', - 'mud-popover-bottom-center': 'mud-popover-top-center', - 'mud-popover-anchor-top-center': 'mud-popover-anchor-bottom-center', - 'mud-popover-bottom-right': 'mud-popover-top-right', - }, - 'top-and-left': { - 'mud-popover-top-left': 'mud-popover-bottom-right', - }, - 'top-and-right': { - 'mud-popover-top-right': 'mud-popover-bottom-left', - }, - 'bottom-and-left': { - 'mud-popover-bottom-left': 'mud-popover-top-right', - }, - 'bottom-and-right': { - 'mud-popover-bottom-right': 'mud-popover-top-left', - }, - - }, - - flipMargin: 0, - - basePopoverZIndex: parseInt(getComputedStyle(document.documentElement) - .getPropertyValue('--mud-zindex-popover')) || 1200, - - baseTooltipZIndex: parseInt(getComputedStyle(document.documentElement) - .getPropertyValue('--mud-zindex-tooltip')) || 1600, - + // used to flip the popover using the flipClassReplacements, so we pass it the flip direction by selector + // with a list of classes and returns the proper flipped position for calculatePopoverPosition getPositionForFlippedPopver: function (inputArray, selector, boundingRect, selfRect) { const classList = []; + const replacementsList = {}; for (var i = 0; i < inputArray.length; i++) { const item = inputArray[i]; - const replacments = window.mudpopoverHelper.flipClassReplacements[selector][item]; - if (replacments) { - classList.push(replacments); + const replacements = window.mudpopoverHelper.flipClassReplacements[selector][item]; + if (replacements) { + replacementsList[item] = replacements; + classList.push(replacements); } else { classList.push(item); } } - return window.mudpopoverHelper.calculatePopoverPosition(classList, boundingRect, selfRect); }, + // primary positioning method placePopover: function (popoverNode, classSelector) { // parentNode is the calling element, mudmenu/tooltip/etc not the parent popover if it's a child popover // this happens at page load unless it's popover inside a popover, then it happens when you activate the parent @@ -176,41 +172,57 @@ window.mudpopoverHelper = { const id = popoverNode.id.substr(8); const popoverContentNode = document.getElementById('popovercontent-' + id); - if (!popoverContentNode) { - return; - } + // if the popover doesn't exist we stop + if (!popoverContentNode) return; + const classList = popoverContentNode.classList; - if (classList.contains('mud-popover-open') == false) { - return; - } + // if the popover isn't open we stop + if (!classList.contains('mud-popover-open')) return; - if (classSelector) { - if (classList.contains(classSelector) == false) { - return; - } - } + // if a classSelector was supplied and doesn't exist we stop + if (classSelector && !classList.contains(classSelector)) return; + + // Batch DOM reads let boundingRect = popoverNode.parentNode.getBoundingClientRect(); - // allow them to be changed after initial creation + const selfRect = popoverContentNode.getBoundingClientRect(); + const popoverNodeStyle = window.getComputedStyle(popoverNode); + const isPositionFixed = popoverNodeStyle.position === 'fixed'; + const isPositionOverride = classList.contains('mud-popover-position-override'); + const isRelativeWidth = classList.contains('mud-popover-relative-width'); + const isAdaptiveWidth = classList.contains('mud-popover-adaptive-width'); + const isFlipOnOpen = classList.contains('mud-popover-overflow-flip-onopen'); + const isFlipAlways = classList.contains('mud-popover-overflow-flip-always'); + const zIndexAuto = popoverNodeStyle.getPropertyValue('z-index') === 'auto'; + const classListArray = Array.from(classList); + + // calculate position based on opening anchor/transform + const position = window.mudpopoverHelper.calculatePopoverPosition(classListArray, boundingRect, selfRect); + let left = position.left; // X-coordinate of the popover + let top = position.top; // Y-coordinate of the popover + let offsetX = position.offsetX; // Horizontal offset of the popover + let offsetY = position.offsetY; // Vertical offset of the popover + let anchorY = position.anchorY; // Y-coordinate of the opening anchor + let anchorX = position.anchorX; // X-coordinate of the opening anchor + + // reset widths and allow them to be changed after initial creation popoverContentNode.style['max-width'] = 'none'; popoverContentNode.style['min-width'] = 'none'; - if (classList.contains('mud-popover-relative-width')) { + if (isRelativeWidth) { popoverContentNode.style['max-width'] = (boundingRect.width) + 'px'; } - else if (classList.contains('mud-popover-adaptive-width')) { + else if (isAdaptiveWidth) { popoverContentNode.style['min-width'] = (boundingRect.width) + 'px'; } - const selfRect = popoverContentNode.getBoundingClientRect(); - const classListArray = Array.from(classList); + // Reset max-height if it was previously set and anchor is in bounds + if (popoverContentNode.mudHeight && anchorY > 0 && anchorY < window.innerHeight) { + popoverContentNode.style.maxHeight = null; + popoverContentNode.mudHeight = null; + } - const postion = window.mudpopoverHelper.calculatePopoverPosition(classListArray, boundingRect, selfRect); - let left = postion.left; - let top = postion.top; - let offsetX = postion.offsetX; - let offsetY = postion.offsetY; // get the top/left/ from popoverContentNode if the popover has been hardcoded for position - if (classList.contains('mud-popover-position-override')) { + if (isPositionOverride) { left = parseInt(popoverContentNode.style['left']) || left; top = parseInt(popoverContentNode.style['top']) || top; // no offset when hardcoded @@ -227,7 +239,7 @@ window.mudpopoverHelper = { }; } // flipping logic - if (classList.contains('mud-popover-overflow-flip-onopen') || classList.contains('mud-popover-overflow-flip-always')) { + if (isFlipOnOpen || isFlipAlways) { const appBarElements = document.getElementsByClassName("mud-appbar mud-appbar-fixed-top"); let appBarOffset = 0; @@ -235,124 +247,235 @@ window.mudpopoverHelper = { appBarOffset = appBarElements[0].getBoundingClientRect().height; } - const graceMargin = window.mudpopoverHelper.flipMargin; - const deltaToLeft = left + offsetX; - const deltaToRight = window.innerWidth - left - selfRect.width; - const deltaTop = top - selfRect.height - appBarOffset; - const spaceToTop = top - appBarOffset; - const deltaBottom = window.innerHeight - top - selfRect.height; - //console.log('self-width: ' + selfRect.width + ' | self-height: ' + selfRect.height); - //console.log('left: ' + deltaToLeft + ' | rigth:' + deltaToRight + ' | top: ' + deltaTop + ' | bottom: ' + deltaBottom + ' | spaceToTop: ' + spaceToTop); - + // mudPopoverFliped is the flip direction for first flip on flip - onopen popovers let selector = popoverContentNode.mudPopoverFliped; + // flip routine off transform origin, sets selector to an axis to flip on if needed if (!selector) { + const popoverHeight = popoverContentNode.offsetHeight; + const popoverWidth = popoverContentNode.offsetWidth; + // For mud-popover-top-left + if (classList.contains('mud-popover-top-left')) { - if (deltaBottom < graceMargin && deltaToRight < graceMargin && spaceToTop >= selfRect.height && deltaToLeft >= selfRect.width) { + // Space available in current direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; // Space below the anchor + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; // Space to the right of the anchor + + // Space available in opposite direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceBelow && spaceAbove > spaceBelow; + const shouldFlipHorizontal = popoverWidth > spaceRight && spaceLeft > spaceRight; + // Apply flips based on space comparisons + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'top-and-left'; - } else if (deltaBottom < graceMargin && spaceToTop >= selfRect.height) { + } + else if (shouldFlipVertical) { selector = 'top'; - } else if (deltaToRight < graceMargin && deltaToLeft >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'left'; } - } else if (classList.contains('mud-popover-top-center')) { - if (deltaBottom < graceMargin && spaceToTop >= selfRect.height) { + } + + // For mud-popover-top-center + else if (classList.contains('mud-popover-top-center')) { + // Space available in current direction vs opposite direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + + // Only flip if popover exceeds available space AND there's more space in opposite direction + if (popoverHeight > spaceBelow && spaceAbove > spaceBelow) { selector = 'top'; } - } else if (classList.contains('mud-popover-top-right')) { - if (deltaBottom < graceMargin && deltaToLeft < graceMargin && spaceToTop >= selfRect.height && deltaToRight >= selfRect.width) { + } + + // For mud-popover-top-right + else if (classList.contains('mud-popover-top-right')) { + // Space available in current direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Space available in opposite direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceBelow && spaceAbove > spaceBelow; + const shouldFlipHorizontal = popoverWidth > spaceLeft && spaceRight > spaceLeft; + + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'top-and-right'; - } else if (deltaBottom < graceMargin && spaceToTop >= selfRect.height) { + } + else if (shouldFlipVertical) { selector = 'top'; - } else if (deltaToLeft < graceMargin && deltaToRight >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'right'; } } + // For mud-popover-center-left else if (classList.contains('mud-popover-center-left')) { - if (deltaToRight < graceMargin && deltaToLeft >= selfRect.width) { + // Space available in current vs opposite direction + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + if (popoverWidth > spaceRight && spaceLeft > spaceRight) { selector = 'left'; } } + + // For mud-popover-center-right else if (classList.contains('mud-popover-center-right')) { - if (deltaToLeft < graceMargin && deltaToRight >= selfRect.width) { + // Space available in current vs opposite direction + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + if (popoverWidth > spaceLeft && spaceRight > spaceLeft) { selector = 'right'; } } + + // For mud-popover-bottom-left else if (classList.contains('mud-popover-bottom-left')) { - if (deltaTop < graceMargin && deltaToRight < graceMargin && deltaBottom >= 0 && deltaToLeft >= selfRect.width) { + // Space available in current direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + // Space available in opposite direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceAbove && spaceBelow > spaceAbove; + const shouldFlipHorizontal = popoverWidth > spaceRight && spaceLeft > spaceRight; + + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'bottom-and-left'; - } else if (deltaTop < graceMargin && deltaBottom >= 0) { + } + else if (shouldFlipVertical) { selector = 'bottom'; - } else if (deltaToRight < graceMargin && deltaToLeft >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'left'; } - } else if (classList.contains('mud-popover-bottom-center')) { - if (deltaTop < graceMargin && deltaBottom >= 0) { + } + + // For mud-popover-bottom-center + else if (classList.contains('mud-popover-bottom-center')) { + // Space available in current vs opposite direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + + if (popoverHeight > spaceAbove && spaceBelow > spaceAbove) { selector = 'bottom'; } - } else if (classList.contains('mud-popover-bottom-right')) { - if (deltaTop < graceMargin && deltaToLeft < graceMargin && deltaBottom >= 0 && deltaToRight >= selfRect.width) { + } + + // For mud-popover-bottom-right + else if (classList.contains('mud-popover-bottom-right')) { + // Space available in current direction + const spaceAbove = anchorY - window.mudpopoverHelper.flipMargin; + const spaceLeft = anchorX - window.mudpopoverHelper.flipMargin; + + // Space available in opposite direction + const spaceBelow = window.innerHeight - anchorY - window.mudpopoverHelper.flipMargin; + const spaceRight = window.innerWidth - anchorX - window.mudpopoverHelper.flipMargin; + + // Check if popover exceeds available space AND if opposite side has more space + const shouldFlipVertical = popoverHeight > spaceAbove && spaceBelow > spaceAbove; + const shouldFlipHorizontal = popoverWidth > spaceLeft && spaceRight > spaceLeft; + + if (shouldFlipVertical && shouldFlipHorizontal) { selector = 'bottom-and-right'; - } else if (deltaTop < graceMargin && deltaBottom >= 0) { + } + else if (shouldFlipVertical) { selector = 'bottom'; - } else if (deltaToLeft < graceMargin && deltaToRight >= selfRect.width) { + } + else if (shouldFlipHorizontal) { selector = 'right'; } } + } + // selector is set in above if statement if it needs to flip if (selector && selector != 'none') { const newPosition = window.mudpopoverHelper.getPositionForFlippedPopver(classListArray, selector, boundingRect, selfRect); left = newPosition.left; top = newPosition.top; offsetX = newPosition.offsetX; offsetY = newPosition.offsetY; - popoverContentNode.setAttribute('data-mudpopover-flip', 'flipped'); + popoverContentNode.setAttribute('data-mudpopover-flip', selector); } else { - // did not flip, ensure the left and top are inside bounds - // appbaroffset is another section - if (left + offsetX < 0 && // it's starting left of the screen - Math.abs(left + offsetX) < selfRect.width) { // it's not starting so far left the entire box would be hidden - left = Math.max(0, left + offsetX); - // set offsetX to 0 to avoid double offset - offsetX = 0; - } + popoverContentNode.removeAttribute('data-mudpopover-flip'); + } - // will be covered by appbar so adjust zindex with appbar as parent - if (top + offsetY < appBarOffset && - appBarElements.length > 0) { - this.updatePopoverZIndex(popoverContentNode, appBarElements[0]); - //console.log(`top: ${top} | offsetY: ${offsetY} | total: ${top + offsetY} | appBarOffset: ${appBarOffset}`); + if (isFlipOnOpen) { // store flip direction on open so it's not recalculated + if (!popoverContentNode.mudPopoverFliped) { + popoverContentNode.mudPopoverFliped = selector || 'none'; } + } - if (top + offsetY < 0 && // it's starting above the screen - Math.abs(top + offsetY) < selfRect.height) { // it's not starting so far above the entire box would be hidden - top = Math.max(0, top + offsetY); - // set offsetY to 0 to avoid double offset - offsetY = 0; - } + // ensure the left is inside bounds + if (left + offsetX < window.mudpopoverHelper.overflowPadding && // it's starting left of the screen + Math.abs(left + offsetX) < selfRect.width) { // it's not starting so far left the entire box would be hidden + left = window.mudpopoverHelper.overflowPadding; + // set offsetX to 0 to avoid double offset + offsetX = 0; + } - // if it contains a mud-list set that mud-list max-height to be the remaining size on screen - const list = popoverContentNode.querySelector('.mud-list'); - const listPadding = 24; - const listMaxHeight = (window.innerHeight - top - offsetY); - // is list defined and does the list calculated height exceed the listmaxheight - if (list && list.offsetHeight > listMaxHeight) { - list.style.maxHeight = (listMaxHeight - listPadding) + 'px'; - } - popoverContentNode.removeAttribute('data-mudpopover-flip'); + // ensure the top is inside bounds + if (top + offsetY < window.mudpopoverHelper.overflowPadding && // it's starting above the screen + boundingRect.top >= 0 && // the popoverNode is still on screen + Math.abs(top + offsetY) < selfRect.height) { // it's not starting so far above the entire box would be hidden + top = window.mudpopoverHelper.overflowPadding; + // set offsetY to 0 to avoid double offset + offsetY = 0; } - if (classList.contains('mud-popover-overflow-flip-onopen')) { - if (!popoverContentNode.mudPopoverFliped) { - popoverContentNode.mudPopoverFliped = selector || 'none'; + // will be covered by appbar so adjust zindex with appbar as parent + if (top + offsetY < appBarOffset && + appBarElements.length > 0) { + this.updatePopoverZIndex(popoverContentNode, appBarElements[0]); + } + + const firstChild = popoverContentNode.firstElementChild; + + // adjust the popover position/maxheight if it or firstChild does not have a max-height set (even if set to 'none') + // exceeds the bounds and doesn't have a max-height set by the user + // maxHeight adjustments stop the minute popoverNode is no longer inside the window + // Check if max-height is set on popover or firstChild + const hasMaxHeight = popoverContentNode.style.maxHeight != '' || (firstChild && firstChild.style.maxHeight != ''); + + if (!hasMaxHeight) { + // in case of a reflow check it should show from top properly + let shouldShowFromTop = false; + // calculate new max height if it exceeds bounds + let newMaxHeight = window.innerHeight - top - offsetY - window.mudpopoverHelper.overflowPadding; // downwards + // moving upwards + if (top + offsetY < anchorY || top + offsetY == window.mudpopoverHelper.overflowPadding) { + shouldShowFromTop = true; + newMaxHeight = anchorY - window.mudpopoverHelper.overflowPadding; + } + + // if calculated height exceeds the new maxheight + if (popoverContentNode.offsetHeight > newMaxHeight) { + if (shouldShowFromTop) { // adjust top to show from top + top = window.mudpopoverHelper.overflowPadding; + offsetY = 0; + } + popoverContentNode.style.maxHeight = (newMaxHeight) + 'px'; + popoverContentNode.mudHeight = "setmaxheight"; } } } - if (window.getComputedStyle(popoverNode).position == 'fixed') { + if (isPositionFixed) { popoverContentNode.style['position'] = 'fixed'; } else if (!classList.contains('mud-popover-fixed')) { @@ -360,7 +483,7 @@ window.mudpopoverHelper = { offsetY += window.scrollY } - if (classList.contains('mud-popover-position-override')) { + if (isPositionOverride) { // no offset if popover position is hardcoded offsetX = 0; offsetY = 0; @@ -371,90 +494,74 @@ window.mudpopoverHelper = { // update z-index by sending the calling popover to update z-index, // and the parentnode of the calling popover (not content parent) - //console.log(popoverContentNode, popoverNode.parentNode); this.updatePopoverZIndex(popoverContentNode, popoverNode.parentNode); - if (window.getComputedStyle(popoverNode).getPropertyValue('z-index') != 'auto') { - popoverContentNode.style['z-index'] = Math.max(window.getComputedStyle(popoverNode).getPropertyValue('z-index'), popoverContentNode.style['z-index']); + if (!zIndexAuto) { + popoverContentNode.style['z-index'] = Math.max(popoverNodeStyle.getPropertyValue('z-index'), popoverContentNode.style['z-index']); popoverContentNode.skipZIndex = true; } + + // adjust overlays as needed with new zindex + window.mudpopoverHelper.popoverOverlayUpdates(); } else { //console.log(`popoverNode: ${popoverNode} ${popoverNode ? popoverNode.parentNode : ""}`); } }, - popoverScrollListener: function (node) { - let currentNode = node.parentNode; - while (currentNode) { - //console.log(currentNode); - const isScrollable = - (currentNode.scrollHeight > currentNode.clientHeight) || // Vertical scroll - (currentNode.scrollWidth > currentNode.clientWidth); // Horizontal scroll - if (isScrollable) { - //console.log("scrollable"); - currentNode.addEventListener('scroll', () => { - //console.log("scrolled"); - window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-fixed'); - window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-overflow-flip-always'); - }); - } - // Stop if we reach the body, or head - if (currentNode.tagName === "BODY") { - break; - } - currentNode = currentNode.parentNode; - } - }, - + // cycles through popovers to reposition those that are open, classSelector is passed on placePopoverByClassSelector: function (classSelector = null) { var items = window.mudPopover.getAllObservedContainers(); - for (let i = 0; i < items.length; i++) { const popoverNode = document.getElementById('popover-' + items[i]); window.mudpopoverHelper.placePopover(popoverNode, classSelector); } }, + // used in the initial placement of a popover placePopoverByNode: function (target) { const id = target.id.substr(15); const popoverNode = document.getElementById('popover-' + id); window.mudpopoverHelper.placePopover(popoverNode); }, + // returns the count of providers countProviders: function () { - return document.querySelectorAll(".mud-popover-provider").length; + return document.querySelectorAll(`.${window.mudpopoverHelper.mainContainerClass}`).length; }, + // sets popoveroverlay to the right z-index updatePopoverOverlay: function (popoverContentNode) { // tooltips don't have an overlay - if (popoverContentNode.classList.contains("mud-tooltip")) { + if (!popoverContentNode || popoverContentNode.classList.contains("mud-tooltip")) { return; } // set any associated overlay to equal z-index - const provider = popoverContentNode.closest('.mud-popover-provider'); + const provider = popoverContentNode.closest(`.${window.mudpopoverHelper.mainContainerClass}`); if (provider && popoverContentNode.classList.contains("mud-popover")) { - const overlay = provider.querySelector('.mud-overlay'); + const overlay = provider.querySelector('.mud-overlay'); // skip any overlay marked with mud-skip-overlay if (overlay && !overlay.classList.contains('mud-skip-overlay-positioning')) { // Only assign z-index if it doesn't already exist or has changed - if (popoverContentNode && overlay.style['z-index'] !== popoverContentNode.style['z-index']) { - overlay.style['z-index'] = popoverContentNode.style['z-index']; + const popoverContentNodeZindex = Number(popoverContentNode.style['z-index'] || 0); + const overlayZindex = Number(overlay.style['z-index'] || 0); + if (popoverContentNodeZindex > overlayZindex) { + overlay.style['z-index'] = popoverContentNodeZindex; } - } } }, + // set zindex order, popoverContentNode is the calling popover, parentNode is the node to compare to updatePopoverZIndex: function (popoverContentNode, parentNode) { - // find the first parent mud-popover if it exists + // find the first parent mud-popover if it exists (nested popovers) const parentPopover = parentNode.closest('.mud-popover'); - const parentOfPopover = popoverContentNode.parentNode; + const popoverNode = document.getElementById('popover-' + popoverContentNode.id.substr(15)); // get --mud-zindex-popover from root let newZIndex = window.mudpopoverHelper.basePopoverZIndex + 1; const origZIndex = parseInt(popoverContentNode.style['z-index']) || 1; const contentZIndex = popoverContentNode.style['z-index']; - // normal nested position update + // normal nested position update parentPopover is a parent with .mud-popover so nested for sure if (parentPopover) { // get parent popover z-index const computedStyle = window.getComputedStyle(parentPopover); @@ -467,17 +574,7 @@ window.mudpopoverHelper = { } popoverContentNode.style['z-index'] = newZIndex; } - // nested popover inside any other child element - else if (parentOfPopover) { - const computedStyle = window.getComputedStyle(parentOfPopover); - const tooltipZIndexValue = computedStyle.getPropertyValue('z-index'); - if (tooltipZIndexValue !== 'auto') { - newZIndex = parseInt(tooltipZIndexValue) + 1; - } - popoverContentNode.style['z-index'] = Math.max(newZIndex, window.mudpopoverHelper.baseTooltipZIndex + 1, origZIndex); - } - // tooltip container update - // (it's not technically a nested popover but when nested inside popover components it doesn't set zindex properly) + // tooltip container update, so the node it's being compared to is a tooltip else if (parentNode && parentNode.classList.contains("mud-tooltip-root")) { const computedStyle = window.getComputedStyle(parentNode); const tooltipZIndexValue = computedStyle.getPropertyValue('z-index'); @@ -496,11 +593,95 @@ window.mudpopoverHelper = { } popoverContentNode.style['z-index'] = newZIndex; } + // nested popover inside any other child element + else if (popoverNode.parentNode) { + const baseZIndexValue = window.mudpopoverHelper.getEffectiveZIndex(popoverNode.parentNode); + newZIndex = baseZIndexValue + 1; + popoverContentNode.style['z-index'] = Math.max(newZIndex, window.mudpopoverHelper.basePopoverZIndex + 1, origZIndex); + } // if popoverContentNode.style['z-index'] is not set or set lower than minimum set it to default popover zIndex else if (!contentZIndex || parseInt(contentZIndex) < 1) { popoverContentNode.style['z-index'] = newZIndex; } }, + + getEffectiveZIndex: function (element) { + let currentElement = element; + let maxZIndex = 0; + // navigate up the body reciording z-index until document.body + while (currentElement && currentElement !== document.body) { + if (currentElement.nodeType !== 1) { // 1 is an element node + currentElement = currentElement.parentElement; + continue; + } + + const style = window.getComputedStyle(currentElement); + const position = style.getPropertyValue('position'); + + if (position === 'static') { // static elements have no z-index + currentElement = currentElement.parentElement; + continue; + } + + const zIndex = style.getPropertyValue('z-index'); + const zIndexValue = parseInt(zIndex, 10); + + // update maxZIndex only if zIndexValue is defined and greater than current max + if (!isNaN(zIndexValue) && zIndexValue > maxZIndex) { + maxZIndex = zIndexValue; + } + + currentElement = currentElement.parentElement; + } + + return maxZIndex; + }, + + popoverOverlayUpdates: function () { + let highestTickItem = null; + let highestTickValue = -1; + + const parentNode = document.querySelector(`.${window.mudpopoverHelper.mainContainerClass}`); + if (!parentNode || !parentNode.children) { return; } + // Traverse children of target.parentNode that contain the class "mud-popover" + for (const child of parentNode.children) { + if (child && child.classList && child.classList.contains("mud-popover-open")) { + const tickValue = Number(child.getAttribute("data-ticks")) || 0; + + if (tickValue > highestTickValue) { + highestTickValue = tickValue; + highestTickItem = child; + } + } + } + if (highestTickItem) { + const isNested = highestTickItem.classList.contains('mud-popover-nested'); + if (!isNested) { + window.mudpopoverHelper.updatePopoverOverlay(highestTickItem); + } + } + }, + + // adds scroll listeners to node + parents up to body + popoverScrollListener: function (node) { + let currentNode = node.parentNode; + const scrollableElements = []; + while (currentNode) { + const isScrollable = + (currentNode.scrollHeight > currentNode.clientHeight) || // Vertical scroll + (currentNode.scrollWidth > currentNode.clientWidth); // Horizontal scroll + if (isScrollable) { + currentNode.addEventListener('scroll', window.mudpopoverHelper.handleScroll, { passive: true }); + scrollableElements.push(currentNode); + } + // Stop if we reach the body, or head + if (currentNode.tagName === "BODY") { + break; + } + currentNode = currentNode.parentNode; + } + return scrollableElements; + }, } class MudPopover { @@ -508,210 +689,276 @@ class MudPopover { constructor() { this.map = {}; this.contentObserver = null; - this.mainContainerClass = null; } - callback(id, mutationsList, observer) { - for (const mutation of mutationsList) { - if (mutation.type === 'attributes') { - const target = mutation.target - if (mutation.attributeName == 'class') { - if (target.classList.contains('mud-popover-overflow-flip-onopen') && - target.classList.contains('mud-popover-open') == false) { - target.mudPopoverFliped = null; - target.removeAttribute('data-mudpopover-flip'); - } + createObservers(id) { + // this is the origin of the popover in the dom, it can be nested inside another popover's content + // e.g. the filter popover for datagrid, this would be the inside of where the mudpopover was placed + // popoverNode.parentNode is it's immediate parent or the actual element in the above example + const popoverNode = document.getElementById('popover-' + id); - window.mudpopoverHelper.placePopoverByNode(target); - } - else if (mutation.attributeName == 'data-ticks') { - // data-ticks are important for Direction and Location, it doesn't reposition - // if they aren't there - const tickAttribute = target.getAttribute('data-ticks'); - - const tickValues = []; - let max = -1; - if (parent && parent.children) { - for (let i = 0; i < parent.children.length; i++) { - const childNode = parent.children[i]; - const tickValue = parseInt(childNode.getAttribute('data-ticks')); - if (tickValue == 0) { - continue; - } - - if (tickValues.indexOf(tickValue) >= 0) { - continue; - } - - tickValues.push(tickValue); - - if (tickValue > max) { - max = tickValue; - } - } - } + // this is the content node in the provider regardless of the RenderFragment that exists when the popover is active + const popoverContentNode = document.getElementById('popovercontent-' + id); - // Iterate over the items in this.map to reset any open overlays - let highestTickItem = null; - let highestTickValue = -1; - // Iterate over the items in this.map to find the highest data-ticks value - for (const mapItem of Object.values(this.map)) { - const popoverContentNode = mapItem.popoverContentNode; - if (popoverContentNode) { - const tickValue = Number(popoverContentNode.getAttribute('data-ticks')); // Convert to Number - - if (tickValue > highestTickValue) { - highestTickValue = tickValue; - highestTickItem = popoverContentNode; - } + if (popoverNode && popoverNode.parentNode && popoverContentNode) { + // add a resize observer to catch resize events + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + const target = entry.target; + for (const childNode of target.childNodes) { + if (childNode.id && childNode.id.startsWith('popover-')) { + window.mudpopoverHelper.debouncedResize(); } } + } + }); - if (highestTickItem) { - window.mudpopoverHelper.updatePopoverOverlay(highestTickItem); - } + resizeObserver.observe(popoverNode.parentNode); - if (tickValues.length == 0) { - continue; - } + // Add scroll event listeners to the content node and its parents up to the Body + const scrollableElements = window.mudpopoverHelper.popoverScrollListener(popoverNode); - const sortedTickValues = tickValues.sort((x, y) => x - y); - // z-index calculation not used here - continue; - for (let i = 0; i < parent.children.length; i++) { - const childNode = parent.children[i]; - const tickValue = parseInt(childNode.getAttribute('data-ticks')); - if (tickValue == 0) { - continue; - } + // Store all references needed for later cleanup + this.map[id].scrollableElements = scrollableElements; + this.map[id].parentResizeObserver = resizeObserver; + + } else { + console.warn(`Could not connect observers to popover with ID ${id}: One or more required elements not found`); + } + } - if (childNode.skipZIndex == true) { - continue; - } - const newIndex = window.mudpopoverHelper.basePopoverZIndex + sortedTickValues.indexOf(tickValue) + 3; - childNode.style['z-index'] = newIndex; - } + disposeObservers(id) { + // Get references to items that need cleanup + const { scrollableElements, parentResizeObserver } = this.map[id]; + + // 1. Remove scroll event listeners from all scrollable parent elements + if (scrollableElements && Array.isArray(scrollableElements)) { + scrollableElements.forEach(element => { + if (element && typeof element.removeEventListener === 'function') { + element.removeEventListener('scroll', window.mudpopoverHelper.handleScroll); } + }); + } + + // 2. Disconnect any resize observers + if (parentResizeObserver && typeof parentResizeObserver.disconnect === 'function') { + parentResizeObserver.disconnect(); + } + + // 3. Clear references to allow garbage collection + this.map[id].scrollableElements = null; + this.map[id].parentResizeObserver = null; + } + + callbackPopover(mutation) { + // good viewertests to check anytime you make a change + // DrawerDialogSelectTest, OverlayNestedFreezeTest, OverlayDialogTest, PopoverDataGridFilterOptionsTest + // TooltipNotRemovedTest (performance), PopoverFlipDirectionTest (flip test) + const target = mutation.target; + if (!target) return; + const id = target.id.substr(15); + if (mutation.type == 'attributes' && mutation.attributeName == 'class') { + if (target.classList.contains('mud-popover-open')) { + // setup for an open popover and create observers + if (this.map[id] && !this.map[id].isOpened) { + this.map[id].isOpened = true; + } + // create observers for this popover (resizeObserver and scroll Listeners) + this.createObservers(id); + + // reposition popover individually + window.mudpopoverHelper.placePopoverByNode(target); + } + else { + // tell the map that this popover is closed + if (this.map[id] && this.map[id].isOpened) { + this.map[id].isOpened = false; + } + // wait this long until we "move it off screen" + const delay = parseFloat(target.style['transition-duration']) || 0; + if (delay == 0) { + // remove left and top styles + target.style.removeProperty('left'); + target.style.removeProperty('top'); + } + else { + setTimeout(() => { + if (this.map[id] && this.map[id].isOpened) return; // in case it's reopened before the timeout is over + if (target && !target.classList.contains('mud-popover-open')) { + target.style.removeProperty('left'); + target.style.removeProperty('top'); + } + }, delay); + } + // reset flip status + target.mudPopoverFliped = null; + target.removeAttribute('data-mudpopover-flip'); + + // Remove individual observers and listeners that might exist + this.disposeObservers(id); + // reposition overlays as needed + window.mudpopoverHelper.popoverOverlayUpdates(); + } + } + else if (mutation.type == 'attributes' && mutation.attributeName == 'data-ticks') { + // when data-ticks attribute is the mutation something has changed with the popover + // and it needs to be repositioned and shown, note we don't use mud-popover-open here + // instead we use data-ticks since we know the newest data-ticks > 0 is the top most. + const tickAttribute = target.getAttribute('data-ticks'); + // data ticks is not 0 so let's reposition the popover and overlay + + if (tickAttribute > 0 && target.parentNode && this.map[id] && this.map[id].isOpened && + target.parentNode.classList.contains(window.mudpopoverHelper.mainContainerClass)) { + // reposition popover individually + window.mudpopoverHelper.placePopoverByNode(target); } } } - initialize(containerClass, flipMargin) { + initialize(containerClass, flipMargin, overflowPadding) { + // only happens when the PopoverService is created which happens on application start and anytime the service might crash const mainContent = document.getElementsByClassName(containerClass); if (mainContent.length == 0) { + console.error(`No Popover Container found with class ${containerClass}`); return; } + // store options from PopoverOptions in mudpopoverHelper + window.mudpopoverHelper.mainContainerClass = containerClass; + window.mudpopoverHelper.overflowPadding = overflowPadding; if (flipMargin) { window.mudpopoverHelper.flipMargin = flipMargin; } + // create a single observer to watch all popovers in the provider + const provider = mainContent[0]; + + // options to observe for + const config = { + attributes: true, // only observe attributes + subtree: true, // all descendants of popover + attributeFilter: ['data-ticks','class'] // limit to just data-ticks and class changes + }; - this.mainContainerClass = containerClass; + // Dispose of any existing observer before creating a new one + if (this.contentObserver) { + this.contentObserver.disconnect(); + this.contentObserver = null; + } - if (!mainContent[0].mudPopoverMark) { - mainContent[0].mudPopoverMark = "mudded"; - if (this.contentObserver != null) { - this.contentObserver.disconnect(); - this.contentObserver = null; + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // if it's direct parent is the provider + // and contains the class mud-popover + if (mutation.target.parentNode === provider && mutation.target.classList.contains('mud-popover')) { + this.callbackPopover(mutation); + } } + }); - this.contentObserver = new ResizeObserver(entries => { - window.mudpopoverHelper.placePopoverByClassSelector(); - }); + observer.observe(provider, config); + // store it so we can dispose of it properly + this.contentObserver = observer; - this.contentObserver.observe(mainContent[0]); - } + // setup event listeners + window.addEventListener('resize', window.mudpopoverHelper.debouncedResize, { passive: true }); + window.addEventListener('scroll', window.mudpopoverHelper.handleScroll, { passive: true }); } + /** + * Connects a popover element to the system, setting up all necessary event listeners and observers + * @param {string} id - The ID of the popover to connect + */ connect(id) { - this.initialize(this.mainContainerClass); + // this happens when a popover is created in the dom (not necessarily displayed) + // Ensure we're not creating duplicate listeners for the same ID + if (this.map[id]) { + this.disconnect(id); + } + // this is the origin of the popover in the dom, it can be nested inside another popover's content + // e.g. the filter popover for datagrid, this would be the inside of where the mudpopover was placed + // popoverNode.parentNode is it's immediate parent or the actual element in the above example const popoverNode = document.getElementById('popover-' + id); - mudpopoverHelper.popoverScrollListener(popoverNode); - const popoverContentNode = document.getElementById('popovercontent-' + id); - if (popoverNode && popoverNode.parentNode && popoverContentNode) { - - window.mudpopoverHelper.placePopover(popoverNode); - const config = { attributeFilter: ['class', 'data-ticks'] }; - - const observer = new MutationObserver(this.callback.bind(this, id)); - - observer.observe(popoverContentNode, config); - - const resizeObserver = new ResizeObserver(entries => { - for (let entry of entries) { - const target = entry.target; - for (const childNode of target.childNodes) { - if (childNode.id && childNode.id.startsWith('popover-')) { - window.mudpopoverHelper.placePopover(childNode); - } - } - } - }); - - resizeObserver.observe(popoverNode.parentNode); - - const contentNodeObserver = new ResizeObserver(entries => { - for (let entry of entries) { - const target = entry.target; - if (target) - window.mudpopoverHelper.placePopoverByNode(target); - } - }); + // this is the content node in the provider regardless of the RenderFragment that exists when the popover is active + const popoverContentNode = document.getElementById('popovercontent-' + id); - contentNodeObserver.observe(popoverContentNode); + // queue a resize event so we ensure if this popover started opened or nested it will be positioned correctly + window.mudpopoverHelper.debouncedResize(); - this.map[id] = { - popoverContentNode: popoverContentNode, - mutationObserver: observer, - resizeObserver: resizeObserver, - contentNodeObserver: contentNodeObserver - }; - } + // Store all references needed for later cleanup + this.map[id] = { + popoverContentNode: popoverContentNode, + scrollableElements: null, + parentResizeObserver: null, + isOpened: false + }; } + /** + * Disconnects a popover element, properly cleaning up all event listeners and observers + * @param {string} id - The ID of the popover to disconnect + */ disconnect(id) { - if (this.map[id]) { + if (!this.map[id]) { + return; // Nothing to disconnect + } + + try { + // 1. Remove individual observers and listeners that might exist + this.disposeObservers(id); - const item = this.map[id] - item.mutationObserver.disconnect(); - item.resizeObserver.disconnect(); - item.contentNodeObserver.disconnect(); + // 2. Clear final reference to allow garbage collection + this.map[id].popoverContentNode = null; + // 3. Remove this entry from the map delete this.map[id]; + } catch (error) { + console.error(`Error disconnecting popover with ID ${id}:`, error); } } + /** + * Disposes all resources used by this MudPopover instance + * Should be called when the component is being unmounted + */ dispose() { - for (var i in this.map) { - disconnect(i); - } + try { + // 1. Disconnect all popovers + const ids = Object.keys(this.map); + for (const id of ids) { + this.disconnect(id); + } - this.contentObserver.disconnect(); - this.contentObserver = null; - } + // 2. Ensure map is empty + this.map = {}; - getAllObservedContainers() { - const result = []; - for (var i in this.map) { - result.push(i); + // 3. Disconnect the content observer + if (this.contentObserver) { + this.contentObserver.disconnect(); + this.contentObserver = null; + } + + // 4. Remove global event listeners (handled outside this class, listed here for reference) + window.removeEventListener('resize', window.mudpopoverHelper.debouncedResize); + window.removeEventListener('scroll', window.mudpopoverHelper.handleScroll); + } catch (error) { + console.error("Error disposing MudPopover:", error); } + } - return result; + getAllObservedContainers() { + return Object.keys(this.map); } } -window.mudPopover = new MudPopover(); - -const debouncedResize = window.mudpopoverHelper.debounce(() => { +window.mudpopoverHelper.debouncedResize = window.mudpopoverHelper.debounce(() => { window.mudpopoverHelper.placePopoverByClassSelector(); -}, 100); +}, 25); -const throttledScroll = window.mudpopoverHelper.rafThrottle(() => { +window.mudpopoverHelper.handleScroll = function () { window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-fixed'); window.mudpopoverHelper.placePopoverByClassSelector('mud-popover-overflow-flip-always'); -}); +}; -window.addEventListener('resize', debouncedResize, { passive: true }); -window.addEventListener('scroll', throttledScroll, { passive: true }); \ No newline at end of file +window.mudPopover = new MudPopover(); \ No newline at end of file diff --git a/src/MudBlazor/TScripts/mudScrollManager.js b/src/MudBlazor/TScripts/mudScrollManager.js index a8bc11d6aef4..b8c71099e7ad 100644 --- a/src/MudBlazor/TScripts/mudScrollManager.js +++ b/src/MudBlazor/TScripts/mudScrollManager.js @@ -3,6 +3,10 @@ // See the LICENSE file in the project root for more information. class MudScrollManager { + constructor() { + this._lockCount = 0; // internal tracking for the # of overlay locks + } + //scrolls to year in MudDatePicker scrollToYear(elementId, offset) { let element = document.getElementById(elementId); @@ -54,27 +58,27 @@ class MudScrollManager { //locks the scroll of the selected element. Default is body lockScroll(selector, lockclass) { - let element = document.querySelector(selector) || document.body; + if (this._lockCount === 0) { + const element = document.querySelector(selector) || document.body; - //if the body doesn't have a scroll bar, don't add the lock class with padding - let hasScrollBar = window.innerWidth > document.body.clientWidth; + //if the body doesn't have a scroll bar, don't add the lock class with padding + const hasScrollBar = window.innerWidth > document.body.clientWidth; + const classToAdd = hasScrollBar ? lockclass : lockclass + "-no-padding"; - if (hasScrollBar) { - element.classList.add(lockclass); - } else { - let lockClassNoPadding = lockclass + "-no-padding"; - element.classList.add(lockClassNoPadding); + element.classList.add(classToAdd); } - + this._lockCount++; } //unlocks the scroll. Default is body unlockScroll(selector, lockclass) { - let element = document.querySelector(selector) || document.body; - - // remove both lock classes to be sure it's unlocked - element.classList.remove(lockclass); - element.classList.remove(lockclass + "-no-padding"); + this._lockCount = Math.max(0, this._lockCount - 1); // subtract 1 or stop at 0 + if (this._lockCount === 0) { + const element = document.querySelector(selector) || document.body; + // remove both lock classes to be sure it's unlocked + element.classList.remove(lockclass); + element.classList.remove(lockclass + "-no-padding"); + } } }; window.mudScrollManager = new MudScrollManager(); \ No newline at end of file