diff --git a/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs b/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs index 2121bed2b..50791dba8 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs +++ b/BlazorBootstrap.Demo.RCL/Components/Layout/DemosMainLayout.razor.cs @@ -28,6 +28,7 @@ internal override IEnumerable GetNavItems() new (){ Id = "406", Text = "OTP Input", Href = DemoRouteConstants.Demos_URL_OTPInput, IconName = IconName.Asterisk, ParentId = "4" }, new (){ Id = "407", Text = "Password Input", Href = DemoRouteConstants.Demos_URL_PasswordInput, IconName = IconName.EyeSlashFill, ParentId = "4" }, new (){ Id = "408", Text = "Radio Input", Href = DemoRouteConstants.Demos_URL_RadioInput, IconName = IconName.RecordCircle, ParentId = "4" }, + new (){ Id = "491", Text = "Rating Input", Href = DemoRouteConstants.Demos_URL_RatingInput, IconName = IconName.StarHalf, ParentId = "4" }, new (){ Id = "409", Text = "Range Input", Href = DemoRouteConstants.Demos_URL_RangeInput, IconName = IconName.Sliders, ParentId = "4" }, //new (){ Id = "404", Text = "Select Input", Href = DemoRouteConstants.Demos_URL_SelectInput, IconName = IconName.MenuButtonWideFill, ParentId = "4" }, new (){ Id = "416", Text = "RichTextEditor", Href = DemoRouteConstants.Demos_URL_RichTextEditor, IconName = IconName.Type, ParentId = "4" }, diff --git a/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs b/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs index 5efd1fb9f..8d0c60a3f 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs +++ b/BlazorBootstrap.Demo.RCL/Components/Layout/DocsMainLayout.razor.cs @@ -27,6 +27,7 @@ internal override IEnumerable GetNavItems() new (){ Id = "406", Text = "OTP Input", Href = DemoRouteConstants.Docs_URL_OTPInput, IconName = IconName.Asterisk, ParentId = "4" }, new (){ Id = "407", Text = "Password Input", Href = DemoRouteConstants.Docs_URL_PasswordInput, IconName = IconName.EyeSlashFill, ParentId = "4" }, new (){ Id = "408", Text = "Radio Input", Href = DemoRouteConstants.Docs_URL_RadioInput, IconName = IconName.RecordCircle, ParentId = "4" }, + new (){ Id = "491", Text = "Rating Input", Href = DemoRouteConstants.Docs_URL_RatingInput, IconName = IconName.StarHalf, ParentId = "4" }, new (){ Id = "409", Text = "Range Input", Href = DemoRouteConstants.Docs_URL_RangeInput, IconName = IconName.Sliders, ParentId = "4" }, //new (){ Id = "404", Text = "Select Input", Href = DemoRouteConstants.Docs_URL_SelectInput, IconName = IconName.MenuButtonWideFill, ParentId = "4" }, new (){ Id = "416", Text = "RichTextEditor", Href = DemoRouteConstants.Docs_URL_RichTextEditor, IconName = IconName.Type, ParentId = "4" }, diff --git a/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs b/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs index 603f63583..dc49cdc30 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs +++ b/BlazorBootstrap.Demo.RCL/Components/Layout/MainLayout.razor.cs @@ -27,6 +27,7 @@ internal override IEnumerable GetNavItems() new (){ Id = "405", Text = "Number Input", Href = DemoRouteConstants.Demos_URL_NumberInput, IconName = IconName.InputCursor, ParentId = "4" }, new (){ Id = "406", Text = "Password Input", Href = DemoRouteConstants.Demos_URL_PasswordInput, IconName = IconName.EyeSlashFill, ParentId = "4" }, new (){ Id = "407", Text = "Radio Input", Href = DemoRouteConstants.Demos_URL_RadioInput, IconName = IconName.RecordCircle, ParentId = "4" }, + new (){ Id = "491", Text = "Rating Input", Href = DemoRouteConstants.Demos_URL_RatingInput, IconName = IconName.StarHalf, ParentId = "4" }, new (){ Id = "408", Text = "Range Input", Href = DemoRouteConstants.Demos_URL_RangeInput, IconName = IconName.Sliders, ParentId = "4" }, //new (){ Id = "404", Text = "Select Input", Href = DemoRouteConstants.Demos_URL_SelectInput, IconName = IconName.MenuButtonWideFill, ParentId = "4" }, new (){ Id = "409", Text = "Switch", Href = DemoRouteConstants.Demos_URL_Switch, IconName = IconName.ToggleOn, ParentId = "4" }, diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInputDocumentation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInputDocumentation.razor new file mode 100644 index 000000000..70a183a9d --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInputDocumentation.razor @@ -0,0 +1,188 @@ +@attribute [Route(pageUrl)] +@layout DemosMainLayout + + + + + + + RatingInput renders Bootstrap Icons. Reference the Bootstrap Icons stylesheet in your app, because Blazor Bootstrap does not bundle it. + + +
+
+ RatingInput lets users pick a score by clicking an icon or using the arrow keys. +
+ How to use: +
+
    +
  1. Add RatingInput and set TValue to int, int?, decimal, or decimal?.
  2. +
  3. Bind the score with @@bind-Value.
  4. +
  5. Optionally set MaxValue to change the number of icons (default 5).
  6. +
  7. Give the group an accessible name with aria-label or aria-labelledby.
  8. +
