diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 2376104993..54f0255f2b 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -7651,9 +7651,25 @@ - + + + + + + + - Gets or sets the maximum value. + Gets or sets the number of elements. + + + + + 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. @@ -7674,30 +7690,35 @@ 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/RatingCustomization.razor b/examples/Demo/Shared/Pages/Rating/Examples/RatingCustomization.razor new file mode 100644 index 0000000000..9c8b167b16 --- /dev/null +++ b/examples/Demo/Shared/Pages/Rating/Examples/RatingCustomization.razor @@ -0,0 +1,96 @@ +

Example

+ + + + + Value: @_value + + +
+ + +
+ + + + + +
+
+ + + + + +
+
+ +@code +{ + readonly string FixedWidth = "max-width: 100px; min-width: 100px; margin: 10px;"; + readonly List IconsName = ["Heart", "Star", "Alert", "PersonCircle"]; + readonly Icon[] IconsFilled = new Icon[] + { + new Icons.Filled.Size20.Heart(), + new Icons.Filled.Size20.Star(), + new Icons.Filled.Size20.Alert(), + new Icons.Filled.Size20.PersonCircle(), + }; + readonly Icon[] IconsOutline = new Icon[] + { + new Icons.Regular.Size20.Heart(), + new Icons.Regular.Size20.Star(), + new Icons.Regular.Size20.Alert(), + new Icons.Regular.Size20.PersonCircle(), + }; + + bool _readOnly = false; + bool _disabled = false; + bool _allowReset = false; + int _maxValue = 10; + int _value = 2; + Color _iconColor = Color.Error; + + Icon _iconFilled = new Icons.Filled.Size20.Star(); + Icon _iconOutline = new Icons.Regular.Size20.Star(); + + + void SetIcon(string? name) + { + var index = name is null ? 0 : IconsName.IndexOf(name); + + _iconFilled = IconsFilled[index]; + _iconOutline = IconsOutline[index]; + } + + protected override void OnParametersSet() => SetIcon(null); +} diff --git a/examples/Demo/Shared/Pages/Rating/Examples/RatingDefault.razor b/examples/Demo/Shared/Pages/Rating/Examples/RatingDefault.razor index e29cc8747d..c77308bce6 100644 --- a/examples/Demo/Shared/Pages/Rating/Examples/RatingDefault.razor +++ b/examples/Demo/Shared/Pages/Rating/Examples/RatingDefault.razor @@ -1,2 +1,28 @@  - + + + @LabelText + + + + Clear Rating + + +@code +{ + int SelectedValue = 0; + int? HoverValue = null; + + private string LabelText => (HoverValue ?? SelectedValue) switch + { + 1 => "Very bad", + 2 => "Bad", + 3 => "Sufficient", + 4 => "Good", + 5 => "Awesome!", + _ => "Rate our product!" + }; +} diff --git a/examples/Demo/Shared/Pages/Rating/Examples/RatingEvent.razor b/examples/Demo/Shared/Pages/Rating/Examples/RatingEvent.razor deleted file mode 100644 index 2decd1a47d..0000000000 --- a/examples/Demo/Shared/Pages/Rating/Examples/RatingEvent.razor +++ /dev/null @@ -1,38 +0,0 @@ -

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 deleted file mode 100644 index 4406034560..0000000000 --- a/examples/Demo/Shared/Pages/Rating/Examples/RatingExample.razor +++ /dev/null @@ -1,69 +0,0 @@ -

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 index a84073aa86..8f0969a788 100644 --- a/examples/Demo/Shared/Pages/Rating/RatingPage.razor +++ b/examples/Demo/Shared/Pages/Rating/RatingPage.razor @@ -14,20 +14,14 @@

Accessibility

- You can use the arrow keys to increase ( / ) or decrease ( / ) the value. Pressing Shift + arrow sets the value to 0 or the maximum. + You can use the arrow keys to increase ( / ) or decrease ( / ) the value.

Examples

- - - - - You can use the OnPointerOver event to capture a changing rating value. - - +

Documentation

