Skip to content

Commit

Permalink
[FluentCheckbox] Add 3-states (check/uncheck/indeterminate) (#1022)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
dvoituron and vnbaaij authored Nov 26, 2023
1 parent df39244 commit 17ec5ed
Show file tree
Hide file tree
Showing 15 changed files with 385 additions and 47 deletions.
41 changes: 41 additions & 0 deletions examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml
Original file line number Diff line number Diff line change
Expand Up @@ -730,11 +730,52 @@
Gets or sets the content to be rendered inside the component.
</summary>
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.JSRuntime">
<summary />
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.Module">
<summary />
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.ChildContent">
<summary>
Gets or sets the content to be rendered inside the component.
</summary>
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.ThreeState">
<summary>
Gets or sets a value indicating whether the CheckBox will allow three check states rather than two.
</summary>
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.ShowIndeterminate">
<summary>
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.
</summary>
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.CheckState">
<summary>
Gets or sets the state of the CheckBox: true, false or null.
</summary>
</member>
<member name="M:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.SetCurrentAndIntermediate(System.Nullable{System.Boolean})">
<summary />
</member>
<member name="M:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.SetIntermediateAsync(System.Boolean)">
<summary />
</member>
<member name="M:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.SetCurrentCheckState(System.Boolean)">
<summary />
</member>
<member name="M:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.OnCheckedChangeHandlerAsync(Microsoft.FluentUI.AspNetCore.Components.CheckboxChangeEventArgs)">
<summary />
</member>
<member name="M:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.UpdateAndRaiseCheckStateEvent(System.Nullable{System.Boolean})">
<summary />
</member>
<member name="M:Microsoft.FluentUI.AspNetCore.Components.FluentCheckbox.TryParseValueFromString(System.String,System.Boolean@,System.String@)">
<summary />
</member>
<member name="P:Microsoft.FluentUI.AspNetCore.Components.FluentCollapsibleRegion.Expanded">
<summary>
If true, the region is expaned, otherwise it is collapsed.
Expand Down
4 changes: 2 additions & 2 deletions examples/Demo/Shared/Pages/Checkbox/CheckboxPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@

<DemoSection Title="Default checkbox examples" Component="@typeof(CheckboxDefault)"></DemoSection>

<DemoSection Title="Vertical checkboxes" Component="@typeof(CheckboxVertical)"></DemoSection>
<DemoSection Title="Three States" Component="@typeof(CheckboxThreeState)"></DemoSection>

<h2 id="documentation">Documentation</h2>

<ApiDocumentation Component="typeof(FluentCheckbox)" GenericLabel="bool"/>
<ApiDocumentation Component="typeof(FluentCheckbox)" GenericLabel="bool"/>
40 changes: 17 additions & 23 deletions examples/Demo/Shared/Pages/Checkbox/Examples/CheckboxDefault.razor
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
<h4>Standard</h4>
<p>Without a label:</p>
<FluentCheckbox @bind-Value=value1></FluentCheckbox>
<h4>Horizontal</h4>
<FluentStack>
<FluentCheckbox @bind-Value="@value1" Label="Apples" />
<FluentCheckbox @bind-Value="@value2" Disabled="true" Label="Bananas (disabled)" />
<FluentCheckbox @bind-Value="@value3" Label="Oranges" />
</FluentStack>

<p>With a label: </p>
<FluentCheckbox @bind-Value=value2 Label="With a label:"></FluentCheckbox>
<br />
<br />

<h4>Checked</h4>
<FluentCheckbox @bind-Value=value3></FluentCheckbox>
<h4>Vertical</h4>
<FluentStack Orientation="Orientation.Vertical">
<FluentCheckbox @bind-Value="@value1">Apples</FluentCheckbox>
<FluentCheckbox @bind-Value="@value2" Disabled="true">Bananas (disabled)</FluentCheckbox>
<FluentCheckbox @bind-Value="@value3">Oranges</FluentCheckbox>
</FluentStack>

<!-- Required -->
<h4>Required</h4>
<FluentCheckbox Required="true" @bind-Value=value4></FluentCheckbox>

<!-- Disabled -->
<h4>Disabled</h4>
<FluentCheckbox Disabled="true" @bind-Value=value5></FluentCheckbox>
<FluentCheckbox Disabled="true" @bind-Value=value6>label</FluentCheckbox>
<FluentCheckbox Disabled="true" @bind-Value=value7>Checked</FluentCheckbox>

<h4>Inline</h4>
<FluentCheckbox @bind-Value=value8>Apples</FluentCheckbox>
<FluentCheckbox @bind-Value=value9>Bananas</FluentCheckbox>
<FluentCheckbox @bind-Value=value10>Honeydew</FluentCheckbox>
<FluentCheckbox @bind-Value=value11>Oranges</FluentCheckbox>
@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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<FluentStack Style="margin: 20px;">
<FluentCheckbox Id="check1" ThreeState="true" @bind-Value="@value1" @bind-CheckState="@state1" Label="ThreeState = true" Style="min-width: 250px;" />
<div>
Value = @value1 - CheckState = @(state1?.ToString() ?? "null (Indeterminate)")
</div>
</FluentStack>

<FluentStack Style="margin: 20px;">
<FluentCheckbox Id="check2" ThreeState="false" @bind-Value="@value2" Label="ThreeState = false" Style="min-width: 250px;" />
<div>
Value = @value2
</div>
</FluentStack>

<FluentStack Style="margin: 20px;">
<FluentCheckbox Id="check3" ThreeState="true" @bind-Value="@value3" @bind-CheckState="@state3" ShowIndeterminate="false" Label="ShowIndeterminate = false" Style="min-width: 250px;" />
<div>
Value = @value3 - CheckState = @(state3?.ToString() ?? "null (Indeterminate)")
</div>
</FluentStack>

@code {
bool value1, value2, value3;
bool? state1 = false, state3 = null;
}

This file was deleted.

5 changes: 3 additions & 2 deletions src/Core/Components/Checkbox/FluentCheckbox.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -21,4 +21,5 @@
</span>
}
@ChildContent
</fluent-checkbox>
</fluent-checkbox>

