From 8fdd4429221a585792955f2c0e726a1068ddcd68 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 21 Sep 2026 22:32:26 +0330 Subject: [PATCH 1/4] improve theme infra of BitRating #13333 --- .../Components/Inputs/BitInputBase.cs | 28 ++ .../Components/Inputs/Rating/BitRating.razor | 199 +++++--- .../Inputs/Rating/BitRating.razor.cs | 149 +++++- .../Components/Inputs/Rating/BitRating.scss | 185 ++++++-- .../Inputs/Rating/BitRatingClassStyles.cs | 20 + .../Inputs/Rating/BitRatingParams.cs | 333 ++++++++++++++ src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts | 5 + .../Utils/Params/BitInputBaseParams.cs | 57 +++ .../Inputs/Rating/BitRatingDemo.razor | 423 ++++++++++-------- .../Inputs/Rating/BitRatingDemo.razor.cs | 211 ++++++++- .../Rating/BitRatingDemo.razor.samples.cs | 272 +++++++---- .../Inputs/Rating/BitRatingDemo.razor.scss | 5 + .../Inputs/Rating/BitRatingTests.cs | 388 +++++++++++++++- 13 files changed, 1890 insertions(+), 385 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs create mode 100644 src/BlazorUI/Bit.BlazorUI/Utils/Params/BitInputBaseParams.cs diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs index 6a696455e66..6ea34b636b4 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs @@ -27,6 +27,7 @@ public abstract class BitInputBase : BitComponentBase private bool _previousParsingAttemptFailed; private string? _incomingValueBeforeParsing; private ValidationMessageStore? _parsingValidationMessages; + private readonly HashSet _assignedInputParameters = []; private readonly EventHandler _validationStateChangedHandler; @@ -152,6 +153,7 @@ public override Task SetParametersAsync(ParameterView parameters) { ValueHasBeenSet = false; DefaultValueHasBeenSet = false; + _assignedInputParameters.Clear(); var parametersDictionary = (ParametersCache ??= parameters.ToDictionary() as Dictionary); @@ -161,37 +163,44 @@ public override Task SetParametersAsync(ParameterView parameters) { case nameof(NoValidate): NoValidate = (bool)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(DefaultValue): DefaultValueHasBeenSet = true; DefaultValue = (TValue?)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(CascadedEditContext): CascadedEditContext = (EditContext?)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(DisplayName): DisplayName = (string?)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(InputHtmlAttributes): InputHtmlAttributes = (Dictionary?)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(Name): Name = (string?)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(OnChange): OnChange = (EventCallback)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; @@ -199,6 +208,7 @@ public override Task SetParametersAsync(ParameterView parameters) var readOnly = (bool)parameter.Value; if (ReadOnly != readOnly) ClassBuilder.Reset(); ReadOnly = readOnly; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; @@ -206,22 +216,26 @@ public override Task SetParametersAsync(ParameterView parameters) var required = (bool)parameter.Value; if (Required != required) ClassBuilder.Reset(); Required = required; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(Value): ValueHasBeenSet = true; Value = (TValue?)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(ValueChanged): ValueChanged = (EventCallback)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; case nameof(ValueExpression): ValueExpression = (Expression>?)parameter.Value; + _assignedInputParameters.Add(parameter.Key); parametersDictionary.Remove(parameter.Key); break; } @@ -308,6 +322,20 @@ protected string? CurrentValueAsString + /// + /// Whether the given parameter of this base class was left unwritten by the markup of the component. + /// + /// + /// The HasNotBeenSet generated for a component covers the parameters that component declares, and + /// the one on those of that class; the parameters of this class are assigned + /// here and answered here. It is what lets a ancestor fill in a parameter an input + /// left unset without overwriting one it wrote for itself. + /// + protected internal bool InputParameterHasNotBeenSet(string name) + { + return _assignedInputParameters.Contains(name) is false; + } + protected virtual void CreateFieldIdentifier() { CreateFieldIdentifier(ValueExpression, typeof(TValue)); diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor index b4abcda1fac..5fa6a8d2db2 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor @@ -9,12 +9,17 @@ var displayValue = _DisplayValue; var tabbableIndex = _TabbableIndex; var interactive = IsEnabled && ReadOnly is false; + var labelledBy = _AriaLabelledBy; + var describedBy = _AriaDescribedBy; } @* A read-only rating is no longer a set of choices but a picture of a value, so it drops the radiogroup and is announced as a single labelled image instead of a group of unreachable radios. That also takes - aria-required and aria-disabled with it: neither is supported on the img role, and an unchangeable - picture of a value has nothing to require or disable in the first place. *@ + the four states of a field with it - aria-readonly, aria-required, aria-disabled and aria-invalid - none + of which the img role supports, and none of which an unchangeable picture of a value has anything to say + about: there is nothing to require, disable or correct in the first place. *@ +@* A name given by reference wins over one given inline, so only one of aria-labelledby and aria-label + is ever rendered: the visible label names the group when there is one, the explicit strings when not. *@
- @for (int item = 1; item <= max; item++) + @* Not a label element: with no single input to point a "for" at, a label here would label nothing; + the group is named through aria-labelledby referencing this id instead. *@ + @if (HasLabel) { - var index = item; - var percentage = GetPercentage(index); - var isCurrent = index == CurrentValue; - // A per-item icon replaces the shared pair for that position only, which is what turns a plain - // scale into one that changes shape as it fills - a frown at one end and a grin at the other. - var itemSelectedIcon = GetSelectedIcon?.Invoke(index) ?? selectedIcon; - var itemUnselectedIcon = GetUnselectedIcon?.Invoke(index) ?? unselectedIcon; - -
- @* Below a precision of a whole item, the item is covered by transparent slices that each commit - their own fraction. They are decorative overlays rather than extra tab stops: the item keeps - being the single radio of the group, and the keyboard reaches the same fractions with the - arrow keys, which step by the Precision. *@ - @if (steps > 1 && interactive) - { - for (int step = 1; step <= steps; step++) +
+ @if (ItemTemplate is not null) + { + @ItemTemplate(new BitRatingItemContext(index, max, percentage, displayValue, CurrentValue)) + } + else + { + @* The base layer swaps to the filled glyph once the item is complete, so the two layers + cannot show a seam between them at 100%. *@ + + + } +
+ + @* Below a precision of a whole item, the item is covered by transparent slices that each commit + their own fraction. They are decorative overlays rather than extra tab stops: the item keeps + being the single radio of the group, and the keyboard reaches the same fractions with the + arrow keys, which step by the Precision. *@ + @if (steps > 1 && interactive) { - var stepValue = GetStepValue(index, step); + for (int step = 1; step <= steps; step++) + { + var stepValue = GetStepValue(index, step); - + + } } + + } + + + @if (HasDescription) + { +
+ @if (DescriptionTemplate is not null) + { + @DescriptionTemplate } - + else + { + @Description + } +
} @* A fractional value checks none of the radios - 3.5 is neither three stars nor four - so it is @@ -117,6 +165,13 @@ @_LiveValueText } + @* A read-only rating named by its own visible label would announce the label and lose the value it + exists to show, so the value joins the name from here instead of through aria-label. *@ + @if (_RendersHiddenValueText) + { + @_ValueText + } + /// Ratings show people’s opinions of a product, helping others make more informed purchasing decisions. -/// It supports fractional values down to any precision, a live hover preview, clearing, per-item titles, -/// a custom item template, and is fully operable from the keyboard. +/// It supports fractional values down to any precision, a live hover preview, clearing, a label and a +/// description, per-item icons and titles, a custom item template, a horizontal or vertical layout, and is +/// fully operable from the keyboard. /// public partial class BitRating : BitInputBase { + private string _labelId = default!; + private string _valueTextId = default!; + private string _descriptionId = default!; private double? _hoverValue; private ElementReference[] _itemRefs = []; @@ -19,6 +23,19 @@ public partial class BitRating : BitInputBase + /// + /// Gets or sets the cascading parameters for the rating component. + /// + /// + /// This property receives its value from an ancestor component via Blazor's cascading parameter mechanism. + ///
+ /// The intended use is to allow shared configuration or settings to be applied to multiple rating components through the component. + ///
+ [CascadingParameter(Name = BitRatingParams.ParamName)] + public BitRatingParams? CascadingParameters { get; set; } + + + /// /// Lets the current value be cleared, by clicking the item that is already selected or by pressing /// Delete or Backspace. Clearing sets the value to 0, so it also makes 0 a reachable value the same @@ -39,6 +56,12 @@ public partial class BitRating : BitInputBase /// [Parameter] public string? AriaLabelFormat { get; set; } + /// + /// The id of an element that names the rating as a whole, for a name that is already written somewhere + /// on the page. It wins over every other source of the name, including the visible . + /// + [Parameter] public string? AriaLabelledBy { get; set; } + /// /// If true, the rating automatically receives focus when the page renders /// (rendered as the autofocus attribute of the item that holds the tab stop). @@ -50,6 +73,19 @@ public partial class BitRating : BitInputBase /// [Parameter] public BitRatingClassStyles? Classes { get; set; } + /// + /// The hint shown under the items and pointed at by aria-describedby, for the instruction a row + /// of stars cannot give by itself - that half a star is selectable, say, or that clicking the current + /// one clears it. It describes the rating rather than naming it, so it is announced after the label. + /// + [Parameter] public string? Description { get; set; } + + /// + /// Replaces the with custom content, which is still what describes the rating + /// for assistive technologies. + /// + [Parameter] public RenderFragment? DescriptionTemplate { get; set; } + /// /// The general color of the rating, applied to the filled part of the items. /// The unfilled part stays neutral so it reads as "not rated yet" whichever color is picked. @@ -107,6 +143,27 @@ public partial class BitRating : BitInputBase /// [Parameter] public IList? ItemTitles { get; set; } + /// + /// The visible label of the rating, which also becomes its accessible name: a row of stars carries no + /// text of its own, so without a label - or an - the group is + /// announced without saying what is being rated. A required rating marks its label with an asterisk. + /// + [Parameter] public string? Label { get; set; } + + /// + /// Where the label sits relative to the items: above them by default, and beside them with + /// or for the compact + /// "Quality: 3 of 5" row. + /// + [Parameter, ResetClassBuilder] + public BitLabelPosition? LabelPosition { get; set; } + + /// + /// Replaces the with custom content, which still names the rating for assistive + /// technologies the same way the plain label does. + /// + [Parameter] public RenderFragment? LabelTemplate { get; set; } + /// /// Maximum rating, which is also the number of rendered items. Values below 1 are treated as 1. /// @@ -219,6 +276,10 @@ public partial class BitRating : BitInputBase protected override async Task OnInitializedAsync() { + _labelId = $"BitRating-{UniqueId}-label"; + _valueTextId = $"BitRating-{UniqueId}-value"; + _descriptionId = $"BitRating-{UniqueId}-description"; + SetDefaultValue(); await base.OnInitializedAsync(); @@ -261,6 +322,18 @@ protected override void RegisterCssClasses() ClassBuilder.Register(() => Vertical ? "bit-rtg-vrt" : string.Empty); + // The asterisk is a property of an answer that is still expected, so a read-only or disabled + // rating - which is no longer asking anything - does not draw one. + ClassBuilder.Register(() => IsEnabled && ReadOnly is false && Required ? "bit-rtg-req" : string.Empty); + + ClassBuilder.Register(() => LabelPosition switch + { + BitLabelPosition.Bottom => "bit-rtg-lbm", + BitLabelPosition.Start => "bit-rtg-lst", + BitLabelPosition.End => "bit-rtg-led", + _ => string.Empty + }); + ClassBuilder.Register(() => Color switch { BitColor.Primary => "bit-rtg-pri", @@ -297,8 +370,11 @@ protected override void RegisterCssStyles() StyleBuilder.Register(() => Styles?.Root); } + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitRatingParams))] protected override void OnParametersSet() { + CascadingParameters?.UpdateParameters(this); + var max = _Max; if (_itemRefs.Length != max) @@ -432,6 +508,69 @@ private string? _AriaLabel } } + /// + /// Whether the rating draws a label of its own, which is also what makes it able to name itself. + /// + /// + /// Only rendered when there is something to show, so its id is only worth referencing then: pointing + /// aria-labelledby at an element that is not there would leave the group without a name at all, since a + /// name given by reference wins over the aria-label beside it. + /// + internal bool HasLabel => LabelTemplate is not null || Label.HasValue(); + + /// + /// Whether the rating draws a description of its own, on the same terms as its label. + /// + internal bool HasDescription => DescriptionTemplate is not null || Description.HasValue(); + + /// + /// The elements that describe the rating. This attribute sits after the HtmlAttributes splat in the + /// markup, so it is what ends up rendered no matter what - a null would even remove a splatted value - + /// which is why an aria-describedby the consumer splatted is carried over here rather than replaced. + /// Both are kept, since aria-describedby is a space separated list of IDREFs. + /// + private string? _AriaDescribedBy + { + get + { + HtmlAttributes.TryGetValue("aria-describedby", out var splatted); + var splattedDescribedBy = splatted?.ToString(); + + if (HasDescription is false) return splattedDescribedBy; + + return splattedDescribedBy.HasValue() ? $"{splattedDescribedBy} {_descriptionId}" : _descriptionId; + } + } + + /// + /// The element the name of the rating is read from, when it is read from the page rather than given as a + /// string: the explicit AriaLabelledBy, then the visible label. The two string forms - AriaLabel and the + /// GetAriaLabel callback - are deliberately allowed to win over the visible label, since aria-labelledby + /// would otherwise silently discard them. + /// + private string? _AriaLabelledBy + { + get + { + if (AriaLabelledBy.HasValue()) return AriaLabelledBy; + + if (AriaLabel.HasValue() || GetAriaLabel is not null || HasLabel is false) return null; + + return _RendersHiddenValueText ? $"{_labelId} {_valueTextId}" : _labelId; + } + } + + /// + /// Whether the value joins the name of the rating from a hidden element of its own. A read-only rating is + /// a picture of a value whose items are hidden behind a single name, so naming it by its visible label + /// alone would leave the value it exists to show unannounced. + /// + private bool _RendersHiddenValueText => ReadOnly + && HasLabel + && AriaLabelledBy.HasNoValue() + && AriaLabel.HasNoValue() + && GetAriaLabel is null; + /// /// The default format both the value text and the per-item labels fall back to. /// @@ -610,6 +749,12 @@ private async Task HandleOnKeyDown(KeyboardEventArgs e) { if (IsEnabled is false || ReadOnly) return; + // Every key this handler answers to is also half of a browser or system shortcut - Alt+ArrowLeft goes + // back, Ctrl+Home reaches the top of a page, Ctrl+digit switches tabs - so a held modifier hands the + // key back rather than silently spending it on the rating. Shift is the exception: it is the rating's + // own modifier, the one that turns a step into a whole item. + if (e.CtrlKey || e.AltKey || e.MetaKey) return; + var isRtl = Dir == BitDir.Rtl; var value = CurrentValue; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss index e348de35347..bb9c181279f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss @@ -1,14 +1,45 @@ @import "../../../Styles/functions.scss"; +// Public CSS variables, read off the root and never declared here, so a value set on :root re-skins every +// rating and one set on the Style of an instance re-skins that one alone: +// --bit-Rating-color color of the filled part of the items (default: the Color role's main color) +// --bit-Rating-unselected-color color of the unfilled part of the items (default: $clr-fg-ter) +// --bit-Rating-hover-color color of the filled part while the pointer is +// previewing a value over the items (default: the role's hover color) +// --bit-Rating-focus-color color of the keyboard focus ring of an item (default: the role's focus color) +// --bit-Rating-disabled-color color of both parts when IsEnabled is false (default: $clr-fg-dis) +// --bit-Rating-invalid-color color of both parts, and of the focus ring, +// while the value is invalid (default: $clr-err / $clr-err-focus) +// --bit-Rating-size size of the item glyphs (default: per size, $siz-icon-*) +// --bit-Rating-target-size smallest pointer target of an item, which the +// glyph is centred in; 0 shrinks the items to +// the glyph and its padding (default: 1.5rem) +// --bit-Rating-padding padding of an item around its glyph (default: spacing(0.25)) +// --bit-Rating-gap extra room between the items (default: 0) +// --bit-Rating-radius corner radius of an item and its focus ring (default: $shp-radius-control) +// --bit-Rating-hover-scale how much the hovered item grows (default: 1.1; 1 turns it off) +// --bit-Rating-label-color text color of the label (default: $clr-fg-pri) +// --bit-Rating-label-font-size text size of the label (default: $tg-fs-sm) +// --bit-Rating-label-gap room between the label and the items, and +// between the items and the description (default: spacing(1)) +// --bit-Rating-description-color text color of the description (default: $clr-fg-sec) +// --bit-Rating-description-font-size text size of the description (default: $tg-fs-xs) + .bit-rtg { - font-weight: $tg-fw-regular; + display: inline-flex; width: fit-content; + // A column of label, items and description, which is the Top position; the label position classes + // below turn it into the other three. The items themselves are laid out by their own container, + // so where the label sits and which way the items run are independent of each other. + flex-direction: column; + align-items: flex-start; + gap: var(--bit-Rating-label-gap, #{spacing(1)}); + font-weight: $tg-fw-regular; font-size: $tg-fs-sm; font-family: $tg-font-family; // The unfilled part stays neutral whatever the Color is, so it keeps reading as "not rated yet" // instead of as a second, dimmer accent. --bit-rtg-clr-uns: #{$clr-fg-ter}; - --bit-rtg-size: #{$siz-icon-md}; // While the pointer is over an interactive rating the filled part - which the hover preview has // already extended to the item under the pointer - shifts to the hover shade, so the preview reads @@ -16,7 +47,14 @@ // left out of it, so the error color it is painted with survives the pointer passing over it. @media (hover: hover) { &:hover:not(.bit-dis):not(.bit-inv):not(.bit-rtg-rdl):not(.bit-rtg-nhp) .bit-rtg-ifl { - color: var(--bit-rtg-clr-hover); + color: var(--bit-Rating-hover-color, var(--bit-rtg-clr-hover)); + } + + // The item under the pointer grows a little, which is the affordance that says the items are + // there to be pressed - the one thing a row of stars does not say by itself. It is not the + // value preview, so a rating that has turned that off still gets it. + &:not(.bit-dis):not(.bit-rtg-rdl) .bit-rtg-btn:hover .bit-rtg-ict { + transform: scale(var(--bit-Rating-hover-scale, 1.1)); } } @@ -26,41 +64,97 @@ pointer-events: none; } - .bit-rtg-iem, .bit-rtg-ifl { - color: $clr-fg-dis; + .bit-rtg-iem, .bit-rtg-ifl, .bit-rtg-lbl, .bit-rtg-dsc { + color: var(--bit-Rating-disabled-color, #{$clr-fg-dis}); } } &.bit-inv { .bit-rtg-iem, .bit-rtg-ifl { - color: $clr-err; + color: var(--bit-Rating-invalid-color, #{$clr-err}); } .bit-rtg-btn:focus-visible { - @include focus-ring($clr-err-focus); + @include focus-ring(var(--bit-Rating-invalid-color, #{$clr-err-focus})); + } + } + + // The asterisk of a required rating, drawn on the label the way every other required input in the + // library draws it. It is decorative: the group already carries aria-required. + &.bit-rtg-req { + .bit-rtg-lbl::after { + content: " *"; + color: $clr-req; + padding-inline-end: spacing(1.5); } } } +// The items, laid out apart from the label: a row that wraps, so a hundred-item scale stays inside a +// narrow container instead of overflowing it. +.bit-rtg-cnt { + order: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--bit-Rating-gap, 0); +} + +// The three boxes are ordered rather than reversed, so that moving the label never drags the description +// along with it: a description belongs under the items it describes whichever side the label is on. +.bit-rtg-lbc { + order: 1; + max-width: 100%; +} + +.bit-rtg-lbl { + display: block; + overflow-wrap: break-word; + font-weight: $tg-fw-semibold; + letter-spacing: $tg-ctrl-letter-spacing; + color: var(--bit-Rating-label-color, #{$clr-fg-pri}); + font-size: var(--bit-Rating-label-font-size, #{$tg-fs-sm}); +} + +// The hint that sits under the items, pointed at by aria-describedby rather than read as part of the +// name, so a screen reader announces the label first and the instructions after it. +.bit-rtg-dsc { + order: 3; + display: block; + max-width: 100%; + overflow-wrap: break-word; + color: var(--bit-Rating-description-color, #{$clr-fg-sec}); + font-size: var(--bit-Rating-description-font-size, #{$tg-fs-xs}); +} + .bit-rtg-btn { margin: 0; border: none; + display: flex; cursor: pointer; position: relative; + align-items: center; outline: transparent; - box-sizing: content-box; - // A minimum rather than a fixed height: the icons never exceed their line box, so the size token still - // decides the height of a plain rating, while an ItemTemplate taller than an icon grows the item to fit - // instead of spilling out of it. - min-height: var(--bit-rtg-size); - font-size: var(--bit-rtg-size); - line-height: var(--bit-rtg-size); + justify-content: center; + box-sizing: border-box; background-color: transparent; - border-radius: $shp-radius-control; - padding: spacing(1) spacing(0.25); + padding: var(--bit-Rating-padding, #{spacing(0.25)}); + // A star is a small glyph, and its own box would be a pointer target well under the 24px minimum of + // WCAG 2.2 (SC 2.5.8) at every size below Large - and the items of a row are adjacent, so the spacing + // exception to that rule cannot save them either. The item takes that minimum as a floor on both axes + // and centres the glyph inside it, which is also what keeps the touch targets of neighbouring items + // apart. The floor is a literal rather than a token on purpose: it is a conformance minimum and not a + // design-system decision, so it must not grow or shrink with the density of a preset - an app that + // wants the roomier targets of a touch platform raises it through the variable. Minimums rather than + // sizes: an ItemTemplate taller or wider than a glyph grows the item to fit instead of spilling out. + min-width: var(--bit-Rating-target-size, 1.5rem); + min-height: var(--bit-Rating-target-size, 1.5rem); + font-size: var(--bit-Rating-size, var(--bit-rtg-size)); + line-height: var(--bit-Rating-size, var(--bit-rtg-size)); + border-radius: var(--bit-Rating-radius, #{$shp-radius-control}); &:focus-visible { - @include focus-ring(var(--bit-rtg-clr-focus)); + @include focus-ring(var(--bit-Rating-focus-color, var(--bit-rtg-clr-focus))); // The ring is drawn with a box-shadow, which the neighbouring items would otherwise paint over. z-index: 1; } @@ -74,7 +168,9 @@ } // A transparent slice of an item, laid over the icons, that commits its own fraction of the item. -// inset-inline-start keeps the first slice on the leading edge in both directions. +// It covers the whole target rather than the glyph alone, so the slices of an item stay contiguous and +// no part of it is a dead zone. inset-inline-start keeps the first slice on the leading edge in both +// directions. .bit-rtg-seg { top: 0; height: 100%; @@ -112,10 +208,11 @@ // The slack costs a horizontal rating nothing, because its filled layer hangs from the top edge the icon // starts at, but a vertical rating anchors that layer to the bottom edge and measures its fill against // this box: the fill would be drawn below the glyph it is meant to cover, and a half-filled item would -// show more than half. It also keeps the step slices, which cover the item, centred on what is drawn. +// show more than half. .bit-rtg-ict { display: flex; position: relative; + transition: transform $mot-duration-short $mot-easing; } .bit-rtg-iem { @@ -123,7 +220,7 @@ font-style: normal; font-weight: $tg-fw-regular; display: inline-block; - color: var(--bit-rtg-clr-uns); + color: var(--bit-Rating-unselected-color, var(--bit-rtg-clr-uns)); transition: color $mot-duration-short $mot-easing; } @@ -140,7 +237,7 @@ font-weight: $tg-fw-regular; display: inline-block; vertical-align: middle; - color: var(--bit-rtg-clr); + color: var(--bit-Rating-color, var(--bit-rtg-clr)); transition: width $mot-duration-short $mot-easing, color $mot-duration-short $mot-easing; } @@ -148,14 +245,8 @@ // A vertical rating stacks its items bottom-up, so that "more" is up the same way the ArrowUp key means // more, and the fill of a partly covered item grows from its bottom edge instead of its leading one. .bit-rtg-vrt { - display: inline-flex; - align-items: center; - flex-direction: column-reverse; - - // The generous padding that separates the items of a row belongs on the other axis once they form a - // column, so the rows do not end up spaced twice as far apart as the columns were. - .bit-rtg-btn { - padding: spacing(0.25) spacing(1); + .bit-rtg-cnt { + flex-direction: column-reverse; } // Anchored to the bottom, and the glyph inside it with them: clipping a box whose content sits at the @@ -178,6 +269,42 @@ } +// The three label positions that are not the default Top. Start and End put the label on the same line as +// the items, which is the compact "Quality: * * * * *" row, so they centre the two against each other and +// let the line wrap - the description then takes a line of its own under both, since it refuses to shrink +// below the full width. +.bit-rtg-lbm { + .bit-rtg-lbc { + order: 2; + } + + .bit-rtg-cnt { + order: 1; + } +} + +.bit-rtg-lst, +.bit-rtg-led { + flex-wrap: wrap; + align-items: center; + flex-direction: row; + + .bit-rtg-dsc { + flex: 1 0 100%; + } +} + +.bit-rtg-led { + .bit-rtg-lbc { + order: 2; + } + + .bit-rtg-cnt { + order: 1; + } +} + + // Role classes are generated from the shared $bit-color-roles map (see color-role-maps.scss). @each $role, $tokens in $bit-color-roles { .bit-rtg-#{$role} { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingClassStyles.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingClassStyles.cs index 452d16874a0..8b31370b117 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingClassStyles.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingClassStyles.cs @@ -7,6 +7,26 @@ public class BitRatingClassStyles /// public string? Root { get; set; } + /// + /// Custom CSS classes/styles for the container of the label of the rating. + /// + public string? LabelContainer { get; set; } + + /// + /// Custom CSS classes/styles for the label of the rating. + /// + public string? Label { get; set; } + + /// + /// Custom CSS classes/styles for the description of the rating. + /// + public string? Description { get; set; } + + /// + /// Custom CSS classes/styles for the container of the rating items. + /// + public string? Container { get; set; } + /// /// Custom CSS classes/styles for the rating's button. /// diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs new file mode 100644 index 00000000000..6afd9c6734e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs @@ -0,0 +1,333 @@ +namespace Bit.BlazorUI; + +/// +/// The parameters for component. +/// +public class BitRatingParams : BitInputBaseParams, IBitComponentParams +{ + /// + /// Represents the parameter name used to identify the cascading parameters within . + /// + /// + /// This constant is typically used when referencing or accessing the BitRating value in + /// parameterized APIs or configuration settings. Using this constant helps ensure consistency and reduces the risk + /// of typographical errors. + /// + public const string ParamName = $"{nameof(BitParams)}.{nameof(BitRating)}"; + + + + public string Name => ParamName; + + + + /// + /// Lets the current value be cleared, by clicking the item that is already selected or by pressing + /// Delete or Backspace. Clearing sets the value to 0, so it also makes 0 a reachable value the same + /// way does. + /// + public bool? AllowClear { get; set; } + + /// + /// Allow the initial rating value be 0. Note that a value of 0 still won't be selectable by mouse or keyboard + /// unless is also set. + /// + public bool? AllowZeroStars { get; set; } + + /// + /// Optional label format for each individual rating star (not the rating control as a whole) that will be read by screen readers. + /// Placeholder {0} is the current rating and placeholder {1} is the max: for example, "Select {0} of {1} stars". + /// + public string? AriaLabelFormat { get; set; } + + /// + /// The id of an element that names the rating as a whole. It wins over every other source of the name, + /// including the visible . + /// + public string? AriaLabelledBy { get; set; } + + /// + /// If true, the rating automatically receives focus when the page renders. + /// + public bool? AutoFocus { get; set; } + + /// + /// Custom CSS classes for different parts of the BitRating. + /// + public BitRatingClassStyles? Classes { get; set; } + + /// + /// The general color of the rating, applied to the filled part of the items. + /// The unfilled part stays neutral so it reads as "not rated yet" whichever color is picked. + /// + public BitColor? Color { get; set; } + + /// + /// The hint shown under the items and pointed at by aria-describedby, for the instruction a row of + /// stars cannot give by itself. + /// + public string? Description { get; set; } + + /// + /// Names the rating as a whole from its current value and the max, used whenever the AriaLabel is not provided. + /// + public Func? GetAriaLabel { get; set; } + + /// + /// Chooses the selected (filled) icon of each rating item separately, from the one-based position of the item. + /// Returning null falls back to / . + /// + public Func? GetSelectedIcon { get; set; } + + /// + /// Chooses the unselected (empty) icon of each rating item separately, from the one-based position of the item. + /// Returning null falls back to / . + /// + public Func? GetUnselectedIcon { get; set; } + + /// + /// Highlights only the item matching the current value instead of every item up to it, + /// turning the rating into a scale of standalone choices rather than a cumulative one. + /// + public bool? HighlightSelectedOnly { get; set; } + + /// + /// The native tooltips of the rating items, in order, shown when hovering over each one, and used as the + /// accessible name of the item unless the AriaLabelFormat overrides it. + /// + public IList? ItemTitles { get; set; } + + /// + /// The visible label of the rating, which also becomes its accessible name. + /// + public string? Label { get; set; } + + /// + /// Where the label sits relative to the items. + /// + public BitLabelPosition? LabelPosition { get; set; } + + /// + /// Maximum rating, which is also the number of rendered items. Values below 1 are treated as 1. + /// + public int? Max { get; set; } + + /// + /// Turns off the preview that follows the pointer over the items and shows the value that a click would commit. + /// + public bool? NoHoverPreview { get; set; } + + /// + /// The smallest change of the value the user can make, as a fraction of a single item. + /// The default of 1 only allows whole items, 0.5 adds halves, 0.1 makes every tenth selectable, and so on. + /// + public double? Precision { get; set; } + + /// + /// The icon to display for selected rating elements using custom CSS classes for external icon libraries. + /// Takes precedence over when both are set. + /// + public BitIconInfo? SelectedIcon { get; set; } + + /// + /// Custom icon name for selected rating elements. If unset, default will be the FavoriteStarFill icon. + /// + public string? SelectedIconName { get; set; } + + /// + /// Size of rating elements. + /// + public BitSize? Size { get; set; } + + /// + /// Custom CSS styles for different parts of the BitRating. + /// + public BitRatingClassStyles? Styles { get; set; } + + /// + /// The icon to display for unselected rating elements using custom CSS classes for external icon libraries. + /// Takes precedence over when both are set. + /// + public BitIconInfo? UnselectedIcon { get; set; } + + /// + /// Custom icon name for unselected rating elements. If unset, default will be the FavoriteStar icon. + /// + public string? UnselectedIconName { get; set; } + + /// + /// The format of the spoken form of the current value, where placeholder {0} is the value and placeholder + /// {1} is the max: for example "{0} out of {1} stars". The default is "{0} of {1}". + /// + public string? ValueTextFormat { get; set; } + + /// + /// Stacks the rating items in a column instead of a row, filling from the bottom up so that "more" is up, + /// the way the ArrowUp key means more. + /// + public bool? Vertical { get; set; } + + + + /// + /// Updates the properties of the specified instance with any values that have been set on + /// this object, if those properties have not already been set on the . + /// + /// + /// Only properties that have a value set and have not already been set on the will be updated. + /// This method does not overwrite existing values on . + /// + /// + /// The instance whose properties will be updated. Cannot be null. + /// + public void UpdateParameters(BitRating bitRating) + { + if (bitRating is null) return; + + UpdateInputParameters(bitRating); + + if (AllowClear.HasValue && bitRating.HasNotBeenSet(nameof(AllowClear))) + { + bitRating.AllowClear = AllowClear.Value; + } + + if (AllowZeroStars.HasValue && bitRating.HasNotBeenSet(nameof(AllowZeroStars))) + { + bitRating.AllowZeroStars = AllowZeroStars.Value; + } + + if (AriaLabelFormat.HasValue() && bitRating.HasNotBeenSet(nameof(AriaLabelFormat))) + { + bitRating.AriaLabelFormat = AriaLabelFormat; + } + + if (AriaLabelledBy.HasValue() && bitRating.HasNotBeenSet(nameof(AriaLabelledBy))) + { + bitRating.AriaLabelledBy = AriaLabelledBy; + } + + if (AutoFocus.HasValue && bitRating.HasNotBeenSet(nameof(AutoFocus))) + { + bitRating.AutoFocus = AutoFocus.Value; + } + + if (Classes is not null && bitRating.HasNotBeenSet(nameof(Classes))) + { + bitRating.Classes = Classes; + + bitRating.ClassBuilder.Reset(); + } + + if (Color.HasValue && bitRating.HasNotBeenSet(nameof(Color))) + { + bitRating.Color = Color.Value; + + bitRating.ClassBuilder.Reset(); + } + + if (Description.HasValue() && bitRating.HasNotBeenSet(nameof(Description))) + { + bitRating.Description = Description; + } + + if (GetAriaLabel is not null && bitRating.HasNotBeenSet(nameof(GetAriaLabel))) + { + bitRating.GetAriaLabel = GetAriaLabel; + } + + if (GetSelectedIcon is not null && bitRating.HasNotBeenSet(nameof(GetSelectedIcon))) + { + bitRating.GetSelectedIcon = GetSelectedIcon; + } + + if (GetUnselectedIcon is not null && bitRating.HasNotBeenSet(nameof(GetUnselectedIcon))) + { + bitRating.GetUnselectedIcon = GetUnselectedIcon; + } + + if (HighlightSelectedOnly.HasValue && bitRating.HasNotBeenSet(nameof(HighlightSelectedOnly))) + { + bitRating.HighlightSelectedOnly = HighlightSelectedOnly.Value; + } + + if (ItemTitles is not null && bitRating.HasNotBeenSet(nameof(ItemTitles))) + { + bitRating.ItemTitles = ItemTitles; + } + + if (Label.HasValue() && bitRating.HasNotBeenSet(nameof(Label))) + { + bitRating.Label = Label; + } + + if (LabelPosition.HasValue && bitRating.HasNotBeenSet(nameof(LabelPosition))) + { + bitRating.LabelPosition = LabelPosition.Value; + + bitRating.ClassBuilder.Reset(); + } + + if (Max.HasValue && bitRating.HasNotBeenSet(nameof(Max))) + { + bitRating.Max = Max.Value; + } + + if (NoHoverPreview.HasValue && bitRating.HasNotBeenSet(nameof(NoHoverPreview))) + { + bitRating.NoHoverPreview = NoHoverPreview.Value; + + bitRating.ClassBuilder.Reset(); + } + + if (Precision.HasValue && bitRating.HasNotBeenSet(nameof(Precision))) + { + bitRating.Precision = Precision.Value; + } + + if (SelectedIcon is not null && bitRating.HasNotBeenSet(nameof(SelectedIcon))) + { + bitRating.SelectedIcon = SelectedIcon; + } + + if (SelectedIconName.HasValue() && bitRating.HasNotBeenSet(nameof(SelectedIconName))) + { + bitRating.SelectedIconName = SelectedIconName; + } + + if (Size.HasValue && bitRating.HasNotBeenSet(nameof(Size))) + { + bitRating.Size = Size.Value; + + bitRating.ClassBuilder.Reset(); + } + + if (Styles is not null && bitRating.HasNotBeenSet(nameof(Styles))) + { + bitRating.Styles = Styles; + + bitRating.StyleBuilder.Reset(); + } + + if (UnselectedIcon is not null && bitRating.HasNotBeenSet(nameof(UnselectedIcon))) + { + bitRating.UnselectedIcon = UnselectedIcon; + } + + if (UnselectedIconName.HasValue() && bitRating.HasNotBeenSet(nameof(UnselectedIconName))) + { + bitRating.UnselectedIconName = UnselectedIconName; + } + + if (ValueTextFormat.HasValue() && bitRating.HasNotBeenSet(nameof(ValueTextFormat))) + { + bitRating.ValueTextFormat = ValueTextFormat; + } + + if (Vertical.HasValue && bitRating.HasNotBeenSet(nameof(Vertical))) + { + bitRating.Vertical = Vertical.Value; + + bitRating.ClassBuilder.Reset(); + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts index 2e0023d9243..345bd327fbb 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts @@ -18,6 +18,11 @@ namespace BitBlazorUI { const handler = (e: KeyboardEvent) => { if (Ratings._navKeys.indexOf(e.key) === -1) return; + // A held Ctrl, Alt or Meta makes the key a browser or system shortcut instead - Alt+ArrowLeft + // goes back, Ctrl+Home reaches the top of the page - and the Blazor handler hands those back + // for the same reason, so their default action has to survive here too. + if (e.ctrlKey || e.altKey || e.metaKey) return; + const target = e.target as HTMLElement | null; if (!target || !target.closest('.bit-rtg-btn')) return; diff --git a/src/BlazorUI/Bit.BlazorUI/Utils/Params/BitInputBaseParams.cs b/src/BlazorUI/Bit.BlazorUI/Utils/Params/BitInputBaseParams.cs new file mode 100644 index 00000000000..f3e4f64db5a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Params/BitInputBaseParams.cs @@ -0,0 +1,57 @@ +namespace Bit.BlazorUI; + +/// +/// The parameters for that a whole subtree of inputs can share. +/// +/// +/// Only the parameters that describe how a group of inputs behaves are here. The ones that identify a single +/// field - Name, DisplayName, Value and the rest of the binding - belong to that one field +/// and would be wrong to share, and NoValidate is read before this object is consulted, since the input +/// wires itself to the as its parameters are set. +/// +public abstract class BitInputBaseParams : BitComponentBaseParams +{ + /// + /// Makes the input read-only. + ///
+ /// . + ///
+ public bool? ReadOnly { get; set; } + + /// + /// Makes the input required. + ///
+ /// . + ///
+ public bool? Required { get; set; } + + + + /// + /// Updates the input base properties of the specified instance with any values + /// that have been set on this object, if those properties have not already been set on the input itself. + /// + /// + /// The instance whose properties will be updated. Cannot be null. + /// + public void UpdateInputParameters(BitInputBase bitInputBase) + { + if (bitInputBase is null) return; + + UpdateBaseParameters(bitInputBase); + + if (ReadOnly.HasValue && bitInputBase.InputParameterHasNotBeenSet(nameof(ReadOnly))) + { + bitInputBase.ReadOnly = ReadOnly.Value; + + bitInputBase.ClassBuilder.Reset(); + } + + if (Required.HasValue && bitInputBase.InputParameterHasNotBeenSet(nameof(Required))) + { + bitInputBase.Required = Required.Value; + + bitInputBase.ClassBuilder.Reset(); + } + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor index a4b7c1510ba..bc26784b163 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor @@ -2,26 +2,24 @@ + Description="A keyboard-accessible star rating with fractional precision down to any step, a live hover preview, horizontal or vertical layout, a label, clearing, per-item icons and tooltips, custom items and read-only display." />
- Left unbound like the first one below, the BitRating keeps its own state with no binding or event - handler involved, and DefaultValue only picks where that state starts. Disabling it greys the - items out and takes it out of the interaction entirely. ReadOnly is the other half of that: - the value is still shown at full contrast, but nothing about it can be changed - which is the mode a - rating spends most of its life in, summarizing what other people already rated. The difference runs - deeper than the styling: a read-only rating stops being a group of choices and is announced as a - single labelled image, while a disabled one is still a group, only an unavailable one. Notice the - read-only one below carries a fractional value: partial fills are rendered whether or not the user - could ever have picked them. + Left unbound, the rating keeps its own state and DefaultValue only picks where that state + starts. Disabling it greys the items out; ReadOnly keeps them at full contrast but frozen - + the mode a rating spends most of its life in, summarizing what other people already rated. The + difference runs deeper than the styling: a read-only rating stops being a group of choices and is + announced as a single labelled image. Note its fractional value - partial fills are rendered + whether or not the user could ever have picked them.