diff --git a/src/Core/Components/Rating/FluentRating.razor b/src/Core/Components/Rating/FluentRating.razor index ca5c8ea961..ac352ec85c 100644 --- a/src/Core/Components/Rating/FluentRating.razor +++ b/src/Core/Components/Rating/FluentRating.razor @@ -1,45 +1,45 @@ @namespace Microsoft.FluentUI.AspNetCore.Components @inherits FluentInputBase -@if (!ReadOnly && !Disabled) +@if (!string.IsNullOrEmpty(Label) || LabelTemplate is not null) { - + @LabelTemplate } -@LabelTemplate +
- - @for (int i = 1; i <= MaxValue; i++) + @foreach (var index in Enumerable.Range(1, Max)) { - var currentValue = i; @if (ReadOnly || Disabled) { - + selected="@(index == Value)" + aria-hidden="true" /> } else { - + + + OnClick="@(() => OnClickAsync(index))" + @onmouseleave="@(() => _updatingCurrentValue = false)" + @onmouseenter="@(() => OnMouseEnterAsync(index))" + selected="@(index == Value)" + aria-hidden="true" /> } } - +
diff --git a/src/Core/Components/Rating/FluentRating.razor.cs b/src/Core/Components/Rating/FluentRating.razor.cs index c566cb7b10..376c7ffcaf 100644 --- a/src/Core/Components/Rating/FluentRating.razor.cs +++ b/src/Core/Components/Rating/FluentRating.razor.cs @@ -7,74 +7,76 @@ namespace Microsoft.FluentUI.AspNetCore.Components; public partial class FluentRating : FluentInputBase { - private int? _mouseOverValue; - private bool _mouseOverDisabled; + private bool _updatingCurrentValue = false; + private int? _hoverValue = null; - public FluentRating() => Id = Identifier.NewId(); + /// + protected override string? ClassValue => new CssBuilder(base.ClassValue) + .AddClass("fluent-rating") + .Build(); + + /// + protected override string? StyleValue => new StyleBuilder(base.StyleValue).Build(); /// - /// Gets or sets the maximum value. + /// Gets or sets the number of elements. /// [Parameter] - public int MaxValue { get; set; } = 5; + public int Max { get; set; } = 5; /// - /// Gets or sets the icon drawing and fill color. - /// Value comes from the enumeration. Defaults to Accent. + /// The icon to display when the rating value is greater than or equal to the item's value. /// [Parameter] - public Color? IconColor { get; set; } + public Icon IconFilled { get; set; } = new CoreIcons.Filled.Size20.Star(); /// - /// 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 to display when the rating value is less than the item's value. /// [Parameter] - public string? IconCustomColor { get; set; } + public Icon IconOutline { get; set; } = new CoreIcons.Regular.Size20.Star(); /// - /// The icon width. + /// Gets or sets the icon drawing and fill color. + /// Value comes from the enumeration. Defaults to Accent. /// [Parameter] - public string IconWidth { get; set; } = "28px"; + public Color? IconColor { get; set; } /// - /// The icon to display when the rating value is greater than or equal to the item's value. + /// 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 Icon IconFilled { get; set; } = new CoreIcons.Filled.Size20.Star(); + public string? IconCustomColor { get; set; } /// - /// The icon to display when the rating value is less than the item's value. + /// The icon width. /// [Parameter] - public Icon IconOutline { get; set; } = new CoreIcons.Regular.Size20.Star(); + public string IconWidth { get; set; } = "28px"; /// /// Gets or sets a value that whether to allow clear when click again. /// [Parameter] - public bool AllowReset { get; set; } + public bool AllowReset { get; set; } = false; /// /// Fires when hovered value changes. Value will be null if no rating item is hovered. /// [Parameter] - public EventCallback OnPointerOver { get; set; } + public EventCallback OnHoverValueChanged { get; set; } /// - protected override string? ClassValue => new CssBuilder(base.ClassValue) - .AddClass("fluent-rating") - .Build(); + private string GroupName => Id ?? $"rating-{Id}"; /// - protected override string? StyleValue => new StyleBuilder(base.StyleValue).Build(); - - private Icon GetIcon(int index) => index <= (_mouseOverValue ?? Value) ? IconFilled : IconOutline; + private Icon GetIcon(int index) => index <= (_hoverValue ?? Value) ? IconFilled : IconOutline; - protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out int result, [NotNullWhen(false)] out string? - validationErrorMessage) + /// + protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out int result, [NotNullWhen(false)] out string? validationErrorMessage) { if (BindConverter.TryConvertTo(value, CultureInfo.InvariantCulture, out result)) { @@ -90,60 +92,53 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa } } - protected internal async Task HandleKeyDownAsync(FluentKeyCodeEventArgs e) + /// + private async Task OnClickAsync(int value, bool fromFocus = false) { - if (e.TargetId != Id) + _updatingCurrentValue = true; + + // Reset ? + if (AllowReset && value == Value && !fromFocus) { - return; + await SetCurrentValueAsync(0); + await UpdateHoverValueAsync(null); } - - int value = e.Key switch + else { - 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); + await SetCurrentValueAsync(value); + } } - private async Task OnPointerOutAsync() + /// + private async Task OnMouseEnterAsync(int value) { - _mouseOverValue = null; - _mouseOverDisabled = false; - if (OnPointerOver.HasDelegate) + if (_updatingCurrentValue) { - await OnPointerOver.InvokeAsync(_mouseOverValue); + return; } + + await UpdateHoverValueAsync(value); + } + + /// + private async Task OnMouseLeaveAsync() + { + await UpdateHoverValueAsync(null); } - private async Task OnPointerOverAsync(int value) + /// + private async Task UpdateHoverValueAsync(int? value) { - if (_mouseOverDisabled) + if (_hoverValue == value) { return; } - _mouseOverValue = value; - if (OnPointerOver.HasDelegate) - { - await OnPointerOver.InvokeAsync(_mouseOverValue); - } - } + _hoverValue = value; - private async Task OnClickAsync(int value) - { - if (value == Value && AllowReset) + if (OnHoverValueChanged.HasDelegate) { - value = 0; - _mouseOverValue = null; - _mouseOverDisabled = true; + await OnHoverValueChanged.InvokeAsync(value); } - await SetCurrentValueAsync(value); } } diff --git a/src/Core/Components/Rating/FluentRating.razor.css b/src/Core/Components/Rating/FluentRating.razor.css new file mode 100644 index 0000000000..04a7d8ccd7 --- /dev/null +++ b/src/Core/Components/Rating/FluentRating.razor.css @@ -0,0 +1,18 @@ +.fluent-rating { + justify-content: start; + align-items: start; + column-gap: 0px; +} + + .fluent-rating ::deep input[type='radio'] { + opacity: 0; + position: absolute; + width: 0px; + height: 0px; + } + + .fluent-rating:focus-within ::deep svg[selected] { + outline-style: auto; + outline-color: var(--focus-stroke-outer); + outline-width: var(--focus-stroke-width); + } diff --git a/tests/Core/Rating/FluentRatingTests.FluentRating_Empty.verified.razor.html b/tests/Core/Rating/FluentRatingTests.FluentRating_Empty.verified.razor.html index e691ceb16e..802c672852 100644 --- a/tests/Core/Rating/FluentRatingTests.FluentRating_Empty.verified.razor.html +++ b/tests/Core/Rating/FluentRatingTests.FluentRating_Empty.verified.razor.html @@ -1,20 +1,23 @@ - -
-
+ + -
\ No newline at end of file +
diff --git a/tests/Core/Rating/FluentRatingTests.FluentRating_Label.verified.razor.html b/tests/Core/Rating/FluentRatingTests.FluentRating_Label.verified.razor.html new file mode 100644 index 0000000000..afa83bb68a --- /dev/null +++ b/tests/Core/Rating/FluentRatingTests.FluentRating_Label.verified.razor.html @@ -0,0 +1,25 @@ + + +
+ + + + + + + + + + +
diff --git a/tests/Core/Rating/FluentRatingTests.FluentRating_LabelTemplate.verified.razor.html b/tests/Core/Rating/FluentRatingTests.FluentRating_LabelTemplate.verified.razor.html new file mode 100644 index 0000000000..57a7aad7a8 --- /dev/null +++ b/tests/Core/Rating/FluentRatingTests.FluentRating_LabelTemplate.verified.razor.html @@ -0,0 +1,28 @@ + + +
+ + + + + + + + + + +
diff --git a/tests/Core/Rating/FluentRatingTests.FluentRating_Value.verified.razor.html b/tests/Core/Rating/FluentRatingTests.FluentRating_Value.verified.razor.html index ca192a3250..dad2dea65e 100644 --- a/tests/Core/Rating/FluentRatingTests.FluentRating_Value.verified.razor.html +++ b/tests/Core/Rating/FluentRatingTests.FluentRating_Value.verified.razor.html @@ -1,35 +1,43 @@ - -
-
+ + -
\ No newline at end of file +
diff --git a/tests/Core/Rating/FluentRatingTests.razor b/tests/Core/Rating/FluentRatingTests.razor index c115e0b4f0..f79e9b4e60 100644 --- a/tests/Core/Rating/FluentRatingTests.razor +++ b/tests/Core/Rating/FluentRatingTests.razor @@ -18,11 +18,31 @@ cut.Verify(); } + [Fact] + public void FluentRating_Label() + { + // Arrange && Act + var cut = Render(@); + + // Assert + cut.Verify(); + } + + [Fact] + public void FluentRating_LabelTemplate() + { + // Arrange && Act + var cut = Render(@My Label Template); + + // Assert + cut.Verify(); + } + [Fact] public void FluentRating_Value() { // Arrange && Act - var cut = Render(@); + var cut = Render(@); // Assert cut.Verify(); @@ -44,11 +64,25 @@ } [Fact] - public void FluentRating_AllowResetFalse() + public void FluentRating_FocusOnRadio() { int value = 0; // Arrange + var cut = Render(@); + + // Act: Focus on the second star + cut.FindAll("input").ElementAt(1).Focus(); + + // Assert + Assert.Equal(2, value); + } + + [Fact] + public void FluentRating_AllowResetFalse() + { + // Arrange + int value = 0; var cut = Render(@); // Act: Click twice on the second star @@ -62,9 +96,8 @@ [Fact] public void FluentRating_AllowResetTrue() { - int value = 0; - // Arrange + int value = 0; var cut = Render(@); // Act: Click twice on the second star @@ -92,9 +125,9 @@ [Fact] public void FluentRating_ReadOnly() { - int value = 3; // Arrange - var cut = Render(@); + int value = 3; + var cut = Render(@ ); // Act: Click on the second star cut.FindAll("svg").ElementAt(1).Click(); @@ -104,162 +137,66 @@ } [Fact] - public async Task FluentRating_KeyDown() + public async Task FluentRating_Hover() { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; - // Arrange - var cut = Render(@); - - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(40, "ArrowDown", false, false, false, false, 0, "myrating")); - - // Assert - Assert.Equal(2, value); - } - - [Fact] - public async Task FluentRating_ShiftKeyDown() - { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; // Arrange - var cut = Render(@); + int value = 0; + int? hover = null; + var cut = Render(@); // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(40, "ArrowDown", false, true, false, false, 0, "myrating")); + var item = cut.FindAll("svg").ElementAt(3); + await item.TriggerEventAsync("onmouseenter", new MouseEventArgs()); // Assert Assert.Equal(0, value); + Assert.Equal(4, hover); } [Fact] - public async Task FluentRating_KeyLeft() + public async Task FluentRating_HoverMultiple() { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; // Arrange - var cut = Render(@); + int value = 0; + int? hover = null; + var cut = Render(@); - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(37, "ArrowLeft", false, false, false, false, 0, "myrating")); + // Enter on the 4th star + var item4 = cut.FindAll("svg").ElementAt(3); + await item4.TriggerEventAsync("onmouseenter", new MouseEventArgs()); - // Assert - Assert.Equal(2, value); - } + Assert.Equal(4, hover); - [Fact] - public async Task FluentRating_ShiftKeyLeft() - { - JSInterop.Mode = JSRuntimeMode.Loose; + // Enter on the 3rd star + var item3 = cut.FindAll("svg").ElementAt(2); + await item3.TriggerEventAsync("onmouseenter", new MouseEventArgs()); - int value = 3; - // Arrange - var cut = Render(@); + Assert.Equal(3, hover); - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(37, "ArrowLeft", false, true, false, false, 0, "myrating")); + // Leave the 3rd star + await item3.TriggerEventAsync("onmouseleave", new MouseEventArgs()); - // Assert Assert.Equal(0, value); } [Fact] - public async Task FluentRating_KeyUp() + public async Task FluentRating_HoverAndLeave() { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; - // Arrange - var cut = Render(@); - - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(38, "ArrowUp", false, false, false, false, 0, "myrating")); - - // Assert - Assert.Equal(4, value); - } - - [Fact] - public async Task FluentRating_ShiftKeyUp() - { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; - // Arrange - var cut = Render(@); - - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(38, "ArrowUp", false, true, false, false, 0, "myrating")); - - // Assert - Assert.Equal(5, value); - } - - [Fact] - public async Task FluentRating_KeyRight() - { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; - // Arrange - var cut = Render(@); - - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(39, "ArrowRight", false, false, false, false, 0, "myrating")); - - // Assert - Assert.Equal(4, value); - } - - [Fact] - public async Task FluentRating_ShiftKeyRight() - { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; // Arrange - var cut = Render(@); - - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(39, "ArrowRight", false, true, false, false, 0, "myrating")); - - // Assert - Assert.Equal(5, value); - } - - [Fact] - public async Task FluentRating_KeyRandom() - { - JSInterop.Mode = JSRuntimeMode.Loose; - - int value = 3; - // Arrange - var cut = Render(@); - - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(65, "A", false, false, false, false, 0, "myrating")); - - // Assert - Assert.Equal(3, value); - } + int value = 0; + int? hover = null; + var cut = Render(@); - [Fact] - public async Task FluentRating_KeyRandomWithDifferentId() - { - JSInterop.Mode = JSRuntimeMode.Loose; + // Act: Hover + var item = cut.FindAll("svg").ElementAt(3); + await item.TriggerEventAsync("onmouseenter", new MouseEventArgs()); - int value = 3; - // Arrange - var cut = Render(@); + Assert.Equal(4, hover); - // Act - await cut.InvokeAsync(() => cut.FindComponent().Instance.OnKeyDownRaisedAsync(65, "A", false, false, false, false, 0, "myrating2")); + // Act: Leave + var rating = cut.Find("div"); + await rating.TriggerEventAsync("onmouseleave", new MouseEventArgs()); - // Assert - Assert.Equal(3, value); + Assert.Null(hover); } }