+
+ This demo binds an int score out of 5. +
+ +
+ +
+
+ Set ReadOnly to display a score that users cannot change. Fractional values are shown to the nearest half, and assistive technology announces the exact value. +
+ How to use: +
+
    +
  1. Set ReadOnly="true".
  2. +
  3. Bind a decimal value such as 4.3m.
  4. +
  5. Expect the display to round to the nearest half: 4.3 shows 4½ and 4.2 shows 4.
  6. +
+
+ This demo shows three read-only averages. +
+ +
+ +
+
+ Replace the default stars with any Bootstrap icon. +
+ How to use: +
+
    +
  1. Set FilledIconName for selected items.
  2. +
  3. Set EmptyIconName for unselected items.
  4. +
  5. Set HalfIconName for half-filled items shown for fractional values.
  6. +
+
+ This demo uses hearts and circles. +
+ +
+ +
+
+ Change the icon size with the Size parameter. +
+ How to use: +
+
    +
  1. Set Size to RatingInputSize.Small, RatingInputSize.Default, or RatingInputSize.Large.
  2. +
+
+ This demo shows each size. +
+ +
+ +
+
+ Change the icon color with the Color parameter. The default is RatingInputColor.Warning. +
+ How to use: +
+
    +
  1. Set Color to a RatingInputColor value such as RatingInputColor.Primary.
  2. +
  3. Use RatingInputColor.None to inherit the surrounding text color.
  4. +
+
+ This demo shows the theme colors. +
+ +
+ +
+
+ A disabled rating input cannot be focused or changed. +
+ How to use: +
+
    +
  1. Set Disabled="true".
  2. +
+
+ This demo toggles the disabled state. +
+ +
+ +
+
+ With AllowClear, clicking the selected icon again clears the value. +
+ How to use: +
+
    +
  1. Set AllowClear="true".
  2. +
  3. Click the selected icon to clear it. The value becomes null for nullable types and 0 otherwise.
  4. +
+
+ This demo binds an int? value. +
+ +
+ +
+
+ With AllowHalf, users can select half values. This works only with decimal and decimal?; integer types throw an exception. +
+ How to use: +
+
    +
  1. Set TValue to decimal or decimal?.
  2. +
  3. Set AllowHalf="true".
  4. +
  5. Click the left half of an icon, or use the arrow keys, to select .5 values.
  6. +
+
+ This demo binds a decimal value in half steps. +
+ +
+ +
+
+ RatingInput takes part in EditForm validation like other form inputs. +
+ How to use: +
+
    +
  1. Place RatingInput inside an EditForm with a DataAnnotationsValidator.
  2. +
  3. Bind a model property with @@bind-Value.
  4. +
  5. Add a ValidationMessage for the property.
  6. +
+
+ This demo requires a score before the form can be submitted. +
+ +
+ +
+
+ ValueChanged fires once for each change made by the user, and never when the component is read-only or disabled. +
+ How to use: +
+
    +
  1. Set Value and handle ValueChanged.
  2. +
  3. Update your field in the handler.
  4. +