@@ -39,15 +37,41 @@
- +
- Max is both the highest value the rating accepts and the number of items it renders, so it is - the scale of the whole control. Five is the familiar default, but a coarser three-point scale or a - much finer one are just as valid; the items wrap when the row runs out of room, which is what keeps a - 100-point rating usable inside a narrow container. A value above the Max is pulled back down to it, - so the scale can be tightened at runtime without leaving an impossible value behind. And at the other - extreme, a Max of 1 turns the rating into the compact single-star badge that sits next to a numeric - score and a review count. + Label names the rating - and since a row of stars carries no text of its own, it is also what + names it for a screen reader. Required marks it with an asterisk, LabelPosition moves + it beside the items for a compact row, and LabelTemplate replaces it with markup of your own, + below a caption that spells the value out in words. Description is the hint under the items, + pointed at by aria-describedby so it is announced after the name rather than as part of + it. ItemTitles gives each item its own tooltip, in order, and those words double as the + accessible name of the item; items past the end of the list fall back to their position. +
+
+
+ +
+ +
+ + + @ratingWords[(int)labelTemplateValue] + + +
+
+ + +
+ Max is both the highest value the rating accepts and the number of items it renders. Five is + the familiar default; the items wrap when the row runs out of room, which is what keeps a 100-point + rating usable inside a narrow container, and a value above the Max is pulled back down to it so the + scale can be tightened at runtime. A Max of 1 turns the rating into the compact single-star badge + beside a numeric score.

@@ -70,15 +94,12 @@
- +
- Vertical stacks the items into a column instead of a row, and fills it from the bottom up so - that "more" is up - the same direction the ArrowUp key already means. Everything else follows: - a fractional value fills its item from the bottom edge rather than the leading one, the transparent - slices a fine Precision lays over an item are stacked the same way, and the group announces itself as - vertically oriented so a screen reader describes the arrangement the user is actually facing. It is - the shape to reach for when the rating sits beside a vertical bar chart or in a narrow column where a - row of five stars would not fit. + Vertical stacks the items into a column and fills it from the bottom up, so that "more" is + up - the direction ArrowUp already means. A fractional value fills its item from the bottom + edge, the slices of a fine Precision are stacked the same way, and the group announces itself as + vertically oriented.

@@ -92,16 +113,15 @@
- +
- Precision is the smallest change the user can make, expressed as a fraction of a single item. - The default of 1 only allows whole items; 0.5 adds halves, 0.25 quarters, and 0.1 makes every tenth - selectable. Below a whole item each item is covered by that many transparent slices, so the half a - pointer lands on is the half that gets committed - and the arrow keys step by exactly the same amount, - which keeps the fractions reachable without a pointer. A precision that does not divide an item evenly - is rounded to the closest number of equal steps, so the items always end on a whole value, and an item - is never split into more than a hundred steps. Remember it constrains only what the user can pick: a - value bound from elsewhere is always drawn at its exact precision. + Precision is the smallest change the user can make, as a fraction of a single item: 1 allows + whole items only, 0.5 adds halves, 0.1 makes every tenth selectable. Each item is covered by that + many transparent slices, so the half a pointer lands on is the half that gets committed, and the + arrow keys step by the same amount. A precision that does not divide an item evenly is rounded to + the closest number of equal steps, and an item is never split into more than a hundred. It + constrains what the user can pick, never what can be shown: a value bound from elsewhere is always + drawn at its exact precision.

@@ -119,14 +139,13 @@
- +
A rating starts at the first item unless it is told that "not rated" is a legitimate answer. - AllowZeroStars says exactly that: a value of 0 is kept instead of being pulled up to 1, so the - rating can start empty - though the user still cannot get back to it. AllowClear is what adds + AllowZeroStars says exactly that: a value of 0 is kept instead of being pulled up to 1, so + the rating can start empty - though the user still cannot get back to it. AllowClear adds that way back: clicking the item that is already selected, or pressing Delete or - Backspace, returns the rating to 0. Since an unrated rating is the whole point of clearing, - AllowClear opens up the 0 on its own, without AllowZeroStars. + Backspace, returns the rating to 0, which is why it opens up the 0 on its own.

@@ -144,13 +163,13 @@
- +
By default a rating is cumulative: picking the fourth item fills the first four, because four stars - means "four out of five". HighlightSelectedOnly switches that to a scale of standalone choices - where only the item matching the value is filled - the right reading when the items are faces, moods - or grades rather than a quantity that accumulates. A fractional value still fills its own item by the - fraction it covers, so the mode composes with Precision instead of rounding against it. + means "four out of five". HighlightSelectedOnly switches to a scale of standalone choices + where only the matching item is filled - the right reading when the items are faces, moods or grades + rather than a quantity. A fractional value still fills its own item by the fraction it covers, so + the mode composes with Precision instead of rounding against it.

