Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public abstract class BitInputBase<TValue> : BitComponentBase
private ValidationMessageStore? _parsingValidationMessages;
private readonly EventHandler<ValidationStateChangedEventArgs> _validationStateChangedHandler;

// The parameters of this class are taken out of the ParameterView below before it reaches
// BitComponentBase, so the set that its own HasNotBeenSet reads never sees them, and the one the source
// generator writes only ever holds the parameters that the component itself declares. A cascade filling
// in what a consumer left unset therefore has no way of telling the two apart without this third set,
// and would overwrite a ReadOnly or a Required that was written on the component by hand.
private readonly HashSet<string> _assignedInputParameters = [];



protected event EventHandler OnValueChanged = default!;
Expand Down Expand Up @@ -146,25 +153,38 @@ public TValue? Value
/// <inheritdoc cref="FocusAsync()" path="/remarks"/>
public virtual ValueTask FocusAsync(bool preventScroll) => InputElement.FocusAsync(preventScroll);

/// <summary>
/// Whether the named parameter of <see cref="BitInputBase{TValue}"/> was left unset on this component,
/// which is what a <see cref="BitParams"/> cascade fills in. It is the input tier of the very same
/// question that <see cref="BitComponentBase.HasNotBeenSet"/> answers for the shared parameters and the
/// generated member of each component answers for the ones it declares itself; a separate member because
/// the parameters of this class never reach either of those two sets.
/// </summary>
protected internal bool HasNotBeenSetOnInput(string name) => _assignedInputParameters.Contains(name) is false;



public override Task SetParametersAsync(ParameterView parameters)
{
ValueHasBeenSet = false;
DefaultValueHasBeenSet = false;

_assignedInputParameters.Clear();

var parametersDictionary = (ParametersCache ??= parameters.ToDictionary() as Dictionary<string, object?>);

foreach (var parameter in parametersDictionary!)
{
switch (parameter.Key)
{
case nameof(NoValidate):
_assignedInputParameters.Add(nameof(NoValidate));
NoValidate = (bool)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(DefaultValue):
_assignedInputParameters.Add(nameof(DefaultValue));
DefaultValueHasBeenSet = true;
DefaultValue = (TValue?)parameter.Value;
parametersDictionary.Remove(parameter.Key);
Expand All @@ -176,51 +196,60 @@ public override Task SetParametersAsync(ParameterView parameters)
break;

case nameof(DisplayName):
_assignedInputParameters.Add(nameof(DisplayName));
DisplayName = (string?)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(InputHtmlAttributes):
_assignedInputParameters.Add(nameof(InputHtmlAttributes));
InputHtmlAttributes = (Dictionary<string, object>?)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(Name):
_assignedInputParameters.Add(nameof(Name));
Name = (string?)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(OnChange):
_assignedInputParameters.Add(nameof(OnChange));
OnChange = (EventCallback<TValue?>)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(ReadOnly):
_assignedInputParameters.Add(nameof(ReadOnly));
var readOnly = (bool)parameter.Value;
if (ReadOnly != readOnly) ClassBuilder.Reset();
ReadOnly = readOnly;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(Required):
_assignedInputParameters.Add(nameof(Required));
var required = (bool)parameter.Value;
if (Required != required) ClassBuilder.Reset();
Required = required;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(Value):
_assignedInputParameters.Add(nameof(Value));
ValueHasBeenSet = true;
Value = (TValue?)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(ValueChanged):
_assignedInputParameters.Add(nameof(ValueChanged));
ValueChanged = (EventCallback<TValue>)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;

case nameof(ValueExpression):
_assignedInputParameters.Add(nameof(ValueExpression));
ValueExpression = (Expression<Func<TValue>>?)parameter.Value;
parametersDictionary.Remove(parameter.Key);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

@* read by the javascript side at the moment of a copy, so that turning the masking on and off never
has to tear the listeners (and the pending WebOTP request behind them) down and set them up again. *@
@* No aria-label on the root: it is a generic element, where the attribute is prohibited and dropped by
assistive technologies anyway, and the group below is the element that the name of the code belongs on.
The AriaLabel is therefore rendered there instead of being rendered twice. *@
<div @ref="RootElement" @attributes="HtmlAttributes"
id="@_Id"
aria-label="@AriaLabel"
style="@StyleBuilder.Value"
class="@ClassBuilder.Value"
dir="@Dir?.ToString().ToLower()"
Expand Down Expand Up @@ -54,8 +56,9 @@
@* The description is referenced by the group rather than by each input on purpose: an aria-describedby
on every one of them would make a screen reader read the whole hint again at every single character
of the code. *@
@* The code is being checked, which is a wait the user is in the middle of rather than a piece of
decoration, so it is announced on the group that holds the code instead of only being drawn. *@
@* The code is being checked, so the group is marked as busy: what an assistive technology takes from
it is that the changes inside are not worth announcing one by one until the answer is back. The wait
itself is carried by the live region at the bottom, which the Description is copied into. *@
<div role="group"
style="@Styles?.InputsWrapper"
class="bit-otp-iwr @Classes?.InputsWrapper"
Expand Down Expand Up @@ -116,8 +119,8 @@

@if (IsLoading)
{
@* The wait itself is carried by the aria-busy of the group above, so the bar is nothing but the
drawing of it and is kept away from the screen readers that already announce the group. *@
@* The wait itself is carried by the live region below, so the bar is nothing but the drawing of it
and is kept away from the screen readers that are already being told about it in words. *@
<div aria-hidden="true"
style="@Styles?.Loader"
class="bit-otp-ldr @Classes?.Loader"></div>
Expand All @@ -139,6 +142,19 @@
</div>
}

@* The two halves of the round trip a code is sent on - the wait while it is being checked and the
answer of a server that rejected it - both happen while the focus is usually nowhere near the boxes,
so neither reaches anybody who cannot see it: the bar is drawn, aria-invalid is only announced when
the focus lands on a box, and a description is only read out with the group it names. This is the
status message of WCAG 2.2 SC 4.1.3 for exactly those two moments. It is rendered at all times and
left empty, since a live region added to the page with its text already in it is not announced by
most screen readers, and it only ever holds the plain Description of a component whose Invalid or
IsLoading state is on, so a countdown or a "resend the code" link, which belongs in a
DescriptionTemplate, is never announced on every tick. *@
<div role="status"
aria-live="polite"
class="bit-otp-sts">@_statusMessage</div>

@if (Name.HasValue())
{
@* The value of an OTP input is the whole code, so it is posted as a single named field
Expand Down
Loading
Loading