diff --git a/.github/workflows/build-core-lib.yml b/.github/workflows/build-core-lib.yml index 664bcdc733..c4df47cbc7 100644 --- a/.github/workflows/build-core-lib.yml +++ b/.github/workflows/build-core-lib.yml @@ -46,7 +46,7 @@ jobs: - name: Setup .NET 9.0 uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 9.0.203 # Waiting a fix in the 9.0.300: https://github.com/dotnet/sdk/issues/49038 dotnet-quality: ga # Build diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/DebugPages/TreeViewDebug.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/DebugPages/TreeViewDebug.razor new file mode 100644 index 0000000000..826a8fef86 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/DebugPages/TreeViewDebug.razor @@ -0,0 +1,83 @@ +@page "/treeview/debug" + +

TreeView Debug

+ +
+ SelectedId = @SelectedId; +
+ +
+ Expanded1 = @Expanded1; + Expanded2 = @Expanded2; + Expanded3 = @Expanded3; + Expanded4 = @Expanded4; +
+ + + + + + + + + Sub Item + + + + Sub Item + + + + ExpandOnly + Sub Item + + + + Sub Item + + + +Other section + + + +@code +{ + string SelectedId = "test-2"; + bool Expanded1 = true; + bool Expanded2 = false; + bool Expanded3 = true; + bool Expanded4 = false; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/SampleDataExtension.cs b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/SampleDataExtension.cs new file mode 100644 index 0000000000..4205115f4f --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/SampleDataExtension.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.FluentUI.AspNetCore.Components; +using FluentUI.Demo.SampleData; +using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons; + +namespace FluentUI.Demo.Client.Documentation.Components.TreeView.Examples; + +public static class SampleDataExtension +{ + /// + /// Converts a collection of objects into a hierarchical collection + /// of objects. + /// + public static IEnumerable ToTreeViewItems(this IEnumerable organization, bool includeIcons = false) + { + return organization.Select(company => new TreeViewItem + { + IconStart = includeIcons ? new Icons.Regular.Size16.BuildingBank().WithColor(Color.Primary) : null, + Id = company.Id, + Text = company.Name, + Items = company.Departments.Select(dept => new TreeViewItem + { + IconStart = includeIcons ? new Icons.Regular.Size16.ContactCardGroup().WithColor(SystemColors.Palette.DarkOrangeForeground1) : null, + Id = dept.Id, + Text = dept.Name, + Items = dept.Employees.Select(emp => new TreeViewItem + { + IconStart = includeIcons ? new Icons.Regular.Size16.PersonVoice().WithColor(SystemColors.Palette.ForestBorderActive) : null, + Id = emp.Id, + Text = $"{emp.FirstName} {emp.LastName}", + }).ToArray() + }).ToArray() + }).ToArray(); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewDefault.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewDefault.razor new file mode 100644 index 0000000000..10b67dd5f9 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewDefault.razor @@ -0,0 +1,22 @@ +

Current selected tree item is @SelectedId - @SelectedItem?.Text

+

Most recently expanded/collapsed tree item is @ExpandedItem?.Text

+ + + + + + + + + + + + + +@code { + string? SelectedId; + FluentTreeItem? SelectedItem; + FluentTreeItem? ExpandedItem; +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewItemTemplate.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewItemTemplate.razor new file mode 100644 index 0000000000..3a23de1753 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewItemTemplate.razor @@ -0,0 +1,34 @@ +
+ Selected item: @SelectedItem?.Text +
+ + + + + @context.Text + + + +@code +{ + private ITreeViewItem? SelectedItem; + private IEnumerable? Items = new List(); + + // Read the Tree content and set the selected item + protected override void OnInitialized() + { + Items = GetCompanyOrganization(); + SelectedItem = Items?.ElementAt(3); + } + + // Example of a tree with a company organization + // (5 companies containing 4 departments with 10 employees) + private TreeViewItem[] GetCompanyOrganization() + { + return SampleData.People + .GetOrganization(companyCount: 5, departmentCount: 4, employeeCount: 10) + .ToTreeViewItems() + .ToArray(); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewItems.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewItems.razor new file mode 100644 index 0000000000..6d4e136378 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewItems.razor @@ -0,0 +1,29 @@ +
+ Selected item: @SelectedItem?.Text +
+ + + +@code +{ + private ITreeViewItem? SelectedItem; + private IEnumerable? Items = new List(); + + // Read the Tree content and set the selected item + protected override void OnInitialized() + { + Items = GetCompanyOrganization(); + SelectedItem = Items?.ElementAt(3); + } + + // Example of a tree with a company organization + // (5 companies containing 4 departments with 10 employees) + private TreeViewItem[] GetCompanyOrganization() + { + return SampleData.People + .GetOrganization(companyCount: 5, departmentCount: 4, employeeCount: 10) + .ToTreeViewItems() + .ToArray(); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewMultiSelect.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewMultiSelect.razor new file mode 100644 index 0000000000..88db22a885 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewMultiSelect.razor @@ -0,0 +1,32 @@ +
+ Selected items: @(string.Join("; ", SelectedItems?.Select(i => i.Text) ?? [])) +
+ + + + +@code +{ + private IEnumerable? SelectedItems; + private IEnumerable? Items = new List(); + + // Read the Tree content + protected override void OnInitialized() + { + Items = GetCompanyOrganization(); + SelectedItems = Items.Take(2); + } + + // Example of a tree with a company organization + // (5 companies containing 4 departments with 10 employees) + private TreeViewItem[] GetCompanyOrganization() + { + return SampleData.People + .GetOrganization(companyCount: 5, departmentCount: 4, employeeCount: 10) + .ToTreeViewItems(includeIcons: false) + .ToArray(); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewMultipleSelectionVisibility.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewMultipleSelectionVisibility.razor new file mode 100644 index 0000000000..3aa276eeb9 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewMultipleSelectionVisibility.razor @@ -0,0 +1,47 @@ +
+ Selected items: @(string.Join("; ", SelectedItems?.Select(i => i.Text) ?? [])) +
+ + + + +@code +{ + private IEnumerable? SelectedItems; + private IEnumerable? Items = new List(); + + // Read the Tree content + protected override void OnInitialized() + { + Items = GetCompanyOrganization(); + } + + // Example of a custom visibility function + private TreeSelectionVisibility GetTreeSelectionVisibility(ITreeViewItem item) + { + return item.Id.First() switch + { + // Company or Department => collapsed checkbox + 'C' => TreeSelectionVisibility.Collapse, + 'D' => TreeSelectionVisibility.Hidden, + + // Employee or others => visible checkbox + 'E' => TreeSelectionVisibility.Visible, + _ => TreeSelectionVisibility.Visible + }; + } + + // Example of a tree with a company organization + // (5 companies containing 4 departments with 10 employees) + private TreeViewItem[] GetCompanyOrganization() + { + return SampleData.People + .GetOrganization(companyCount: 5, departmentCount: 4, employeeCount: 10) + .ToTreeViewItems(includeIcons: false) + .ToArray(); + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewWithUnlimitedItems.razor b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewWithUnlimitedItems.razor new file mode 100644 index 0000000000..e936f5ff81 --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/Examples/TreeViewWithUnlimitedItems.razor @@ -0,0 +1,49 @@ +
+ Selected item: @SelectedItem?.Text +
+ + + +@code +{ + private ITreeViewItem? SelectedItem; + private IEnumerable? Items = new List(); + + protected override async Task OnInitializedAsync() + { + Items = await GetItemsAsync(); + } + + // Generate a random number of items + // Including a "Fake" sub-item to simulate the [+] + private async Task> GetItemsAsync() + { + await Task.Delay(300); // Simulate a delay for loading items + + var nbItems = Random.Shared.Next(3, 9); + + return Enumerable.Range(1, nbItems) + .Select(i => new TreeViewItem() + { + Text = $"Item {Random.Shared.Next(1, 9999)}", + OnExpandedAsync = OnExpandedAsync, + Items = TreeViewItem.LoadingTreeViewItems("Loading..."), // "Fake" sub-item to simulate the [+] + }).ToArray(); + } + + // Handle the expanded event to load items + private async Task OnExpandedAsync(TreeViewItemExpandedEventArgs e) + { + if (e.Expanded) + { + e.CurrentItem.Items = await GetItemsAsync(); + } + else + { + // Remove sub-items and add a "Fake" item to simulate the [+] + e.CurrentItem.Items = TreeViewItem.LoadingTreeViewItems("Loading..."); + } + } +} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/FluentTreeView.md b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/FluentTreeView.md new file mode 100644 index 0000000000..2be8fd18fc --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/Components/TreeView/FluentTreeView.md @@ -0,0 +1,141 @@ + +--- +title: TreeView +route: /TreeView +--- + +# TreeView + +A hierarchical list structure component for displaying data in a collapsible and expandable way. + +Use this component when you need to present your users with a clear visual structure of content or data, +allowing them to efficiently interact and navigate through the information. +If the information is less hierarchical or node-based, consider using a list or table instead. + +When using this component, it is possible not to select an item. +However, once an item has been selected, it is mandatory to keep an item selected. +If you no longer want to select anything, you must do so by code: `SelectedId = null`. + +It is not possible to select multiple items at once. However, a custom development (see below) can be implemented to allow the selection of multiple items. + +You can create a Tree manually by nesting `FluentTreeItem` components or by using the `Items` property of `FluentTreeView` to dynamically generate a tree from a list of objects. +If you use the `Items` property, you can also set the `ItemTemplate` property to specify how each item should be displayed. +With these two ways of creating a tree, using the `Text` parameter will display the text of the element, adding an ellipsis `...` if the text is too long. +Each element also has the properties `IconStart`, `IconEnd`, and `IconAside` to display an icon to the left, right, or at the end of the text. + +> [!NOTE] +> When a user clicks on an item, it is selected and expanded/collapsed if children are present. +> It is not possible to open an item without selecting it (except by using the keyboard, pressing the `Enter` or `Space` key) +> or using the `Mutiple` selection feature (see below). + +## Manual TreeView + +In this example, we create a tree manually by nesting `FluentTreeItem` components. + +Using `SelectedId` parameter, we can select an item by passing its `Id` to the `FluentTreeView` component. +When this parameter is bound to a variable, the selected item will be highlighted for other usages. + +Using `CurrentSelected` parameter, we can select an item by passing its `FluentTreeItem` to the `FluentTreeView` component. +When this parameter is bound to a variable, the selected item will be highlighted for other usages. + +The event `OnExpandedChanged` is triggered when the user expands or collapses an item. + +{{ TreeViewDefault }} + +## Items parameter + +In this example, we create a tree dynamically by using the `Items` property of `FluentTreeView`. +The `Items` parameter is a list of [**TreeViewItem**](/TreeView#class-treeviewitem) that represent the items in the tree. + +When a user selects an item, the `SelectedItem` parameter is updated with the `ITreeViewItem` of the selected item. + +{{ TreeViewItems }} + +## ItemTemplate + +Using an ItemTemplate, we can specify how each item should be displayed. +`context` is the current item `ITreeViewItem` in the loop, and we can use its properties to display the item. + +```razor + + + @context.Text + +``` + +{{ TreeViewItemTemplate }} + +**Note**: +In some situation, the tree item elements may not catch the click event to select the item. +To avoid this, you can use the `Style="pointer-events: none;"` property to disable the pointer events on the element +and let the click event pass through to the parent element. + +## Unlimited Items + +If you have a very large number of items, you can use the `LazyLoadItems` parameter. +This parameter tells the component to load items only when the node is expanded. +Once the node is closed, the items are removed from the DOM and are not displayed. + +{{ TreeViewWithUnlimitedItems }} + +## Mutliple Selection + +The `FluentTreeView` component supports the multiple selection of items using the `SelectionMode` parameter. +When this parameter is set to `TreeSelectionMode.Multiple`, a checkbox is displayed next to each item. + +Each time the user clicks on an item, the checkbox is checked or unchecked, and the parameter `SelectedItems` +is updated with the list of selected items. + +We recommand to set the `HideSelection` parameter to `true` to hide the default selection of the item when the `MultiSelect` +parameter is set. This is more user-friendly and allows the user to see the selected items more clearly. + +> [!NOTE] +> This **Multiple Selection** feature is only available when the `Items` parameter is used to generate the tree. + +{{ TreeViewMultiSelect }} + + +## Mutliple Selection with customized checkbox visibility + +You can customize the visibility of the checkbox using the `MultipleSelectionVisibility` parameter. +This function allows you to show, hide (keeping the space) or hide and remove the checkbox, based on each `ITreeViewItem` objects. + +In this example, we use the `GetTreeSelectionVisibility` function to determine the visibility of the checkbox based +on the first letter of the `Id` of the item. The result is a TreeView with a checkbox only for the `Employee` items (level 3). + +```csharp +TreeSelectionVisibility GetTreeSelectionVisibility(ITreeViewItem item) +{ + return item.Id.First() switch + { + // Company or Department => collapsed checkbox + 'C' => TreeSelectionVisibility.Collapse, + 'D' => TreeSelectionVisibility.Hidden, + + // Employee or others => visible checkbox + 'E' => TreeSelectionVisibility.Visible, + _ => TreeSelectionVisibility.Visible + }; +} +``` + +{{ TreeViewMultipleSelectionVisibility }} + +We don't have a possibility to customize the type of checkbox used in the `FluentTreeView` component. +For example, if you want to use a mixed checkbox, you can use the `ItemTemplate` part to create your own checkbox logic. + +## API FluentTreeView + +{{ API Type=FluentTreeView }} + +## API FluentTreeItem + +{{ API Type=FluentTreeItem }} + +## Class TreeViewItem + +{{ API Type=TreeViewItem Properties=All }} + +## Migrating to v5 + +{{ INCLUDE File=MigrationFluentTreeView }} diff --git a/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentTreeView.md b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentTreeView.md new file mode 100644 index 0000000000..cee711207e --- /dev/null +++ b/examples/Demo/FluentUI.Demo.Client/Documentation/GetStarted/Migration/MigrationFluentTreeView.md @@ -0,0 +1,22 @@ +--- +title: Migration FluentTreeView +route: /Migration/TreeView +hidden: true +--- + +### New properties + `Appearance`, `HideSelection`, `MultipleSelectionVisibility`, + `SelectedId`, `SelectedItems`, `SelectionMode`, `Size` are new properties. + +### Removed properties💥 + - The `RenderCollapsedNodes` property has been removed. + Use `LazyLoadItems` instead. + + - The `FluentTreeItem.InitiallyExpanded` property has been removed. You can use the `FluentTreeItem.Expanded` parameter instead. + This parameter is now a two-way binding, so you can use it to control the expanded state of the item. + + - The `FluentTreeItem.InitiallySelected` property has been removed. You can use the `FluentTreeView.SelectedId` or `FluentTreeView.SelectedItem`parameter instead. + These parametere are now a two-way binding. + The main reason is than the FluentTreeView supports only one selected item at a time (except using the `SelectionMode.Multiple` mode). + + - The `FluentTreeItem.Disabled` property has been removed. The underline webcomponent does not support this property. diff --git a/examples/Tools/FluentUI.Demo.SampleData/People.cs b/examples/Tools/FluentUI.Demo.SampleData/People.cs index 2085ba9685..139c1728e6 100644 --- a/examples/Tools/FluentUI.Demo.SampleData/People.cs +++ b/examples/Tools/FluentUI.Demo.SampleData/People.cs @@ -149,4 +149,71 @@ public record Person(string Id, string FirstName, string LastName, bool Male, st /// public int Age => DateTime.Today.Year - BirthDay.Year; } + + /// + /// Definition of an Employee + /// + /// Id + /// First name + /// Last name + /// Job title + public record Employee(string Id, string FirstName, string LastName, string JobTitle) { } + + /// + /// Definition of a Department + /// + /// Department Id + /// Department name + /// List of employees + public record Department(string Id, string Name, IEnumerable Employees) { } + + /// + /// Definition of a Company + /// + /// Id + /// Company name + /// List of departments + public record Company(string Id, string Name, IEnumerable Departments) { }; + + /// + /// Generates a list of companies with random data. + /// + /// Number of companies + /// Number of departments + /// Number of employees + /// + public static IEnumerable GetOrganization(int companyCount, int departmentCount, int employeeCount) + { + var companies = new List(companyCount); + + for (var i = 0; i < companyCount; i++) + { + var companyId = "C" + RandomNumber(1000, 9999).ToString(CultureInfo.InvariantCulture); + var companyName = Companies[Random.Next(Companies.Length)]; + + var departments = new List(departmentCount); + for (var j = 0; j < departmentCount; j++) + { + var departmentId = "D" + RandomNumber(100, 999).ToString(CultureInfo.InvariantCulture); + var departmentName = Departments[Random.Next(Departments.Length)]; + + var employees = new List(employeeCount); + for (var k = 0; k < employeeCount; k++) + { + var employeeId = "E" + RandomNumber(10000, 99999).ToString(CultureInfo.InvariantCulture); + var firstName = FirstNames[Random.Next(FirstNames.Length)]; + var lastName = LastNames[Random.Next(LastNames.Length)]; + var jobTitle = JobTitles[Random.Next(JobTitles.Length)]; + + employees.Add(new Employee(employeeId, firstName, lastName, jobTitle)); + } + + departments.Add(new Department(departmentId, departmentName, employees)); + } + + companies.Add(new Company(companyId, companyName, departments)); + } + + return companies; + } } diff --git a/spelling.dic b/spelling.dic index fc18c0dec5..ef868a5175 100644 --- a/spelling.dic +++ b/spelling.dic @@ -39,6 +39,7 @@ muhenkan myid noattribute nonfile +onchange ondialogbeforetoggle ondialogtoggle ondropdownchange diff --git a/src/Core.Scripts/package-lock.json b/src/Core.Scripts/package-lock.json index 663419ecd5..688db8d511 100644 --- a/src/Core.Scripts/package-lock.json +++ b/src/Core.Scripts/package-lock.json @@ -8,7 +8,7 @@ "name": "microsoft.fluentui.aspnetcore.components.assets", "license": "ISC", "dependencies": { - "@fluentui/web-components": "3.0.0-beta.89" + "@fluentui/web-components": "3.0.0-beta.99" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^7.6.0", @@ -486,9 +486,9 @@ } }, "node_modules/@fluentui/web-components": { - "version": "3.0.0-beta.89", - "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@fluentui/web-components/-/web-components-3.0.0-beta.89.tgz", - "integrity": "sha1-KF8b8aZxs89FVR1VJ6XpI/CHKno=", + "version": "3.0.0-beta.99", + "resolved": "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/@fluentui/web-components/-/web-components-3.0.0-beta.99.tgz", + "integrity": "sha1-fgPZRWISrU+AQs9yJOxCRKndJ1w=", "dependencies": { "@fluentui/tokens": "1.0.0-alpha.21", "@microsoft/fast-web-utilities": "^6.0.0", diff --git a/src/Core.Scripts/package.json b/src/Core.Scripts/package.json index 408df7e36e..ad27271030 100644 --- a/src/Core.Scripts/package.json +++ b/src/Core.Scripts/package.json @@ -25,6 +25,6 @@ "typescript": "^5.4.4" }, "dependencies": { - "@fluentui/web-components": "3.0.0-beta.89" + "@fluentui/web-components": "3.0.0-beta.99" } } diff --git a/src/Core.Scripts/src/FluentUICustomEvents.ts b/src/Core.Scripts/src/FluentUICustomEvents.ts index 2ffccad047..dda3cf10e7 100644 --- a/src/Core.Scripts/src/FluentUICustomEvents.ts +++ b/src/Core.Scripts/src/FluentUICustomEvents.ts @@ -1,3 +1,5 @@ +import { Microsoft as FluentTreeView } from './Components/TreeView/FluentTreeView'; + export namespace Microsoft.FluentUI.Blazor.FluentUICustomEvents { /** @@ -92,6 +94,33 @@ export namespace Microsoft.FluentUI.Blazor.FluentUICustomEvents { }); } + export function TreeView(blazor: Blazor) { + + // Event when an element is selected or deselected + blazor.registerCustomEventType('treechanged', { + browserEventName: 'change', + createEventArgs: (event: EventType) => { + return { + id: event.target.id, + selected: event.target.selected, + }; + } + }); + + // Event when an element is expanded or collapsed + blazor.registerCustomEventType('treetoggle', { + browserEventName: 'toggle', + createEventArgs: (event: any) => { + return { + id: event.target.id, + type: event.type, + oldState: event.detail?.oldState ?? event.oldState, + newState: event.detail?.newState ?? event.newState, + }; + } + }); + } + // [^^^ Add your other custom events before this line ^^^] } diff --git a/src/Core.Scripts/src/FluentUIWebComponents.ts b/src/Core.Scripts/src/FluentUIWebComponents.ts index d164e16f41..c46c4912b0 100644 --- a/src/Core.Scripts/src/FluentUIWebComponents.ts +++ b/src/Core.Scripts/src/FluentUIWebComponents.ts @@ -12,7 +12,6 @@ export namespace Microsoft.FluentUI.Blazor.FluentUIWebComponents { // To generate these definitions, run the `_ExtractWebComponents.ps1` file // and paste the output here. - FluentUIComponents.accordionDefinition.define(registry); FluentUIComponents.accordionItemDefinition.define(registry); FluentUIComponents.AnchorButtonDefinition.define(registry); @@ -55,6 +54,8 @@ export namespace Microsoft.FluentUI.Blazor.FluentUIWebComponents { FluentUIComponents.TextInputDefinition.define(registry); FluentUIComponents.ToggleButtonDefinition.define(registry); FluentUIComponents.TooltipDefinition.define(registry); + FluentUIComponents.TreeDefinition.define(registry); + FluentUIComponents.TreeItemDefinition.define(registry); } /** diff --git a/src/Core.Scripts/src/Startup.ts b/src/Core.Scripts/src/Startup.ts index 492aa904be..d43f1ab9be 100644 --- a/src/Core.Scripts/src/Startup.ts +++ b/src/Core.Scripts/src/Startup.ts @@ -54,6 +54,7 @@ export namespace Microsoft.FluentUI.Blazor.Startup { FluentUICustomEvents.MenuItem(blazor); FluentUICustomEvents.DropdownList(blazor); FluentUICustomEvents.Tabs(blazor); + FluentUICustomEvents.TreeView(blazor); // [^^^ Add your other custom events before this line ^^^] // Finishing diff --git a/src/Core.Scripts/src/d-ts/Blazor.d.ts b/src/Core.Scripts/src/d-ts/Blazor.d.ts index dfd370f939..ff0d70e2d3 100644 --- a/src/Core.Scripts/src/d-ts/Blazor.d.ts +++ b/src/Core.Scripts/src/d-ts/Blazor.d.ts @@ -11,5 +11,4 @@ interface Blazor { setLightTheme(): void, setDarkTheme(): void, } - } diff --git a/src/Core/Components/Base/FluentSlot.cs b/src/Core/Components/Base/FluentSlot.cs index d80c628884..45da56ad68 100644 --- a/src/Core/Components/Base/FluentSlot.cs +++ b/src/Core/Components/Base/FluentSlot.cs @@ -97,4 +97,14 @@ public static class FluentSlot /// Slot for the title action elements (e.g. Close button). When the dialog type is set to non-modal and no title action is provided, a default title action button is rendered. /// internal const string DialogTitleAction = "title-action"; + + /// + /// Slot for the expanded/collapsed element of a fluent-tree-item. + /// + internal const string Chevron = "chevron"; + + /// + /// Slot for the right-side element of a fluent-tree-item. + /// + internal const string Aside = "aside"; } diff --git a/src/Core/Components/TreeView/FluentTreeItem.razor b/src/Core/Components/TreeView/FluentTreeItem.razor new file mode 100644 index 0000000000..682630db66 --- /dev/null +++ b/src/Core/Components/TreeView/FluentTreeItem.razor @@ -0,0 +1,85 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + + + + @* Expandable icons: _toggleTreeItem(this) manages expand/collapse, which does not work by default in the webcomponent *@ + @if (IconCollapsed is not null || IconExpanded is not null) + { + + @if (IconCollapsed is not null) + { + + @(IconExpanded is null ? DefaultIcon_Expanded : null) + } + + @if (IconExpanded is not null) + { + + @(IconCollapsed is null ? DefaultIcon_Collapsed : null) + } + + } + + @* Text *@ + @if (!string.IsNullOrEmpty(Text)) + { + + @Text + + } + + @* Custom content and sub-items *@ + @ChildContent + + @* Items *@ + @if (OwnerTreeView is not null && Items is not null) + { + if (OwnerTreeView.LazyLoadItems && Items.Any() && !Expanded) + { + @* Lazy loading required a "fake" sub-item to simulate the [+] *@ + @Localizer[Localization.LanguageResource.TreeItem_LoadingMessage] + } + else + { + foreach (var item in Items) + { + @FluentTreeItem.GetFluentTreeItem(OwnerTreeView, item) + } + } + } + + @* Start *@ + @if (IconStart is not null) + { + + + + } + + @* End *@ + @if (IconEnd is not null) + { + + + + } + + @* Aside *@ + @if (IconAside is not null) + { + + + + } + diff --git a/src/Core/Components/TreeView/FluentTreeItem.razor.cs b/src/Core/Components/TreeView/FluentTreeItem.razor.cs new file mode 100644 index 0000000000..74bd50d91c --- /dev/null +++ b/src/Core/Components/TreeView/FluentTreeItem.razor.cs @@ -0,0 +1,371 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a tree item in a . +/// +public partial class FluentTreeItem : FluentComponentBase +{ + private const string DefaultIcon_CommonSvgAttributes = "width=\"12\" height=\"12\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" slot=\"chevron\""; + private const string DefaultIcon_CommonSvgPath = ""; + private static readonly MarkupString DefaultIcon_Expanded = new($" {DefaultIcon_CommonSvgPath}"); + private static readonly MarkupString DefaultIcon_Collapsed = new($"{DefaultIcon_CommonSvgPath}"); + + /// + public FluentTreeItem() + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .AddStyle("--tree-item-height", Height, when: () => !string.IsNullOrEmpty(Height)) + .Build(); + + /// + [CascadingParameter] + internal FluentTreeView? OwnerTreeView { get; set; } + + /// + /// Gets or sets the size of the tree item. + /// Default is . + /// + [Parameter] + public TreeSize? Size { get; set; } + + /// + /// Gets or sets the height of the tree item. + /// + [Parameter] + public string? Height { get; set; } + + /// + /// Gets or sets the appearance of the tree item. + /// Default is . + /// + [Parameter] + public TreeAppearance? Appearance { get; set; } + + /// + /// Gets or sets the list of sub-items to bind to the tree item + /// + [Parameter] + public IEnumerable? Items { get; set; } + + /// + /// Gets or sets the text of the tree item. + /// If this text is too long, it will be truncated with ellipsis. + /// + [Parameter] + public string? Text { get; set; } + + /// + /// Gets or sets the content to be rendered inside the component. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the displayed at the start of item content. + /// We recommend using an icon size of 16px. + /// + [Parameter] + public Icon? IconStart { get; set; } + + /// + /// Gets or sets the displayed at the end of item content. + /// We recommend using an icon size of 16px. + /// + [Parameter] + public Icon? IconEnd { get; set; } + + /// + /// Gets or sets the displayed at the "far end" of item content. + /// We recommend using an icon size of 16px. + /// + [Parameter] + public Icon? IconAside { get; set; } + + /// + /// Gets or sets the displayed to indicate the tree item is collapsed. + /// If this icon is not set, the will be used. + /// We recommend using an icon size of 16px. + /// + [Parameter] + public Icon? IconCollapsed { get; set; } + + /// + /// Gets or sets the displayed to indicate the tree item is expanded. + /// If this icon is not set, the will be used. + /// A 90-degree rotation effect is applied to the icon. + /// Please select an icon that will look correct after rotation. + /// We recommend using an icon size of 16px. + /// + [Parameter] + public Icon? IconExpanded { get; set; } + + /// + /// Returns if the tree item is expanded, + /// and if collapsed. + /// + [Parameter] + public bool Expanded { get; set; } + + /// + /// Called whenever changes. + /// + [Parameter] + public EventCallback ExpandedChanged { get; set; } + + /// + /// Called whenever the selected item changes. + /// + [Parameter] + public EventCallback SelectedChanged { get; set; } + + /// + /// Gets the item associated with the current element, based on its Id. + /// + public ITreeViewItem? Item => OwnerTreeView is null || OwnerTreeView.Items is null + ? null + : TreeViewItem.FindItemById(OwnerTreeView.Items, Id); + + /// + private bool IsSelected => string.CompareOrdinal(OwnerTreeView?.SelectedId, Id) == 0 || + string.CompareOrdinal(OwnerTreeView?.SelectedItem?.Id, Id) == 0; + + /// + protected override void OnInitialized() + { + if (OwnerTreeView is not null && !string.IsNullOrEmpty(Id)) + { + OwnerTreeView.InternalItems.TryAdd(Id, this); + } + } + + /// + internal async Task OnTreeChangedAsync(TreeItemChangedEventArgs args) + { + if (!string.Equals(Id, args.Id, StringComparison.Ordinal)) + { + return; + } + + // Selected? + var isSelected = args.Selected; + + // Update the state + if (SelectedChanged.HasDelegate) + { + await SelectedChanged.InvokeAsync(isSelected); + } + + // Update the FluentTree owner (only to inform the new selected item + if (OwnerTreeView is not null && isSelected) + { + // SelectedIdChanged + if (OwnerTreeView.SelectedIdChanged.HasDelegate && + !string.Equals(OwnerTreeView.SelectedId, Id, StringComparison.Ordinal)) + { + await OwnerTreeView.SelectedIdChanged.InvokeAsync(Id); + } + + // CurrentSelectedItem + if (OwnerTreeView.CurrentSelectedChanged.HasDelegate && + OwnerTreeView.CurrentSelected != this) + { + await OwnerTreeView.CurrentSelectedChanged.InvokeAsync(this); + } + + // SelectedItem + if (OwnerTreeView.Items is not null && + OwnerTreeView.SelectedItemChanged.HasDelegate) + { + var selectedItem = TreeViewItem.FindItemById(OwnerTreeView.Items, Id); + + if (OwnerTreeView.SelectedItem != selectedItem) + { + await OwnerTreeView.SelectedItemChanged.InvokeAsync(selectedItem); + } + } + + // OnSelectedChanged + if (OwnerTreeView.OnSelectedChanged.HasDelegate) + { + await OwnerTreeView.OnSelectedChanged.InvokeAsync(this); + } + } + } + + /// + internal async Task OnTreeToggleAsync(TreeItemToggleEventArgs args) + { + // Only for the correct TreeItem + if (!string.Equals(Id, args.Id, StringComparison.Ordinal)) + { + return; + } + + // Expanded? + var isExpanded = string.Equals(args.NewState, "open", StringComparison.Ordinal); + + // Update the state + if (isExpanded != Expanded) + { + Expanded = isExpanded; + + if (ExpandedChanged.HasDelegate) + { + await InvokeAsync(async () => await ExpandedChanged.InvokeAsync(isExpanded)); + } + } + + // Update the FluentTree owner + if (OwnerTreeView is not null && OwnerTreeView.OnExpandedChanged.HasDelegate) + { + await InvokeAsync(async () => await OwnerTreeView.OnExpandedChanged.InvokeAsync(this)); + } + } + + /// + internal async Task OnCheckChangedHandlerAsync() + { + var checkedItem = TreeViewItem.FindItemById(OwnerTreeView?.Items, Id); + + if (OwnerTreeView is null || checkedItem is null) + { + return; + } + + var selectedItems = OwnerTreeView.SelectedItems?.ToList() ?? []; + var isSelected = selectedItems.Contains(checkedItem); + + if (isSelected) + { + selectedItems.Remove(checkedItem); + } + else + { + selectedItems.Add(checkedItem); + } + + if (OwnerTreeView.SelectedItemsChanged.HasDelegate) + { + await OwnerTreeView.SelectedItemsChanged.InvokeAsync(selectedItems); + } + } + + /// + /// Renders a FluentTreeItem component, using a + /// + /// + /// + /// + internal static RenderFragment GetFluentTreeItem(FluentTreeView owner, ITreeViewItem item) + { + RenderFragment fluentTreeItem = builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(Id), item.Id); + builder.AddAttribute(2, nameof(Items), item.Items); + builder.AddAttribute(3, nameof(Text), owner.ItemTemplate is null && owner.SelectionMode == TreeSelectionMode.Single ? item.Text : null); + builder.AddAttribute(4, nameof(Expanded), item.Expanded); + builder.AddAttribute(5, nameof(IconStart), item.IconStart); + builder.AddAttribute(6, nameof(IconEnd), item.IconEnd); + builder.AddAttribute(7, nameof(IconAside), item.IconAside); + builder.AddAttribute(8, nameof(IconCollapsed), item.IconCollapsed); + builder.AddAttribute(9, nameof(IconExpanded), item.IconExpanded); + + AddFluentTreeItemChildContent(builder, owner, item); + + builder.AddAttribute(11, nameof(ExpandedChanged), EventCallback.Factory.Create(owner, async expanded => + { + item.Expanded = expanded; + if (item.OnExpandedAsync != null) + { + await item.OnExpandedAsync(new TreeViewItemExpandedEventArgs(item, expanded)); + } + })); + + builder.CloseComponent(); + }; + + return fluentTreeItem; + } + + /// + private static void AddFluentTreeItemChildContent(RenderTreeBuilder builder, FluentTreeView owner, ITreeViewItem item) + { + switch (owner.SelectionMode) + { + case TreeSelectionMode.Single: + if (owner.ItemTemplate != null) + { + builder.AddAttribute(10, nameof(ChildContent), owner.ItemTemplate.Invoke(item)); + } + + break; + + case TreeSelectionMode.Multiple: + builder.AddAttribute(10, nameof(ChildContent), (RenderFragment)(childBuilder => + { + var visibility = owner.MultipleSelectionVisibility?.Invoke(item) ?? TreeSelectionVisibility.Visible; + + // Checkbox + switch (visibility) + { + // Visible + case TreeSelectionVisibility.Visible: + childBuilder.OpenElement(0, "fluent-checkbox"); + childBuilder.AddAttribute(1, "checked", owner.SelectedItems?.Contains(item) == true ? "true" : null); + childBuilder.AddAttribute(2, "onchange", EventCallback.Factory.Create(owner, async e => + { + // Call the handler on the FluentTreeItem instance + var fluentTreeItem = owner.InternalItems.TryGetValue(item.Id, out var ti) ? ti : null; + if (fluentTreeItem != null) + { + await fluentTreeItem.OnCheckChangedHandlerAsync(); + } + })); + childBuilder.AddAttribute(3, "tabindex", -1); + childBuilder.CloseElement(); + break; + + // Hidden + case TreeSelectionVisibility.Hidden: + childBuilder.OpenElement(0, "div"); + childBuilder.AddAttribute(1, "class", "hidden-checkbox"); + childBuilder.CloseElement(); + break; + } + + // Content + childBuilder.AddContent(4, owner.ItemTemplate?.Invoke(item) ?? (RenderFragment)(builder2 => builder2.AddContent(0, item.Text))); + })); + + break; + } + } + + /// + public override ValueTask DisposeAsync() + { + if (OwnerTreeView is not null && !string.IsNullOrEmpty(Id)) + { + OwnerTreeView.InternalItems.TryRemove(Id, out _); + } + + return base.DisposeAsync(); + } +} diff --git a/src/Core/Components/TreeView/FluentTreeItem.razor.css b/src/Core/Components/TreeView/FluentTreeItem.razor.css new file mode 100644 index 0000000000..a238eaa6bd --- /dev/null +++ b/src/Core/Components/TreeView/FluentTreeItem.razor.css @@ -0,0 +1,53 @@ +fluent-tree-item > span[slot="chevron"], +fluent-tree-item > span[slot="start"]:has(svg), +fluent-tree-item > span[slot="end"]:has(svg), +fluent-tree-item > span[slot="aside"]:has(svg) { + display: flex; +} + +fluent-tree-item[expanded] > span[slot="chevron"] > svg[collapsed] { + display: none; +} + +fluent-tree-item:not([expanded]) > span[slot="chevron"] > svg[expanded] { + display: none; +} + +fluent-tree-item span[part="text"] { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +fluent-tree-item::part(positioning-region) { + height: var(--tree-item-height, var(--spacingVerticalXXXL)); +} + +fluent-tree-item[size="small"]::part(positioning-region) { + height: var(--tree-item-height, var(--spacingVerticalXXL)); +} + +fluent-tree[hide-selection] fluent-tree-item[selected]::part(positioning-region) { + background-color: var(--colorSubtleBackground); + color: var(--colorNeutralForeground2); +} + +fluent-tree-item > span[slot="chevron"], +fluent-tree-item > span[slot="start"], +fluent-tree-item > span[slot="end"], +fluent-tree-item > span[slot="aside"], +fluent-tree-item > span[part="text"] { + pointer-events: none; +} + +fluent-tree-item fluent-checkbox { + margin-right: var(--spacingHorizontalXS); +} + +fluent-tree-item .hidden-checkbox { + width: 16px; /* Default checkbox size */ + height: 16px; + margin-right: var(--spacingHorizontalXS); +} diff --git a/src/Core/Components/TreeView/FluentTreeView.razor b/src/Core/Components/TreeView/FluentTreeView.razor new file mode 100644 index 0000000000..e4a7af108f --- /dev/null +++ b/src/Core/Components/TreeView/FluentTreeView.razor @@ -0,0 +1,25 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@inherits FluentComponentBase + + + + + @ChildContent + + @if (Items != null) + { + foreach (var item in Items) + { + @FluentTreeItem.GetFluentTreeItem(this, item) + } + } + + + diff --git a/src/Core/Components/TreeView/FluentTreeView.razor.cs b/src/Core/Components/TreeView/FluentTreeView.razor.cs new file mode 100644 index 0000000000..c1e6a7b69a --- /dev/null +++ b/src/Core/Components/TreeView/FluentTreeView.razor.cs @@ -0,0 +1,174 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using Microsoft.JSInterop; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Represents a tree view component. +/// +public partial class FluentTreeView : FluentComponentBase +{ + private const string JAVASCRIPT_FILE = FluentJSModule.JAVASCRIPT_ROOT + "TreeView/FluentTreeView.razor.js"; + + internal ConcurrentDictionary InternalItems { get; } = new(StringComparer.Ordinal); + + /// + /// Initializes a new instance of the class. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TreeItemChangedEventArgs))] + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TreeItemToggleEventArgs))] + public FluentTreeView() + { + Id = Identifier.NewId(); + } + + /// + protected string? ClassValue => DefaultClassBuilder + .Build(); + + /// + protected string? StyleValue => DefaultStyleBuilder + .Build(); + + /// + /// Gets or sets the size of the tree. Default is . + /// + [Parameter] + public TreeSize? Size { get; set; } = TreeSize.Medium; + + /// + /// Gets or sets the appearance of the tree. Default is . + /// + [Parameter] + public TreeAppearance? Appearance { get; set; } = TreeAppearance.Subtle; + + /// + /// Gets or sets whether the tree view element is not highlighted to indicate that it is selected. + /// + [Parameter] + public bool HideSelection { get; set; } + + /// + /// Gets or sets the list of items to bind to the tree. + /// + [Parameter] + public IEnumerable? Items { get; set; } + + /// + /// Gets or sets the template for rendering tree items. + /// + [Parameter] + public RenderFragment? ItemTemplate { get; set; } + + /// + /// Can only be used when the is defined. + /// Gets or sets whether the tree should use lazy loading when expanding nodes. + /// If True, the tree will only render the children of a node when it is expanded and will remove them when it is collapsed. + /// + [Parameter] + public bool LazyLoadItems { get; set; } = false; + + /// + /// Gets or sets the content to be rendered inside the component. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the selected item id. + /// + [Parameter] + public string? SelectedId { get; set; } + + /// + /// Called whenever the selected item changes. + /// + [Parameter] + public EventCallback SelectedIdChanged { get; set; } + + /// + /// Gets or sets the selected item. + /// + [Parameter] + public FluentTreeItem? CurrentSelected { get; set; } + + /// + /// Called whenever the selected changes. + /// + [Parameter] + public EventCallback CurrentSelectedChanged { get; set; } + + /// + /// Gets or sets the selected item. + /// + [Parameter] + public ITreeViewItem? SelectedItem { get; set; } + + /// + /// Called whenever the selected changes. + /// + [Parameter] + public EventCallback SelectedItemChanged { get; set; } + + /// + /// Gets or sets whether the tree allows multiple selections. + /// This Multiple Selection feature is only available when the parameter is used to generate the tree. + /// By default, the tree allows only single selection. + /// + [Parameter] + public TreeSelectionMode SelectionMode { get; set; } = TreeSelectionMode.Single; + + /// + /// Gets or sets the visibility of the multi-selection checkbox. + /// By default all items are visible. + /// + [Parameter] + public Func? MultipleSelectionVisibility { get; set; } + + /// + /// Gets or sets the multi-selected items. + /// + [Parameter] + public IEnumerable? SelectedItems { get; set; } + + /// + /// Called whenever the multi-selected changes. + /// + [Parameter] + public EventCallback?> SelectedItemsChanged { get; set; } + + /// + /// Called whenever changes on an item within the tree. + /// You cannot update FluentTreeItem properties. + /// + [Parameter] + public EventCallback OnExpandedChanged { get; set; } + + /// + /// Called whenever the selected item changes. + /// You cannot update FluentTreeItem properties. + /// + [Parameter] + public EventCallback OnSelectedChanged { get; set; } + + /// + [ExcludeFromCodeCoverage(Justification = "JavaScript is not covered by unit tests")] + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && SelectionMode != TreeSelectionMode.Single) + { + // Import the JavaScript module + var jsModule = await JSModule.ImportJavaScriptModuleAsync(JAVASCRIPT_FILE); + + // Call a function from the JavaScript module + await jsModule.InvokeVoidAsync("Microsoft.FluentUI.Blazor.TreeView.Initialize", Id, true); + } + } +} diff --git a/src/Core/Components/TreeView/FluentTreeView.razor.ts b/src/Core/Components/TreeView/FluentTreeView.razor.ts new file mode 100644 index 0000000000..085d7396fc --- /dev/null +++ b/src/Core/Components/TreeView/FluentTreeView.razor.ts @@ -0,0 +1,25 @@ +export namespace Microsoft.FluentUI.Blazor.TreeView { + + /** + * Initializes the Fluent TreeView component. + * @param id + * @param multiple + */ + export function Initialize(id: string, multiple: boolean) { + const treeView = document.getElementById(id); + + if (treeView && multiple) { + + treeView.addEventListener('keydown', (event: KeyboardEvent) => { + + if (event.code === 'Space' && event.target instanceof HTMLElement) { + + const checkbox = event.target.querySelector('fluent-checkbox') as HTMLElement; + if (checkbox) { + checkbox.click(); + } + } + }); + } + } +} diff --git a/src/Core/Components/TreeView/ITreeViewItem.cs b/src/Core/Components/TreeView/ITreeViewItem.cs new file mode 100644 index 0000000000..9cdeab0e5e --- /dev/null +++ b/src/Core/Components/TreeView/ITreeViewItem.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Interface for tree view items +/// +public interface ITreeViewItem +{ + /// + /// Gets or sets the unique identifier of the tree item. + /// + string Id { get; set; } + + /// + /// Gets or sets the text of the tree item. + /// If this text is too long, it will be truncated with ellipsis. + /// + string Text { get; set; } + + /// + /// Gets or sets the sub-items of the tree item. + /// + IEnumerable? Items { get; set; } + + /// + /// Gets or sets the displayed at the start of item content. + /// We recommend using an icon size of 16px. + /// + Icon? IconStart { get; set; } + + /// + /// Gets or sets the displayed at the end of item content. + /// We recommend using an icon size of 16px. + /// + Icon? IconEnd { get; set; } + + /// + /// Gets or sets the displayed at the "far end" of item content. + /// We recommend using an icon size of 16px. + /// + Icon? IconAside { get; set; } + + /// + /// Gets or sets the displayed to indicate the tree item is collapsed. + /// If this icon is not set, the will be used. + /// We recommend using an icon size of 16px. + /// + Icon? IconCollapsed { get; set; } + + /// + /// Gets or sets the displayed to indicate the tree item is expanded. + /// If this icon is not set, the will be used. + /// A 90-degree rotation effect is applied to the icon. + /// Please select an icon that will look correct after rotation. + /// We recommend using an icon size of 16px. + /// + Icon? IconExpanded { get; set; } + + /// + /// Gets or sets a value indicating the default state of the tree item. + /// + bool Expanded { get; set; } + + /// + /// Gets or sets the action to be performed when the tree item is expanded or collapsed + /// + Func? OnExpandedAsync { get; set; } +} diff --git a/src/Core/Components/TreeView/TreeViewItem.cs b/src/Core/Components/TreeView/TreeViewItem.cs new file mode 100644 index 0000000000..3f1a45dab0 --- /dev/null +++ b/src/Core/Components/TreeView/TreeViewItem.cs @@ -0,0 +1,125 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.Diagnostics; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Implementation of +/// +[DebuggerDisplay("{DebuggerDisplay}")] +public class TreeViewItem : ITreeViewItem +{ + /// + /// Returns an array with a single that represents a loading state. + /// + /// The loading message + public static IEnumerable LoadingTreeViewItems(string loadingMessage) => [new TreeViewItem() { Text = loadingMessage }]; + + /// + /// Initializes a new instance of the class. + /// + public TreeViewItem() + { + Id = Identifier.NewId(); + } + + /// + /// Initializes a new instance of the class. + /// + /// Text of the tree item + /// Sub-items of the tree item. + public TreeViewItem(string text, IEnumerable? items = null) + { + Id = Identifier.NewId(); + Text = text; + Items = items; + } + + /// + /// Initializes a new instance of the class. + /// + /// Unique identifier of the tree item + /// Text of the tree item + /// Sub-items of the tree item. + public TreeViewItem(string id, string text, IEnumerable? items = null) + { + Id = id; + Text = text; + Items = items; + } + + /// + public string Id { get; set; } + + /// + public string Text { get; set; } = string.Empty; + + /// + public IEnumerable? Items { get; set; } + + /// + public Icon? IconStart { get; set; } + + /// + public Icon? IconEnd { get; set; } + + /// + public Icon? IconAside { get; set; } + + /// + public Icon? IconCollapsed { get; set; } + + /// + public Icon? IconExpanded { get; set; } + + /// + public bool Expanded { get; set; } + + /// + public Func? OnExpandedAsync { get; set; } + + /// + /// Returns the first item with the specified id in the tree view items. + /// + /// The tree view items to search in. + /// Identifier of the item to find. + /// + internal static ITreeViewItem? FindItemById(IEnumerable? items, string? id) + { + if (items == null) + { + return null; + } + + foreach (var item in items) + { + if (string.Equals(item.Id, id, StringComparison.Ordinal)) + { + return item; + } + + var nestedItem = FindItemById(item.Items, id); + if (nestedItem != null) + { + return nestedItem; + } + } + + return null; + } + + internal string DebuggerDisplay + { + get + { + var count = Items?.Count() ?? 0; + return count > 0 + ? $"[{Id}] {Text} (+ {count} sub-items)" + : $"[{Id}] {Text}"; + } + } +} diff --git a/src/Core/Components/TreeView/TreeViewItemExpandedEventArgs.cs b/src/Core/Components/TreeView/TreeViewItemExpandedEventArgs.cs new file mode 100644 index 0000000000..95ffa88ad2 --- /dev/null +++ b/src/Core/Components/TreeView/TreeViewItemExpandedEventArgs.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Class that contains the event arguments for the event. +/// +public class TreeViewItemExpandedEventArgs +{ + /// + internal TreeViewItemExpandedEventArgs(ITreeViewItem item, bool expanded) + { + CurrentItem = item; + Expanded = expanded; + } + + /// + /// Gets the that was expanded or collapsed. + /// + public ITreeViewItem CurrentItem { get; } + + /// + /// Gets a value indicating whether the item was expanded or collapsed. + /// + public bool Expanded { get; } +} diff --git a/src/Core/Enums/TreeAppearance.cs b/src/Core/Enums/TreeAppearance.cs new file mode 100644 index 0000000000..e906f10d22 --- /dev/null +++ b/src/Core/Enums/TreeAppearance.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Sets the visual appearance of the or the +/// +public enum TreeAppearance +{ + /// + /// Sublte appearance + /// + [Description("subtle")] + Subtle, + + /// + /// Subtle appearance with alpha + /// + [Description("subtle-alpha")] + SubtleAlpha, + + /// + /// Transparent appearance + /// + [Description("transparant")] + Transparent, +} diff --git a/src/Core/Enums/TreeSelectionMode.cs b/src/Core/Enums/TreeSelectionMode.cs new file mode 100644 index 0000000000..8275633495 --- /dev/null +++ b/src/Core/Enums/TreeSelectionMode.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Specifies the selection mode for a component. +/// +public enum TreeSelectionMode +{ + /// + /// The user can select only one item at a time. + /// + Single, + + /// + /// The user can select multiple items at a time, at any level. + /// + Multiple, +} diff --git a/src/Core/Enums/TreeSelectionVisibility.cs b/src/Core/Enums/TreeSelectionVisibility.cs new file mode 100644 index 0000000000..c227707e54 --- /dev/null +++ b/src/Core/Enums/TreeSelectionVisibility.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Specifies the selection mode for a component. +/// +public enum TreeSelectionVisibility +{ + /// + /// The checkbox is visible. + /// + Visible, + + /// + /// The checkbox is invisible (not drawn), but still affects layout as normal. + /// + Hidden, + + /// + /// The checkbox is invisible (not drawn), and the space it would take up is collapsed. + /// + Collapse, +} diff --git a/src/Core/Enums/TreeSize.cs b/src/Core/Enums/TreeSize.cs new file mode 100644 index 0000000000..5bd7fbe554 --- /dev/null +++ b/src/Core/Enums/TreeSize.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Sets the visual size of the or the +/// +public enum TreeSize +{ + /// + /// Medium size + /// + [Description("medium")] + Medium, + + /// + /// Small size + /// + [Description("small")] + Small, +} diff --git a/src/Core/Events/EventHandlers.cs b/src/Core/Events/EventHandlers.cs index 9df1d174ac..c9727c574c 100644 --- a/src/Core/Events/EventHandlers.cs +++ b/src/Core/Events/EventHandlers.cs @@ -33,6 +33,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components; [EventHandler("onmenuitemchange", typeof(MenuItemEventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("ontabchange", typeof(TabChangeEventArgs), enableStopPropagation: true, enablePreventDefault: true)] [EventHandler("ondropdownchange", typeof(DropdownEventArgs), enableStopPropagation: true, enablePreventDefault: true)] +[EventHandler("ontreechanged", typeof(TreeItemChangedEventArgs), enableStopPropagation: true, enablePreventDefault: true)] +[EventHandler("ontreetoggle", typeof(TreeItemToggleEventArgs), enableStopPropagation: true, enablePreventDefault: true)] public static class EventHandlers { } diff --git a/src/Core/Events/TreeItemChangedEventArgs.cs b/src/Core/Events/TreeItemChangedEventArgs.cs new file mode 100644 index 0000000000..0bf24c78ec --- /dev/null +++ b/src/Core/Events/TreeItemChangedEventArgs.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Event arguments for the FluentTabs ActiveId changed event. +/// +internal class TreeItemChangedEventArgs : EventArgs +{ + /// + /// Gets or sets the ID of the tree item. + /// + public string? Id { get; set; } + + /// + /// Gets or sets a value indicating whether the item is selected. + /// + public bool Selected { get; set; } +} diff --git a/src/Core/Events/TreeItemToggleEventArgs.cs b/src/Core/Events/TreeItemToggleEventArgs.cs new file mode 100644 index 0000000000..3901d2a61c --- /dev/null +++ b/src/Core/Events/TreeItemToggleEventArgs.cs @@ -0,0 +1,12 @@ +// ------------------------------------------------------------------------ +// MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------------------ + +namespace Microsoft.FluentUI.AspNetCore.Components; + +/// +/// Event arguments for the TreeItem Expanded event. +/// +internal class TreeItemToggleEventArgs : DialogToggleEventArgs +{ +} diff --git a/src/Core/Localization/LanguageResource.resx b/src/Core/Localization/LanguageResource.resx index e9b4b5e3e6..f59ab4878e 100644 --- a/src/Core/Localization/LanguageResource.resx +++ b/src/Core/Localization/LanguageResource.resx @@ -216,4 +216,7 @@ Close + + Loading... + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_Default.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_Default.verified.razor.html new file mode 100644 index 0000000000..5290394213 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_Default.verified.razor.html @@ -0,0 +1,4 @@ + + + My text + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_Icons.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_Icons.verified.razor.html new file mode 100644 index 0000000000..21a9036f4d --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_Icons.verified.razor.html @@ -0,0 +1,21 @@ + + + My text + + + + +
+ + + +
+
+ + + +
\ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-custom-custom.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-custom-custom.verified.razor.html new file mode 100644 index 0000000000..71a33c0944 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-custom-custom.verified.razor.html @@ -0,0 +1,14 @@ + + + +
+ + + +
+ +
+ My text +
\ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-custom-default.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-custom-default.verified.razor.html new file mode 100644 index 0000000000..a65a00ab30 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-custom-default.verified.razor.html @@ -0,0 +1,12 @@ + + + + + + + + + My text + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-default-custom.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-default-custom.verified.razor.html new file mode 100644 index 0000000000..cc6c13f360 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-default-custom.verified.razor.html @@ -0,0 +1,14 @@ + + + +
+ + + +
+ + + +
+ My text +
\ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-default-default.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-default-default.verified.razor.html new file mode 100644 index 0000000000..7dd05d51c3 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeItemTests.FluentTreeItem_IconsExpand-default-default.verified.razor.html @@ -0,0 +1,4 @@ + + + My text + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeItemTests.razor b/tests/Core/Components/TreeView/FluentTreeItemTests.razor new file mode 100644 index 0000000000..7b94e4a005 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeItemTests.razor @@ -0,0 +1,161 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using Xunit; +@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples; +@inherits Bunit.TestContext + +@code +{ + public FluentTreeItemTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentTreeItem_Default() + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Theory] + [InlineData(TreeAppearance.Subtle)] + [InlineData(TreeAppearance.SubtleAlpha)] + [InlineData(TreeAppearance.Transparent)] + [InlineData((TreeAppearance)999)] + public void FluentTreeItem_Appearance(TreeAppearance appearance) + { + // Arrange && Act + var cut = Render(@); + + var tree = cut.Find("fluent-tree-item"); + + // Assert + Assert.Equal(appearance.ToAttributeValue(isNull: TreeAppearance.Subtle), tree.GetAttribute("appearance")); + } + + [Theory] + [InlineData(TreeSize.Medium)] + [InlineData(TreeSize.Small)] + [InlineData((TreeSize)999)] + public void FluentTreeItem_Size(TreeSize size) + { + // Arrange && Act + var cut = Render(@); + + var treeItem = cut.Find("fluent-tree-item"); + + // Assert + Assert.Equal(size.ToAttributeValue(isNull: TreeSize.Medium), treeItem.GetAttribute("size")); + } + + [Fact] + public void FluentTreeItem_Selected() + { + // Arrange && Act + var cut = Render(@ + + + Item 2.1 + Item 2.2 + + ); + + var treeItem = cut.Find("fluent-tree-item[selected]"); + + // Assert + Assert.Equal("item21", treeItem.GetAttribute("id")); + } + + [Theory] + [InlineData("item1", true, "item1")] + [InlineData(null, null, null)] + public async Task FluentTreeItem_SelectedChanged(string? selectId, bool? expectedSelected, string? expectedSelectedId) + { + var selectedItem = null as FluentTreeItem; + var item1Selected = null as bool?; + + // Arrange + var cut = Render(@ + + + Item 2.1 + Item 2.2 + + + ); + + // Act + var item = cut.FindComponents().Where(i => i.Instance.Id == "item1").First().Instance; + await item.OnTreeChangedAsync(new() + { + Id = selectId, + Selected = true, + }); + + // Assert + Assert.Equal(expectedSelected, item1Selected); + Assert.Equal(expectedSelectedId, selectedItem?.Id); + } + + [Fact] + public void FluentTreeItem_Expanded() + { + // Arrange && Act + var cut = Render(@ + + + Item 2.1 + Item 2.2 + + ); + + var treeItems = cut.FindAll("fluent-tree-item[expanded]"); + + // Assert + Assert.Equal(2, treeItems.Count); + Assert.Equal("item1", treeItems[0].GetAttribute("id")); + Assert.Equal("item2", treeItems[1].GetAttribute("id")); + } + + [Fact] + public void FluentTreeItem_Icons() + { + var iconStart = new Icons.Samples.Info(); + var iconEnd = new Icons.Samples.MyCircle(); + var iconAside = new Icons.Samples.PresenceAvailable(); + + + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void FluentTreeItem_IconsExpand(bool withExpandIcon, bool withCollaspeIcon) + { + var iconStart = withExpandIcon ? new Icons.Samples.Info() : null; + var iconEnd = withCollaspeIcon ? new Icons.Samples.MyCircle() : null; + + + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(suffix: $"{(withExpandIcon ? "custom" : "default")}-{(withCollaspeIcon ? "custom" : "default")}"); + } +} diff --git a/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Default.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Default.verified.razor.html new file mode 100644 index 0000000000..c6bbec9009 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Default.verified.razor.html @@ -0,0 +1,19 @@ + + + + Item 1 + Item 1.1 + + Item 1.2 + + Item 1.3 + + + Item 2 + Item 2.1 + + Item 2.2 + + Item 2.3 + + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Visibility_CollapseHidden-Collapse.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Visibility_CollapseHidden-Collapse.verified.razor.html new file mode 100644 index 0000000000..e7e4521eae --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Visibility_CollapseHidden-Collapse.verified.razor.html @@ -0,0 +1,17 @@ + + + Item 1 + Item 1.1 + + Item 1.2 + + Item 1.3 + + Item 2 + Item 2.1 + + Item 2.2 + + Item 2.3 + + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Visibility_CollapseHidden-Hidden.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Visibility_CollapseHidden-Hidden.verified.razor.html new file mode 100644 index 0000000000..c9fb098c9b --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeMultipleTests.FluentTreeMultiple_Visibility_CollapseHidden-Hidden.verified.razor.html @@ -0,0 +1,19 @@ + + + +
Item 1 + Item 1.1 + + Item 1.2 + + Item 1.3 +
+ +
Item 2 + Item 2.1 + + Item 2.2 + + Item 2.3 +
+
\ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeMultipleTests.razor b/tests/Core/Components/TreeView/FluentTreeMultipleTests.razor new file mode 100644 index 0000000000..4f474eb61a --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeMultipleTests.razor @@ -0,0 +1,108 @@ +@using Xunit; +@inherits Bunit.TestContext + +@code +{ + public FluentTreeMultipleTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + // Data source + private readonly TreeViewItem[] Items = + { + new TreeViewItem("id1", "Item 1", + [ + new TreeViewItem("id11", "Item 1.1"), + new TreeViewItem("id12", "Item 1.2"), + new TreeViewItem("id13", "Item 1.3"), + ]), + new TreeViewItem("id2", "Item 2", + [ + new TreeViewItem("id21", "Item 2.1"), + new TreeViewItem("id22", "Item 2.2"), + new TreeViewItem("id23", "Item 2.3"), + ]), + }; + + [Fact] + public void FluentTreeMultiple_Default() + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentTreeMultiple_SelectedItems_Add() + { + var selectedItems = Items.Where(i => i.Id == "id2").Cast(); + + // Arrange + var cut = Render(@); + + // Before updates + var checkedItems = cut.FindAll("fluent-tree-item:has(fluent-checkbox[checked='true'])"); + Assert.Single(selectedItems); + Assert.Single(checkedItems); + Assert.Equal("id2", checkedItems.First().GetAttribute("id")); + + // Act + var checkbox = cut.FindComponents().Where(i => i.Instance.Id == "id1").First().Find("fluent-checkbox"); + checkbox.Change(new ChangeEventArgs()); + + // After updates + Assert.Equal(2, selectedItems.Count()); + Assert.Equal("id2", selectedItems.ElementAt(0).Id); + Assert.Equal("id1", selectedItems.ElementAt(1).Id); + } + + [Fact] + public void FluentTreeMultiple_SelectedItems_Remove() + { + var selectedItems = Items.Where(i => i.Id == "id2").Cast(); + + // Arrange + var cut = Render(@); + + // Act + var checkbox = cut.FindComponents().Where(i => i.Instance.Id == "id2").First().Find("fluent-checkbox"); + checkbox.Change(new ChangeEventArgs()); + + // Assert + Assert.Empty(selectedItems); + } + + [Fact] + public async Task TaskFluentTreeMultiple_SelectedItems_WhenOwnerTreeViewOrCheckedItemIsNull() + { + // OwnerTreeView is null + var cut = Render(@); + var item1 = cut.FindComponent().Instance; + + await item1.OnCheckChangedHandlerAsync(); + + // CheckedItem is null + cut = Render(@); + var item2 = cut.FindComponent().Instance; + + await item2.OnCheckChangedHandlerAsync(); + } + + [Theory] + [InlineData(TreeSelectionVisibility.Collapse)] + [InlineData(TreeSelectionVisibility.Hidden)] + public void FluentTreeMultiple_Visibility_CollapseHidden(TreeSelectionVisibility visibility) + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(suffix: visibility.ToString()); + } +} diff --git a/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_Default.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_Default.verified.razor.html new file mode 100644 index 0000000000..14484d8a3c --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_Default.verified.razor.html @@ -0,0 +1,27 @@ + + + + Item 1 + + Item 1.1 + + + Item 1.2 + + + Item 1.3 + + + + Item 2 + + Item 2.1 + + + Item 2.2 + + + Item 2.3 + + + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_Icons.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_Icons.verified.razor.html new file mode 100644 index 0000000000..105be5104d --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_Icons.verified.razor.html @@ -0,0 +1,19 @@ + + + + + + + + Item 1 + Loading... + + + Item 2 + Loading... + + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_ItemTemplate.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_ItemTemplate.verified.razor.html new file mode 100644 index 0000000000..086437fedc --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_ItemTemplate.verified.razor.html @@ -0,0 +1,27 @@ + + + + Item 1 + + Item 1.1 + + + Item 1.2 + + + Item 1.3 + + + + Item 2 + + Item 2.1 + + + Item 2.2 + + + Item 2.3 + + + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_LazyLoading.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_LazyLoading.verified.razor.html new file mode 100644 index 0000000000..ad1bbaede3 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_LazyLoading.verified.razor.html @@ -0,0 +1,11 @@ + + + + Item 1 + Loading... + + + Item 2 + Loading... + + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_LazyLoading_Expanded.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_LazyLoading_Expanded.verified.razor.html new file mode 100644 index 0000000000..10bcc871cd --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.FluentTreeViewItems_LazyLoading_Expanded.verified.razor.html @@ -0,0 +1,19 @@ + + + + Item 1 + + Item 1.1 + + + Item 1.2 + + + Item 1.3 + + + + Item 2 + Loading... + + diff --git a/tests/Core/Components/TreeView/FluentTreeViewItemsTests.razor b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.razor new file mode 100644 index 0000000000..733fdb6896 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewItemsTests.razor @@ -0,0 +1,250 @@ +@using Xunit; +@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples; +@inherits Bunit.TestContext + +@code +{ + public FluentTreeViewItemsTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + // Data source + private readonly TreeViewItem[] Items = + { + new TreeViewItem("id1", "Item 1", + [ + new TreeViewItem("id11", "Item 1.1"), + new TreeViewItem("id12", "Item 1.2"), + new TreeViewItem("id13", "Item 1.3"), + ]), + new TreeViewItem("id2", "Item 2", + [ + new TreeViewItem("id21", "Item 2.1"), + new TreeViewItem("id22", "Item 2.2"), + new TreeViewItem("id23", "Item 2.3"), + ]), + }; + + private readonly Icon IconCollapsed = new Icons.Samples.Info(); + private readonly Icon IconExpanded = new Icons.Samples.Warning(); + + [Fact] + public void FluentTreeViewItems_Default() + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentTreeViewItems_LazyLoading() + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public async Task FluentTreeViewItems_LazyLoading_Expanded() + { + // Arrange && Act + var cut = Render(@); + + // Act + var item = cut.FindComponents().Where(i => i.Instance.Id == "id1").First().Instance; + await item.OnTreeToggleAsync(new() + { + Id = "id1", + NewState = "open", + }); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentTreeViewItems_SelectedItem() + { + // Arrange && Act + var selectedItem = Items.ElementAt(0).Items?.ElementAt(1); // Item 1.2 + var cut = Render(@); + + var item12 = cut.Find("fluent-tree-item[id='id12']"); + + // Assert + Assert.True(item12.HasAttribute("selected")); + } + + [Fact] + public async Task FluentTreeViewItems_CurrentSelected() + { + // Arrange + var selectedItem = null as FluentTreeItem; + var cut = Render(@); + + // Act + var item = cut.FindComponents().Where(i => i.Instance.Id == "id12").First().Instance; + await item.OnTreeChangedAsync(new() + { + Id = "id12", + Selected = true, + }); + + // Assert + Assert.Equal("id12", selectedItem?.Id); + Assert.Equal("Item 1.2", selectedItem?.Text); + Assert.Equal("id12", selectedItem?.Item?.Id); + } + + [Theory] + [InlineData("id1")] + [InlineData("id12")] + [InlineData(null)] + public async Task FluentTreeViewItems_SelectedItem_Change(string? initialItemId) + { + // Arrange + var selectedItem = initialItemId switch + { + "id1" => Items.ElementAt(0) as ITreeViewItem, // Item 1 + "id12" => Items.ElementAt(1).Items?.ElementAt(1), // Item 2.2 + _ => null, + }; + + var cut = Render(@); + + // Act + var item = cut.FindComponents().Where(i => i.Instance.Id == "id12").First().Instance; + await item.OnTreeChangedAsync(new() + { + Id = "id12", + Selected = true, + }); + + // Assert + Assert.Equal("id12", selectedItem?.Id); + } + + [Fact] + public void FluentTreeViewItems_Icons() + { + Items[0].IconExpanded = IconExpanded; + Items[0].IconCollapsed = IconCollapsed; + + // Arrange && Act + var cut = Render(@); + + // Assert + Assert.Single(cut.FindAll("svg[collapsed]")); + Assert.Single(cut.FindAll("svg[expanded]")); + cut.Verify(); + } + + + [Fact] + public void FluentTreeViewItems_ItemTemplate() + { + // Arrange && Act + var cut = Render(@ + + @context.Text + + ); + + // Assert + cut.Verify(); + } + + [Fact] + public async Task FluentTreeViewItems_OnExpanded() + { + // Arrange + TreeViewItemExpandedEventArgs expandedArgs = default!; + Items[0].OnExpandedAsync = (e) => + { + expandedArgs = e; + return Task.CompletedTask; + }; + + var cut = Render(@); + + // Act + var first = cut.FindComponent().Instance; + await first.OnTreeToggleAsync(new() + { + Id = "id1", + NewState = "open", + }); + + // Assert + Assert.Equal("Item 1", expandedArgs.CurrentItem.Text); + Assert.True(expandedArgs.Expanded); + } + + [Fact] + public async Task FluentTreeViewItems_Selected() + { + var selectedId = string.Empty; + + // Arrange + var cut = Render(@); + + // Act + var first = cut.FindComponent().Instance; + await first.OnTreeChangedAsync(new() + { + Id = "id1", + Selected = true, + }); + + // Assert + Assert.Equal("id1", selectedId); + } + + [Fact] + public void FluentTreeViewItems_LoadingObjects() + { + // Arrange && Act + var loadingItems = TreeViewItem.LoadingTreeViewItems("Loading..."); + var item1 = new TreeViewItem(); + var item2 = new TreeViewItem("Item 1", new[] { new TreeViewItem("Item 1.1") }); + + // Assert + Assert.Single(loadingItems); + Assert.Equal("Loading...", loadingItems.First().Text); + + Assert.Empty(item1.Text); + + Assert.Equal("Item 1", item2.Text); + Assert.Equal("Item 1.1", item2.Items?.First().Text); + } + + [Fact] + public void FluentTreeViewItems_DebugDisplay() + { + // Arrange + var item1 = new TreeViewItem("id1", "Item 1"); + var item2 = new TreeViewItem("id2", "Item 2", new[] { new TreeViewItem("Item 2.1") }); + var item3 = new TreeViewItem("id3", "Item 3", Array.Empty()); + + // Assert + Assert.Equal("[id1] Item 1", item1.DebuggerDisplay); + Assert.Equal("[id2] Item 2 (+ 1 sub-items)", item2.DebuggerDisplay); + Assert.Equal("[id3] Item 3", item3.DebuggerDisplay); + } + + [Fact] + public void FluentTreeViewItems_FindItemById() + { + // Assert + Assert.Null(TreeViewItem.FindItemById(null, "id11")); + Assert.Equal("id1", TreeViewItem.FindItemById(Items, "id1")?.Id); + Assert.Equal("id11", TreeViewItem.FindItemById(Items, "id11")?.Id); + Assert.Null(TreeViewItem.FindItemById(Items, "INVALID")); + } +} diff --git a/tests/Core/Components/TreeView/FluentTreeViewTests.FluentTreeView_Default.verified.razor.html b/tests/Core/Components/TreeView/FluentTreeViewTests.FluentTreeView_Default.verified.razor.html new file mode 100644 index 0000000000..26574a6812 --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewTests.FluentTreeView_Default.verified.razor.html @@ -0,0 +1,11 @@ + + + + Item 1 + + + Item 2 + Item 2.1 + Item 2.2 + + \ No newline at end of file diff --git a/tests/Core/Components/TreeView/FluentTreeViewTests.razor b/tests/Core/Components/TreeView/FluentTreeViewTests.razor new file mode 100644 index 0000000000..49c293cd3c --- /dev/null +++ b/tests/Core/Components/TreeView/FluentTreeViewTests.razor @@ -0,0 +1,146 @@ +@using Microsoft.FluentUI.AspNetCore.Components.Extensions +@using Xunit; +@using Microsoft.FluentUI.AspNetCore.Components.Tests.Samples; +@inherits Bunit.TestContext + +@code +{ + public FluentTreeViewTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddFluentUIComponents(); + } + + [Fact] + public void FluentTreeView_Default() + { + // Arrange && Act + var cut = Render(@ + + + Item 2.1 + Item 2.2 + + ); + + // Assert + cut.Verify(); + } + + [Theory] + [InlineData(TreeAppearance.Subtle)] + [InlineData(TreeAppearance.SubtleAlpha)] + [InlineData(TreeAppearance.Transparent)] + [InlineData((TreeAppearance)999)] + public void FluentTreeView_Appearance(TreeAppearance appearance) + { + // Arrange && Act + var cut = Render(@ + + ); + + var tree = cut.Find("fluent-tree"); + + // Assert + Assert.Equal(appearance.ToAttributeValue(isNull: TreeAppearance.Subtle), tree.GetAttribute("appearance")); + } + + [Theory] + [InlineData(TreeSize.Medium)] + [InlineData(TreeSize.Small)] + [InlineData((TreeSize)999)] + public void FluentTreeView_Size(TreeSize size) + { + // Arrange && Act + var cut = Render(@ + + ); + + var tree = cut.Find("fluent-tree"); + + // Assert + Assert.Equal(size.ToAttributeValue(isNull: TreeSize.Medium), tree.GetAttribute("size")); + } + + [Fact] + public void FluentTreeView_HideSelection() + { + // Arrange && Act + var cut = Render(@ + + ); + + var tree = cut.Find("fluent-tree"); + + // Assert + Assert.True(tree.HasAttribute("hide-selection")); + } + + [Fact] + public async Task FluentTreeView_CurrentSelected() + { + FluentTreeItem? currentSelected = null; + + // Arrange + var cut = Render(@ + + + Item 2.1 + Item 2.2 + + ); + + // Act + var item = cut.FindComponents().ElementAt(2).Instance; + await item.OnTreeChangedAsync(new() + { + Id = "item21", + Selected = true, + }); + + // Assert + Assert.Equal("item21", currentSelected?.Id); + } + + [Theory] + [InlineData(false, "item1", "closed", "open", true)] // Normal: collapsed -> expanded + [InlineData(true, "item1", "closed", "open", true)] // Normal: expanded -> expanded + [InlineData(true, "item1", "open", "closed", false)] // Normal: expanded -> collapsed + [InlineData(false, "item1", "open", "closed", false)] // Normal: collapsed -> collapsed + [InlineData(false, "item9", "closed", "open", false)] // Not the same item: no change + [InlineData(false, "item1", "INVALID", "open", true)] // Only NewState is used + public async Task FluentTreeView_Expand(bool before, string itemId, string itemOldState, string itemNewState, bool expectedAfter) + { + var expandedId = string.Empty; + var isExpanded = before; + + // Arrange + var cut = Render(@ + + Item 2.1 + Item 2.2 + + ); + + // Act + var item = cut.FindComponents().First(i => i.Instance.Id == "item1").Instance; + await item.OnTreeToggleAsync(new() + { + Id = itemId, + OldState = itemOldState, + NewState = itemNewState, + }); + + // Assert + if (expectedAfter) + { + Assert.True(item.Expanded); + Assert.Equal("item1", expandedId); + Assert.Equal(expectedAfter, isExpanded); + } + else + { + Assert.False(item.Expanded); + } + } +}