From 17ec5ed3ecc8641d021468d1ffb195364c42c2ab Mon Sep 17 00:00:00 2001 From: Denis Voituron Date: Sun, 26 Nov 2023 10:55:58 +0100 Subject: [PATCH] [FluentCheckbox] Add 3-states (check/uncheck/indeterminate) (#1022) * First version * Update using a isUpdating flag * Fix events * Update the CheckState property * Add ShowIndeterminate property * Add Unit Tests * Update js path --------- Co-authored-by: Vincent Baaij --- ...crosoft.FluentUI.AspNetCore.Components.xml | 41 +++++ .../Shared/Pages/Checkbox/CheckboxPage.razor | 4 +- .../Checkbox/Examples/CheckboxDefault.razor | 40 ++-- .../Examples/CheckboxThreeState.razor | 25 +++ .../Checkbox/Examples/CheckboxVertical.razor | 11 -- .../Components/Checkbox/FluentCheckbox.razor | 5 +- .../Checkbox/FluentCheckbox.razor.cs | 172 +++++++++++++++++- .../Checkbox/FluentCheckbox.razor.js | 12 ++ src/Core/Components/Switch/FluentSwitch.razor | 2 +- src/Core/Events/CheckboxChangeEventArgs.cs | 3 +- ...uentUI.AspNetCore.Components.lib.module.js | 12 +- ....FluentCheckbox_Labels.verified.razor.html | 4 + .../FluentCheckboxThreeStatesTests.razor | 93 ++++++++++ tests/Core/_StartCodeCoverage.cmd | 4 +- unit-tests.md | 4 +- 15 files changed, 385 insertions(+), 47 deletions(-) create mode 100644 examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxThreeState.razor delete mode 100644 examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxVertical.razor create mode 100644 src/Core/Components/Checkbox/FluentCheckbox.razor.js create mode 100644 tests/Core/Checkbox/FluentCheckboxThreeStatesTests.FluentCheckbox_Labels.verified.razor.html create mode 100644 tests/Core/Checkbox/FluentCheckboxThreeStatesTests.razor diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index cf5475148e..c4b126f2ea 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -730,11 +730,52 @@ Gets or sets the content to be rendered inside the component. + + + + + + Gets or sets the content to be rendered inside the component. + + + Gets or sets a value indicating whether the CheckBox will allow three check states rather than two. + + + + + Gets or sets a value indicating whether the user can display the indeterminate state by clicking the CheckBox. + If this is not the case, the checkbox can be started in the indeterminate state, but the user cannot activate it with the mouse. + Default is true. + + + + + Gets or sets the state of the CheckBox: true, false or null. + + + + + + + + + + + + + + + + + + + + If true, the region is expaned, otherwise it is collapsed. diff --git a/examples/Demo/Shared/Pages/Checkbox/CheckboxPage.razor b/examples/Demo/Shared/Pages/Checkbox/CheckboxPage.razor index f6e4a0bc5e..5502df368d 100644 --- a/examples/Demo/Shared/Pages/Checkbox/CheckboxPage.razor +++ b/examples/Demo/Shared/Pages/Checkbox/CheckboxPage.razor @@ -13,8 +13,8 @@ - +

Documentation

- + \ No newline at end of file diff --git a/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxDefault.razor b/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxDefault.razor index d63ae34d3f..b9002e2246 100644 --- a/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxDefault.razor +++ b/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxDefault.razor @@ -1,28 +1,22 @@ -

Standard

-

Without a label:

- +

Horizontal

+ + + + + -

With a label:

- +
+
-

Checked

- +

Vertical

+ + Apples + Bananas (disabled) + Oranges + - -

Required

- - - -

Disabled

- -label -Checked - -

Inline

-Apples -Bananas -Honeydew -Oranges @code { - bool value1, value2, value3 = true, value4, value5, value6, value7 = true, value8 = true, value9 = true, value10, value11 = true; + bool value1 = true; + bool value2 = true; + bool value3; } \ No newline at end of file diff --git a/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxThreeState.razor b/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxThreeState.razor new file mode 100644 index 0000000000..e15cccceb7 --- /dev/null +++ b/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxThreeState.razor @@ -0,0 +1,25 @@ + + +
+ Value = @value1 - CheckState = @(state1?.ToString() ?? "null (Indeterminate)") +
+
+ + + +
+ Value = @value2 +
+
+ + + +
+ Value = @value3 - CheckState = @(state3?.ToString() ?? "null (Indeterminate)") +
+
+ +@code { + bool value1, value2, value3; + bool? state1 = false, state3 = null; +} diff --git a/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxVertical.razor b/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxVertical.razor deleted file mode 100644 index 41e03ee96a..0000000000 --- a/examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxVertical.razor +++ /dev/null @@ -1,11 +0,0 @@ - -
- Fruit - Apples - Bananas - Honeydew - Oranges -
-@code { - bool value1 = true , value2 = true, value3, value4 = true; -} \ No newline at end of file diff --git a/src/Core/Components/Checkbox/FluentCheckbox.razor b/src/Core/Components/Checkbox/FluentCheckbox.razor index fb0343ce89..34bab38d5a 100644 --- a/src/Core/Components/Checkbox/FluentCheckbox.razor +++ b/src/Core/Components/Checkbox/FluentCheckbox.razor @@ -11,7 +11,7 @@ name=@Name required="@Required" current-checked="@CurrentValue" - @oncheckedchange="@((e) => SetCurrentValue(e.Checked))" + @oncheckedchange="@OnCheckedChangeHandlerAsync" @attributes="@AdditionalAttributes"> @if (!string.IsNullOrEmpty(Label) || !string.IsNullOrEmpty(AriaLabel) || LabelTemplate is not null) { @@ -21,4 +21,5 @@ } @ChildContent - + + \ No newline at end of file diff --git a/src/Core/Components/Checkbox/FluentCheckbox.razor.cs b/src/Core/Components/Checkbox/FluentCheckbox.razor.cs index 93280d2cdd..7817fa9109 100644 --- a/src/Core/Components/Checkbox/FluentCheckbox.razor.cs +++ b/src/Core/Components/Checkbox/FluentCheckbox.razor.cs @@ -1,21 +1,189 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentCheckbox : FluentInputBase { + private const bool VALUE_FOR_INDETERMINATE = false; + private bool _intermediate = false; + private bool? _checkState = false; + private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Checkbox/FluentCheckbox.razor.js"; + + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CheckboxChangeEventArgs))] + public FluentCheckbox() + { + Id = Identifier.NewId(); + } + + /// + [Inject] + private IJSRuntime JSRuntime { get; set; } = default!; + + /// + private IJSObjectReference? Module { get; set; } + /// /// Gets or sets the content to be rendered inside the component. /// [Parameter] public RenderFragment? ChildContent { get; set; } - [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CheckboxChangeEventArgs))] + /// + /// Gets or sets a value indicating whether the CheckBox will allow three check states rather than two. + /// + [Parameter] + public bool ThreeState { get; set; } = false; - public FluentCheckbox() + /// + /// Gets or sets a value indicating whether the user can display the indeterminate state by clicking the CheckBox. + /// If this is not the case, the checkbox can be started in the indeterminate state, but the user cannot activate it with the mouse. + /// Default is true. + /// + [Parameter] + public bool ShowIndeterminate { get; set; } = true; + + /// + /// Gets or sets the state of the CheckBox: true, false or null. + /// + [Parameter] + public bool? CheckState + { + get => _checkState; + set + { + if (!ThreeState) + { + throw new ArgumentException("Set the `ThreeState` attribute to True to use this `CheckState` property."); + } + + if (_checkState != value) + { + _checkState = value; + _ = SetCurrentAndIntermediate(value); + } + } + } + + [Parameter] + public EventCallback CheckStateChanged { get; set; } + + /// + private async Task SetCurrentAndIntermediate(bool? value) + { + switch (value) + { + // Checked + case true: + await SetCurrentValue(true); + await SetIntermediateAsync(false); + break; + + // Unchecked + case false: + await SetCurrentValue(false); + await SetIntermediateAsync(false); + break; + + // Indeterminate + default: + await SetCurrentValue(VALUE_FOR_INDETERMINATE); + await SetIntermediateAsync(true); + break; + } + } + + /// + private async Task SetIntermediateAsync(bool intermediate) { + // Force the Indeterminate state to be set. + // Each time the user clicks the checkbox, the Indeterminate state is reset to false. + Module ??= await JSRuntime.InvokeAsync("import", JAVASCRIPT_FILE); + await Module.InvokeVoidAsync("setFluentCheckBoxIndeterminate", Id, intermediate, Value); + _intermediate = intermediate; } + /// + private async Task SetCurrentCheckState(bool newChecked) + { + // Value: False -> True -> False -> False + // Intermediate: False -> False -> True -> False + // --------------------------------------------- + // Uncheck -> Check -> Indeterminate -> Uncheck + + // Uncheck -> Check + if (newChecked && !_intermediate) + { + await SetCurrentAndIntermediate(true); + await UpdateAndRaiseCheckStateEvent(true); + } + + // Check -> Indeterminate (or Uncheck is ShowIndeterminate is false) + else if (!newChecked && !_intermediate) + { + if (ShowIndeterminate) + { + await SetCurrentAndIntermediate(null); + await UpdateAndRaiseCheckStateEvent(null); + } + else + { + await SetCurrentAndIntermediate(false); + await UpdateAndRaiseCheckStateEvent(false); + } + } + + // Indeterminate -> Uncheck + else if (newChecked && _intermediate) + { + await SetCurrentAndIntermediate(false); + await UpdateAndRaiseCheckStateEvent(false); + } + + // + else + { + await SetCurrentAndIntermediate(false); + await UpdateAndRaiseCheckStateEvent(false); + } + } + + /// + private async Task OnCheckedChangeHandlerAsync(CheckboxChangeEventArgs e) + { + if (e.Checked == null && e.Indeterminate == null) + { + return; + } + + if (ThreeState) + { + await SetCurrentCheckState(e.Checked ?? false); + } + else + { + await SetCurrentValue(e.Checked ?? false); + await SetIntermediateAsync(false); + await UpdateAndRaiseCheckStateEvent(e.Checked ?? false); + } + } + + /// + private async Task UpdateAndRaiseCheckStateEvent(bool? value) + { + if (_checkState != value) + { + _checkState = value; + + if (CheckStateChanged.HasDelegate) + { + await CheckStateChanged.InvokeAsync(value); + } + } + } + + /// protected override bool TryParseValueFromString(string? value, out bool result, [NotNullWhen(false)] out string? validationErrorMessage) => throw new NotSupportedException($"This component does not parse string inputs. Bind to the '{nameof(CurrentValue)}' property, not '{nameof(CurrentValueAsString)}'."); + } \ No newline at end of file diff --git a/src/Core/Components/Checkbox/FluentCheckbox.razor.js b/src/Core/Components/Checkbox/FluentCheckbox.razor.js new file mode 100644 index 0000000000..09bee56582 --- /dev/null +++ b/src/Core/Components/Checkbox/FluentCheckbox.razor.js @@ -0,0 +1,12 @@ +export function setFluentCheckBoxIndeterminate(id, indeterminate, checked) { + var item = document.getElementById(id); + if (!!item) { + item.isUpdating = true; + + // Need to update Checked before Indeterminate + item.checked = checked; + item.indeterminate = indeterminate; + + item.isUpdating = false; + } +} diff --git a/src/Core/Components/Switch/FluentSwitch.razor b/src/Core/Components/Switch/FluentSwitch.razor index be1284ceb7..a09d585150 100644 --- a/src/Core/Components/Switch/FluentSwitch.razor +++ b/src/Core/Components/Switch/FluentSwitch.razor @@ -12,7 +12,7 @@ aria-label="@(string.IsNullOrEmpty(AriaLabel) ? Label : AriaLabel)" required=@Required current-checked="@CurrentValue" - @onswitchcheckedchange="@((e) => CurrentValue = e.Checked)" + @onswitchcheckedchange="@((e) => CurrentValue = e.Checked ?? false)" @attributes="@AdditionalAttributes"> @Label @LabelTemplate diff --git a/src/Core/Events/CheckboxChangeEventArgs.cs b/src/Core/Events/CheckboxChangeEventArgs.cs index 95a1ddda63..b1e4a0064a 100644 --- a/src/Core/Events/CheckboxChangeEventArgs.cs +++ b/src/Core/Events/CheckboxChangeEventArgs.cs @@ -2,5 +2,6 @@ public class CheckboxChangeEventArgs : EventArgs { - public bool Checked { get; set; } + public bool? Checked { get; set; } + public bool? Indeterminate { get; set; } } diff --git a/src/Core/wwwroot/Microsoft.FluentUI.AspNetCore.Components.lib.module.js b/src/Core/wwwroot/Microsoft.FluentUI.AspNetCore.Components.lib.module.js index b55d80f8ca..ea8d43de86 100644 --- a/src/Core/wwwroot/Microsoft.FluentUI.AspNetCore.Components.lib.module.js +++ b/src/Core/wwwroot/Microsoft.FluentUI.AspNetCore.Components.lib.module.js @@ -57,8 +57,18 @@ export function afterStarted(blazor) { blazor.registerCustomEventType('checkedchange', { browserEventName: 'change', createEventArgs: event => { + + // Hacking of a fake update + if (event.target.isUpdating) { + return { + checked: null, + indeterminate: null + } + } + return { - checked: event.target.currentChecked + checked: event.target.currentChecked, + indeterminate: event.target.indeterminate }; } }); diff --git a/tests/Core/Checkbox/FluentCheckboxThreeStatesTests.FluentCheckbox_Labels.verified.razor.html b/tests/Core/Checkbox/FluentCheckboxThreeStatesTests.FluentCheckbox_Labels.verified.razor.html new file mode 100644 index 0000000000..4366fee829 --- /dev/null +++ b/tests/Core/Checkbox/FluentCheckboxThreeStatesTests.FluentCheckbox_Labels.verified.razor.html @@ -0,0 +1,4 @@ + + MyLabel + My customized content + \ No newline at end of file diff --git a/tests/Core/Checkbox/FluentCheckboxThreeStatesTests.razor b/tests/Core/Checkbox/FluentCheckboxThreeStatesTests.razor new file mode 100644 index 0000000000..3322eb8a0b --- /dev/null +++ b/tests/Core/Checkbox/FluentCheckboxThreeStatesTests.razor @@ -0,0 +1,93 @@ +@using Xunit; +@inherits TestContext +@code +{ + [Theory] + [InlineData(false, true, true)] // Unchecked => Checked + [InlineData(true, null, false)] // Checked => Indeterminate + [InlineData(null, false, false)] // Indeterminate => Unchecked + public void FluentCheckbox_ThreeStates(bool? initialState, bool? assertState, bool assertValue) + { + this.JSInterop.Mode = JSRuntimeMode.Loose; + + bool myValue = initialState ?? false; + bool? myState = initialState; + + // Arrange && Act + var cut = Render(@); + + var component = cut.Find("fluent-checkbox"); + component.TriggerEvent("oncheckedchange", new CheckboxChangeEventArgs() { Checked = !myValue }); + + // Assert + Assert.Equal(assertState, myState); + Assert.Equal(assertValue, myValue); + } + + [Fact] + public void FluentCheckbox_ThreeStates_ShowIntermediateFalse() + { + this.JSInterop.Mode = JSRuntimeMode.Loose; + + // Start Intermediate + bool? myState = null; + + // Arrange + var cut = Render(@); + + // Act + var component = cut.Find("fluent-checkbox"); + + // ... Intermediate => Uncheck + // ... Uncheck => Check + // ... Check => Uncheck (not Intermediate because ) + component.TriggerEvent("oncheckedchange", new CheckboxChangeEventArgs() { Checked = false }); + component.TriggerEvent("oncheckedchange", new CheckboxChangeEventArgs() { Checked = true }); + component.TriggerEvent("oncheckedchange", new CheckboxChangeEventArgs() { Checked = false }); + + // Assert + Assert.False(myState); + } + + [Theory] + [InlineData(true, false)] // Unchecked => Checked + [InlineData(false, true)] // Checked => Unchecked + public void FluentCheckbox_ThreeStatesFalse(bool initialValue, bool assertValue) + { + this.JSInterop.Mode = JSRuntimeMode.Loose; + + bool myValue = initialValue; + + // Arrange && Act + var cut = Render(@ + ); + + var component = cut.Find("fluent-checkbox"); + component.TriggerEvent("oncheckedchange", new CheckboxChangeEventArgs() { Checked = !myValue }); + + // Assert + Assert.Equal(assertValue, myValue); + } + + [Fact] + public void FluentCheckbox_Labels() + { + this.JSInterop.Mode = JSRuntimeMode.Loose; + + // Start Intermediate + bool? myState = null; + + // Arrange + var cut = Render(@My customized content); + + cut.Verify(); + } +} \ No newline at end of file diff --git a/tests/Core/_StartCodeCoverage.cmd b/tests/Core/_StartCodeCoverage.cmd index 7c78a2e18b..a7048f4ddb 100644 --- a/tests/Core/_StartCodeCoverage.cmd +++ b/tests/Core/_StartCodeCoverage.cmd @@ -18,6 +18,6 @@ REM $:\> explorer C:\Temp\Coverage\index.html echo on cls -dotnet test --framework net8.0 /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura -reportgenerator "-reports:coverage.net8.0.cobertura.xml" "-targetdir:C:\Temp\FluentUI\Coverage" -reporttypes:HtmlInline_AzurePipelines -classfilters:"-Microsoft.Fast.Components.FluentUI.DesignTokens.*" +dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura +reportgenerator "-reports:coverage.cobertura.xml" "-targetdir:C:\Temp\FluentUI\Coverage" -reporttypes:HtmlInline_AzurePipelines -classfilters:"-Microsoft.FluentUI.AspNetCore.Components.DesignTokens.*" start "" "C:\Temp\FluentUI\Coverage\index.htm" \ No newline at end of file diff --git a/unit-tests.md b/unit-tests.md index 2354096f18..daa5c32b5f 100644 --- a/unit-tests.md +++ b/unit-tests.md @@ -252,7 +252,7 @@ This chapter discusses the usage of code coverage for unit testing with **Coverl Each unit test project folders will contain a file `coverage.cobertura.xml`. ``` - dotnet test --framework net8.0 /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura + dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura ``` 4. **Generate a report** @@ -260,7 +260,7 @@ This chapter discusses the usage of code coverage for unit testing with **Coverl Merge and convert all `Cobertura.xml` files to an HTML report (change the sample folder Temp/FluentUI/Coverage). ``` - reportgenerator "-reports:coverage.net8.0.cobertura.xml" "-targetdir:C:\Temp\FluentUI\Coverage" -reporttypes:HtmlInline_AzurePipelines -classfilters:"-Microsoft.Fast.Components.FluentUI.DesignTokens.*" + reportgenerator "-reports:coverage.cobertura.xml" "-targetdir:C:\Temp\FluentUI\Coverage" -reporttypes:HtmlInline_AzurePipelines -classfilters:"-Microsoft.FluentUI.AspNetCore.Components.DesignTokens.*" ``` > **Note:** The `_StartCodeCoverage.cmd` file contains these two command lines.