diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index c847541d17..3c32e73e8a 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -7559,6 +7559,55 @@ + + + Gets or sets the maximum value. + + + + + Gets or sets the icon drawing and fill color. + Value comes from the enumeration. Defaults to Accent. + + + + + Gets or sets the icon drawing and fill color to a custom value. + Needs to be formatted as an HTML hex color string (#rrggbb or #rgb) or CSS variable. + ⚠️ Only available when Color is set to Color.Custom. + + + + + The icon width. + + + + + The icon to display when the rating value is greater than or equal to the item's value. + + + + + The icon to display when the rating value is less than the item's value. + + + + + Gets or sets a value that whether to allow clear when click again. + + + + + Fires when hovered value changes. Value will be null if no rating item is hovered. + + + + + + + + diff --git a/examples/Demo/Shared/Pages/Rating/Examples/RatingDefault.razor b/examples/Demo/Shared/Pages/Rating/Examples/RatingDefault.razor new file mode 100644 index 0000000000..e29cc8747d --- /dev/null +++ b/examples/Demo/Shared/Pages/Rating/Examples/RatingDefault.razor @@ -0,0 +1,2 @@ + + diff --git a/examples/Demo/Shared/Pages/Rating/Examples/RatingEvent.razor b/examples/Demo/Shared/Pages/Rating/Examples/RatingEvent.razor new file mode 100644 index 0000000000..2decd1a47d --- /dev/null +++ b/examples/Demo/Shared/Pages/Rating/Examples/RatingEvent.razor @@ -0,0 +1,38 @@ +

Event

+ + + + +
Value: @_value
+
Hovered value: @_overedValue
+
Hovered text value: @_overedTextValue
+
+ +@code +{ + int _value = 2; + int? _overedValue; + string _overedTextValue = default!; + + private void OnPointerOver(int? value) + { + _overedValue = value; + _overedTextValue = value.HasValue + ? new string[] + { + "Very bad", + "Bad", + "Sufficient -2", + "Sufficient -1", + "Sufficient", + "Good -4", + "Good -3", + "Good -2" , + "Good -1", + "Good" + }[value.Value - 1] + : string.Empty; + } +} diff --git a/examples/Demo/Shared/Pages/Rating/Examples/RatingExample.razor b/examples/Demo/Shared/Pages/Rating/Examples/RatingExample.razor new file mode 100644 index 0000000000..4406034560 --- /dev/null +++ b/examples/Demo/Shared/Pages/Rating/Examples/RatingExample.razor @@ -0,0 +1,69 @@ +

Example

+ + + + + + + + + + + + + + + + +
Value: @_value
+
+ +@code +{ + bool _readOnly; + bool _disabled; + bool _allowReset; + int _maxValue = 10; + int _value = 2; + Color _iconColor = Color.Accent; + + Icon _iconFilled = new Icons.Filled.Size20.Star(); + Icon _iconOutline = new Icons.Regular.Size20.Star(); + List _icons = ["Star", "Heart", "Alert", "PersonCircle"]; + + private void SelectedOptionChanged(string name) => SetIcon(_icons.IndexOf(name)); + + private void SetIcon(int index) + { + _iconFilled = new Icon[] + { new Icons.Filled.Size20.Star(), + new Icons.Filled.Size20.Heart(), + new Icons.Filled.Size20.Alert(), + new Icons.Filled.Size20.PersonCircle(), + }[index]; + + _iconOutline = new Icon[] + { new Icons.Regular.Size20.Star(), + new Icons.Regular.Size20.Heart(), + new Icons.Regular.Size20.Alert(), + new Icons.Regular.Size20.PersonCircle(), + }[index]; + } + + protected override void OnParametersSet() => SetIcon(0); +} diff --git a/examples/Demo/Shared/Pages/Rating/RatingPage.razor b/examples/Demo/Shared/Pages/Rating/RatingPage.razor new file mode 100644 index 0000000000..ee4dd289e0 --- /dev/null +++ b/examples/Demo/Shared/Pages/Rating/RatingPage.razor @@ -0,0 +1,30 @@ +@page "/Rating" +@using FluentUI.Demo.Shared.Pages.Rating.Examples + +@App.PageTitle("Rating") + +

Rating

+ +

+ A <FluentRating> component allows users to provide a rating for a particular item. + <FluentRating> allows customers to determine a sense of value of a good or a service. + By default, the rating is selected out of 5 stars, but the number and symbol used can be customized. +

+ +

Examples

+ + + + + + +

Accessibility

+

+ You can use the arrow keys to increase or decrease the value. Pressing Shift+arrow changes the value to 0 or the maximum value. +

+ + + +

Documentation

+ + diff --git a/examples/Demo/Shared/Shared/DemoNavProvider.cs b/examples/Demo/Shared/Shared/DemoNavProvider.cs index b2443934f8..4cba6dae4e 100644 --- a/examples/Demo/Shared/Shared/DemoNavProvider.cs +++ b/examples/Demo/Shared/Shared/DemoNavProvider.cs @@ -231,6 +231,11 @@ public DemoNavProvider() icon: new Icons.Regular.Size20.RadioButton(), title: "Radio Group" ), + new NavLink( + href: "/Rating", + icon: new Icons.Regular.Size20.Star(), + title: "Rating" + ), new NavLink( href: "/Search", icon: new Icons.Regular.Size20.SearchSquare(), diff --git a/src/Core/Components/Icons/CoreIcons.cs b/src/Core/Components/Icons/CoreIcons.cs index d1ab13659e..dfb7bf051f 100644 --- a/src/Core/Components/Icons/CoreIcons.cs +++ b/src/Core/Components/Icons/CoreIcons.cs @@ -41,7 +41,8 @@ public class Info : Icon { public Info() : base("Info", IconVariant.Filled, Icon public class Warning : Icon { public Warning() : base("Warning", IconVariant.Filled, IconSize.Size20, "") { } } public class CheckboxChecked : Icon { public CheckboxChecked() : base("CheckboxChecked", IconVariant.Filled, IconSize.Size20, "") { } } public class CheckboxIndeterminate : Icon { public CheckboxIndeterminate() : base("CheckboxIndeterminate", IconVariant.Filled, IconSize.Size20, "") { } } - public class RadioButton : Icon { public RadioButton() : base("RadioButton", IconVariant.Filled, IconSize.Size20, "") { } }; + public class RadioButton : Icon { public RadioButton() : base("RadioButton", IconVariant.Regular, IconSize.Size20, "") { } }; + public class Star : Icon { public Star() : base("Star", IconVariant.Filled, IconSize.Size20, "") { } }; } } @@ -99,6 +100,7 @@ public class Dismiss : Icon { public Dismiss() : base("Dismiss", IconVariant.Reg public class DismissCircle : Icon { public DismissCircle() : base("DismissCircle", IconVariant.Regular, IconSize.Size20, "") { } } public class CheckboxUnchecked : Icon { public CheckboxUnchecked() : base("CheckboxUnchecked", IconVariant.Regular, IconSize.Size20, "") { } } public class RadioButton : Icon { public RadioButton() : base("RadioButton", IconVariant.Regular, IconSize.Size20, "") { } }; + public class Star : Icon { public Star() : base("Star", IconVariant.Regular, IconSize.Size20, "") { } }; } } diff --git a/src/Core/Components/Rating/FluentRating.razor b/src/Core/Components/Rating/FluentRating.razor new file mode 100644 index 0000000000..0106a40652 --- /dev/null +++ b/src/Core/Components/Rating/FluentRating.razor @@ -0,0 +1,43 @@ +@namespace Microsoft.FluentUI.AspNetCore.Components +@inherits FluentInputBase + +@if (!ReadOnly && !Disabled) +{ + +} + +@LabelTemplate + + + @for (int i = 1; i <= MaxValue; i++) + { + var currentValue = i; + @if (ReadOnly || Disabled) + { + + } + else + { + + } + } + diff --git a/src/Core/Components/Rating/FluentRating.razor.cs b/src/Core/Components/Rating/FluentRating.razor.cs new file mode 100644 index 0000000000..c566cb7b10 --- /dev/null +++ b/src/Core/Components/Rating/FluentRating.razor.cs @@ -0,0 +1,149 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.FluentUI.AspNetCore.Components.Utilities; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Microsoft.FluentUI.AspNetCore.Components; + +public partial class FluentRating : FluentInputBase +{ + private int? _mouseOverValue; + private bool _mouseOverDisabled; + + public FluentRating() => Id = Identifier.NewId(); + + /// + /// Gets or sets the maximum value. + /// + [Parameter] + public int MaxValue { get; set; } = 5; + + /// + /// Gets or sets the icon drawing and fill color. + /// Value comes from the enumeration. Defaults to Accent. + /// + [Parameter] + public Color? IconColor { get; set; } + + /// + /// Gets or sets the icon drawing and fill color to a custom value. + /// Needs to be formatted as an HTML hex color string (#rrggbb or #rgb) or CSS variable. + /// ⚠️ Only available when Color is set to Color.Custom. + /// + [Parameter] + public string? IconCustomColor { get; set; } + + /// + /// The icon width. + /// + [Parameter] + public string IconWidth { get; set; } = "28px"; + + /// + /// The icon to display when the rating value is greater than or equal to the item's value. + /// + [Parameter] + public Icon IconFilled { get; set; } = new CoreIcons.Filled.Size20.Star(); + + /// + /// The icon to display when the rating value is less than the item's value. + /// + [Parameter] + public Icon IconOutline { get; set; } = new CoreIcons.Regular.Size20.Star(); + + /// + /// Gets or sets a value that whether to allow clear when click again. + /// + [Parameter] + public bool AllowReset { get; set; } + + /// + /// Fires when hovered value changes. Value will be null if no rating item is hovered. + /// + [Parameter] + public EventCallback OnPointerOver { get; set; } + + /// + protected override string? ClassValue => new CssBuilder(base.ClassValue) + .AddClass("fluent-rating") + .Build(); + + /// + protected override string? StyleValue => new StyleBuilder(base.StyleValue).Build(); + + private Icon GetIcon(int index) => index <= (_mouseOverValue ?? Value) ? IconFilled : IconOutline; + + protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out int result, [NotNullWhen(false)] out string? + validationErrorMessage) + { + if (BindConverter.TryConvertTo(value, CultureInfo.InvariantCulture, out result)) + { + validationErrorMessage = null; + return true; + } + else + { + validationErrorMessage = string.Format(CultureInfo.InvariantCulture, + "The {0} field must be a number.", + FieldBound ? FieldIdentifier.FieldName : UnknownBoundField); + return false; + } + } + + protected internal async Task HandleKeyDownAsync(FluentKeyCodeEventArgs e) + { + if (e.TargetId != Id) + { + return; + } + + int value = e.Key switch + { + KeyCode.Right or KeyCode.Up when e.ShiftKey => value = MaxValue, + KeyCode.Right or KeyCode.Up => Math.Min(Value + 1, MaxValue), + KeyCode.Left or KeyCode.Down when e.ShiftKey => value = 0, + KeyCode.Left or KeyCode.Down => Math.Max(Value - 1, 1), + _ => Value + }; + + _mouseOverValue = null; + _mouseOverDisabled = true; + + await SetCurrentValueAsync(value); + } + + private async Task OnPointerOutAsync() + { + _mouseOverValue = null; + _mouseOverDisabled = false; + if (OnPointerOver.HasDelegate) + { + await OnPointerOver.InvokeAsync(_mouseOverValue); + } + } + + private async Task OnPointerOverAsync(int value) + { + if (_mouseOverDisabled) + { + return; + } + + _mouseOverValue = value; + if (OnPointerOver.HasDelegate) + { + await OnPointerOver.InvokeAsync(_mouseOverValue); + } + } + + private async Task OnClickAsync(int value) + { + if (value == Value && AllowReset) + { + value = 0; + _mouseOverValue = null; + _mouseOverDisabled = true; + } + await SetCurrentValueAsync(value); + } +} diff --git a/tests/Core/Rating/FluentRatingTests.FluentRating_Empty.verified.razor.html b/tests/Core/Rating/FluentRatingTests.FluentRating_Empty.verified.razor.html new file mode 100644 index 0000000000..bc82c8c16a --- /dev/null +++ b/tests/Core/Rating/FluentRatingTests.FluentRating_Empty.verified.razor.html @@ -0,0 +1,20 @@ + + +
+ + + + + +
\ No newline at end of file diff --git a/tests/Core/Rating/FluentRatingTests.FluentRating_Value.verified.razor.html b/tests/Core/Rating/FluentRatingTests.FluentRating_Value.verified.razor.html new file mode 100644 index 0000000000..5e20fd113b --- /dev/null +++ b/tests/Core/Rating/FluentRatingTests.FluentRating_Value.verified.razor.html @@ -0,0 +1,35 @@ + + +
+ + + + + + + + + + +
\ No newline at end of file diff --git a/tests/Core/Rating/FluentRatingTests.razor b/tests/Core/Rating/FluentRatingTests.razor new file mode 100644 index 0000000000..6a6aaa243c --- /dev/null +++ b/tests/Core/Rating/FluentRatingTests.razor @@ -0,0 +1,77 @@ +@using Xunit; +@inherits TestContext +@code +{ + public FluentRatingTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddSingleton(new LibraryConfiguration()); + } + + [Fact] + public void FluentRating_Empty() + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentRating_Value() + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentRating_SelectValue() + { + int value = 0; + + // Arrange + var cut = Render(@); + + // Act: Click on the second star + cut.FindAll("svg").ElementAt(1).Click(); + + // Assert + Assert.Equal(2, value); + } + + [Fact] + public void FluentRating_AllowResetFalse() + { + int value = 0; + + // Arrange + var cut = Render(@); + + // Act: Click twice on the second star + cut.FindAll("svg").ElementAt(1).Click(); + cut.FindAll("svg").ElementAt(1).Click(); + + // Assert + Assert.Equal(2, value); + } + + [Fact] + public void FluentRating_AllowResetTrue() + { + int value = 0; + + // Arrange + var cut = Render(@); + + // Act: Click twice on the second star + cut.FindAll("svg").ElementAt(1).Click(); + cut.FindAll("svg").ElementAt(1).Click(); + + // Assert + Assert.Equal(0, value); + } +}