172 changes: 170 additions & 2 deletions src/Core/Components/Checkbox/FluentCheckbox.razor.cs
Original file line number Diff line number Diff line change
@@ -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<bool>
{
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();
}

/// <summary />
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;

/// <summary />
private IJSObjectReference? Module { get; set; }

/// <summary>
/// Gets or sets the content to be rendered inside the component.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }

[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(CheckboxChangeEventArgs))]
/// <summary>
/// Gets or sets a value indicating whether the CheckBox will allow three check states rather than two.
/// </summary>
[Parameter]
public bool ThreeState { get; set; } = false;

public FluentCheckbox()
/// <summary>
/// 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.
/// </summary>
[Parameter]
public bool ShowIndeterminate { get; set; } = true;

/// <summary>
/// Gets or sets the state of the CheckBox: true, false or null.
/// </summary>
[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<bool?> CheckStateChanged { get; set; }

/// <summary />
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;
}
}

/// <summary />
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<IJSObjectReference>("import", JAVASCRIPT_FILE);
await Module.InvokeVoidAsync("setFluentCheckBoxIndeterminate", Id, intermediate, Value);

_intermediate = intermediate;
}

/// <summary />
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);
}
}

/// <summary />
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);
}
}

/// <summary />
private async Task UpdateAndRaiseCheckStateEvent(bool? value)
{
if (_checkState != value)
{
_checkState = value;

if (CheckStateChanged.HasDelegate)
{
await CheckStateChanged.InvokeAsync(value);
}
}
}

/// <summary />
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)}'.");

}
12 changes: 12 additions & 0 deletions src/Core/Components/Checkbox/FluentCheckbox.razor.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 1 addition & 1 deletion src/Core/Components/Switch/FluentSwitch.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/Core/Events/CheckboxChangeEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public class CheckboxChangeEventArgs : EventArgs
{
public bool Checked { get; set; }
public bool? Checked { get; set; }
public bool? Indeterminate { get; set; }
}
Loading

0 comments on commit 17ec5ed

Please sign in to comment.