@@ -164,34 +183,47 @@
- +
- While the pointer is over the items the rating previews the value a click would commit, painting it in - the hover shade so it reads as a proposal rather than as the committed value; leaving the rating - restores what is actually bound. OnHoverChange reports that previewed value as it changes, and - null once the pointer leaves - which is how the familiar word-per-star caption below is built. At a - fractional Precision the preview follows the slices too, so the caption can react to a half step. - NoHoverPreview turns the whole behaviour off for the rare case where the preview competes with - something else on the page. + While the pointer is over the items the rating previews the value a click would commit, painting it + in the hover shade so it reads as a proposal rather than as the committed value; leaving restores + what is bound. OnHoverChange reports that previewed value as it changes, and null once the + pointer leaves - which is all a caption needs to follow the pointer, down to a half step at a + fractional Precision. NoHoverPreview turns the preview off where it competes with something + else on the page; the items still grow under the pointer, since that is an affordance and not a + value.

Hover over the stars:
- - @(hoverLabels[(int)(hoverPreviewValue ?? hoverBoundValue)]) + + + @ratingWords[(int)(hoverPreviewValue ?? hoverBoundValue)] + +
NoHoverPreview:
- + +
+ SelectedIconName and UnselectedIconName replace the pair of built-in star glyphs, + which is what lets the same component read as a like, a heart or a checklist. The two icons are + drawn on top of each other and the selected one is clipped to the fill percentage, so the pair works + best when both glyphs share an outline - a hollow shape and its filled twin. +
+
- SelectedIconName and UnselectedIconName replace the pair of built-in star glyphs, which - is what lets the same component read as a like, a heart or a checklist. The two icons are drawn on top - of each other and the selected one is clipped to the fill percentage, so the pair works best when both - glyphs share an outline - a hollow shape and its filled twin. For icons from an external library, see - the External Icons section below. + GetSelectedIcon and GetUnselectedIcon answer for a single position instead of the + whole scale, which turns a rating into one that changes shape as it fills. Each receives the + one-based position and may return null to fall back to the pair above, so only the positions that + need their own glyph have to be answered - and since the component keeps drawing the item, the + two-layer partial fill of a fractional value goes on working. For icons from an external library see + External Icons.

@@ -201,24 +233,7 @@
Checkbox:

-
Like:
- -
-
- - -
- GetSelectedIcon and GetUnselectedIcon answer with the icon of a single position instead - of one pair for the whole scale, which is what turns a rating into a scale that changes shape as it - fills - thumbs down at the low end and thumbs up at the high end, or a different face at every step. - Each callback receives the one-based position of the item and may return null to fall back to - SelectedIcon / SelectedIconName, so only the positions that need their own glyph have to - be answered. This is the lightweight half of ItemTemplate: the - component keeps drawing the item, so the two-layer partial fill of a fractional value goes on working. -
-
-
-
Thumbs (down for the first two, up for the rest):
+
Thumbs, down for the first two and up for the rest (GetSelectedIcon):
@@ -235,32 +250,15 @@
- +
- ItemTitles gives each item its own native tooltip, in order, so hovering an item spells out what - it means instead of leaving the user to count stars. The same words double as the accessible name of - the item, which is a better answer for a screen reader than "4 of 5" - unless - AriaLabelFormat overrides it. A list shorter than the rating simply - leaves the remaining items without a tooltip, and they fall back to their position in the scale. -
-
-
-
Hover over each star:
- -
-
- - -
- ItemTemplate replaces the pair of icons of every item with content of your own, while the - BitRating keeps everything around it: the hit areas, the hover preview, the keyboard handling and the - accessibility. The template receives a BitRatingItemContext describing the item - its - Index, the Percentage it is filled by, whether it IsFull or merely - IsSelected, and both the previewed DisplayValue and the committed Value - which is - enough to drive anything from a numbered scale to the mood faces below. Note that a template renders - whatever you give it as a whole, so a partial fill is yours to express if it matters; when all you - need is a different glyph per position, GetSelectedIcon keeps the - built-in partial fill instead. + ItemTemplate replaces the pair of icons with content of your own while the BitRating keeps + everything around it: the hit areas, the hover preview, the keyboard handling and the accessibility. + The template receives a BitRatingItemContext - the item's Index, the Percentage + it is filled by, whether it IsFull or merely IsSelected, and both the previewed + DisplayValue and the committed Value. A template renders as a whole, so a partial fill + is yours to express if it matters; when all you need is a different glyph per position, + GetSelectedIcon keeps the built-in one.

@@ -282,14 +280,12 @@
- +
- A one-way Value makes the rating a pure display of whatever the page decides - the toggle below - drives it and clicking the stars does nothing, because there is nowhere for the new value to go. - @@bind-Value completes the circuit in both directions, which is why the number field and the - stars below stay in step whichever one is used. Notice the number field can push a fractional value - into a rating whose Precision is 1: the constraint is on what the user can pick, never on what can be - displayed. + A one-way Value makes the rating a pure display of whatever the page decides - the toggle + below drives it and clicking the stars does nothing, because there is nowhere for the new value to + go. @@bind-Value completes the circuit both ways. Notice the number field can push a + fractional value into a rating whose Precision is 1.

@@ -303,14 +299,14 @@
- +
- OnChange fires after a new value lands, which is what an uncontrolled rating reports its result - through. OnChanging runs first and can stop the change altogether: setting Cancel on the - BitRatingChangeArgs keeps the current value, and since the callback is awaited it can take its - time - long enough to ask the user a question. The args carry both the incoming Value and the - OldValue being left behind, so the decision can depend on the direction of the change; the - second rating below refuses to be lowered. + OnChange fires after a new value lands, which is what an uncontrolled rating reports its + result through. OnChanging runs first and can stop the change: setting Cancel on the + BitRatingChangeArgs keeps the current value, and since the callback is awaited it can take + long enough to ask the user a question. The args carry both the incoming Value and the + OldValue, so the decision can depend on the direction of the change - the second rating below + refuses to be lowered.

@@ -324,15 +320,14 @@
- +
- Inside an EditForm the BitRating is a form field like any other: it participates in the - EditContext, reports its changes to it, and turns red while the value is invalid. Combined with - AllowZeroStars, a Range annotation that starts at 1 is what turns "pick a rating" into a - required question - the form starts unrated, and stays unsubmittable until something is picked. - Required announces that expectation to assistive technologies ahead of the failure, rather than - leaving the error message to be the first mention of it. The value itself travels in a hidden number - input, so Name also makes the rating readable by a plain form post. + Inside an EditForm the rating is a form field like any other: it joins the EditContext, + reports its changes to it, and turns red while the value is invalid. Combined with + AllowZeroStars, a Range annotation starting at 1 turns "pick a rating" into a required + question - the form starts unrated and stays unsubmittable. Required announces that + expectation ahead of the failure and marks the label with an asterisk. The value travels in a hidden + number input, so Name also makes the rating readable by a plain form post.

@@ -342,7 +337,7 @@ - +
@@ -357,30 +352,36 @@
- + +
+ An interactive rating is a WAI-ARIA radiogroup of radio items: the whole row is a single tab stop + landing on the selected item, and from there the keyboard covers the scale, stepping by the + Precision. +
+
    +
  • → / ↑ raise the value by one step, ← / ↓ lower it
  • +
  • Shift with an arrow, or PageUp / PageDown, moves a whole item - so a rating split into tenths is five presses wide rather than fifty
  • +
  • Home / End jump to the ends of the scale
  • +
  • 1 … 9 jump straight to that rating
  • +
  • Delete / Backspace clear it, where AllowClear permits
  • +
- An interactive BitRating is a WAI-ARIA radiogroup of radio items and behaves like one: the whole row is - a single tab stop that lands on the selected item, and from there the keyboard covers the entire scale. - ArrowRight / ArrowUp raise the value by one Precision step and ArrowLeft / - ArrowDown lower it; holding Shift - or using PageUp / PageDown, which need - no modifier - moves a whole item at a time, so a rating split into tenths is still five presses wide - rather than fifty. Home and End jump to the ends, the digit keys jump straight to - that rating, and Delete or Backspace clear it where AllowClear permits. The horizontal - arrows follow the reading direction, so they swap in RTL, and Escape is deliberately left alone so a - rating inside a modal does not clear itself on the way to dismissing it. + The horizontal arrows follow the reading direction and swap in RTL. A held Ctrl, Alt + or Meta hands the key back to the page, so Alt+← still goes back, and Escape is left + alone so a rating inside a modal does not clear itself on the way to dismissing it. Every item is a + pointer target of at least 24×24 CSS pixels whatever the Size, and the fill survives a forced-colors + palette, where it is re-established in system colors rather than left to color alone.

- Since a group of stars carries no visible label, name it: AriaLabel names the rating as a whole - and AriaLabelFormat names each item, with {0} as the item and {1} as the max - - "Select 3 of 5 stars". Without it an item is named by its tooltip from ItemTitles, and failing - that by its position, so no item is ever left nameless. GetAriaLabel is a callback given the - current value and the max, used as the name whenever AriaLabel is not set; a read-only rating, which is - announced as a single image rather than a group of choices, falls back to a plain "3.5 of 5" if - neither is provided. ValueTextFormat is what rewords that fallback - and the live announcement - an interactive rating makes for a fractional value, which no single radio can express - for another - language or for a scale whose items are not stars. AutoFocus hands the rating the focus as the - page renders. + Name the group: a visible Label does it, and + AriaLabel or AriaLabelledBy where the name is already written elsewhere on the page. + AriaLabelFormat names each item, with {0} as the item and {1} as the max - + "Select 3 of 5 stars"; without it an item falls back to its ItemTitles tooltip and then to its + position, so no item is ever nameless. GetAriaLabel builds the group's name from the current + value and the max. ValueTextFormat rewords what is announced for a value no radio can carry - + a fractional one, and the value a read-only rating adds to its own name. AutoFocus hands the + rating the focus as the page renders.

@@ -395,14 +396,17 @@
GetAriaLabel (inspect the aria-label of the read-only rating):
+
+
Overall satisfaction
+
- +
Visibility decides how the rating disappears: Hidden keeps its box and only stops - painting it, so the layout around it does not move, while Collapsed removes the box entirely and - lets the rest close the gap. The brackets below mark where each one sits. + painting it, so the layout around it does not move, while Collapsed removes the box entirely. + The brackets below mark where each one sits.

@@ -412,15 +416,14 @@
- +
- The parameters of the rating are re-read on every render, so they can be computed from the very - value the rating is bound to - which is all it takes to restyle the scale by the score it shows. - The first rating below derives its Color from its own value, turning from red through orange - to green as the score climbs the bands; the second derives its icons the same way, so a low score - frowns and a high one smiles. This is the styling to reach for when "2 of 5" and "5 of 5" are - different kinds of news rather than different amounts of the same one - and it needs no dedicated - API, just callbacks and parameters that close over the bound value. + The parameters are re-read on every render, so they can be computed from the very value the rating is + bound to - which is all it takes to restyle the scale by the score it shows. The first rating below + derives its Color from its own value, turning from + red through orange to green as the score climbs; the second derives its icons the same way, so a low + score frowns and a high one smiles. No dedicated API is involved: just callbacks and parameters that + close over the bound value.

@@ -436,16 +439,38 @@
+ +
+ BitParams carries a BitRatingParams down to every rating under it, so a review list, a + card or a whole page sets the shared scale, glyphs and mode once instead of on every rating. What it + carries is a default and not an override: a rating that writes a parameter for itself keeps its own + value, and only what it left unset is filled in from the cascade - parameter by parameter, which is + how the last rating below steps out of the group it is in without the group knowing about it. +
+
+
+ + +
+ +
+ +
+
+
+
+
Outside the cascade, and back to the defaults:
+ +
+
+
Color picks the theme color the filled part of the items is painted in, with Primary as - the default. The unfilled part deliberately stays neutral whatever the color is, so it keeps reading as - "not rated yet" instead of as a second, dimmer accent. The semantic colors - Success, - Warning, Error and friends - tie the rating to what the score means, while the - Background, Foreground and Border families keep it legible on non-default - surfaces like the inverted panel below. And since Color is an ordinary parameter, it can be - computed from the bound value itself - which is what the - Score-based styling section above builds on. + the default. The unfilled part stays neutral whatever the color is, so it keeps reading as "not rated + yet" instead of as a second, dimmer accent. The semantic colors tie the rating to what the score + means, while the Background, Foreground and Border families keep it legible on + non-default surfaces like the inverted panel below.

@@ -481,11 +506,11 @@
SelectedIcon and UnselectedIcon take a BitIconInfo instead of a built-in icon name, which is how icons from an external library are used. BitIconInfo.Fa and - BitIconInfo.Bi build the class names for FontAwesome and Bootstrap Icons, BitIconInfo.Css - takes whatever classes you hand it, and a plain string works too. They take precedence over - SelectedIconName / UnselectedIconName when both are set, and the same type is what the - per-item GetSelectedIcon callback returns. Remember to reference the - stylesheet of the icon library itself. + BitIconInfo.Bi build the class names for FontAwesome and Bootstrap Icons, + BitIconInfo.Css takes whatever classes you hand it, and a plain string works too. They take + precedence over SelectedIconName / UnselectedIconName, and the same type is what the + per-item GetSelectedIcon callback returns. Remember + to reference the stylesheet of the icon library itself.


@@ -515,9 +540,10 @@
- Size scales the items, from the Small that fits inline next to a line of text to the - Large that carries a rating asked as the main question on a page. Medium is the default. - A larger size also means larger hit areas, which matters most on touch and at fine precisions. + Size scales the item glyphs, from the Small that fits inline next to a line of text to + the Large that carries a rating asked as the main question on a page. Medium is the + default. The pointer target of an item never drops below 24×24 CSS pixels, so a smaller size buys a + smaller glyph rather than a harder one to hit.

@@ -532,12 +558,13 @@
- +
- Style and Class reach the root element of the rating, which is enough for the framing - around it. Styles and Classes go a level deeper and address the parts by name - - Root, Button, IconContainer, SelectedIcon and UnselectedIcon - so - the filled and unfilled halves can be styled apart from each other without leaving the component. + Style and Class land on the root. Styles and Classes reach the parts by + name - Root, LabelContainer, Label, Description, Container, + Button, IconContainer, SelectedIcon and UnselectedIcon - so the filled and unfilled + halves can be styled apart from each other. Prefer Classes where your CSS should own states + such as hover and focus, which inline styles cannot express.

@@ -549,22 +576,52 @@
Styles & Classes:


- + +
+

+
+ The rating also reads a set of CSS variables off its + root for what no parameter covers. They inherit, so a value on :root or any ancestor + re-skins every rating below it, and one on the Style of an instance re-skins that one alone. +
+
+
+
Its own palette:
+ +
+
Bigger glyphs, room between them, a livelier press:
+ +
+
Shrunk to the glyph, for a rating that sits inside a line of text:
+
+ Rated + + by 1,034 people. +
+
+
+
Set once on an ancestor, inherited by every rating inside it:
+
+
+ +
Setting Dir to Rtl mirrors the whole rating: the first item moves to the right, the - partial fill of a fractional value grows from the right edge of its item, the transparent slices of a - fine Precision are laid out from that same edge, and the horizontal arrow keys swap so that ArrowLeft - still means "more". The fractional read-only rating below shows the fill anchored on the correct side. + partial fill of a fractional value grows from the right edge of its item, the slices of a fine + Precision are laid out from that same edge, and the horizontal arrow keys swap so that ArrowLeft + still means "more".