+
+ This demo logs each change. +
+ +
+ +@code { + private const string pageUrl = DemoRouteConstants.Demos_URL_RatingInput; + private const string pageTitle = "Blazor RatingInput"; + private const string pageDescription = "The Blazor Bootstrap RatingInput component lets users select or display a score using icons."; + private const string metaTitle = "Blazor RatingInput Component"; + private const string metaDescription = "The Blazor Bootstrap RatingInput component lets users select or display a score using icons."; + private const string imageUrl = DemoScreenshotSrcConstants.Demos_URL_RatingInput; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_01_Basic_Usage.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_01_Basic_Usage.razor new file mode 100644 index 000000000..e685af99d --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_01_Basic_Usage.razor @@ -0,0 +1,8 @@ +
+ + @score of 5 +
+ +@code { + private int score = 3; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_02_Read_Only_Fractional.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_02_Read_Only_Fractional.razor new file mode 100644 index 000000000..d8b896a77 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_02_Read_Only_Fractional.razor @@ -0,0 +1,14 @@ +
+
+ + 4.3 +
+
+ + 3.75 +
+
+ + 4.2 +
+
diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_03_Custom_Icons.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_03_Custom_Icons.razor new file mode 100644 index 000000000..685b76152 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_03_Custom_Icons.razor @@ -0,0 +1,21 @@ +
+ + +
+ +@code { + private int hearts = 4; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_04_Sizes.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_04_Sizes.razor new file mode 100644 index 000000000..b2a55ea7c --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_04_Sizes.razor @@ -0,0 +1,9 @@ +
+ + + +
+ +@code { + private int score = 3; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_05_Colors.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_05_Colors.razor new file mode 100644 index 000000000..ef582d5a9 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_05_Colors.razor @@ -0,0 +1,22 @@ +
+ @foreach (var color in colors) + { +
+ + RatingInputColor.@color +
+ } +
+ +@code { + private readonly RatingInputColor[] colors = + { + RatingInputColor.Primary, + RatingInputColor.Secondary, + RatingInputColor.Success, + RatingInputColor.Danger, + RatingInputColor.Warning, + RatingInputColor.Info, + RatingInputColor.Dark + }; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_06_Disabled.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_06_Disabled.razor new file mode 100644 index 000000000..894c3dc0e --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_06_Disabled.razor @@ -0,0 +1,9 @@ +
+ +
+ + +@code { + private int score = 2; + private bool disabled = true; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_07_Allow_Clear.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_07_Allow_Clear.razor new file mode 100644 index 000000000..8b9137067 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_07_Allow_Clear.razor @@ -0,0 +1,8 @@ +
+ + Value: @(score?.ToString() ?? "null") +
+ +@code { + private int? score = 3; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_08_Allow_Half.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_08_Allow_Half.razor new file mode 100644 index 000000000..01dbc90fe --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_08_Allow_Half.razor @@ -0,0 +1,8 @@ +
+ + Value: @score +
+ +@code { + private decimal score = 3.5m; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_09_EditForm_Validation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_09_EditForm_Validation.razor new file mode 100644 index 000000000..6d2d7b012 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_09_EditForm_Validation.razor @@ -0,0 +1,31 @@ +@using System.ComponentModel.DataAnnotations + + + +
+ +
+ +
+ +
+ +
+ +@if (submitted) +{ +
Thanks! You rated @review.Score of 5.
+} + +@code { + private Review review = new(); + private bool submitted; + + private void HandleValidSubmit() => submitted = true; + + public class Review + { + [Required(ErrorMessage = "Please select a score.")] + public int? Score { get; set; } + } +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_10_Events_ValueChanged.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_10_Events_ValueChanged.razor new file mode 100644 index 000000000..f1a43d761 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Demos/Form/RatingInput/RatingInput_Demo_10_Events_ValueChanged.razor @@ -0,0 +1,20 @@ +
+ +
+
    + @foreach (var entry in log) + { +
  • @entry
  • + } +
+ +@code { + private int score = 2; + private readonly List log = new(); + + private void OnScoreChanged(int value) + { + score = value; // this is mandatory + log.Add($"ValueChanged: {value}"); + } +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Docs/Form/RatingInput/RatingInput_Doc_01_Documentation.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Docs/Form/RatingInput/RatingInput_Doc_01_Documentation.razor new file mode 100644 index 000000000..440a72332 --- /dev/null +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Docs/Form/RatingInput/RatingInput_Doc_01_Documentation.razor @@ -0,0 +1,33 @@ +@attribute [Route(pageUrl)] +@layout DocsMainLayout + + + + + +
+ @metaTitle +
+ +
+ +
+ +
+ +
+ +@code { + private const string componentName = nameof(RatingInput); + private const string pageUrl = DemoRouteConstants.Docs_URL_RatingInput; + private const string pageTitle = componentName; + private const string pageDescription = $"This documentation provides a comprehensive reference for the {componentName} component, guiding you through its configuration options."; + private const string metaTitle = $"Blazor {componentName} Component"; + private const string metaDescription = $"This documentation provides a comprehensive reference for the {componentName} component, guiding you through its configuration options."; + private const string imageUrl = DemoScreenshotSrcConstants.Demos_URL_RatingInput; +} diff --git a/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor b/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor index 67cf8f50f..56a9e4af1 100644 --- a/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor +++ b/BlazorBootstrap.Demo.RCL/Components/Pages/Home/Index.razor @@ -210,6 +210,11 @@

Radio Input

+ +

Range Input

diff --git a/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs b/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs index 65ab47f61..084e4bc6f 100644 --- a/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs +++ b/BlazorBootstrap.Demo.RCL/Constants/DemoRouteConstants.cs @@ -37,6 +37,7 @@ public static class DemoRouteConstants public const string Demos_URL_OTPInput = Demos_URL_Forms_Prefix + "/otp-input"; public const string Demos_URL_PasswordInput = Demos_URL_Forms_Prefix + "/password-input"; public const string Demos_URL_RadioInput = Demos_URL_Forms_Prefix + "/radio-input"; + public const string Demos_URL_RatingInput = Demos_URL_Forms_Prefix + "/rating-input"; public const string Demos_URL_RangeInput = Demos_URL_Forms_Prefix + "/range-input"; public const string Demos_URL_RichTextEditor = Demos_URL_Forms_Prefix + "/rich-text-editor"; public const string Demos_URL_SelectInput = Demos_URL_Forms_Prefix + "/select-input"; @@ -154,6 +155,7 @@ public static class DemoRouteConstants public const string Docs_URL_OTPInput = Docs_URL_Forms_Prefix + "/otp-input"; public const string Docs_URL_PasswordInput = Docs_URL_Forms_Prefix + "/password-input"; public const string Docs_URL_RadioInput = Docs_URL_Forms_Prefix + "/radio-input"; + public const string Docs_URL_RatingInput = Docs_URL_Forms_Prefix + "/rating-input"; public const string Docs_URL_RangeInput = Docs_URL_Forms_Prefix + "/range-input"; public const string Docs_URL_RichTextEditor = Docs_URL_Forms_Prefix + "/rich-text-editor"; public const string Docs_URL_SelectInput = Docs_URL_Forms_Prefix + "/select-input"; diff --git a/BlazorBootstrap.Demo.RCL/Constants/DemoScreenshotSrcConstants.cs b/BlazorBootstrap.Demo.RCL/Constants/DemoScreenshotSrcConstants.cs index 1a06c1222..6053b45b9 100644 --- a/BlazorBootstrap.Demo.RCL/Constants/DemoScreenshotSrcConstants.cs +++ b/BlazorBootstrap.Demo.RCL/Constants/DemoScreenshotSrcConstants.cs @@ -29,6 +29,7 @@ public class DemoScreenshotSrcConstants public const string Demos_URL_OTPInput = DemoScreenshotSrcPrefix + "otp-input.png"; public const string Demos_URL_PasswordInput = DemoScreenshotSrcPrefix + "password-input.png"; public const string Demos_URL_RadioInput = DemoScreenshotSrcPrefix + "radio-input.png"; + public const string Demos_URL_RatingInput = DemoScreenshotSrcPrefix + "rating-input.png"; public const string Demos_URL_RangeInput = DemoScreenshotSrcPrefix + "range-input.png"; public const string Demos_URL_SelectInput = DemoScreenshotSrcPrefix + "select-input.png"; public const string Demos_URL_RichTextEditor = DemoScreenshotSrcPrefix + "rich-text-editor.png"; diff --git a/BlazorBootstrap.Demo.RCL/wwwroot/images/screenshots/rating-input.png b/BlazorBootstrap.Demo.RCL/wwwroot/images/screenshots/rating-input.png new file mode 100644 index 000000000..9b3b6028d Binary files /dev/null and b/BlazorBootstrap.Demo.RCL/wwwroot/images/screenshots/rating-input.png differ diff --git a/blazorbootstrap/Components/Form/RatingInput/RatingInput.razor b/blazorbootstrap/Components/Form/RatingInput/RatingInput.razor new file mode 100644 index 000000000..259c87510 --- /dev/null +++ b/blazorbootstrap/Components/Form/RatingInput/RatingInput.razor @@ -0,0 +1,64 @@ +@namespace BlazorBootstrap +@inherits BlazorBootstrapComponentBase +@typeparam TValue + +
+ @if (ReadOnly) + { + @for (var i = 1; i <= MaxValue; i++) + { + var itemValue = (decimal)i; + var isChecked = itemValue == readOnlyCheckedValue; + + @RenderIcons(isHalfItem: false) + + } + } + else + { + @foreach (var itemValue in GetSelectableValues()) + { + var selectedValue = itemValue; + var radioId = $"{Id}-{selectedValue.ToString(CultureInfo.InvariantCulture)}"; + var isStartHalf = isHalfStepEnabled && selectedValue % 1 != 0; + + + } + } + @GetItemLabel(descriptionValue) +
+ +@code { + private RenderFragment RenderIcons(bool isHalfItem) => __builder => + { + + @if (!isHalfItem) + { + + } + + }; +} diff --git a/blazorbootstrap/Components/Form/RatingInput/RatingInput.razor.cs b/blazorbootstrap/Components/Form/RatingInput/RatingInput.razor.cs new file mode 100644 index 000000000..83a1cc394 --- /dev/null +++ b/blazorbootstrap/Components/Form/RatingInput/RatingInput.razor.cs @@ -0,0 +1,373 @@ +namespace BlazorBootstrap; + +/// +/// Represents a Blazor component that lets users select or display a score using icons. +/// +/// The type of the rating value. +/// +/// Supported types for TValue: int, int?, decimal, decimal? +/// +[AddedVersion("4.1.0")] +public partial class RatingInput : BlazorBootstrapComponentBase +{ + #region Fields and Constants + + private decimal? currentValue; + + private string descriptionId = default!; + + private decimal descriptionValue; + + private decimal displayValue; + + private FieldIdentifier fieldIdentifier; + + private decimal readOnlyCheckedValue; + + private decimal readOnlyTabStopValue; + + #endregion + + #region Methods + + #region Protected Override Methods + + protected override void OnInitialized() + { + base.OnInitialized(); + + if (ValueExpression is not null) + fieldIdentifier = FieldIdentifier.Create(ValueExpression); + } + + protected override void OnParametersSet() + { + if (!(typeof(TValue) == typeof(int) + || typeof(TValue) == typeof(int?) + || typeof(TValue) == typeof(decimal) + || typeof(TValue) == typeof(decimal?) + )) + throw new InvalidOperationException($"{typeof(TValue)} is not supported."); + + if (AllowHalf && IsIntegerType()) + throw new InvalidOperationException($"The {nameof(AllowHalf)} parameter is supported only for decimal and decimal? values. {typeof(TValue)} cannot hold half values."); + + if (MaxValue < 1) + throw new InvalidOperationException($"The {nameof(MaxValue)} parameter value must be greater than or equal to 1."); + + descriptionId = $"{Id}-description"; + currentValue = ToDecimal(Value); + UpdateDisplayState(); + + base.OnParametersSet(); + } + + #endregion + + #region Private Methods + + private TValue ConvertToValue(decimal? value) + { + if (typeof(TValue) == typeof(int)) + return (TValue)(object)(int)(value ?? 0); + + if (typeof(TValue) == typeof(int?)) + return value.HasValue ? (TValue)(object)(int)value.Value : default!; + + if (typeof(TValue) == typeof(decimal)) + return (TValue)(object)(value ?? 0m); + + return value.HasValue ? (TValue)(object)value.Value : default!; + } + + private string GetColorClass() => + Color switch + { + RatingInputColor.Primary => TextColor.Primary.ToTextColorClass(), + RatingInputColor.Secondary => TextColor.Secondary.ToTextColorClass(), + RatingInputColor.Success => TextColor.Success.ToTextColorClass(), + RatingInputColor.Danger => TextColor.Danger.ToTextColorClass(), + RatingInputColor.Warning => TextColor.Warning.ToTextColorClass(), + RatingInputColor.Info => TextColor.Info.ToTextColorClass(), + RatingInputColor.Light => TextColor.Light.ToTextColorClass(), + RatingInputColor.Dark => TextColor.Dark.ToTextColorClass(), + RatingInputColor.Body => TextColor.Body.ToTextColorClass(), + RatingInputColor.Muted => TextColor.Muted.ToTextColorClass(), + RatingInputColor.White => TextColor.White.ToTextColorClass(), + _ => "" + }; + + private string GetItemClassNames(decimal itemValue, bool isHalfItem, bool isStartHalf) => + BuildClassNames( + ("bb-rating-input-item", true), + ("bb-rating-input-item-start", isHalfItem && isStartHalf), + ("bb-rating-input-item-end", isHalfItem && !isStartHalf), + (GetItemStateClass(itemValue, isHalfItem), true)); + + private string GetItemLabel(decimal value) => + string.Format(CultureInfo.CurrentCulture, ItemLabelFormat, NormalizeDecimal(value), MaxValue); + + private string GetItemStateClass(decimal itemValue, bool isHalfItem) + { + if (displayValue >= itemValue) + return "bb-rating-input-item-filled"; + + if (!isHalfItem && displayValue == itemValue - 0.5m) + return "bb-rating-input-item-half"; + + return "bb-rating-input-item-empty"; + } + + private IEnumerable GetSelectableValues() + { + var step = isHalfStepEnabled ? 0.5m : 1m; + + for (var value = step; value <= MaxValue; value += step) + yield return value; + } + + private bool IsIntegerType() => typeof(TValue) == typeof(int) || typeof(TValue) == typeof(int?); + + /// + /// Removes trailing zeros so labels read "3" and "3.5" instead of "3.0" and "3.50". + /// + private static decimal NormalizeDecimal(decimal value) => value / 1.0000000000000000000000000000m; + + private async Task OnItemChangedAsync(decimal itemValue) + { + if (ReadOnly || Disabled) + return; + + await SetValueAsync(itemValue); + } + + private async Task OnItemClickedAsync(decimal itemValue) + { + if (!AllowClear || ReadOnly || Disabled || itemValue != currentValue) + return; + + await SetValueAsync(null); + } + + private async Task SetValueAsync(decimal? newValue) + { + var value = ConvertToValue(newValue); + + if (EqualityComparer.Default.Equals(value, Value)) + return; + + Value = value; + currentValue = ToDecimal(Value); + UpdateDisplayState(); + + await ValueChanged.InvokeAsync(Value); + + if (fieldIdentifier.FieldName is not null) + EditContext?.NotifyFieldChanged(fieldIdentifier); + } + + private static decimal? ToDecimal(TValue value) => + value switch + { + int intValue => intValue, + decimal decimalValue => decimalValue, + _ => null + }; + + private void UpdateDisplayState() + { + descriptionValue = Math.Clamp(currentValue ?? 0m, 0m, MaxValue); + displayValue = Math.Round(descriptionValue * 2, MidpointRounding.AwayFromZero) / 2; + readOnlyCheckedValue = Math.Round(descriptionValue, MidpointRounding.AwayFromZero); + readOnlyTabStopValue = readOnlyCheckedValue > 0 ? readOnlyCheckedValue : 1; + } + + #endregion + + #endregion + + #region Properties, Indexers + + /// + /// Gets or sets whether clicking the currently selected item clears the value. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(false)] + [Description("Gets or sets whether clicking the currently selected item clears the value to empty (null for nullable types, 0 otherwise).")] + [Parameter] + public bool AllowClear { get; set; } + + /// + /// Gets or sets whether half values can be selected. Supported only for decimal and decimal? values. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(false)] + [Description("Gets or sets whether half values can be selected. Supported only for decimal and decimal? values.")] + [Parameter] + public bool AllowHalf { get; set; } + + protected override string? ClassNames => + BuildClassNames(Class, + ("bb-rating-input", true), + ("bb-rating-input-interactive", !ReadOnly && !Disabled), + ("bb-rating-input-readonly", ReadOnly), + ("bb-rating-input-disabled", Disabled), + ("bb-rating-input-sm", Size == RatingInputSize.Small), + ("bb-rating-input-lg", Size == RatingInputSize.Large), + (GetColorClass(), Color != RatingInputColor.None), + (fieldCssClasses, true)); + + /// + /// Gets or sets the color of the rating icons. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(RatingInputColor.Warning)] + [Description("Gets or sets the color of the rating icons.")] + [Parameter] + public RatingInputColor Color { get; set; } = RatingInputColor.Warning; + + /// + /// Gets or sets the disabled state. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(false)] + [Description("Gets or sets whether the rating input is disabled. When true, users cannot focus or change it.")] + [Parameter] + public bool Disabled { get; set; } + + [CascadingParameter] private EditContext EditContext { get; set; } = default!; + + /// + /// Gets or sets the icon used for empty items. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(IconName.Star)] + [Description("Gets or sets the icon used for empty items.")] + [Parameter] + public IconName EmptyIconName { get; set; } = IconName.Star; + + private string fieldCssClasses => + EditContext is not null && fieldIdentifier.FieldName is not null + ? EditContext.FieldCssClass(fieldIdentifier) + : ""; + + /// + /// Gets or sets the icon used for filled items. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(IconName.StarFill)] + [Description("Gets or sets the icon used for filled items.")] + [Parameter] + public IconName FilledIconName { get; set; } = IconName.StarFill; + + /// + /// Gets or sets the icon used for half-filled items when a fractional value is displayed. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(IconName.StarHalf)] + [Description("Gets or sets the icon used for half-filled items when a fractional value is displayed.")] + [Parameter] + public IconName HalfIconName { get; set; } = IconName.StarHalf; + + private bool isHalfStepEnabled => AllowHalf && !IsIntegerType(); + + /// + /// Gets or sets the format of each item's accessible label and of the value description. + /// Placeholder {0} is the item value and {1} is . + /// + /// Default value is "{0} of {1}". + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue("{0} of {1}")] + [Description("Gets or sets the format of each item's accessible label and of the value description. Placeholder {0} is the item value and {1} is the maximum value.")] + [Parameter] + public string ItemLabelFormat { get; set; } = "{0} of {1}"; + + /// + /// Gets or sets the number of rating items. + /// + /// Default value is 5. + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(5)] + [Description("Gets or sets the number of rating items. Must be greater than or equal to 1.")] + [Parameter] + public int MaxValue { get; set; } = 5; + + /// + /// Gets or sets the read-only state. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(false)] + [Description("Gets or sets whether the rating input is read-only. When true, the value is displayed, including half values, and cannot be changed.")] + [Parameter] + public bool ReadOnly { get; set; } + + /// + /// Gets or sets the size of the rating icons. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(RatingInputSize.Default)] + [Description("Gets or sets the size of the rating icons.")] + [Parameter] + public RatingInputSize Size { get; set; } = RatingInputSize.Default; + + /// + /// Gets or sets the rating value. + /// + [AddedVersion("4.1.0")] + [Description("Gets or sets the current rating value.")] + [Parameter] + public TValue Value { get; set; } = default!; + + /// + /// This event fires when the user changes the rating value. + /// + [AddedVersion("4.1.0")] + [Description("Fires once whenever user interaction changes the rating value. It does not fire when the component is read-only or disabled.")] + [Parameter] + public EventCallback ValueChanged { get; set; } + + /// + /// Gets or sets an expression that identifies the bound value. + /// + /// Default value is . + /// + /// + [AddedVersion("4.1.0")] + [DefaultValue(null)] + [Description("Gets or sets the expression that identifies the bound value for validation and EditContext notifications.")] + [Parameter] + public Expression> ValueExpression { get; set; } = default!; + + #endregion +} diff --git a/blazorbootstrap/Enums/Color/RatingInputColor.cs b/blazorbootstrap/Enums/Color/RatingInputColor.cs new file mode 100644 index 000000000..17cd14efd --- /dev/null +++ b/blazorbootstrap/Enums/Color/RatingInputColor.cs @@ -0,0 +1,91 @@ +namespace BlazorBootstrap; + +/// +/// Defines the supported icon colors for a . +/// +public enum RatingInputColor +{ + /// + /// Indicates no color is applied. Default color is inherited from parent element. + /// + [AddedVersion("4.1.0")] + [Description("Indicates no color is applied. Default color is inherited from parent element.")] + None, + + /// + /// Indicates the rating icons should use the primary color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the primary color.")] + Primary, + + /// + /// Indicates the rating icons should use the secondary color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the secondary color.")] + Secondary, + + /// + /// Indicates the rating icons should use the success color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the success color.")] + Success, + + /// + /// Indicates the rating icons should use the danger color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the danger color.")] + Danger, + + /// + /// Indicates the rating icons should use the warning color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the warning color.")] + Warning, + + /// + /// Indicates the rating icons should use the info color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the info color.")] + Info, + + /// + /// Indicates the rating icons should use the light color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the light color.")] + Light, + + /// + /// Indicates the rating icons should use the dark color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the dark color.")] + Dark, + + /// + /// Indicates the rating icons should use the body text color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the body text color.")] + Body, + + /// + /// Indicates the rating icons should use the muted text color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the muted text color.")] + Muted, + + /// + /// Indicates the rating icons should use the white color. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the rating icons should use the white color.")] + White +} diff --git a/blazorbootstrap/Enums/RatingInputSize.cs b/blazorbootstrap/Enums/RatingInputSize.cs new file mode 100644 index 000000000..307755bf4 --- /dev/null +++ b/blazorbootstrap/Enums/RatingInputSize.cs @@ -0,0 +1,28 @@ +namespace BlazorBootstrap; + +/// +/// Defines the supported sizes for a . +/// +public enum RatingInputSize +{ + /// + /// Indicates the default rating input size. + /// + [AddedVersion("4.1.0")] + [Description("Indicates the default rating input size.")] + Default, + + /// + /// Indicates a small rating input. + /// + [AddedVersion("4.1.0")] + [Description("Indicates a small rating input.")] + Small, + + /// + /// Indicates a large rating input. + /// + [AddedVersion("4.1.0")] + [Description("Indicates a large rating input.")] + Large +} diff --git a/blazorbootstrap/wwwroot/blazor.bootstrap.css b/blazorbootstrap/wwwroot/blazor.bootstrap.css index 225561f1f..5eb306ce3 100644 --- a/blazorbootstrap/wwwroot/blazor.bootstrap.css +++ b/blazorbootstrap/wwwroot/blazor.bootstrap.css @@ -840,4 +840,97 @@ main { .bb-sortable-list-handle .bi-grip-vertical::before { cursor: grab !important; -} \ No newline at end of file +} + +/* rating-input */ +.bb-rating-input { + position: relative; + display: inline-flex; + align-items: center; + font-size: 1.5rem; + line-height: 1; +} + +.bb-rating-input-sm { + font-size: 1rem; +} + +.bb-rating-input-lg { + font-size: 2rem; +} + +.bb-rating-input-item { + position: relative; + display: inline-block; + width: 1.25em; + height: 1em; + overflow: hidden; +} + +.bb-rating-input-item-start { + width: .5em; +} + +.bb-rating-input-item-end { + width: .75em; +} + +.bb-rating-input-icon { + position: absolute; + top: 0; + inset-inline-start: 0; + display: none; + width: 1em; + height: 1em; +} + +.bb-rating-input-item-end .bb-rating-input-icon { + inset-inline-start: -.5em; +} + +.bb-rating-input-icon .bi::before { + vertical-align: top; +} + +.bb-rating-input-item-empty .bb-rating-input-icon-empty, +.bb-rating-input-item-half .bb-rating-input-icon-half, +.bb-rating-input-item-filled .bb-rating-input-icon-filled { + display: block; +} + +.bb-rating-input-interactive .bb-rating-input-item { + cursor: pointer; +} + +.bb-rating-input-disabled { + opacity: .65; +} + +.bb-rating-input-disabled .bb-rating-input-item { + cursor: not-allowed; +} + +.bb-rating-input-radio:focus-visible + .bb-rating-input-item, +.bb-rating-input-readonly .bb-rating-input-item:focus-visible { + outline: 2px solid var(--bs-focus-ring-color, rgba(13, 110, 253, .5)); + outline-offset: 1px; + border-radius: .125em; +} + +@media (hover: hover) { + .bb-rating-input-interactive:hover .bb-rating-input-item .bb-rating-input-icon { + display: none; + } + + .bb-rating-input-interactive:hover .bb-rating-input-item .bb-rating-input-icon-filled { + display: block; + } + + .bb-rating-input-interactive .bb-rating-input-item:hover ~ .bb-rating-input-item .bb-rating-input-icon-filled { + display: none; + } + + .bb-rating-input-interactive .bb-rating-input-item:hover ~ .bb-rating-input-item .bb-rating-input-icon-empty { + display: block; + } +} diff --git a/docs/docs/04-forms/rating-input.mdx b/docs/docs/04-forms/rating-input.mdx new file mode 100644 index 000000000..95582f7c2 --- /dev/null +++ b/docs/docs/04-forms/rating-input.mdx @@ -0,0 +1,199 @@ +--- +title: Blazor Rating Input Component +description: The Blazor Bootstrap RatingInput component lets users select or display a score using icons, with EditForm validation, half values, and keyboard support. + +sidebar_label: Rating Input +sidebar_position: 11.5 +--- + +import CarbonAd from '/carbon-ad.mdx' + +# Blazor Rating Input + +The Blazor Bootstrap `RatingInput` component lets users select or display a score using icons, stars by default. It is built with native radio inputs and works without JavaScript. + + + +:::info +`RatingInput` renders Bootstrap Icons. Reference the Bootstrap Icons stylesheet in your app, because Blazor Bootstrap does not bundle it. +::: + +Supported types for `TValue`: `int`, `int?`, `decimal`, `decimal?`. Any other type throws an `InvalidOperationException`. + +## Parameters + +| Name | Type | Default | Required | Description | Added Version | +|:--|:--|:--|:--|:--|:--| +| AllowClear | bool | false | | Gets or sets whether clicking the currently selected item clears the value to empty (null for nullable types, 0 otherwise). | 4.1.0 | +| AllowHalf | bool | false | | Gets or sets whether half values can be selected. Supported only for decimal and decimal? values. | 4.1.0 | +| Color | `RatingInputColor` | `RatingInputColor.Warning` | | Gets or sets the color of the rating icons. | 4.1.0 | +| Disabled | bool | false | | Gets or sets whether the rating input is disabled. When true, users cannot focus or change it. | 4.1.0 | +| EmptyIconName | `IconName` | `IconName.Star` | | Gets or sets the icon used for empty items. | 4.1.0 | +| FilledIconName | `IconName` | `IconName.StarFill` | | Gets or sets the icon used for filled items. | 4.1.0 | +| HalfIconName | `IconName` | `IconName.StarHalf` | | Gets or sets the icon used for half-filled items when a fractional value is displayed. | 4.1.0 | +| ItemLabelFormat | string | `"{0} of {1}"` | | Gets or sets the format of each item's accessible label and of the value description. Placeholder `{0}` is the item value and `{1}` is the maximum value. | 4.1.0 | +| MaxValue | int | 5 | | Gets or sets the number of rating items. Must be greater than or equal to 1. | 4.1.0 | +| ReadOnly | bool | false | | Gets or sets whether the rating input is read-only. When true, the value is displayed, including half values, and cannot be changed. | 4.1.0 | +| Size | `RatingInputSize` | `RatingInputSize.Default` | | Gets or sets the size of the rating icons. | 4.1.0 | +| Value | TValue | | | Gets or sets the current rating value. | 4.1.0 | +| ValueExpression | `Expression>` | null | | Gets or sets the expression that identifies the bound value for validation and EditContext notifications. | 4.1.0 | + +## Events + +| Name | Description | Added Version | +|:--|:--|:--| +| ValueChanged | Fires once whenever user interaction changes the rating value. It does not fire when the component is read-only or disabled. | 4.1.0 | + +## Accessibility + +- The component renders a `radiogroup`. Give it an accessible name with `aria-label` or `aria-labelledby`. +- Each item is a radio labelled with `ItemLabelFormat`, for example "3 of 5". The group's description announces the exact value, for example "4.3 of 5". +- **Tab** moves focus into and out of the group in a single stop. **Arrow keys** change the value and wrap at the ends; Left and Right are mirrored in right-to-left layouts. +- A read-only rating can be focused but not changed. A disabled rating cannot be focused. + +## Examples + +### Basic usage + +```cshtml {2} showLineNumbers +
+ + @score of 5 +
+``` + +```cs {} showLineNumbers +@code { + private int score = 3; +} +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#basic-usage) + +### Read-only fractional values + +Set `ReadOnly` to display a score that users cannot change. Fractional values are shown to the nearest half: 4.3 shows 4½ and 4.2 shows 4. + +```cshtml {} showLineNumbers + +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#read-only-fractional-values) + +### Custom icons + +Use `FilledIconName`, `EmptyIconName`, and `HalfIconName` to replace the default stars. + +```cshtml {3-5} showLineNumbers + +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#custom-icons) + +### Sizes + +Set `Size` to `RatingInputSize.Small`, `RatingInputSize.Default`, or `RatingInputSize.Large`. + +```cshtml {} showLineNumbers + + +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#sizes) + +### Colors + +Set `Color` to a `RatingInputColor` value. Use `RatingInputColor.None` to inherit the surrounding text color. + +```cshtml {} showLineNumbers + +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#colors) + +### Disabled + +```cshtml {} showLineNumbers + +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#disabled) + +### Allow clear + +With `AllowClear`, clicking the selected icon again clears the value. + +```cshtml {} showLineNumbers + +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#allow-clear) + +### Allow half + +With `AllowHalf`, users can select half values by clicking the left half of an icon or using the arrow keys. + +:::caution +`AllowHalf` is supported only for `decimal` and `decimal?`. Setting it with `int` or `int?` throws an `InvalidOperationException`. +::: + +```cshtml {} showLineNumbers + +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#allow-half) + +### EditForm validation + +```cshtml {5} showLineNumbers + + + +
+ +
+ + +
+``` + +```cs {} showLineNumbers +@code { + private Review review = new(); + + private void HandleValidSubmit() { /* save the review */ } + + public class Review + { + [Required(ErrorMessage = "Please select a score.")] + public int? Score { get; set; } + } +} +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#edit-form-validation) + +### Events: ValueChanged + +```cshtml {} showLineNumbers + +``` + +```cs {} showLineNumbers +@code { + private int score = 2; + + private void OnScoreChanged(int value) + { + score = value; // this is mandatory + } +} +``` + +[See demo here](https://demos.blazorbootstrap.com/form/rating-input#events-value-changed)