+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs index fffae9f6b79..ac142baa0c1 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs @@ -26,6 +26,13 @@ public partial class BitRatingDemo Description = "Optional label format for each individual rating star (not the rating control as a whole) that will be read by screen readers. Placeholder {0} is the current rating and placeholder {1} is the max. Without it an item is named by its ItemTitles tooltip, and failing that by its position in the scale.", }, new() + { + Name = "AriaLabelledBy", + Type = "string?", + DefaultValue = "null", + Description = "The id of an element that names the rating as a whole, for a name that is already written somewhere on the page. It wins over every other source of the name, including the visible Label.", + }, + new() { Name = "AutoFocus", Type = "bool", @@ -51,6 +58,20 @@ public partial class BitRatingDemo Href = "#color-enum", }, new() + { + Name = "Description", + Type = "string?", + DefaultValue = "null", + Description = "The hint shown under the items and pointed at by aria-describedby, for the instruction a row of stars cannot give by itself. It describes the rating rather than naming it, so it is announced after the label.", + }, + new() + { + Name = "DescriptionTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the Description with custom content, which is still what describes the rating for assistive technologies.", + }, + new() { Name = "GetAriaLabel", Type = "Func?", @@ -99,6 +120,29 @@ public partial class BitRatingDemo Description = "The native tooltips of the rating items, in order, shown when hovering over each one, and used as the accessible name of the item unless AriaLabelFormat overrides it. Items beyond the end of the list simply get no tooltip.", }, new() + { + Name = "Label", + Type = "string?", + DefaultValue = "null", + Description = "The visible label of the rating, which also becomes its accessible name: a row of stars carries no text of its own, so without a label - or an AriaLabel - the group is announced without saying what is being rated. A required rating marks its label with an asterisk.", + }, + new() + { + Name = "LabelPosition", + Type = "BitLabelPosition?", + DefaultValue = "null", + Description = "Where the label sits relative to the items: above them by default, and beside them with Start or End for the compact single-line row.", + LinkType = LinkType.Link, + Href = "#label-position-enum", + }, + new() + { + Name = "LabelTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "Replaces the Label with custom content, which still names the rating for assistive technologies the same way the plain label does.", + }, + new() { Name = "Max", Type = "int", @@ -216,6 +260,34 @@ public partial class BitRatingDemo Description = "Custom CSS classes/styles for the root element of the rating.", }, new() + { + Name = "LabelContainer", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the container of the label of the rating.", + }, + new() + { + Name = "Label", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the label of the rating.", + }, + new() + { + Name = "Description", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the description of the rating.", + }, + new() + { + Name = "Container", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS classes/styles for the container of the rating items.", + }, + new() { Name = "Button", Type = "string?", @@ -386,6 +458,19 @@ public partial class BitRatingDemo ] }, new() + { + Id = "label-position-enum", + Name = "BitLabelPosition", + Description = "Determines where the label of the rating sits relative to its items.", + Items = + [ + new() { Name = "Top", Description = "The label sits above the items.", Value = "0" }, + new() { Name = "End", Description = "The label sits after the items, on the same line.", Value = "1" }, + new() { Name = "Bottom", Description = "The label sits below the items.", Value = "2" }, + new() { Name = "Start", Description = "The label sits before the items, on the same line.", Value = "3" } + ] + }, + new() { Id = "color-enum", Name = "BitColor", @@ -413,8 +498,117 @@ public partial class BitRatingDemo } ]; + private readonly List componentCssVariables = + [ + new() + { + Name = "--bit-Rating-color", + DefaultValue = "The Color role's main color", + Description = "Color of the filled part of the items.", + }, + new() + { + Name = "--bit-Rating-unselected-color", + DefaultValue = "--bit-clr-fg-ter", + Description = "Color of the unfilled part of the items, which stays neutral whatever the Color is so that it keeps reading as \"not rated yet\".", + }, + new() + { + Name = "--bit-Rating-hover-color", + DefaultValue = "The Color role's hover color", + Description = "Color of the filled part while the pointer is previewing a value over the items (pointer devices only).", + }, + new() + { + Name = "--bit-Rating-focus-color", + DefaultValue = "The Color role's focus color", + Description = "Color of the keyboard focus ring of an item.", + }, + new() + { + Name = "--bit-Rating-disabled-color", + DefaultValue = "--bit-clr-fg-dis", + Description = "Color of both parts of the items, and of the label, when IsEnabled is false.", + }, + new() + { + Name = "--bit-Rating-invalid-color", + DefaultValue = "--bit-clr-err (items), --bit-clr-err-focus (ring)", + Description = "Color of both parts of the items, and of the focus ring, while the value is invalid.", + }, + new() + { + Name = "--bit-Rating-size", + DefaultValue = "Per size: --bit-siz-icon-sm / -md / -lg", + Description = "Size of the item glyphs, which the Size parameter otherwise picks.", + }, + new() + { + Name = "--bit-Rating-target-size", + DefaultValue = "1.5rem", + Description = "Smallest pointer target of an item on both axes, which the glyph is centred in - the 24px minimum of WCAG 2.2 (SC 2.5.8). Raise it for the roomier targets of a touch platform, or set it to 0 to shrink the items to the glyph and its padding, for a rating that has to sit inside a line of running text.", + }, + new() + { + Name = "--bit-Rating-padding", + DefaultValue = "spacing(0.25)", + Description = "Padding of an item around its glyph, which only widens the item once it exceeds the target size.", + }, + new() + { + Name = "--bit-Rating-gap", + DefaultValue = "0", + Description = "Extra room between the items, beyond their own padding.", + }, + new() + { + Name = "--bit-Rating-radius", + DefaultValue = "--bit-shp-radius-control", + Description = "Corner radius of an item and of its focus ring.", + }, + new() + { + Name = "--bit-Rating-hover-scale", + DefaultValue = "1.1", + Description = "How much the item under the pointer grows, which is the affordance that says the items are there to be pressed. A value of 1 turns it off.", + }, + new() + { + Name = "--bit-Rating-label-color", + DefaultValue = "--bit-clr-fg-pri", + Description = "Text color of the label.", + }, + new() + { + Name = "--bit-Rating-label-font-size", + DefaultValue = "--bit-tpg-fs-sm", + Description = "Text size of the label.", + }, + new() + { + Name = "--bit-Rating-label-gap", + DefaultValue = "spacing(1)", + Description = "Room between the label and the items, and between the items and the description.", + }, + new() + { + Name = "--bit-Rating-description-color", + DefaultValue = "--bit-clr-fg-sec", + Description = "Text color of the description.", + }, + new() + { + Name = "--bit-Rating-description-font-size", + DefaultValue = "--bit-tpg-fs-xs", + Description = "Text size of the description.", + } + ]; + + private double labelValue = 3; + private double labelTemplateValue = 4; + private double verticalValue = 3; private double verticalPrecisionValue = 3.5; @@ -430,7 +624,7 @@ public partial class BitRatingDemo private double hoverBoundValue = 3; private double? hoverPreviewValue; - private readonly string[] hoverLabels = ["Not rated yet", "Terrible", "Bad", "Normal", "Good", "Wonderful"]; + private readonly string[] ratingWords = ["Not rated yet", "Terrible", "Bad", "Normal", "Good", "Wonderful"]; private double perItemIconValue = 4; private double faceValue = 3; @@ -459,6 +653,21 @@ public partial class BitRatingDemo private double scoreValue = 2; private readonly string[] scoreWords = ["Unrated", "Poor", "Poor", "Okay", "Great", "Great"]; + private double cascadeValue = 3; + private readonly BitRatingParams[] ratingParams = + [ + new() + { + ReadOnly = true, + Precision = 0.5, + Size = BitSize.Small, + Color = BitColor.Warning, + SelectedIconName = BitIconName.HeartFill, + UnselectedIconName = BitIconName.Heart, + LabelPosition = BitLabelPosition.Start + } + ]; + public BitRatingDemoFormModel ValidationModel = new(); public string? SuccessMessage; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs index b920ed267ec..d2deb92cdb0 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs @@ -12,6 +12,26 @@ public partial class BitRatingDemo "; private readonly string example2RazorCode = @" + + + + + + + @ratingWords[(int)labelTemplateValue] + +"; + private readonly string example2CsharpCode = @" +private double labelValue = 3; +private double labelTemplateValue = 4; + +private readonly string[] ratingWords = [""Not rated yet"", ""Terrible"", ""Bad"", ""Normal"", ""Good"", ""Wonderful""];"; + + private readonly string example3RazorCode = @"
4.2 · 1,034 reviews @@ -25,17 +45,17 @@ public partial class BitRatingDemo
"; - private readonly string example3RazorCode = @" + private readonly string example4RazorCode = @" Value: @verticalValue Value: @verticalPrecisionValue"; - private readonly string example3CsharpCode = @" + private readonly string example4CsharpCode = @" private double verticalValue = 3; private double verticalPrecisionValue = 3.5;"; - private readonly string example4RazorCode = @" + private readonly string example5RazorCode = @" Value: @halfPrecisionValue @@ -44,12 +64,12 @@ public partial class BitRatingDemo Value: @exactPrecisionValue"; - private readonly string example4CsharpCode = @" + private readonly string example5CsharpCode = @" private double halfPrecisionValue = 2.5; private double quarterPrecisionValue = 3.25; private double exactPrecisionValue = 3.7;"; - private readonly string example5RazorCode = @" + private readonly string example6RazorCode = @" Value: @noZeroValue @@ -58,38 +78,41 @@ public partial class BitRatingDemo Value: @allowClearValue"; - private readonly string example5CsharpCode = @" + private readonly string example6CsharpCode = @" private double noZeroValue; private double allowZeroValue; private double allowClearValue = 3;"; - private readonly string example6RazorCode = @" + private readonly string example7RazorCode = @" Value: @highlightValue"; - private readonly string example6CsharpCode = @" + private readonly string example7CsharpCode = @" private double highlightValue = 3;"; - private readonly string example7RazorCode = @" - hoverPreviewValue = v"" /> -@(hoverLabels[(int)(hoverPreviewValue ?? hoverBoundValue)]) + private readonly string example8RazorCode = @" + hoverPreviewValue = v""> + + @ratingWords[(int)(hoverPreviewValue ?? hoverBoundValue)] + + "; - private readonly string example7CsharpCode = @" + private readonly string example8CsharpCode = @" private double hoverBoundValue = 3; private double? hoverPreviewValue; -private readonly string[] hoverLabels = [""Not rated yet"", ""Terrible"", ""Bad"", ""Normal"", ""Good"", ""Wonderful""];"; - private readonly string example8RazorCode = @" +private readonly string[] ratingWords = [""Not rated yet"", ""Terrible"", ""Bad"", ""Normal"", ""Good"", ""Wonderful""];"; + + private readonly string example9RazorCode = @" -"; - - private readonly string example9RazorCode = @" BitIconInfo.Bit(i > 2 ? BitIconName.LikeSolid : BitIconName.DislikeSolid)"" GetUnselectedIcon=""i => BitIconInfo.Bit(i > 2 ? BitIconName.Like : BitIconName.Dislike)"" /> @@ -118,44 +141,6 @@ public partial class BitRatingDemo private BitIconInfo GetFaceIcon(int index) => BitIconInfo.Bit(faceIcons[index - 1]);"; private readonly string example10RazorCode = @" -"; - - private readonly string example11RazorCode = @" - - - @item.Index @@ -169,28 +154,66 @@ public partial class BitRatingDemo Value: @moodValue"; - private readonly string example11CsharpCode = @" + private readonly string example10CsharpCode = @" private double templateValue = 7; private double moodValue = 4; private readonly string[] moodFaces = [""😖"", ""😐"", ""🙂"", ""😀"", ""🤩""];"; + private const string example10ScssCode = @" +// A plain badge that fills once its item does. +.number-item { + width: 1.75rem; + height: 1.75rem; + line-height: 1; + font-size: 0.875rem; + align-items: center; + display: inline-flex; + border-radius: 0.25rem; + justify-content: center; + color: $bit-color-foreground-tertiary; + border: 1px solid $bit-color-border-primary; +} - private readonly string example12RazorCode = @" +.number-item-on { + color: $bit-color-primary-text; + border-color: $bit-color-primary; + background-color: $bit-color-primary; +} + +// The mood faces, greyed out until their item is the selected one. +.emoji-item { + opacity: 0.4; + line-height: 1; + font-size: 1.75rem; + display: inline-block; + filter: grayscale(1); +} + +.emoji-item-on { + opacity: 1; + filter: none; +}"; + private readonly DemoCodeFile[] example10CodeFiles = + [ + new("BitRatingDemo.razor.scss", example10ScssCode), + ]; + + private readonly string example11RazorCode = @" oneWayBinding = v ? 5 : 0"" Text=""@(oneWayBinding == 5 ? ""Unstar All"" : ""Star All"")"" /> "; - private readonly string example12CsharpCode = @" + private readonly string example11CsharpCode = @" private double oneWayBinding = 0; private double twoWayBinding = 3;"; - private readonly string example13RazorCode = @" + private readonly string example12RazorCode = @" onChangeValue = v"" /> Changed value: @onChangeValue Value: @onChangingValue @(changeRejected ? ""(lowering was rejected)"" : """")"; - private readonly string example13CsharpCode = @" + private readonly string example12CsharpCode = @" private double onChangeValue; private double onChangingValue = 3; private bool changeRejected; @@ -202,24 +225,17 @@ private void HandleOnChanging(BitRatingChangeArgs args) args.Cancel = changeRejected; }"; - private readonly string example14RazorCode = @" - - - + private readonly string example13RazorCode = @" - + ValidationModel.Value)"" /> Submit "; - private readonly string example14CsharpCode = @" + private readonly string example13CsharpCode = @" public class BitRatingDemoFormModel { [Range(typeof(double), ""1"", ""5"", ErrorMessage = ""Your rate must be between {1} and {2}"")] @@ -230,8 +246,16 @@ public class BitRatingDemoFormModel private void HandleValidSubmit() { } private void HandleInvalidSubmit() { }"; + private const string example13ScssCode = @" +.validation-message { + color: $bit-color-error; +}"; + private readonly DemoCodeFile[] example13CodeFiles = + [ + new("BitRatingDemo.razor.scss", example13ScssCode), + ]; - private readonly string example15RazorCode = @" + private readonly string example14RazorCode = @" Value: @accessibilityValue -"; - private readonly string example15CsharpCode = @" + + +
Overall satisfaction
+"; + private readonly string example14CsharpCode = @" private double accessibilityValue = 3; private string GetRatingAriaLabel(double value, double max) => $""Rated {value} out of {max}"";"; - private readonly string example16RazorCode = @" + private readonly string example15RazorCode = @" Visible: [ ] Hidden: [ ] Collapsed: [ ]"; - private readonly string example17RazorCode = @" + private readonly string example16RazorCode = @" @scoreWords[(int)scoreValue] @@ -259,7 +286,7 @@ private void HandleValidSubmit() { } GetSelectedIcon=""GetScoreIcon"" GetUnselectedIcon=""GetScoreIcon"" @bind-Value=""scoreValue"" />"; - private readonly string example17CsharpCode = @" + private readonly string example16CsharpCode = @" private double scoreValue = 2; private readonly string[] scoreWords = [""Unrated"", ""Poor"", ""Poor"", ""Okay"", ""Great"", ""Great""]; @@ -277,6 +304,34 @@ private void HandleValidSubmit() { } _ => BitIconName.Emoji2 });"; + private readonly string example17RazorCode = @" + + + + + + + + + +"; + private readonly string example17CsharpCode = @" +private double cascadeValue = 3; + +private readonly BitRatingParams[] ratingParams = +[ + new() + { + ReadOnly = true, + Precision = 0.5, + Size = BitSize.Small, + Color = BitColor.Warning, + SelectedIconName = BitIconName.HeartFill, + UnselectedIconName = BitIconName.Heart, + LabelPosition = BitLabelPosition.Start + } +];"; + private readonly string example18RazorCode = @" @@ -320,36 +375,61 @@ private void HandleValidSubmit() { } "; private readonly string example21RazorCode = @" - + - + - + - + + +
+ Rated + + by 1,034 people. +
-"; + +
+ + +
"; + private const string example21ScssCode = @" +.custom-class { + margin-inline: 1rem; + border-radius: 0.25rem; + padding-inline: 0.5rem; + border: 1px solid dodgerblue; + box-shadow: dodgerblue 0 0 1rem; +} + +.custom-label { + color: dodgerblue; + text-transform: uppercase; +} + +.custom-selected { + color: seagreen; +} + +.custom-unselected { + color: mediumseagreen; +}"; + private readonly DemoCodeFile[] example21CodeFiles = + [ + new("BitRatingDemo.razor.scss", example21ScssCode), + ]; private readonly string example22RazorCode = @" -"; + + +"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss index 95e8f3aabeb..b2a369b2e03 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss @@ -21,6 +21,11 @@ box-shadow: dodgerblue 0 0 1rem; } + .custom-label { + color: dodgerblue; + text-transform: uppercase; + } + .custom-selected { color: seagreen; } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs index 16a28466979..4481b83fa7f 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.VisualStudio.TestTools.UnitTesting; using Bunit; @@ -53,7 +54,6 @@ public void BitRatingShouldRespectReadOnly() var bitRating = component.Find(".bit-rtg"); Assert.IsTrue(bitRating.ClassList.Contains("bit-rtg-rdl")); - Assert.AreEqual("true", bitRating.GetAttribute("aria-readonly")); // A read-only rating is a picture of a value rather than a set of choices, so it is announced // as a single labelled image instead of a group of unreachable radios. @@ -82,9 +82,11 @@ public void BitRatingReadOnlyShouldDropTheAriaOfAnInteractiveGroup() var bitRating = component.Find(".bit-rtg"); - // Neither attribute is supported on the img role, where there is nothing to require or disable. + // None of these is supported on the img role, where there is nothing to require, disable or correct. + Assert.IsFalse(bitRating.HasAttribute("aria-readonly")); Assert.IsFalse(bitRating.HasAttribute("aria-required")); Assert.IsFalse(bitRating.HasAttribute("aria-disabled")); + Assert.IsFalse(bitRating.HasAttribute("aria-invalid")); } [TestMethod] @@ -1152,6 +1154,34 @@ public void BitRatingKeyboardShouldStepByAWholeItem(string key, bool shift, doub Assert.AreEqual(expected, value); } + [TestMethod, + DataRow("ArrowLeft", true, false, false), + DataRow("ArrowRight", false, true, false), + DataRow("Home", false, false, true), + DataRow("End", true, false, false), + DataRow("3", false, true, false) + ] + public void BitRatingKeyboardShouldLeaveAModifiedKeyToTheBrowser(string key, bool alt, bool ctrl, bool meta) + { + var value = 3d; + var component = RenderComponent(parameters => + { + parameters.Bind(p => p.Value, value, v => value = v); + }); + + // Alt+ArrowLeft goes back, Ctrl+Home reaches the top of the page, Ctrl+digit switches tabs: a held + // modifier makes the key a shortcut of the page rather than a move inside the rating. + component.Find(".bit-rtg").KeyDown(new KeyboardEventArgs + { + Key = key, + AltKey = alt, + CtrlKey = ctrl, + MetaKey = meta + }); + + Assert.AreEqual(3d, value); + } + [TestMethod] public void BitRatingKeyboardShouldLeaveEscapeToTheContainer() { @@ -1281,6 +1311,360 @@ public void BitRatingShouldRespectName() Assert.AreEqual("product-rate", component.Find(".bit-input-hidden").GetAttribute("name")); } + [TestMethod] + public void BitRatingShouldRenderAndBeNamedByItsLabel() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Rate this product"); + }); + + var root = component.Find(".bit-rtg"); + var label = component.Find(".bit-rtg-lbl"); + + Assert.AreEqual("Rate this product", label.TextContent); + + // A row of stars carries no text of its own, so the visible label is what names the group. + Assert.AreEqual(component.Find(".bit-rtg-lbc").Id, root.GetAttribute("aria-labelledby")); + Assert.IsFalse(root.HasAttribute("aria-label")); + } + + [TestMethod] + public void BitRatingShouldRenderALabelTemplate() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.LabelTemplate, (RenderFragment)(builder => + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "custom-label"); + builder.AddContent(2, "How was it?"); + builder.CloseElement(); + })); + }); + + var root = component.Find(".bit-rtg"); + + Assert.AreEqual("How was it?", component.Find(".custom-label").TextContent); + Assert.AreEqual(0, component.FindAll(".bit-rtg-lbl").Count); + Assert.AreEqual(component.Find(".bit-rtg-lbc").Id, root.GetAttribute("aria-labelledby")); + } + + [TestMethod] + public void BitRatingShouldRenderNoLabelContainerWithoutALabel() + { + var component = RenderComponent(); + + Assert.AreEqual(0, component.FindAll(".bit-rtg-lbc").Count); + Assert.IsFalse(component.Find(".bit-rtg").HasAttribute("aria-labelledby")); + } + + [TestMethod, + DataRow(null, ""), + DataRow(BitLabelPosition.Top, ""), + DataRow(BitLabelPosition.Bottom, "bit-rtg-lbm"), + DataRow(BitLabelPosition.Start, "bit-rtg-lst"), + DataRow(BitLabelPosition.End, "bit-rtg-led") + ] + public void BitRatingShouldRespectLabelPosition(BitLabelPosition? position, string expectedClass) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Quality"); + parameters.Add(p => p.LabelPosition, position); + }); + + var root = component.Find(".bit-rtg"); + + if (string.IsNullOrEmpty(expectedClass)) + { + Assert.IsFalse(root.ClassList.Any(c => c is "bit-rtg-lbm" or "bit-rtg-lst" or "bit-rtg-led")); + } + else + { + Assert.IsTrue(root.ClassList.Contains(expectedClass)); + } + } + + [TestMethod] + public void BitRatingAriaLabelShouldWinOverTheLabel() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Quality"); + parameters.Add(p => p.AriaLabel, "Rate the build quality"); + }); + + var root = component.Find(".bit-rtg"); + + // aria-labelledby would silently discard the explicit string, so only one of the two is rendered. + Assert.IsFalse(root.HasAttribute("aria-labelledby")); + Assert.AreEqual("Rate the build quality", root.GetAttribute("aria-label")); + } + + [TestMethod] + public void BitRatingShouldRespectAriaLabelledBy() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Quality"); + parameters.Add(p => p.AriaLabelledBy, "external-heading"); + }); + + var root = component.Find(".bit-rtg"); + + Assert.AreEqual("external-heading", root.GetAttribute("aria-labelledby")); + Assert.IsFalse(root.HasAttribute("aria-label")); + } + + [TestMethod] + public void BitRatingReadOnlyWithALabelShouldKeepTheValueInItsName() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ReadOnly, true); + parameters.Add(p => p.Label, "Average rating"); + parameters.Add(p => p.DefaultValue, 4.2); + }); + + var root = component.Find(".bit-rtg"); + var valueText = component.Find(".bit-rtg-alb[id]"); + + // Naming the picture of a value by its label alone would lose the value it exists to show. + Assert.AreEqual($"{component.Find(".bit-rtg-lbc").Id} {valueText.Id}", root.GetAttribute("aria-labelledby")); + Assert.AreEqual(string.Format(CultureInfo.CurrentCulture, "{0} of {1}", 4.2, 5), valueText.TextContent); + Assert.IsFalse(root.HasAttribute("aria-label")); + } + + [TestMethod] + public void BitRatingShouldRenderNoHiddenValueTextWithoutALabel() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ReadOnly, true); + parameters.Add(p => p.DefaultValue, 4.2); + }); + + // Without a label there is nothing for the value to join, so it stays in the aria-label instead. + Assert.AreEqual(string.Format(CultureInfo.CurrentCulture, "{0} of {1}", 4.2, 5), + component.Find(".bit-rtg").GetAttribute("aria-label")); + Assert.AreEqual(0, component.FindAll(".bit-rtg-alb").Count); + } + + [TestMethod] + public void BitRatingShouldRenderAndBeDescribedByItsDescription() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Quality"); + parameters.Add(p => p.Description, "Half a star is selectable."); + }); + + var root = component.Find(".bit-rtg"); + var description = component.Find(".bit-rtg-dsc"); + + Assert.AreEqual("Half a star is selectable.", description.TextContent.Trim()); + + // The description describes the group rather than naming it, so it joins aria-describedby and the + // name still comes from the label. + Assert.AreEqual(description.Id, root.GetAttribute("aria-describedby")); + Assert.AreEqual(component.Find(".bit-rtg-lbc").Id, root.GetAttribute("aria-labelledby")); + } + + [TestMethod] + public void BitRatingShouldRenderADescriptionTemplate() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.DescriptionTemplate, (RenderFragment)(builder => + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "custom-description"); + builder.AddContent(2, "Tap a star to rate"); + builder.CloseElement(); + })); + }); + + Assert.AreEqual("Tap a star to rate", component.Find(".custom-description").TextContent); + Assert.AreEqual(component.Find(".bit-rtg-dsc").Id, component.Find(".bit-rtg").GetAttribute("aria-describedby")); + } + + [TestMethod] + public void BitRatingShouldKeepASplattedAriaDescribedBy() + { + // A hyphenated aria-* name never reaches a parameter, so it arrives as a splatted attribute - which the + // component's own value would otherwise replace. aria-describedby is a space separated list of IDREFs, + // so the description of the rating joins the page's rather than taking its place. + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddMultipleAttributes(1, new Dictionary + { + [nameof(BitRating.Description)] = "Half a star is selectable.", + ["aria-describedby"] = "external-hint" + }); + builder.CloseComponent(); + }); + + Assert.AreEqual($"external-hint {component.Find(".bit-rtg-dsc").Id}", + component.Find(".bit-rtg").GetAttribute("aria-describedby")); + } + + [TestMethod] + public void BitRatingShouldRenderNoDescriptionContainerWithoutADescription() + { + var component = RenderComponent(); + + Assert.AreEqual(0, component.FindAll(".bit-rtg-dsc").Count); + Assert.IsFalse(component.Find(".bit-rtg").HasAttribute("aria-describedby")); + } + + [TestMethod, + DataRow(true, false, true), + DataRow(false, false, false), + DataRow(true, true, false) + ] + public void BitRatingShouldMarkARequiredLabel(bool required, bool readOnly, bool expected) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Quality"); + parameters.Add(p => p.Required, required); + parameters.Add(p => p.ReadOnly, readOnly); + }); + + Assert.AreEqual(expected, component.Find(".bit-rtg").ClassList.Contains("bit-rtg-req")); + } + + [TestMethod] + public void BitRatingParamsShouldHaveCorrectParamName() + { + Assert.AreEqual($"{nameof(BitParams)}.{nameof(BitRating)}", BitRatingParams.ParamName); + } + + [TestMethod] + public void BitRatingParamsShouldImplementIBitComponentParams() + { + var @params = new BitRatingParams(); + + Assert.IsInstanceOfType(@params); + Assert.IsInstanceOfType(@params); + Assert.AreEqual(BitRatingParams.ParamName, @params.Name); + } + + [TestMethod] + public void BitRatingShouldApplyCascadingParametersFromBitParams() + { + var paramsList = new List + { + new BitRatingParams + { + Max = 3, + Color = BitColor.Success, + Size = BitSize.Large, + Vertical = true, + ReadOnly = true, + Label = "Cascaded label", + LabelPosition = BitLabelPosition.End, + SelectedIconName = "HeartFill", + UnselectedIconName = "Heart" + } + }; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + var root = component.Find(".bit-rtg"); + + Assert.AreEqual(3, component.FindAll(".bit-rtg-btn").Count); + Assert.IsTrue(root.ClassList.Contains("bit-rtg-suc")); + Assert.IsTrue(root.ClassList.Contains("bit-rtg-lg")); + Assert.IsTrue(root.ClassList.Contains("bit-rtg-vrt")); + Assert.IsTrue(root.ClassList.Contains("bit-rtg-rdl")); + Assert.IsTrue(root.ClassList.Contains("bit-rtg-led")); + Assert.AreEqual("Cascaded label", component.Find(".bit-rtg-lbl").TextContent); + Assert.IsTrue(component.FindAll(".bit-rtg-iem")[2].ClassList.Contains("bit-icon--Heart")); + Assert.IsTrue(component.FindAll(".bit-rtg-ifl")[0].ClassList.Contains("bit-icon--HeartFill")); + } + + [TestMethod] + public void BitRatingDirectParametersShouldOverrideCascadingParameters() + { + var paramsList = new List + { + new BitRatingParams + { + Max = 3, + Color = BitColor.Success, + ReadOnly = true, + Label = "Cascaded label" + } + }; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitRating.Max), 6); + builder.AddAttribute(2, nameof(BitRating.Color), BitColor.Error); + builder.AddAttribute(3, nameof(BitRating.ReadOnly), false); + builder.CloseComponent(); + }); + }); + + var root = component.Find(".bit-rtg"); + + Assert.AreEqual(6, component.FindAll(".bit-rtg-btn").Count); + Assert.IsTrue(root.ClassList.Contains("bit-rtg-err")); + + // An explicit false is a written parameter like any other, so the cascade does not fill it in. + Assert.IsFalse(root.ClassList.Contains("bit-rtg-rdl")); + + // What the markup left unwritten still comes from the cascade. + Assert.AreEqual("Cascaded label", component.Find(".bit-rtg-lbl").TextContent); + } + + [TestMethod] + public void BitRatingShouldRespectTheLabelClassesAndStyles() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Quality"); + parameters.Add(p => p.Classes, new BitRatingClassStyles + { + Label = "custom-label", + LabelContainer = "custom-label-container", + Container = "custom-container" + }); + parameters.Add(p => p.Styles, new BitRatingClassStyles + { + Label = "color: red;", + LabelContainer = "padding: 1rem;", + Container = "gap: 1rem;" + }); + }); + + var labelContainer = component.Find(".bit-rtg-lbc"); + var label = component.Find(".bit-rtg-lbl"); + var container = component.Find(".bit-rtg-cnt"); + + Assert.IsTrue(labelContainer.ClassList.Contains("custom-label-container")); + Assert.IsTrue(label.ClassList.Contains("custom-label")); + Assert.IsTrue(container.ClassList.Contains("custom-container")); + StringAssert.Contains(labelContainer.GetAttribute("style"), "padding: 1rem;"); + StringAssert.Contains(label.GetAttribute("style"), "color: red;"); + StringAssert.Contains(container.GetAttribute("style"), "gap: 1rem;"); + } + [TestMethod] public void BitRatingValidationFormTest() { From 1d390f0dfa01b6a8854d400355dded78444ca01a Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 21 Sep 2026 23:43:18 +0330 Subject: [PATCH 2/4] further improvements --- .../Components/Inputs/Rating/BitRating.razor | 12 +- .../Inputs/Rating/BitRating.razor.cs | 151 +++++++-- .../Components/Inputs/Rating/BitRating.scss | 28 +- .../Inputs/Rating/BitRatingItemContext.cs | 11 + .../Inputs/Rating/BitRatingParams.cs | 7 +- .../Inputs/Rating/BitRatingDemo.razor | 82 +++-- .../Inputs/Rating/BitRatingDemo.razor.cs | 48 ++- .../Rating/BitRatingDemo.razor.samples.cs | 47 ++- .../Inputs/Rating/BitRatingDemo.razor.scss | 13 + .../Shared/MainLayout.razor.NavItems.cs | 2 +- .../Inputs/Rating/BitRatingTests.cs | 301 +++++++++++++++++- 11 files changed, 620 insertions(+), 82 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor index 5fa6a8d2db2..10bc6008f3b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor @@ -22,6 +22,8 @@ is ever rendered: the visible label names the group when there is one, the explicit strings when not. *@
diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs index 82c066a1a90..5268e8ece9f 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs @@ -173,6 +173,10 @@ public partial class BitRating : BitInputBase /// Turns off the preview that follows the pointer over the items and shows the value /// that a click would commit. /// + /// + /// Only the preview drawn by the component stops: goes on reporting the + /// hovered value, which is what a page that draws a preview of its own needs. + /// [Parameter, ResetClassBuilder] public bool NoHoverPreview { get; set; } @@ -185,10 +189,24 @@ public partial class BitRating : BitInputBase /// [Parameter] public EventCallback OnChanging { get; set; } + /// + /// Callback for when the rating receives the focus. + /// + [Parameter] public EventCallback OnFocusIn { get; set; } + + /// + /// Callback for when the focus leaves the rating. + /// + [Parameter] public EventCallback OnFocusOut { get; set; } + /// /// Callback for when the previewed value changes, which is the value a click would commit. /// It receives null when the pointer leaves the rating and the preview ends. /// + /// + /// It reports the hovered value whether or not the component draws the preview itself, so it keeps + /// working under . + /// [Parameter] public EventCallback OnHoverChange { get; set; } /// @@ -200,6 +218,10 @@ public partial class BitRating : BitInputBase /// /// A precision that does not divide an item evenly - 0.3, say - is rounded to the closest number of equal /// steps, so the items always end on a whole value, and an item is never split into more than 100 steps. + ///
+ /// It is also the floor of the scale: the smallest rating that can be given is a single step, so a + /// half-star rating reaches 0.5 without opening up the unrated 0 that and + /// are for. ///
[Parameter] public double Precision { get; set; } = 1; @@ -225,7 +247,7 @@ public partial class BitRating : BitInputBase [Parameter] public string? SelectedIconName { get; set; } /// - /// Size of rating elements. + /// Size of the rating, which scales the item glyphs, the label and the description together. /// [Parameter, ResetClassBuilder] public BitSize? Size { get; set; } @@ -384,7 +406,9 @@ protected override void OnParametersSet() // A rating turned read-only or disabled under the pointer stops receiving the mouseleave that would // normally end the preview, so a stale one would go on rendering in place of the committed value. - if (IsEnabled is false || ReadOnly || NoHoverPreview) + // NoHoverPreview is not one of these: it hides the preview rather than ending it, so the hovered + // value stays tracked for OnHoverChange and simply stops being what the items are drawn from. + if (IsEnabled is false || ReadOnly) { _hoverValue = null; } @@ -443,9 +467,11 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa private int _Max => Math.Max(Max, 1); /// - /// The smallest value the rating can hold. Both AllowZeroStars and AllowClear open up the unrated 0. + /// The smallest value the rating can hold. Both AllowZeroStars and AllowClear open up the unrated 0; + /// without them the floor is the smallest rating that can still be given, which is a single step - + /// a whole item at the default Precision, and the first half of the first one at a Precision of 0.5. /// - private double _MinValue => (AllowZeroStars || AllowClear) ? 0 : 1; + private double _MinValue => (AllowZeroStars || AllowClear) ? 0 : _Step; /// /// How many selectable steps each item is divided into, derived from the Precision. @@ -471,9 +497,11 @@ private int _StepsPerItem private double _Step => 1d / _StepsPerItem; /// - /// The value the items are rendered from: the hovered one while a preview is active, the committed one otherwise. + /// The value the items are rendered from: the hovered one while a preview is active, the committed one + /// otherwise. A rating whose preview is turned off still tracks the hovered value for OnHoverChange, + /// so the choice of what to draw is made here rather than by stopping the tracking. /// - private double _DisplayValue => _hoverValue ?? CurrentValue; + private double _DisplayValue => (NoHoverPreview ? null : _hoverValue) ?? CurrentValue; /// /// The item that holds the single tab stop of the group. It follows the value - a fractional one lands @@ -524,17 +552,27 @@ private string? _AriaLabel internal bool HasDescription => DescriptionTemplate is not null || Description.HasValue(); /// - /// The elements that describe the rating. This attribute sits after the HtmlAttributes splat in the - /// markup, so it is what ends up rendered no matter what - a null would even remove a splatted value - - /// which is why an aria-describedby the consumer splatted is carried over here rather than replaced. - /// Both are kept, since aria-describedby is a space separated list of IDREFs. + /// The value of an aria attribute the consumer splatted onto the component. Every aria attribute the + /// rating computes sits after the HtmlAttributes splat in the markup, so it is what ends up rendered no + /// matter what - and a null would even remove a splatted value. This is what the computed attributes + /// hand back rather than erasing what the page wrote. + /// + private string? _GetSplattedAttribute(string name) + { + HtmlAttributes.TryGetValue(name, out var value); + + return value?.ToString(); + } + + /// + /// The elements that describe the rating, which is a splatted aria-describedby carried over rather than + /// replaced: both are kept, since aria-describedby is a space separated list of IDREFs. /// private string? _AriaDescribedBy { get { - HtmlAttributes.TryGetValue("aria-describedby", out var splatted); - var splattedDescribedBy = splatted?.ToString(); + var splattedDescribedBy = _GetSplattedAttribute("aria-describedby"); if (HasDescription is false) return splattedDescribedBy; @@ -542,6 +580,15 @@ private string? _AriaDescribedBy } } + /// + /// The name of the rating as an inline string, which is only rendered when nothing names it by + /// reference. A name the page splatted is the last resort, so that writing aria-label on the component + /// works as it reads even though the component owns the attribute. + /// + private string? _AriaLabelAttribute => _AriaLabelledBy is null + ? (_AriaLabel ?? _GetSplattedAttribute("aria-label")) + : null; + /// /// The element the name of the rating is read from, when it is read from the page rather than given as a /// string: the explicit AriaLabelledBy, then the visible label. The two string forms - AriaLabel and the @@ -554,7 +601,9 @@ private string? _AriaLabelledBy { if (AriaLabelledBy.HasValue()) return AriaLabelledBy; - if (AriaLabel.HasValue() || GetAriaLabel is not null || HasLabel is false) return null; + if (AriaLabel.HasValue() || GetAriaLabel is not null) return null; + + if (HasLabel is false) return _GetSplattedAttribute("aria-labelledby"); return _RendersHiddenValueText ? $"{_labelId} {_valueTextId}" : _labelId; } @@ -720,7 +769,11 @@ private async Task HandleOnClick(double value) private async Task HandleOnHover(double value) { - if (IsEnabled is false || ReadOnly || NoHoverPreview) return; + if (IsEnabled is false || ReadOnly) return; + + // With the preview off and nobody listening there is nothing a hover could change, so the render + // it would cost is skipped entirely. + if (NoHoverPreview && OnHoverChange.HasDelegate is false) return; if (_hoverValue == value) return; @@ -729,7 +782,26 @@ private async Task HandleOnHover(double value) await OnHoverChange.InvokeAsync(value); } - private async Task HandleOnMouseLeave() + private async Task HandleOnFocusIn(FocusEventArgs e) + { + if (IsEnabled is false) return; + + await OnFocusIn.InvokeAsync(e); + } + + private async Task HandleOnFocusOut(FocusEventArgs e) + { + if (IsEnabled is false) return; + + await OnFocusOut.InvokeAsync(e); + } + + private Task HandleOnMouseLeave() => EndPreview(); + + /// + /// Takes the preview down, which puts the items back to the committed value and reports the end of it. + /// + private async Task EndPreview() { if (_hoverValue is null) return; @@ -761,8 +833,9 @@ private async Task HandleOnKeyDown(KeyboardEventArgs e) // Holding Shift - and the Page keys, which need no modifier for it - moves by a whole item to the // next or previous one, so crossing a rating split into tenths costs five presses instead of fifty. var coarse = e.ShiftKey || e.Key is "PageUp" or "PageDown"; - var up = coarse ? Math.Floor(value) + 1 : value + _Step; - var down = coarse ? Math.Ceiling(value) - 1 : value - _Step; + var step = coarse ? 1 : _Step; + var up = StepFrom(value, step, true); + var down = StepFrom(value, step, false); double? newValue = e.Key switch { @@ -786,6 +859,30 @@ private async Task HandleOnKeyDown(KeyboardEventArgs e) await FocusItem(_TabbableIndex); } + /// + /// The next value up or down the grid a step of the given size lays over the scale. + /// + /// + /// The move lands on that grid rather than adding the step to whatever the value happens to be, which + /// matters for a value the Precision never snapped: one bound from elsewhere at 4.3 on a half-star + /// scale moves to 4.5 and 4 instead of carrying its own remainder up and down a scale that cannot + /// express it. On a value already on the grid the two are the same thing. + /// + private static double StepFrom(double value, double step, bool up) + { + var steps = value / step; + + // A value that is exactly on the grid divides into a whole number of steps only to within the + // rounding of binary floating point, so the index is taken with a tolerance: without it the floor + // of a 2.9999999999 would move up to the step the value is already sitting on. + const double tolerance = 1e-4; + + var index = up ? Math.Floor(steps + tolerance) + 1 + : Math.Ceiling(steps - tolerance) - 1; + + return index * step; + } + /// /// The value a digit key jumps straight to, which is how a keyboard user reaches "4 of 5" in one press /// instead of four. A digit beyond the ends of the scale is held to them like any other value. @@ -804,6 +901,17 @@ private async Task HandleOnKeyDown(KeyboardEventArgs e) } private async Task ChangeValue(double value) + { + await CommitValue(value); + + // Whatever became of it, the interaction that got here has spent the preview: keeping it would go + // on rendering the hovered value in place of the committed one, and a value an OnChanging refused + // or a one-way binding never took would be previewed as though it had landed. The pointer leaving + // is no longer the only way out of it, because on a touch device that never happens. + await EndPreview(); + } + + private async Task CommitValue(double value) { if (InvalidValueBinding()) return; @@ -820,15 +928,6 @@ private async Task ChangeValue(double value) if (args.Cancel) return; } - // The preview has served its purpose once a value lands, and keeping it would render the - // hovered value instead of the committed one until the pointer moves away. - if (_hoverValue is not null) - { - _hoverValue = null; - - await OnHoverChange.InvokeAsync(null); - } - await SetCurrentValueAsync(value); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss index bb9c181279f..1e1b42b940a 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss @@ -19,11 +19,13 @@ // --bit-Rating-radius corner radius of an item and its focus ring (default: $shp-radius-control) // --bit-Rating-hover-scale how much the hovered item grows (default: 1.1; 1 turns it off) // --bit-Rating-label-color text color of the label (default: $clr-fg-pri) -// --bit-Rating-label-font-size text size of the label (default: $tg-fs-sm) +// --bit-Rating-label-font-size text size of the label (default: per size, $tg-fs-xs/sm/md) +// --bit-Rating-label-font-weight text weight of the label (default: $tg-fw-semibold) // --bit-Rating-label-gap room between the label and the items, and // between the items and the description (default: spacing(1)) // --bit-Rating-description-color text color of the description (default: $clr-fg-sec) -// --bit-Rating-description-font-size text size of the description (default: $tg-fs-xs) +// --bit-Rating-description-font-size text size of the description (default: per size, +// $tg-fs-2xs/xs/sm) .bit-rtg { display: inline-flex; @@ -35,7 +37,9 @@ align-items: flex-start; gap: var(--bit-Rating-label-gap, #{spacing(1)}); font-weight: $tg-fw-regular; - font-size: $tg-fs-sm; + // The text of the rating scales with its Size the way every other sized control in the library + // scales its own, so a Large rating is not a large row of stars under a small label. + font-size: var(--bit-rtg-lbl-size); font-family: $tg-font-family; // The unfilled part stays neutral whatever the Color is, so it keeps reading as "not rated yet" // instead of as a second, dimmer accent. @@ -46,7 +50,9 @@ // as "this is what a click would give you" rather than as the committed value. An invalid rating is // left out of it, so the error color it is painted with survives the pointer passing over it. @media (hover: hover) { - &:hover:not(.bit-dis):not(.bit-inv):not(.bit-rtg-rdl):not(.bit-rtg-nhp) .bit-rtg-ifl { + // Scoped to the box the items live in rather than to the root: the pointer resting on the label + // or the description previews nothing, so it must not repaint the fill as though it did. + &:not(.bit-dis):not(.bit-inv):not(.bit-rtg-rdl):not(.bit-rtg-nhp) .bit-rtg-cnt:hover .bit-rtg-ifl { color: var(--bit-Rating-hover-color, var(--bit-rtg-clr-hover)); } @@ -110,10 +116,10 @@ .bit-rtg-lbl { display: block; overflow-wrap: break-word; - font-weight: $tg-fw-semibold; letter-spacing: $tg-ctrl-letter-spacing; color: var(--bit-Rating-label-color, #{$clr-fg-pri}); - font-size: var(--bit-Rating-label-font-size, #{$tg-fs-sm}); + font-size: var(--bit-Rating-label-font-size, var(--bit-rtg-lbl-size)); + font-weight: var(--bit-Rating-label-font-weight, #{$tg-fw-semibold}); } // The hint that sits under the items, pointed at by aria-describedby rather than read as part of the @@ -124,7 +130,7 @@ max-width: 100%; overflow-wrap: break-word; color: var(--bit-Rating-description-color, #{$clr-fg-sec}); - font-size: var(--bit-Rating-description-font-size, #{$tg-fs-xs}); + font-size: var(--bit-Rating-description-font-size, var(--bit-rtg-dsc-size)); } .bit-rtg-btn { @@ -178,16 +184,24 @@ position: absolute; } +// Each size class carries the whole ramp of the rating - the glyph, the label and the description - +// so that one parameter scales the control rather than only the stars in the middle of it. .bit-rtg-sm { --bit-rtg-size: #{$siz-icon-sm}; + --bit-rtg-lbl-size: #{$tg-fs-xs}; + --bit-rtg-dsc-size: #{$tg-fs-2xs}; } .bit-rtg-md { --bit-rtg-size: #{$siz-icon-md}; + --bit-rtg-lbl-size: #{$tg-fs-sm}; + --bit-rtg-dsc-size: #{$tg-fs-xs}; } .bit-rtg-lg { --bit-rtg-size: #{$siz-icon-lg}; + --bit-rtg-lbl-size: #{$tg-fs-md}; + --bit-rtg-dsc-size: #{$tg-fs-sm}; } .bit-rtg-alb { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs index adc4abfdfc1..accbcc4090d 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs @@ -67,4 +67,15 @@ public BitRatingItemContext(int index, int max, double percentage, double displa /// Whether the item is completely filled, meaning its is 100. /// public bool IsFull => Percentage >= 100; + + /// + /// Whether this is the item the shown value lands in - the fourth of a 3.5, and the one under the + /// pointer while a hover preview is running. + /// + /// + /// This is the item being picked rather than the exact committed value, which is what tells it apart + /// from the run of filled ones behind it. It is the same thing the data-is-current attribute of + /// the item marks for CSS that styles the built-in glyphs. + /// + public bool IsCurrent => Index == Math.Ceiling(DisplayValue); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs index 6afd9c6734e..f6a638f0537 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingParams.cs @@ -113,13 +113,16 @@ public class BitRatingParams : BitInputBaseParams, IBitComponentParams public int? Max { get; set; } /// - /// Turns off the preview that follows the pointer over the items and shows the value that a click would commit. + /// Turns off the preview that follows the pointer over the items and shows the value that a click would + /// commit. Only the preview the component paints stops: the OnHoverChange callback goes on reporting. /// public bool? NoHoverPreview { get; set; } /// /// The smallest change of the value the user can make, as a fraction of a single item. /// The default of 1 only allows whole items, 0.5 adds halves, 0.1 makes every tenth selectable, and so on. + /// It is also the floor of the scale unless or + /// opens up the unrated 0. /// public double? Precision { get; set; } @@ -135,7 +138,7 @@ public class BitRatingParams : BitInputBaseParams, IBitComponentParams public string? SelectedIconName { get; set; } /// - /// Size of rating elements. + /// Size of the rating, which scales the item glyphs, the label and the description together. /// public BitSize? Size { get; set; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor index bc26784b163..adf16afadd3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor @@ -44,7 +44,8 @@ it beside the items for a compact row, and LabelTemplate replaces it with markup of your own, below a caption that spells the value out in words. Description is the hint under the items, pointed at by aria-describedby so it is announced after the name rather than as part of - it. ItemTitles gives each item its own tooltip, in order, and those words double as the + it, and DescriptionTemplate replaces it with markup while keeping that role. + ItemTitles gives each item its own tooltip, in order, and those words double as the accessible name of the item; items past the end of the list fall back to their position.

@@ -57,6 +58,12 @@

+ + + Averaged over 1,034 stays, updated nightly. + + +
@ratingWords[(int)labelTemplateValue] @@ -121,7 +128,9 @@ arrow keys step by the same amount. A precision that does not divide an item evenly is rounded to the closest number of equal steps, and an item is never split into more than a hundred. It constrains what the user can pick, never what can be shown: a value bound from elsewhere is always - drawn at its exact precision. + drawn at its exact precision. It also sets the floor of the scale - the smallest rating that can + still be given is one step, so a half-star rating reaches 0.5 without having to open up the + unrated 0 that AllowZeroStars is for.

@@ -141,18 +150,23 @@
- A rating starts at the first item unless it is told that "not rated" is a legitimate answer. - AllowZeroStars says exactly that: a value of 0 is kept instead of being pulled up to 1, so - the rating can start empty - though the user still cannot get back to it. AllowClear adds - that way back: clicking the item that is already selected, or pressing Delete or - Backspace, returns the rating to 0, which is why it opens up the 0 on its own. + A rating starts at its smallest step - a whole item at the default Precision - unless it is told + that "not rated" is a legitimate answer. AllowZeroStars says exactly that: a value of 0 is + kept instead of being pulled up, so the rating can start empty - though the user still cannot get + back to it. AllowClear adds that way back: clicking the item that is already selected, or + pressing Delete or Backspace, returns the rating to 0, which is why it opens up the + 0 on its own.

-
Default (0 is pulled up to 1):
+
Default (0 is pulled up to the smallest step):
Value: @noZeroValue
+
Default with a half Precision (the floor is half an item, not a whole one):
+ + Value: @noZeroHalfValue +
AllowZeroStars (starts empty, but cannot be emptied again):
Value: @allowZeroValue @@ -189,9 +203,9 @@ in the hover shade so it reads as a proposal rather than as the committed value; leaving restores what is bound. OnHoverChange reports that previewed value as it changes, and null once the pointer leaves - which is all a caption needs to follow the pointer, down to a half step at a - fractional Precision. NoHoverPreview turns the preview off where it competes with something - else on the page; the items still grow under the pointer, since that is an affordance and not a - value. + fractional Precision. NoHoverPreview turns off the preview the component paints, for a page + that competes with it or draws one of its own - the items still grow under the pointer, since that + is an affordance and not a value, and OnHoverChange goes on reporting.

@@ -204,8 +218,11 @@
-
NoHoverPreview:
- +
NoHoverPreview (the stars stay put, the caption still follows the pointer):
+ + @(noPreviewHoverValue is null ? $"Rated {noPreviewValue}" : $"Click for {noPreviewHoverValue}")
@@ -255,7 +272,8 @@ ItemTemplate replaces the pair of icons with content of your own while the BitRating keeps everything around it: the hit areas, the hover preview, the keyboard handling and the accessibility. The template receives a BitRatingItemContext - the item's Index, the Percentage - it is filled by, whether it IsFull or merely IsSelected, and both the previewed + it is filled by, whether it IsFull or merely IsSelected, whether it IsCurrent + (the one the shown value lands in, which the numbered scale below rings), and both the previewed DisplayValue and the committed Value. A template renders as a whole, so a partial fill is yours to express if it matters; when all you need is a different glyph per position, GetSelectedIcon keeps the built-in one. @@ -265,7 +283,7 @@
Numbered scale:
- @item.Index + @item.Index Value: @templateValue @@ -306,7 +324,9 @@ BitRatingChangeArgs keeps the current value, and since the callback is awaited it can take long enough to ask the user a question. The args carry both the incoming Value and the OldValue, so the decision can depend on the direction of the change - the second rating below - refuses to be lowered. + refuses to be lowered. OnFocusIn and OnFocusOut report the focus arriving at and + leaving the rating as a whole rather than each item, so moving along the scale with the arrow keys + does not read as leaving and re-entering it.

@@ -317,6 +337,10 @@
OnChanging (this one refuses to go down):
Value: @onChangingValue @(changeRejected ? "(lowering was rejected)" : "") +
+
OnFocusIn & OnFocusOut (tab to it, then arrow along it):
+ + @(isFocused ? "Focused" : "Not focused")
@@ -540,21 +564,19 @@
- Size scales the item glyphs, from the Small that fits inline next to a line of text to - the Large that carries a rating asked as the main question on a page. Medium is the - default. The pointer target of an item never drops below 24×24 CSS pixels, so a smaller size buys a - smaller glyph rather than a harder one to hit. + Size scales the whole control - the glyphs, the label and the description - from the + Small that fits inline next to a line of text to the Large that carries a rating + asked as the main question on a page. Medium is the default. The pointer target of an item + never drops below 24×24 CSS pixels, so a smaller size buys a smaller glyph rather than a harder one + to hit.

-
Small:
- +
-
Medium:
- +
-
Large:
- +
@@ -564,7 +586,10 @@ name - Root, LabelContainer, Label, Description, Container, Button, IconContainer, SelectedIcon and UnselectedIcon - so the filled and unfilled halves can be styled apart from each other. Prefer Classes where your CSS should own states - such as hover and focus, which inline styles cannot express. + such as hover and focus, which inline styles cannot express. Each item also carries + data-is-current, which marks the one the shown value lands in - the fourth of a 3.5, + and the one under the pointer while a preview is running - so the item being picked can be told + apart from the run of filled ones behind it.
@@ -577,6 +602,9 @@
+

+
data-is-current (the item being picked is ringed):

+


diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs index ac142baa0c1..033c3731fa0 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs @@ -154,7 +154,7 @@ public partial class BitRatingDemo Name = "NoHoverPreview", Type = "bool", DefaultValue = "false", - Description = "Turns off the preview that follows the pointer over the items and shows the value that a click would commit.", + Description = "Turns off the preview that follows the pointer over the items and shows the value that a click would commit. Only the preview the component paints stops: OnHoverChange goes on reporting the hovered value.", }, new() { @@ -165,17 +165,29 @@ public partial class BitRatingDemo Href = "#rating-change-args", }, new() + { + Name = "OnFocusIn", + Type = "EventCallback", + Description = "Callback for when the rating receives the focus. It reports the focus arriving at the rating as a whole, not at each item, so moving along the scale does not raise it again.", + }, + new() + { + Name = "OnFocusOut", + Type = "EventCallback", + Description = "Callback for when the focus leaves the rating.", + }, + new() { Name = "OnHoverChange", Type = "EventCallback", - Description = "Callback for when the previewed value changes, which is the value a click would commit. It receives null when the pointer leaves the rating and the preview ends.", + Description = "Callback for when the hovered value changes, which is the value a click would commit. It receives null when the pointer leaves the rating, and keeps reporting under NoHoverPreview.", }, new() { Name = "Precision", Type = "double", DefaultValue = "1", - Description = "The smallest change of the value the user can make, as a fraction of a single item. The default of 1 only allows whole items, 0.5 adds halves, 0.1 makes every tenth selectable. It constrains what the user can pick, not what can be displayed.", + Description = "The smallest change of the value the user can make, as a fraction of a single item. The default of 1 only allows whole items, 0.5 adds halves, 0.1 makes every tenth selectable. It constrains what the user can pick, not what can be displayed, and it is also the floor of the scale unless AllowZeroStars or AllowClear opens up the unrated 0.", }, new() { @@ -198,7 +210,7 @@ public partial class BitRatingDemo Name = "Size", Type = "BitSize?", DefaultValue = "null", - Description = "Size of rating elements.", + Description = "Size of the rating, which scales the item glyphs, the label and the description together.", LinkType = LinkType.Link, Href = "#size-enum", }, @@ -393,6 +405,12 @@ public partial class BitRatingDemo Name = "IsFull", Type = "bool", Description = "Whether the item is completely filled, meaning its Percentage is 100.", + }, + new() + { + Name = "IsCurrent", + Type = "bool", + Description = "Whether this is the item the shown value lands in - the fourth of a 3.5, and the one under the pointer while a hover preview is running. It is the item being picked rather than the exact committed value.", } ] }, @@ -540,7 +558,7 @@ public partial class BitRatingDemo { Name = "--bit-Rating-size", DefaultValue = "Per size: --bit-siz-icon-sm / -md / -lg", - Description = "Size of the item glyphs, which the Size parameter otherwise picks.", + Description = "Size of the item glyphs, which the Size parameter otherwise picks. It does not move the label or the description, which have text sizes of their own.", }, new() { @@ -581,8 +599,14 @@ public partial class BitRatingDemo new() { Name = "--bit-Rating-label-font-size", - DefaultValue = "--bit-tpg-fs-sm", - Description = "Text size of the label.", + DefaultValue = "Per size: --bit-tpg-fs-xs / -sm / -md", + Description = "Text size of the label, which the Size parameter otherwise picks.", + }, + new() + { + Name = "--bit-Rating-label-font-weight", + DefaultValue = "--bit-tpg-fw-semibold", + Description = "Text weight of the label.", }, new() { @@ -599,8 +623,8 @@ public partial class BitRatingDemo new() { Name = "--bit-Rating-description-font-size", - DefaultValue = "--bit-tpg-fs-xs", - Description = "Text size of the description.", + DefaultValue = "Per size: --bit-tpg-fs-2xs / -xs / -sm", + Description = "Text size of the description, which the Size parameter otherwise picks.", } ]; @@ -617,6 +641,7 @@ public partial class BitRatingDemo private double exactPrecisionValue = 3.7; private double noZeroValue; + private double noZeroHalfValue; private double allowZeroValue; private double allowClearValue = 3; @@ -624,6 +649,8 @@ public partial class BitRatingDemo private double hoverBoundValue = 3; private double? hoverPreviewValue; + private double noPreviewValue = 2; + private double? noPreviewHoverValue; private readonly string[] ratingWords = ["Not rated yet", "Terrible", "Bad", "Normal", "Good", "Wonderful"]; private double perItemIconValue = 4; @@ -647,9 +674,12 @@ public partial class BitRatingDemo private double onChangeValue; private double onChangingValue = 3; private bool changeRejected; + private bool isFocused; private double accessibilityValue = 3; + private double currentItemValue = 3.5; + private double scoreValue = 2; private readonly string[] scoreWords = ["Unrated", "Poor", "Poor", "Okay", "Great", "Great"]; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs index d2deb92cdb0..1558d31462d 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs @@ -20,6 +20,12 @@ public partial class BitRatingDemo + + + Averaged over 1,034 stays, updated nightly. + + + @ratingWords[(int)labelTemplateValue] @@ -73,6 +79,9 @@ public partial class BitRatingDemo Value: @noZeroValue + +Value: @noZeroHalfValue + Value: @allowZeroValue @@ -80,6 +89,7 @@ public partial class BitRatingDemo Value: @allowClearValue"; private readonly string example6CsharpCode = @" private double noZeroValue; +private double noZeroHalfValue; private double allowZeroValue; private double allowClearValue = 3;"; @@ -101,11 +111,17 @@ public partial class BitRatingDemo -"; + noPreviewHoverValue = v"" /> +@(noPreviewHoverValue is null ? $""Rated {noPreviewValue}"" : $""Click for {noPreviewHoverValue}"")"; private readonly string example8CsharpCode = @" private double hoverBoundValue = 3; private double? hoverPreviewValue; +private double noPreviewValue = 2; +private double? noPreviewHoverValue; + private readonly string[] ratingWords = [""Not rated yet"", ""Terrible"", ""Bad"", ""Normal"", ""Good"", ""Wonderful""];"; private readonly string example9RazorCode = @" @@ -143,7 +159,7 @@ public partial class BitRatingDemo private readonly string example10RazorCode = @" - @item.Index + @item.Index Value: @templateValue @@ -179,6 +195,12 @@ public partial class BitRatingDemo background-color: $bit-color-primary; } +// IsCurrent is the item the value lands in, which is the last of the filled run rather than all of it. +.number-item-current { + outline: 2px solid $bit-color-primary; + outline-offset: 2px; +} + // The mood faces, greyed out until their item is the selected one. .emoji-item { opacity: 0.4; @@ -212,11 +234,15 @@ public partial class BitRatingDemo Changed value: @onChangeValue -Value: @onChangingValue @(changeRejected ? ""(lowering was rejected)"" : """")"; +Value: @onChangingValue @(changeRejected ? ""(lowering was rejected)"" : """") + + isFocused = true"" OnFocusOut=""() => isFocused = false"" /> +@(isFocused ? ""Focused"" : ""Not focused"")"; private readonly string example12CsharpCode = @" private double onChangeValue; private double onChangingValue = 3; private bool changeRejected; +private bool isFocused; private void HandleOnChanging(BitRatingChangeArgs args) { @@ -368,11 +394,11 @@ private void HandleValidSubmit() { } "; private readonly string example20RazorCode = @" - + - + -"; +"; private readonly string example21RazorCode = @" @@ -384,6 +410,8 @@ private void HandleValidSubmit() { } + + @@ -420,6 +448,13 @@ private void HandleValidSubmit() { } .custom-unselected { color: mediumseagreen; +} + +// data-is-current marks the item the shown value lands in - the fourth of a 3.5, and the one +// under the pointer while a hover preview is running. +.ringed-item[data-is-current=""true""] { + outline: 1px dashed $bit-color-primary; + outline-offset: -1px; }"; private readonly DemoCodeFile[] example21CodeFiles = [ diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss index b2a369b2e03..8307f17c243 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.scss @@ -34,6 +34,13 @@ color: mediumseagreen; } + // data-is-current marks the item the shown value lands in - the fourth of a 3.5, and the one under the + // pointer while a hover preview is running. + .ringed-item[data-is-current="true"] { + outline: 1px dashed $bit-color-primary; + outline-offset: -1px; + } + // The numbered scale of the ItemTemplate example: a plain badge that fills once its item does. .number-item { width: 1.75rem; @@ -54,6 +61,12 @@ background-color: $bit-color-primary; } + // IsCurrent is the item the value lands in, which is the last of the filled run rather than all of it. + .number-item-current { + outline: 2px solid $bit-color-primary; + outline-offset: 2px; + } + // The mood faces of the ItemTemplate example, greyed out until their item is the selected one. .emoji-item { opacity: 0.4; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs index d1bc9c7c01d..91479d263c4 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Shared/MainLayout.razor.NavItems.cs @@ -38,7 +38,7 @@ public partial class MainLayout new() { Text = "FileUpload", Url = "/components/fileupload", AdditionalUrls = ["/components/file-upload"] }, new() { Text = "NumberField", Url = "/components/numberfield", AdditionalUrls = ["/components/numerictextfield", "/components/numeric-text-field", "/components/spinbutton", "/components/spin-button"], Description = "NumberInput" }, new() { Text = "OtpInput", Url = "/components/otpinput", AdditionalUrls = ["/components/otp-input"] }, - new() { Text = "Rating", Url = "/components/rating" }, + new() { Text = "Rating", Url = "/components/rating", Description = "Rate, Stars", Data = "Review, Score, Feedback" }, new() { Text = "SearchBox", Url = "/components/searchbox", AdditionalUrls = ["/components/search-box"], Data = "AutoComplete" }, new() { Text = "Slider", Url = "/components/slider", Description = "Range" }, new() { Text = "TagsInput", Url = "/components/tagsinput", AdditionalUrls = ["/components/tags-input"] }, diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs index 4481b83fa7f..e6de3a26b54 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs @@ -232,6 +232,41 @@ public void BitRatingShouldRespectAllowZeroStars(bool allowZeroStars) Assert.AreEqual(allowZeroStars ? "false" : "true", firstButton.GetAttribute("aria-checked")); } + [TestMethod, + DataRow(1d, 1d), + DataRow(0.5d, 0.5d), + DataRow(0.25d, 0.25d), + DataRow(0.1d, 0.1d)] + public void BitRatingFloorShouldBeASingleStep(double precision, double expected) + { + // Without AllowZeroStars or AllowClear the smallest rating that can be given is one step, so a + // half-star scale reaches 0.5 instead of being held at a whole item. + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Precision, precision); + }); + + Assert.AreEqual(expected, component.Instance.Value); + Assert.AreEqual(expected.ToString(CultureInfo.InvariantCulture), component.Find("input").GetAttribute("min")); + } + + [TestMethod] + public void BitRatingShouldCommitTheFirstFractionOfAFractionalScale() + { + double value = 3; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Precision, 0.5); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + // The first slice of the first item is half a star, which the floor of a whole item would have + // pulled back up to 1. + component.FindAll(".bit-rtg-seg")[0].Click(); + + Assert.AreEqual(0.5d, value); + } + [TestMethod] public void BitRatingShouldClampAValueAboveTheMax() { @@ -516,11 +551,34 @@ public void BitRatingShouldRespectNoHoverPreview() component.FindAll(".bit-rtg-btn")[3].MouseOver(); - Assert.IsNull(hovered); + // Only the preview the component paints is off: the hovered value is still reported, which is + // what a page drawing a preview of its own needs. + Assert.AreEqual(4d, hovered); StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[3].GetAttribute("style"), "width:0%"); // The class turns off the CSS half of the preview, which shades the filled part on hover. Assert.IsTrue(component.Find(".bit-rtg").ClassList.Contains("bit-rtg-nhp")); + + component.Find(".bit-rtg").MouseLeave(); + + Assert.IsNull(hovered); + } + + [TestMethod] + public void BitRatingNoHoverPreviewShouldKeepPaintingTheCommittedValue() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, 2); + parameters.Add(p => p.NoHoverPreview, true); + parameters.Add(p => p.OnHoverChange, (double? v) => { }); + }); + + component.FindAll(".bit-rtg-btn")[4].MouseOver(); + + var fills = component.FindAll(".bit-rtg-ifl"); + StringAssert.Contains(fills[1].GetAttribute("style"), "width:100%"); + StringAssert.Contains(fills[2].GetAttribute("style"), "width:0%"); } [TestMethod] @@ -650,6 +708,47 @@ public void BitRatingKeyboardShouldStepByThePrecision() Assert.AreEqual(3.5, value); } + [TestMethod, + DataRow("ArrowUp", 4.5d), + DataRow("ArrowDown", 4d)] + public void BitRatingKeyboardShouldMoveAnOffGridValueOntoTheGrid(string key, double expected) + { + // A value the Precision never snapped - one bound from elsewhere - has to move onto the grid the + // Precision lays over the scale rather than carry its own remainder up and down it. + double value = 4.3; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Precision, 0.5); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + component.Find(".bit-rtg").KeyDown(new KeyboardEventArgs { Key = key }); + + Assert.AreEqual(expected, value); + } + + [TestMethod] + public void BitRatingKeyboardShouldWalkAFractionalScaleOntoWholeItems() + { + double value = 1; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Precision, 0.5); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + var root = component.Find(".bit-rtg"); + + root.KeyDown(new KeyboardEventArgs { Key = "ArrowUp" }); + Assert.AreEqual(1.5d, value); + + root.KeyDown(new KeyboardEventArgs { Key = "ArrowUp" }); + Assert.AreEqual(2d, value); + + root.KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + Assert.AreEqual(1.5d, value); + } + [TestMethod] public void BitRatingKeyboardShouldReverseTheHorizontalArrowsInRtl() { @@ -725,6 +824,40 @@ public void BitRatingTabStopShouldFollowTheValue() Assert.AreEqual("0", buttons[3].GetAttribute("tabindex")); } + [TestMethod] + public void BitRatingShouldMarkTheCurrentItemApartFromTheCheckedOne() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, 3.5); + }); + + var items = component.FindAll(".bit-rtg-btn"); + + // A radio cannot be half checked, so a fractional value checks none of them - but the item the + // value lands in is still the one being picked, which is what the styling hook marks. + Assert.IsTrue(items.All(i => i.GetAttribute("aria-checked") == "false")); + Assert.AreEqual("true", items[3].GetAttribute("data-is-current")); + Assert.AreEqual(1, items.Count(i => i.GetAttribute("data-is-current") == "true")); + } + + [TestMethod] + public void BitRatingCurrentItemShouldFollowTheHoverPreview() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, 2); + }); + + component.FindAll(".bit-rtg-btn")[4].MouseOver(); + + var items = component.FindAll(".bit-rtg-btn"); + + Assert.AreEqual("true", items[4].GetAttribute("data-is-current")); + // The committed value is what the radio reports, whatever the preview is showing. + Assert.AreEqual("true", items[1].GetAttribute("aria-checked")); + } + [TestMethod] public void BitRatingShouldReportThePositionOfEachItem() { @@ -788,6 +921,29 @@ public void BitRatingShouldRespectItemTemplate() Assert.AreEqual(0, component.FindAll(".bit-rtg-iem").Count); } + [TestMethod] + public void BitRatingItemContextShouldReportTheCurrentItem() + { + var contexts = new List(); + var component = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, 3.5); + parameters.Add(p => p.ItemTemplate, (BitRatingItemContext context) => + (builder) => + { + contexts.Add(context); + builder.AddContent(0, context.Index); + }); + }); + + var current = contexts.Where(c => c.IsCurrent).ToList(); + + Assert.AreEqual(1, current.Count); + Assert.AreEqual(4, current[0].Index); + // The run behind it is filled, but only the fourth is the one the value lands in. + Assert.AreEqual(3, contexts.Count(c => c.IsFull)); + } + [TestMethod] public void BitRatingShouldRespectOnChangingCancel() { @@ -803,6 +959,40 @@ public void BitRatingShouldRespectOnChangingCancel() Assert.AreEqual(1d, value); } + [TestMethod] + public void BitRatingShouldEndThePreviewWhenAChangeIsRefused() + { + double value = 2; + var component = RenderComponent(parameters => + { + parameters.Bind(p => p.Value, value, v => value = v); + parameters.Add(p => p.OnChanging, (BitRatingChangeArgs args) => args.Cancel = true); + }); + + component.FindAll(".bit-rtg-btn")[4].MouseOver(); + component.FindAll(".bit-rtg-btn")[4].Click(); + + // A refused value must not go on being previewed: on a touch device no mouseleave ever arrives + // to end the preview, so the rating would keep showing the value it just refused. + Assert.AreEqual(2d, value); + StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[4].GetAttribute("style"), "width:0%"); + } + + [TestMethod] + public void BitRatingShouldEndThePreviewWhenAOneWayBindingRefusesTheValue() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Value, 2d); + }); + + component.FindAll(".bit-rtg-btn")[4].MouseOver(); + component.FindAll(".bit-rtg-btn")[4].Click(); + + Assert.AreEqual(2d, component.Instance.Value); + StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[4].GetAttribute("style"), "width:0%"); + } + [TestMethod] public void BitRatingShouldReportBothValuesToOnChanging() { @@ -1284,6 +1474,97 @@ public void BitRatingHighlightSelectedOnlyShouldFillAFractionalItemPartially() StringAssert.Contains(fills[4].GetAttribute("style"), "width:0%"); } + [TestMethod] + public void BitRatingShouldRespectFocusEvents() + { + var focusedIn = 0; + var focusedOut = 0; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnFocusIn, () => focusedIn++); + parameters.Add(p => p.OnFocusOut, () => focusedOut++); + }); + + component.Find(".bit-rtg").FocusIn(); + Assert.AreEqual(1, focusedIn); + Assert.AreEqual(0, focusedOut); + + component.Find(".bit-rtg").FocusOut(); + Assert.AreEqual(1, focusedIn); + Assert.AreEqual(1, focusedOut); + } + + [TestMethod] + public void BitRatingShouldNotRaiseFocusEventsWhenDisabled() + { + var focusedIn = 0; + var focusedOut = 0; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.IsEnabled, false); + parameters.Add(p => p.OnFocusIn, () => focusedIn++); + parameters.Add(p => p.OnFocusOut, () => focusedOut++); + }); + + component.Find(".bit-rtg").FocusIn(); + component.Find(".bit-rtg").FocusOut(); + + Assert.AreEqual(0, focusedIn); + Assert.AreEqual(0, focusedOut); + } + + [TestMethod] + public void BitRatingShouldKeepASplattedAriaLabel() + { + // Every aria attribute the component computes is rendered after the HtmlAttributes splat, so one + // it leaves empty must hand back what the page wrote rather than erase it. + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddMultipleAttributes(1, new Dictionary { ["aria-label"] = "Rate this product" }); + builder.CloseComponent(); + }); + + Assert.AreEqual("Rate this product", component.Find(".bit-rtg").GetAttribute("aria-label")); + } + + [TestMethod] + public void BitRatingShouldKeepASplattedAriaLabelledBy() + { + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddMultipleAttributes(1, new Dictionary { ["aria-labelledby"] = "external-label" }); + builder.CloseComponent(); + }); + + var root = component.Find(".bit-rtg"); + + Assert.AreEqual("external-label", root.GetAttribute("aria-labelledby")); + Assert.IsFalse(root.HasAttribute("aria-label")); + } + + [TestMethod] + public void BitRatingLabelShouldWinOverASplattedAriaLabel() + { + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddMultipleAttributes(1, new Dictionary + { + [nameof(BitRating.Label)] = "Quality", + ["aria-label"] = "ignored" + }); + builder.CloseComponent(); + }); + + var root = component.Find(".bit-rtg"); + + // A name given by reference wins, so the inline one is not rendered beside it. + StringAssert.Contains(root.GetAttribute("aria-labelledby"), "-label"); + Assert.IsFalse(root.HasAttribute("aria-label")); + } + [TestMethod] public void BitRatingShouldRespectAutoFocus() { @@ -1510,6 +1791,24 @@ public void BitRatingShouldKeepASplattedAriaDescribedBy() component.Find(".bit-rtg").GetAttribute("aria-describedby")); } + [TestMethod] + public void BitRatingShouldRespectAllowClearOnAFractionalValue() + { + double value = 2.5; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AllowClear, true); + parameters.Add(p => p.Precision, 0.5); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + // Committing the fraction that is already committed clears the rating, the same way a whole item + // does - the slices are the choices of a fractional scale. + component.FindAll(".bit-rtg-seg")[4].Click(); + + Assert.AreEqual(0d, value); + } + [TestMethod] public void BitRatingShouldRenderNoDescriptionContainerWithoutADescription() { From 4c2f661844f3d30315ceaec6a067d12c8f7c1b10 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 22 Sep 2026 07:04:57 +0330 Subject: [PATCH 3/4] improve further --- .../Components/Inputs/Rating/BitRating.razor | 18 +- .../Inputs/Rating/BitRating.razor.cs | 62 ++++++- .../Components/Inputs/Rating/BitRating.scss | 53 ++++++ .../Inputs/Rating/BitRatingClassStyles.cs | 3 +- .../Inputs/Rating/BitRatingParams.cs | 24 ++- .../Inputs/Rating/BitRatingDemo.razor | 63 ++++--- .../Inputs/Rating/BitRatingDemo.razor.cs | 26 ++- .../Rating/BitRatingDemo.razor.samples.cs | 9 +- .../Inputs/Rating/BitRatingTests.cs | 165 ++++++++++++++++++ 9 files changed, 368 insertions(+), 55 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor index 10bc6008f3b..a8939540197 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor @@ -104,7 +104,13 @@ } -
+ @* The whole presentation of an item is hidden from assistive technologies, because the item + already carries a name of its own above. That covers an ItemTemplate as well as the two + built-in glyphs: a template that draws a number or a face would otherwise append it to + that name, leaving the third of five items announced as "3 of 5, 3". *@ + +
+
+ AllowClear is clearing as an action: clicking the item that is already selected, or + pressing Delete or Backspace, returns the rating to 0 - which is why it opens up the + 0 on its own. Because the preview is always the value a click would commit, the items empty while + the pointer rests on the committed value, so clearing is discovered rather than explained.

@@ -167,11 +173,11 @@ Value: @noZeroHalfValue
-
AllowZeroStars (starts empty, but cannot be emptied again):
+
AllowZeroStars (starts empty; only Home or the 0 key gets back there):
Value: @allowZeroValue
-
AllowClear (click the selected item, or press Delete, to clear):
+
AllowClear (hover the selected item to watch the row empty, then click - or press Delete):
Value: @allowClearValue
@@ -204,8 +210,9 @@ what is bound. OnHoverChange reports that previewed value as it changes, and null once the pointer leaves - which is all a caption needs to follow the pointer, down to a half step at a fractional Precision. NoHoverPreview turns off the preview the component paints, for a page - that competes with it or draws one of its own - the items still grow under the pointer, since that - is an affordance and not a value, and OnHoverChange goes on reporting. + that competes with it or draws one of its own - the items still grow under the pointer and dip + under a press, since those are affordances and not values, and OnHoverChange goes on reporting. + The press is what carries a touch device, which has no hover to preview with at all.

@@ -276,7 +283,9 @@ (the one the shown value lands in, which the numbered scale below rings), and both the previewed DisplayValue and the committed Value. A template renders as a whole, so a partial fill is yours to express if it matters; when all you need is a different glyph per position, - GetSelectedIcon keeps the built-in one. + GetSelectedIcon keeps the built-in one. The drawing + is hidden from assistive technologies just as the built-in glyphs are, so the numbers and faces + below are not appended to the names the items already carry.

@@ -354,6 +363,14 @@ number input, so Name also makes the rating readable by a plain form post.

+
+ aria-invalid says that something is wrong but not what, so the failure message is + given an id and pointed at from the rating. A splatted aria-describedby is + carried over rather than replaced - both it and the component's own Description are kept, + since the attribute is a list - so the reason is announced with the field instead of only being + visible under it. +
+
@if (string.IsNullOrEmpty(SuccessMessage)) { @@ -361,8 +378,11 @@ - - + +
@@ -386,7 +406,8 @@
  • → / ↑ raise the value by one step, ← / ↓ lower it
  • Shift with an arrow, or PageUp / PageDown, moves a whole item - so a rating split into tenths is five presses wide rather than fifty
  • Home / End jump to the ends of the scale
  • -
  • 1 … 9 jump straight to that rating
  • +
  • Space / Enter commit the item the focus is on, whole
  • +
  • 1 … 9 jump straight to that rating, and 0 to the bottom of the scale - which is the unrated 0 once AllowZeroStars or AllowClear has opened it up
  • Delete / Backspace clear it, where AllowClear permits
  • @@ -446,8 +467,7 @@ bound to - which is all it takes to restyle the scale by the score it shows. The first rating below derives its Color from its own value, turning from red through orange to green as the score climbs; the second derives its icons the same way, so a low - score frowns and a high one smiles. No dedicated API is involved: just callbacks and parameters that - close over the bound value. + score frowns and a high one smiles. No dedicated API is involved.

    @@ -491,10 +511,11 @@
    Color picks the theme color the filled part of the items is painted in, with Primary as - the default. The unfilled part stays neutral whatever the color is, so it keeps reading as "not rated - yet" instead of as a second, dimmer accent. The semantic colors tie the rating to what the score - means, while the Background, Foreground and Border families keep it legible on - non-default surfaces like the inverted panel below. + the default; the hover shade, the press shade and the focus ring all come from the same role. The + unfilled part stays neutral whatever the color is, so it keeps reading as "not rated yet" instead + of as a second, dimmer accent. The semantic colors tie the rating to what the score means, while + the Background, Foreground and Border families keep it legible on non-default + surfaces like the inverted panel below.

    @@ -617,8 +638,8 @@
    Its own palette:

    -
    Bigger glyphs, room between them, a livelier press:
    - +
    Bigger glyphs, room between them, and a livelier press (hold an item down):
    +
    Shrunk to the glyph, for a rating that sits inside a line of text:
    diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs index 033c3731fa0..d494fbbd3c8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.cs @@ -16,14 +16,14 @@ public partial class BitRatingDemo Name = "AllowZeroStars", Type = "bool", DefaultValue = "false", - Description = "Allow the initial rating value be 0. Note that a value of 0 still won't be selectable by mouse or keyboard unless AllowClear is also set.", + Description = "Puts the unrated 0 in the range of the rating, so a value of 0 is kept instead of being pulled up to the smallest step and the rating can start empty. The keys that reach the ends of the range - Home and the 0 key - reach it, while the pointer always commits at least one step and Delete stays behind AllowClear.", }, new() { Name = "AriaLabelFormat", Type = "string?", DefaultValue = "null", - Description = "Optional label format for each individual rating star (not the rating control as a whole) that will be read by screen readers. Placeholder {0} is the current rating and placeholder {1} is the max. Without it an item is named by its ItemTitles tooltip, and failing that by its position in the scale.", + Description = "Names each individual rating item - not the rating as a whole - for screen readers. Placeholder {0} is the rating that item stands for, which is its one-based position, and placeholder {1} is the max. Without it an item is named by its ItemTitles tooltip, and failing that by its position in the scale.", }, new() { @@ -76,7 +76,7 @@ public partial class BitRatingDemo Name = "GetAriaLabel", Type = "Func?", DefaultValue = "null", - Description = "Optional callback to set the aria-label for rating control in readOnly mode. Also used as a fallback aria-label if the AriaLabel parameter is not provided. The first argument is the current value and the second one is the max.", + Description = "Names the rating as a whole from its current value and the max, which arrive as the first and the second argument. It is used whenever AriaLabel is not set, and like that label it wins over the visible Label. A read-only rating has to carry its value in its name, since its items are hidden behind that single name; this is how to word it.", }, new() { @@ -108,7 +108,7 @@ public partial class BitRatingDemo Name = "ItemTemplate", Type = "RenderFragment?", DefaultValue = "null", - Description = "Replaces the default pair of icons of every rating item with custom content.", + Description = "Replaces the default pair of icons of every rating item with custom content. The template draws the item and nothing else: the item keeps its hit area, hover preview, keyboard handling and name, and the drawing is hidden from assistive technologies as the built-in glyphs are.", LinkType = LinkType.Link, Href = "#rating-item-context", }, @@ -117,7 +117,7 @@ public partial class BitRatingDemo Name = "ItemTitles", Type = "IList?", DefaultValue = "null", - Description = "The native tooltips of the rating items, in order, shown when hovering over each one, and used as the accessible name of the item unless AriaLabelFormat overrides it. Items beyond the end of the list simply get no tooltip.", + Description = "The native tooltips of the rating items, in order, shown when hovering over each one, and used as the accessible name of the item unless AriaLabelFormat overrides it. Items beyond the end of the list simply get no tooltip, and the items of a read-only or disabled rating take no pointer events, so their tooltips never appear there.", }, new() { @@ -187,7 +187,7 @@ public partial class BitRatingDemo Name = "Precision", Type = "double", DefaultValue = "1", - Description = "The smallest change of the value the user can make, as a fraction of a single item. The default of 1 only allows whole items, 0.5 adds halves, 0.1 makes every tenth selectable. It constrains what the user can pick, not what can be displayed, and it is also the floor of the scale unless AllowZeroStars or AllowClear opens up the unrated 0.", + Description = "The smallest change of the value the user can make, as a fraction of a single item. The default of 1 only allows whole items, 0.5 adds halves, 0.1 makes every tenth selectable; anything at or above 1, and anything at or below 0, leaves the items whole. It constrains what the user can pick, not what can be displayed, and it is also the floor of the scale unless AllowZeroStars or AllowClear opens up the unrated 0.", }, new() { @@ -304,7 +304,7 @@ public partial class BitRatingDemo Name = "Button", Type = "string?", DefaultValue = "null", - Description = "Custom CSS classes/styles for the rating's button.", + Description = "Custom CSS classes/styles for the button of each rating item, which is the pointer target that holds the glyphs and carries the data-is-current attribute marking the item the shown value lands in.", }, new() { @@ -537,6 +537,12 @@ public partial class BitRatingDemo Description = "Color of the filled part while the pointer is previewing a value over the items (pointer devices only).", }, new() + { + Name = "--bit-Rating-active-color", + DefaultValue = "The Color role's active color", + Description = "Color of the filled part while an item is being pressed, which on a touch device - where there is no hover - is the only feedback a tap gets before the new value lands.", + }, + new() { Name = "--bit-Rating-focus-color", DefaultValue = "The Color role's focus color", @@ -591,6 +597,12 @@ public partial class BitRatingDemo Description = "How much the item under the pointer grows, which is the affordance that says the items are there to be pressed. A value of 1 turns it off.", }, new() + { + Name = "--bit-Rating-active-scale", + DefaultValue = "0.9", + Description = "How much the item being pressed dips - it shrinks rather than grows, since a pointer has already grown it by hovering it. A value of 1 turns it off.", + }, + new() { Name = "--bit-Rating-label-color", DefaultValue = "--bit-clr-fg-pri", diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs index 1558d31462d..6131ebccb57 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs @@ -256,8 +256,11 @@ private void HandleOnChanging(BitRatingChangeArgs args) - - ValidationModel.Value)"" /> + + ValidationModel.Value)"" /> Submit "; @@ -415,7 +418,7 @@ private void HandleValidSubmit() { } - +
    Rated diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs index e6de3a26b54..f4d86023cca 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs @@ -1063,6 +1063,10 @@ public void BitRatingShouldAnnounceAFractionalValueThroughALiveRegion() var live = component.Find("[aria-live]"); Assert.AreEqual(string.Format(CultureInfo.CurrentCulture, "{0} of {1}", 3.5, 5), live.TextContent); + + // Announced as the one string it is, rather than as whichever part of it changed. + Assert.AreEqual("polite", live.GetAttribute("aria-live")); + Assert.AreEqual("true", live.GetAttribute("aria-atomic")); } [TestMethod] @@ -1809,6 +1813,167 @@ public void BitRatingShouldRespectAllowClearOnAFractionalValue() Assert.AreEqual(0d, value); } + [TestMethod] + public void BitRatingAllowClearShouldPreviewTheClear() + { + double value = 3; + double? hovered = null; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AllowClear, true); + parameters.Bind(p => p.Value, value, v => value = v); + parameters.Add(p => p.OnHoverChange, (double? v) => hovered = v); + }); + + // The preview is the value a click would commit, and a click on the committed value clears it, so + // the items empty rather than showing the value the pointer happens to be over. + component.FindAll(".bit-rtg-btn")[2].MouseOver(); + + Assert.AreEqual(0d, hovered); + StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[0].GetAttribute("style"), "width:0%"); + StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[2].GetAttribute("style"), "width:0%"); + + // Any other item previews itself as usual, and the committed value is untouched throughout. + component.FindAll(".bit-rtg-btn")[4].MouseOver(); + + Assert.AreEqual(5d, hovered); + Assert.AreEqual(3d, value); + } + + [TestMethod] + public void BitRatingAllowClearShouldPreviewTheClearOfAFractionalValue() + { + double value = 2.5; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AllowClear, true); + parameters.Add(p => p.Precision, 0.5); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + // The leading half of the third item is the 2.5 that is already committed, so hovering it previews + // the clear; the trailing half is a 3 like any other step. + component.FindAll(".bit-rtg-seg")[4].MouseOver(); + + StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[1].GetAttribute("style"), "width:0%"); + + component.FindAll(".bit-rtg-seg")[5].MouseOver(); + + StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[2].GetAttribute("style"), "width:100%"); + } + + [TestMethod] + public void BitRatingShouldPreviewTheCommittedValueWithoutAllowClear() + { + double? hovered = null; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.DefaultValue, 3); + parameters.Add(p => p.OnHoverChange, (double? v) => hovered = v); + }); + + // Nothing is cleared without AllowClear, so the committed value previews as itself. + component.FindAll(".bit-rtg-btn")[2].MouseOver(); + + Assert.AreEqual(3d, hovered); + StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[2].GetAttribute("style"), "width:100%"); + } + + [TestMethod, + DataRow("Home"), + DataRow("0") + ] + public void BitRatingAllowZeroStarsShouldPutZeroWithinReachOfTheRangeKeys(string key) + { + double value = 3; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AllowZeroStars, true); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + // AllowZeroStars puts 0 in the range, and the keys that reach the ends of the range reach it - + // unlike the pointer, which always commits at least one step, and unlike Delete, which is the + // clearing key and stays behind AllowClear. + component.Find(".bit-rtg").KeyDown(new KeyboardEventArgs { Key = key }); + + Assert.AreEqual(0d, value); + } + + [TestMethod, + DataRow("Home"), + DataRow("0") + ] + public void BitRatingWithoutAllowZeroStarsShouldHoldTheRangeKeysAtTheFloor(string key) + { + double value = 3; + var component = RenderComponent(parameters => + { + parameters.Bind(p => p.Value, value, v => value = v); + }); + + component.Find(".bit-rtg").KeyDown(new KeyboardEventArgs { Key = key }); + + // Without it the floor is a single step, so the same keys stop there instead of clearing. + Assert.AreEqual(1d, value); + } + + [TestMethod] + public void BitRatingShouldHideTheDrawingOfEveryItemFromAssistiveTechnologies() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Max, 3); + }); + + // Every item names itself, so its drawing is presentation only - which is what keeps an ItemTemplate + // that renders a number or a face from being appended to that name. + foreach (var iconContainer in component.FindAll(".bit-rtg-ict")) + { + Assert.AreEqual("true", iconContainer.GetAttribute("aria-hidden")); + } + } + + [TestMethod] + public void BitRatingShouldHideTheDrawingOfAnItemTemplateToo() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Max, 3); + parameters.Add(p => p.ItemTemplate, (BitRatingItemContext context) => + (builder) => + { + builder.OpenElement(0, "span"); + builder.AddContent(1, context.Index); + builder.CloseElement(); + }); + }); + + var containers = component.FindAll(".bit-rtg-ict"); + + Assert.AreEqual(3, containers.Count); + Assert.AreEqual("true", containers[0].GetAttribute("aria-hidden")); + + // The name of the item is the hidden label alone, and not that label plus the number the template drew. + Assert.AreEqual("1 of 3", component.FindAll(".bit-rtg-btn > .bit-rtg-alb")[0].TextContent.Trim()); + } + + [TestMethod, + DataRow(-1d), + DataRow(double.NaN) + ] + public void BitRatingShouldFallBackToWholeItemsForAnUnusablePrecision(double precision) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Precision, precision); + }); + + // A precision that asks for no steps, or for infinitely many, leaves the items whole. + Assert.AreEqual(0, component.FindAll(".bit-rtg-seg").Count); + Assert.AreEqual("1", component.Find(".bit-input-hidden").GetAttribute("step")); + } + [TestMethod] public void BitRatingShouldRenderNoDescriptionContainerWithoutADescription() { From f2cfd1ce5d6a83876d89f7e4d6228987782a9560 Mon Sep 17 00:00:00 2001 From: msynk Date: Sun, 4 Oct 2026 13:59:59 +0330 Subject: [PATCH 4/4] code review --- .../Components/Inputs/Rating/BitRating.razor | 35 ++-- .../Inputs/Rating/BitRating.razor.cs | 157 ++++++++++-------- .../Components/Inputs/Rating/BitRating.scss | 14 +- .../Inputs/Rating/BitRatingItemContext.cs | 8 +- .../JsInterop/RatingsJsRuntimeExtensions.cs | 8 +- src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts | 52 ++++-- .../Inputs/Rating/BitRatingDemo.razor | 6 +- .../Rating/BitRatingDemo.razor.samples.cs | 4 +- .../Inputs/Rating/BitRatingTests.cs | 140 +++++++++++++++- 9 files changed, 306 insertions(+), 118 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor index a8939540197..678778382b7 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor @@ -9,7 +9,12 @@ var displayValue = _DisplayValue; var tabbableIndex = _TabbableIndex; var interactive = IsEnabled && ReadOnly is false; - var labelledBy = _AriaLabelledBy; + // A name given by reference wins over one given inline, so only one of the two is ever rendered. A + // read-only rating named by reference adds its value to the reference from a hidden element of its own. + var nameReference = _NameReference; + var rendersValueText = ReadOnly && nameReference is not null; + var labelledBy = rendersValueText ? $"{nameReference} {_valueTextId}" : nameReference; + var ariaLabel = nameReference is null ? _AriaLabel : null; var describedBy = _AriaDescribedBy; } @@ -19,12 +24,11 @@ of which the img role supports, and none of which an unchangeable picture of a value has anything to say about: there is nothing to require, disable or correct in the first place. *@ @* A name given by reference wins over one given inline, so only one of aria-labelledby and aria-label - is ever rendered: the visible label names the group when there is one, the explicit strings when not. *@ + is ever rendered: the visible label names the group when there is one, the explicit strings when not. + OnFocusIn and OnFocusOut are raised from Ratings.setup rather than from @onfocusin and @onfocusout, which + would also report every move of the focus from one item to the next. *@
    @@ -42,9 +46,11 @@ the group is named through aria-labelledby referencing this id instead. *@ @if (HasLabel) { + @* A template has no .bit-rtg-lbl for the asterisk of a required rating to follow, so it follows the + container instead, which then holds nothing but the template. *@
    + class="bit-rtg-lbc @(LabelTemplate is not null ? "bit-rtg-ltp" : null) @Classes?.LabelContainer"> @if (LabelTemplate is not null) { @LabelTemplate @@ -57,8 +63,8 @@ } @* The items live in a box of their own so that the row (or the bottom-up column) they form is laid out - independently of where the label sits beside them. Its mouseleave ends a preview the pointer walks out - of onto the label, which is still inside the root and so would not end it there. *@ + independently of where the label sits beside them. Its mouseleave is what ends a preview: it covers the + pointer leaving the rating altogether as well as walking out of the items onto the label. *@
    @@ -71,7 +77,7 @@ // checked. Current is the item the displayed value lands in, fractional or previewed, which is // the styling hook for "the one being picked" rather than a state of the radio. var isChecked = index == CurrentValue; - var isCurrent = index == Math.Ceiling(displayValue); + var isCurrent = BitRatingItemContext.IsCurrentItem(index, displayValue); // A per-item icon replaces the shared pair for that position only, which is what turns a plain // scale into one that changes shape as it fills - a frown at one end and a grin at the other. var itemSelectedIcon = GetSelectedIcon?.Invoke(index) ?? selectedIcon; @@ -177,9 +183,10 @@ @_LiveValueText } - @* A read-only rating named by its own visible label would announce the label and lose the value it - exists to show, so the value joins the name from here instead of through aria-label. *@ - @if (_RendersHiddenValueText) + @* A read-only rating named by reference - its own visible label or an element elsewhere on the page - + would announce that name and lose the value it exists to show, so the value joins the name from here + instead of through aria-label. *@ + @if (rendersValueText) { @_ValueText } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs index 6fc71e34cd8..142f604f1e9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.razor.cs @@ -16,6 +16,7 @@ public partial class BitRating : BitInputBase private string _descriptionId = default!; private double? _hoverValue; private ElementReference[] _itemRefs = []; + private DotNetObjectReference? _dotnetObj; @@ -224,12 +225,13 @@ public partial class BitRating : BitInputBase [Parameter] public EventCallback OnChanging { get; set; } /// - /// Callback for when the rating receives the focus. + /// Callback for when the rating receives the focus. It reports the focus arriving at the rating as a whole, + /// not at each item, so moving along the scale does not raise it again. /// [Parameter] public EventCallback OnFocusIn { get; set; } /// - /// Callback for when the focus leaves the rating. + /// Callback for when the focus leaves the rating as a whole, which moving from one item to another does not. /// [Parameter] public EventCallback OnFocusOut { get; set; } @@ -355,11 +357,15 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (firstRender) { + _dotnetObj = DotNetObjectReference.Create(this); + try { // Prevents the default behavior (scrolling) of the navigation keys handled by the items' - // keydown handler, since Blazor cannot conditionally preventDefault per key. - await _js.BitRatingsSetup(_Id); + // keydown handler, since Blazor cannot conditionally preventDefault per key, and reports the + // focus entering and leaving the rating as a whole - which takes the relatedTarget of the focus + // event that Blazor's FocusEventArgs does not carry. + await _js.BitRatingsSetup(_Id, _dotnetObj, nameof(_HandleFocusIn), nameof(_HandleFocusOut)); } catch (JSDisconnectedException) { } // we can ignore this exception here } @@ -379,9 +385,7 @@ protected override void RegisterCssClasses() ClassBuilder.Register(() => Vertical ? "bit-rtg-vrt" : string.Empty); - // The asterisk is a property of an answer that is still expected, so a read-only or disabled - // rating - which is no longer asking anything - does not draw one. - ClassBuilder.Register(() => IsEnabled && ReadOnly is false && Required ? "bit-rtg-req" : string.Empty); + ClassBuilder.Register(() => _IsRequired ? "bit-rtg-req" : string.Empty); ClassBuilder.Register(() => LabelPosition switch { @@ -506,7 +510,11 @@ protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(fa /// without them the floor is the smallest rating that can still be given, which is a single step - /// a whole item at the default Precision, and the first half of the first one at a Precision of 0.5. /// - private double _MinValue => (AllowZeroStars || AllowClear) ? 0 : _Step; + /// + /// Rounded like every value a step commits, so the first step - a third of an item, say - is the same + /// 0.33333 whether it is reached by clamping or by clicking. + /// + private double _MinValue => (AllowZeroStars || AllowClear) ? 0 : Math.Round(_Step, 5); /// /// How many selectable steps each item is divided into, derived from the Precision. @@ -554,9 +562,22 @@ private int _TabbableIndex } /// - /// The accessible name of the whole rating: the explicit AriaLabel, then the GetAriaLabel callback, and - /// finally - in read-only mode, where there is nothing left to describe the value - a "3.5 of 5" fallback. + /// Whether the rating still asks for an answer it requires. The asterisk and aria-required both follow it: + /// a read-only or disabled rating is no longer asking anything, so it marks nothing as required. + /// + private bool _IsRequired => IsEnabled && ReadOnly is false && Required; + + /// + /// The accessible name of the whole rating as an inline string: the explicit AriaLabel, then the + /// GetAriaLabel callback, then an aria-label the page splatted onto the component, and finally - in + /// read-only mode, where there is nothing left to describe the value - a "3.5 of 5" fallback. /// + /// + /// The splatted aria-label is resolved here because the component owns the attribute: it is rendered after + /// the HtmlAttributes splat, so writing it as anything else - a null included - would erase what the page + /// wrote. It is only rendered when is null, since a name given by reference + /// wins over one given inline. + /// private string? _AriaLabel { get @@ -565,12 +586,41 @@ private string? _AriaLabel if (GetAriaLabel is not null) return GetAriaLabel(CurrentValue, _Max); + var splattedAriaLabel = GetSplattedAttribute("aria-label"); + + if (splattedAriaLabel.HasValue()) return splattedAriaLabel; + if (ReadOnly is false) return null; return _ValueText; } } + /// + /// The element the name of the rating is read from, when it is read from the page rather than given as a + /// string: the explicit AriaLabelledBy, then the visible label, then an aria-labelledby the page splatted. + /// The two string forms - AriaLabel and the GetAriaLabel callback - are deliberately allowed to win over + /// the visible label, since aria-labelledby would otherwise silently discard them. + /// + /// + /// A read-only rating is a picture of a value whose items are hidden behind a single name, so a name read + /// by reference would leave the value it exists to show unannounced: wherever this is not null, a read-only + /// rating renders its value in a hidden element of its own and adds that to the reference. + /// + private string? _NameReference + { + get + { + if (AriaLabelledBy.HasValue()) return AriaLabelledBy; + + if (AriaLabel.HasValue() || GetAriaLabel is not null) return null; + + if (HasLabel) return _labelId; + + return GetSplattedAttribute("aria-labelledby"); + } + } + /// /// Whether the rating draws a label of its own, which is also what makes it able to name itself. /// @@ -586,19 +636,6 @@ private string? _AriaLabel /// internal bool HasDescription => DescriptionTemplate is not null || Description.HasValue(); - /// - /// The value of an aria attribute the consumer splatted onto the component. Every aria attribute the - /// rating computes sits after the HtmlAttributes splat in the markup, so it is what ends up rendered no - /// matter what - and a null would even remove a splatted value. This is what the computed attributes - /// hand back rather than erasing what the page wrote. - /// - private string? _GetSplattedAttribute(string name) - { - HtmlAttributes.TryGetValue(name, out var value); - - return value?.ToString(); - } - /// /// The elements that describe the rating, which is a splatted aria-describedby carried over rather than /// replaced: both are kept, since aria-describedby is a space separated list of IDREFs. @@ -607,7 +644,7 @@ private string? _AriaDescribedBy { get { - var splattedDescribedBy = _GetSplattedAttribute("aria-describedby"); + var splattedDescribedBy = GetSplattedAttribute("aria-describedby"); if (HasDescription is false) return splattedDescribedBy; @@ -615,46 +652,6 @@ private string? _AriaDescribedBy } } - /// - /// The name of the rating as an inline string, which is only rendered when nothing names it by - /// reference. A name the page splatted is the last resort, so that writing aria-label on the component - /// works as it reads even though the component owns the attribute. - /// - private string? _AriaLabelAttribute => _AriaLabelledBy is null - ? (_AriaLabel ?? _GetSplattedAttribute("aria-label")) - : null; - - /// - /// The element the name of the rating is read from, when it is read from the page rather than given as a - /// string: the explicit AriaLabelledBy, then the visible label. The two string forms - AriaLabel and the - /// GetAriaLabel callback - are deliberately allowed to win over the visible label, since aria-labelledby - /// would otherwise silently discard them. - /// - private string? _AriaLabelledBy - { - get - { - if (AriaLabelledBy.HasValue()) return AriaLabelledBy; - - if (AriaLabel.HasValue() || GetAriaLabel is not null) return null; - - if (HasLabel is false) return _GetSplattedAttribute("aria-labelledby"); - - return _RendersHiddenValueText ? $"{_labelId} {_valueTextId}" : _labelId; - } - } - - /// - /// Whether the value joins the name of the rating from a hidden element of its own. A read-only rating is - /// a picture of a value whose items are hidden behind a single name, so naming it by its visible label - /// alone would leave the value it exists to show unannounced. - /// - private bool _RendersHiddenValueText => ReadOnly - && HasLabel - && AriaLabelledBy.HasNoValue() - && AriaLabel.HasNoValue() - && GetAriaLabel is null; - /// /// The default format both the value text and the per-item labels fall back to. /// @@ -720,7 +717,7 @@ private double GetPercentage(int index) // still filled by however much of it the value covers, so a fractional value stays readable. if (HighlightSelectedOnly) { - return Math.Ceiling(value) == index ? fill : 0; + return BitRatingItemContext.IsCurrentItem(index, value) ? fill : 0; } return fill; @@ -826,18 +823,26 @@ private async Task HandleOnHover(double value) await OnHoverChange.InvokeAsync(value); } - private async Task HandleOnFocusIn(FocusEventArgs e) + /// + /// Called from JavaScript when the focus arrives at the rating from outside of it. + /// + [JSInvokable(nameof(_HandleFocusIn))] + public async Task _HandleFocusIn() { if (IsEnabled is false) return; - await OnFocusIn.InvokeAsync(e); + await OnFocusIn.InvokeAsync(new FocusEventArgs { Type = "focusin" }); } - private async Task HandleOnFocusOut(FocusEventArgs e) + /// + /// Called from JavaScript when the focus leaves the rating for something outside of it. + /// + [JSInvokable(nameof(_HandleFocusOut))] + public async Task _HandleFocusOut() { if (IsEnabled is false) return; - await OnFocusOut.InvokeAsync(e); + await OnFocusOut.InvokeAsync(new FocusEventArgs { Type = "focusout" }); } private Task HandleOnMouseLeave() => EndPreview(); @@ -916,10 +921,14 @@ private static double StepFrom(double value, double step, bool up) { var steps = value / step; - // A value that is exactly on the grid divides into a whole number of steps only to within the - // rounding of binary floating point, so the index is taken with a tolerance: without it the floor - // of a 2.9999999999 would move up to the step the value is already sitting on. - const double tolerance = 1e-4; + // A value that is on the grid divides into a whole number of steps only to within the rounding it + // went through, so the index is taken with a tolerance: without it the floor of a 2.9999999999 would + // move up to the step the value is already sitting on. Every committed step is rounded to five + // decimals, which on a step with no exact decimal form - a 67th of an item, say - leaves it up to + // 0.000005 off the grid, so the tolerance is set above that in value units and only then measured + // in steps. Even at the finest step of a hundredth it stays a thousandth of a step, far from the + // half-step that would let it mistake one step for another. + var tolerance = 1e-5 / step; var index = up ? Math.Floor(steps + tolerance) + 1 : Math.Ceiling(steps - tolerance) - 1; @@ -1001,6 +1010,8 @@ protected override async ValueTask DisposeAsync(bool disposing) } catch (JSDisconnectedException) { } // we can ignore this exception here + _dotnetObj?.Dispose(); + await base.DisposeAsync(disposing); } } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss index ab06ddd06dc..f95824eb637 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRating.scss @@ -31,7 +31,9 @@ // $tg-fs-2xs/xs/sm) .bit-rtg { - display: inline-flex; + // Block-level like the plain div the rating has always been, so two ratings written one after the other + // still stack; a rating that belongs inside a line of text is made inline-flex through its Style. + display: flex; width: fit-content; // A column of label, items and description, which is the Top position; the label position classes // below turn it into the other three. The items themselves are laid out by their own container, @@ -106,9 +108,11 @@ } // The asterisk of a required rating, drawn on the label the way every other required input in the - // library draws it. It is decorative: the group already carries aria-required. + // library draws it - or after the LabelTemplate, which has no .bit-rtg-lbl of its own. It is + // decorative: the group already carries aria-required. &.bit-rtg-req { - .bit-rtg-lbl::after { + .bit-rtg-lbl::after, + .bit-rtg-ltp::after { content: " *"; color: $clr-req; padding-inline-end: spacing(1.5); @@ -289,6 +293,10 @@ // A vertical rating stacks its items bottom-up, so that "more" is up the same way the ArrowUp key means // more, and the fill of a partly covered item grows from its bottom edge instead of its leading one. .bit-rtg-vrt { + // Inline, as a vertical rating has always been, so a column of items can stand beside the content + // around it instead of taking a line of its own. + display: inline-flex; + .bit-rtg-cnt { flex-direction: column-reverse; } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs index accbcc4090d..7965d163d6e 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/Rating/BitRatingItemContext.cs @@ -77,5 +77,11 @@ public BitRatingItemContext(int index, int max, double percentage, double displa /// from the run of filled ones behind it. It is the same thing the data-is-current attribute of /// the item marks for CSS that styles the built-in glyphs. /// - public bool IsCurrent => Index == Math.Ceiling(DisplayValue); + public bool IsCurrent => IsCurrentItem(Index, DisplayValue); + + /// + /// Whether the item at the given position is the one the given value lands in, which is the one rule + /// behind , the data-is-current attribute and HighlightSelectedOnly. + /// + internal static bool IsCurrentItem(int index, double value) => index == Math.Ceiling(value); } diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/RatingsJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/RatingsJsRuntimeExtensions.cs index 1abffa6070b..4baf00fe062 100644 --- a/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/RatingsJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Extensions/JsInterop/RatingsJsRuntimeExtensions.cs @@ -2,9 +2,13 @@ namespace Bit.BlazorUI; internal static class RatingsJsRuntimeExtensions { - internal static ValueTask BitRatingsSetup(this IJSRuntime jsRuntime, string id) + internal static ValueTask BitRatingsSetup(this IJSRuntime jsRuntime, + string id, + DotNetObjectReference dotnetObj, + string focusInHandler, + string focusOutHandler) { - return jsRuntime.InvokeVoid("BitBlazorUI.Ratings.setup", id); + return jsRuntime.InvokeVoid("BitBlazorUI.Ratings.setup", id, dotnetObj, focusInHandler, focusOutHandler); } internal static ValueTask BitRatingsDispose(this IJSRuntime jsRuntime, string id) diff --git a/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts b/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts index 345bd327fbb..dcc63867d32 100644 --- a/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts +++ b/src/BlazorUI/Bit.BlazorUI/Scripts/Ratings.ts @@ -1,21 +1,23 @@ namespace BitBlazorUI { export class Ratings { - private static _handlers = new Map void }>(); + private static _controllers = new Map(); private static _navKeys = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']; - // Attaches a keydown listener that only prevents the default behavior (page scrolling) of the - // navigation keys pressed on the rating items. The actual keyboard logic runs in the Blazor - // keydown handler, which cannot conditionally preventDefault per key: its flag is applied by - // the next render, so the first press of a key would scroll the page anyway. Kept key-scoped - // so Tab, Space and Enter still behave normally. - public static setup(id: string) { + public static setup(id: string, dotnetObj: DotNetObject, focusInHandler: string, focusOutHandler: string) { Ratings.dispose(id); const root = document.getElementById(id); if (!root) return; - const handler = (e: KeyboardEvent) => { + const controller = new AbortController(); + + // Attaches a keydown listener that only prevents the default behavior (page scrolling) of the + // navigation keys pressed on the rating items. The actual keyboard logic runs in the Blazor + // keydown handler, which cannot conditionally preventDefault per key: its flag is applied by + // the next render, so the first press of a key would scroll the page anyway. Kept key-scoped + // so Tab, Space and Enter still behave normally. + root.addEventListener('keydown', e => { if (Ratings._navKeys.indexOf(e.key) === -1) return; // A held Ctrl, Alt or Meta makes the key a browser or system shortcut instead - Alt+ArrowLeft @@ -27,18 +29,38 @@ namespace BitBlazorUI { if (!target || !target.closest('.bit-rtg-btn')) return; e.preventDefault(); - }; - root.addEventListener('keydown', handler); + }, { signal: controller.signal }); + + // The focus moving from one item to the next - which every arrow key does - is a focusout of the + // one and a focusin of the other, both bubbling up to the root. Only the focus crossing the edge + // of the rating is reported, which takes the relatedTarget that Blazor's FocusEventArgs lacks. + // A relatedTarget of null is the focus coming from or going to nowhere in the page - the window + // itself, say - which is crossing that edge as well. + root.addEventListener('focusin', e => { + const from = e.relatedTarget as Node | null; + if (from && root.contains(from)) return; + + dotnetObj.invokeMethodAsync(focusInHandler); + }, { signal: controller.signal }); - Ratings._handlers.set(id, { element: root, handler }); + root.addEventListener('focusout', e => { + const to = e.relatedTarget as Node | null; + if (to && root.contains(to)) return; + + dotnetObj.invokeMethodAsync(focusOutHandler); + }, { signal: controller.signal }); + + Ratings._controllers.set(id, controller); } public static dispose(id: string) { - const entry = Ratings._handlers.get(id); - if (!entry) return; + const controller = Ratings._controllers.get(id); + if (!controller) return; + + controller.abort(); + Ratings._controllers.delete(id); - entry.element.removeEventListener('keydown', entry.handler); - Ratings._handlers.delete(id); + // The DotNetObjectReference is owned and disposed by the component itself. } } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor index 645515f9806..30dbb1a63cc 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor @@ -601,7 +601,7 @@
    - +
    Style and Class land on the root. Styles and Classes reach the parts by name - Root, LabelContainer, Label, Description, Container, @@ -641,10 +641,10 @@
    Bigger glyphs, room between them, and a livelier press (hold an item down):

    -
    Shrunk to the glyph, for a rating that sits inside a line of text:
    +
    Shrunk to the glyph and made inline, for a rating that sits inside a line of text:
    Rated - + by 1,034 people.
    diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs index 6131ebccb57..9433ca62029 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Rating/BitRatingDemo.razor.samples.cs @@ -422,7 +422,7 @@ private void HandleValidSubmit() { }
    Rated - + by 1,034 people.
    @@ -431,6 +431,8 @@ private void HandleValidSubmit() { }
    "; + private readonly string example21CsharpCode = @" +private double currentItemValue = 3.5;"; private const string example21ScssCode = @" .custom-class { margin-inline: 1rem; diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs index 8afdda93d72..800e2f62dd3 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/Rating/BitRatingTests.cs @@ -250,6 +250,27 @@ public void BitRatingFloorShouldBeASingleStep(double precision, double expected) Assert.AreEqual(expected.ToString(CultureInfo.InvariantCulture), component.Find("input").GetAttribute("min")); } + [TestMethod] + public void BitRatingFloorShouldBeRoundedLikeTheStepsAre() + { + // A third of an item has no exact decimal form, so the floor is rounded the way every committed step + // is - the first step is the same 0.33333 whether it is clamped to or clicked. + double value = 0; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Precision, 0.33); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + Assert.AreEqual(0.33333d, value); + Assert.AreEqual("0.33333", component.Find("input").GetAttribute("min")); + + component.FindAll(".bit-rtg-btn")[1].Click(); + component.FindAll(".bit-rtg-seg")[0].Click(); + + Assert.AreEqual(0.33333d, value); + } + [TestMethod] public void BitRatingShouldCommitTheFirstFractionOfAFractionalScale() { @@ -532,7 +553,7 @@ public void BitRatingShouldPreviewTheHoveredValue() Assert.AreEqual(1d, value); StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[3].GetAttribute("style"), "width:100%"); - component.Find(".bit-rtg").MouseLeave(); + component.Find(".bit-rtg-cnt").MouseLeave(); Assert.IsNull(hovered); StringAssert.Contains(component.FindAll(".bit-rtg-ifl")[3].GetAttribute("style"), "width:0%"); @@ -559,7 +580,7 @@ public void BitRatingShouldRespectNoHoverPreview() // The class turns off the CSS half of the preview, which shades the filled part on hover. Assert.IsTrue(component.Find(".bit-rtg").ClassList.Contains("bit-rtg-nhp")); - component.Find(".bit-rtg").MouseLeave(); + component.Find(".bit-rtg-cnt").MouseLeave(); Assert.IsNull(hovered); } @@ -727,6 +748,26 @@ public void BitRatingKeyboardShouldMoveAnOffGridValueOntoTheGrid(string key, dou Assert.AreEqual(expected, value); } + [TestMethod, + DataRow("ArrowUp", 0.16418d), + DataRow("ArrowDown", 0.13433d)] + public void BitRatingKeyboardShouldLeaveAValueRoundedOffAStepWithNoExactDecimalForm(string key, double expected) + { + // A precision of 0.015 splits an item into 67 steps, none of which has an exact decimal form, and every + // committed step is rounded to five decimals: the tenth step lands at 0.14925, a hair below the grid. + // The next step either way has to be one step further, never the one the value is already sitting on. + double value = 0.14925; + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Precision, 0.015); + parameters.Bind(p => p.Value, value, v => value = v); + }); + + component.Find(".bit-rtg").KeyDown(new KeyboardEventArgs { Key = key }); + + Assert.AreEqual(expected, value); + } + [TestMethod] public void BitRatingKeyboardShouldWalkAFractionalScaleOntoWholeItems() { @@ -1489,11 +1530,13 @@ public void BitRatingShouldRespectFocusEvents() parameters.Add(p => p.OnFocusOut, () => focusedOut++); }); - component.Find(".bit-rtg").FocusIn(); + // The focus crossing the edge of the rating is reported from JavaScript, which alone can tell it + // apart from the focus moving between two items of the same rating. + component.InvokeAsync(component.Instance._HandleFocusIn); Assert.AreEqual(1, focusedIn); Assert.AreEqual(0, focusedOut); - component.Find(".bit-rtg").FocusOut(); + component.InvokeAsync(component.Instance._HandleFocusOut); Assert.AreEqual(1, focusedIn); Assert.AreEqual(1, focusedOut); } @@ -1510,8 +1553,8 @@ public void BitRatingShouldNotRaiseFocusEventsWhenDisabled() parameters.Add(p => p.OnFocusOut, () => focusedOut++); }); - component.Find(".bit-rtg").FocusIn(); - component.Find(".bit-rtg").FocusOut(); + component.InvokeAsync(component.Instance._HandleFocusIn); + component.InvokeAsync(component.Instance._HandleFocusOut); Assert.AreEqual(0, focusedIn); Assert.AreEqual(0, focusedOut); @@ -1548,6 +1591,59 @@ public void BitRatingShouldKeepASplattedAriaLabelledBy() Assert.IsFalse(root.HasAttribute("aria-label")); } + [TestMethod] + public void BitRatingShouldKeepASplattedAriaDescribedByWrittenInAnyCase() + { + // The renderer drops a splatted attribute the component writes itself whatever its case, so the + // splatted value has to be found whatever its case too, or it is erased instead of carried over. + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitRating.Description), "hint"); + builder.AddMultipleAttributes(2, new Dictionary { ["Aria-Describedby"] = "err" }); + builder.CloseComponent(); + }); + + Assert.AreEqual($"err {component.Find(".bit-rtg-dsc").Id}", component.Find(".bit-rtg").GetAttribute("aria-describedby")); + } + + [TestMethod] + public void BitRatingReadOnlyWithASplattedAriaLabelledByShouldKeepTheValueInItsName() + { + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitRating.ReadOnly), true); + builder.AddAttribute(2, nameof(BitRating.DefaultValue), 4.2); + builder.AddMultipleAttributes(3, new Dictionary { ["aria-labelledby"] = "title" }); + builder.CloseComponent(); + }); + + var root = component.Find(".bit-rtg"); + var valueText = component.Find(".bit-rtg-alb[id]"); + + // A name read by reference alone would lose the value a read-only rating exists to show. + Assert.AreEqual($"title {valueText.Id}", root.GetAttribute("aria-labelledby")); + Assert.AreEqual(string.Format(CultureInfo.CurrentCulture, "{0} of {1}", 4.2, 5), valueText.TextContent); + Assert.IsFalse(root.HasAttribute("aria-label")); + } + + [TestMethod] + public void BitRatingReadOnlyShouldKeepASplattedAriaLabel() + { + // A splatted aria-label is a name the page wrote, which outranks the "4.2 of 5" the component falls back to. + var component = Context.Render(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitRating.ReadOnly), true); + builder.AddAttribute(2, nameof(BitRating.DefaultValue), 4.2); + builder.AddMultipleAttributes(3, new Dictionary { ["aria-label"] = "Rated 4.2 by 1,034 people" }); + builder.CloseComponent(); + }); + + Assert.AreEqual("Rated 4.2 by 1,034 people", component.Find(".bit-rtg").GetAttribute("aria-label")); + } + [TestMethod] public void BitRatingLabelShouldWinOverASplattedAriaLabel() { @@ -2000,6 +2096,38 @@ public void BitRatingShouldMarkARequiredLabel(bool required, bool readOnly, bool Assert.AreEqual(expected, component.Find(".bit-rtg").ClassList.Contains("bit-rtg-req")); } + [TestMethod] + public void BitRatingShouldMarkTheContainerOfALabelTemplate() + { + // A template has no .bit-rtg-lbl for the asterisk of a required rating to follow, so the container + // holding it is marked for the asterisk to follow instead. + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Required, true); + parameters.Add(p => p.LabelTemplate, (RenderFragment)(b => b.AddContent(0, "Your rating"))); + }); + + Assert.IsTrue(component.Find(".bit-rtg").ClassList.Contains("bit-rtg-req")); + Assert.IsTrue(component.Find(".bit-rtg-lbc").ClassList.Contains("bit-rtg-ltp")); + } + + [TestMethod] + public void BitRatingShouldNotClaimToBeRequiredWhenDisabled() + { + // Like the asterisk, aria-required follows an answer that is still being asked for. + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Label, "Quality"); + parameters.Add(p => p.Required, true); + parameters.Add(p => p.IsEnabled, false); + }); + + var root = component.Find(".bit-rtg"); + + Assert.IsFalse(root.HasAttribute("aria-required")); + Assert.IsFalse(root.ClassList.Contains("bit-rtg-req")); + } + [TestMethod] public void BitRatingParamsShouldHaveCorrectParamName() {