diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs index 6a696455e66..2370f906a10 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/BitInputBase.cs @@ -29,6 +29,13 @@ public abstract class BitInputBase : BitComponentBase private ValidationMessageStore? _parsingValidationMessages; private readonly EventHandler _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 _assignedInputParameters = []; + protected event EventHandler OnValueChanged = default!; @@ -146,6 +153,15 @@ public TValue? Value /// public virtual ValueTask FocusAsync(bool preventScroll) => InputElement.FocusAsync(preventScroll); + /// + /// Whether the named parameter of was left unset on this component, + /// which is what a cascade fills in. It is the input tier of the very same + /// question that 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. + /// + protected internal bool HasNotBeenSetOnInput(string name) => _assignedInputParameters.Contains(name) is false; + public override Task SetParametersAsync(ParameterView parameters) @@ -153,6 +169,8 @@ public override Task SetParametersAsync(ParameterView parameters) ValueHasBeenSet = false; DefaultValueHasBeenSet = false; + _assignedInputParameters.Clear(); + var parametersDictionary = (ParametersCache ??= parameters.ToDictionary() as Dictionary); foreach (var parameter in parametersDictionary!) @@ -160,11 +178,13 @@ public override Task SetParametersAsync(ParameterView parameters) 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); @@ -176,26 +196,31 @@ 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?)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)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; @@ -203,6 +228,7 @@ public override Task SetParametersAsync(ParameterView parameters) break; case nameof(Required): + _assignedInputParameters.Add(nameof(Required)); var required = (bool)parameter.Value; if (Required != required) ClassBuilder.Reset(); Required = required; @@ -210,17 +236,20 @@ public override Task SetParametersAsync(ParameterView parameters) 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)parameter.Value; parametersDictionary.Remove(parameter.Key); break; case nameof(ValueExpression): + _assignedInputParameters.Add(nameof(ValueExpression)); ValueExpression = (Expression>?)parameter.Value; parametersDictionary.Remove(parameter.Key); break; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor index fa782a2a665..923d46733f0 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor @@ -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. *@ @@ -139,6 +142,19 @@ } + @* 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. *@ +
@_statusMessage
+ @if (Name.HasValue()) { @* The value of an OTP input is the whole code, so it is posted as a single named field diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs index 07778ef33c1..d988e6cc797 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor.cs @@ -14,6 +14,7 @@ public partial class BitOtpInput : BitInputBase private bool _isSetup; private bool _autoFocused; private bool _setupSmsAutoFill; + private string? _statusMessage; private string _labelId = default!; private string? _lastFilledValue; private Regex? _patternRegex; @@ -44,6 +45,19 @@ public partial class BitOtpInput : BitInputBase + /// + /// Provides the cascading parameters of the BitOtpInput component, supplied by a + /// ancestor. Each of them is a default rather than an override: a component that writes a parameter for + /// itself keeps its own value, and only what it left unset is filled in from here. + /// + /// + /// The intended use is to allow shared configuration or settings to be applied to multiple otp input components through the component. + /// + [CascadingParameter(Name = BitOtpInputParams.ParamName)] + public BitOtpInputParams? CascadingParameters { get; set; } + + + /// /// The accent color of the inputs, applied to the border and the focus ring of the focused input. /// @@ -60,6 +74,13 @@ public partial class BitOtpInput : BitInputBase /// [Parameter] public bool AutoShift { get; set; } + /// + /// Submits the form the component sits in (a plain form or an EditForm) as soon as the code is complete, + /// right after the , the way pressing Enter would: the form still validates before it + /// is submitted, and nothing happens outside of a form. + /// + [Parameter] public bool AutoSubmit { get; set; } + /// /// Removes the focus from the inputs as soon as the code is complete, which is what dismisses the /// virtual keyboard of a phone once there is nothing left to type. @@ -76,6 +97,9 @@ public partial class BitOtpInput : BitInputBase /// through its aria-describedby so that screen readers announce it along with the name of the group. /// It is where the sentence that turns a row of empty boxes into a question the user can answer /// belongs: where the code was sent, how long it is good for, or what a server that rejected it said. + /// While or is on it also sits in the live region of the + /// component, so the wait for the answer and a rejection are announced at the moment they happen rather + /// than waiting for the focus to come back to the code. /// [Parameter] public string? Description { get; set; } @@ -86,6 +110,17 @@ public partial class BitOtpInput : BitInputBase /// [Parameter] public RenderFragment? DescriptionTemplate { get; set; } + /// + /// Stretches the row of inputs across the width it is given and lets the inputs share it evenly, instead + /// of drawing them at the fixed width of their . It is what keeps a long code from + /// running off the side of a narrow screen, and what lines a code entry up with the full width fields + /// above and below it on a sign-in form. Only the axis the code is laid out on is affected: the height + /// of the inputs stays the one of the size class, and a row stretches its inputs + /// across the column instead of stacking more of them. + /// + [Parameter, ResetClassBuilder] + public bool FullWidth { get; set; } + /// /// Label displayed above the inputs. /// @@ -107,8 +142,10 @@ public partial class BitOtpInput : BitInputBase /// Paints the inputs with the error state without an EditContext taking part in it, which is what /// reports a code that the server has rejected ("that code is not correct, try again"): the failure /// only becomes known once the code has been submitted, so there is nothing for a validator to see. - /// It also marks the inputs with aria-invalid, and a failing validation of an EditContext still shows - /// the very same state on its own. + /// It also marks the inputs with aria-invalid, puts the into the live region of + /// the component so that the rejection is announced at the moment it arrives rather than when the focus + /// comes back to the code, and a failing validation of an EditContext still shows the very same state on + /// its own. /// [Parameter, ResetClassBuilder] public bool Invalid { get; set; } @@ -117,8 +154,10 @@ public partial class BitOtpInput : BitInputBase /// Puts the component into the busy state of a code that has been submitted and is being checked, which /// is the step between the and the answer that either lets the user through or sets /// the . It paints an indeterminate progress bar under the inputs, marks the group - /// with aria-busy so that the wait is announced rather than only shown, and holds the code still the way - /// the does, so that nothing can be typed over a code whose + /// with aria-busy so that the changes inside it are not announced one by one while the code is being + /// checked, puts the into the live region of the component so that the wait + /// itself is announced rather than only drawn, and holds the code still the way the + /// does, so that nothing can be typed over a code whose /// answer is already on its way. /// [Parameter, ResetClassBuilder] @@ -390,6 +429,29 @@ public ValueTask FocusAsync(int index = 0) return _inputRefs[Math.Clamp(index, 0, _inputRefs.Length - 1)].FocusAsync(); } + /// + /// Gives focus to the input holding the first character of the code. + /// + /// + /// The inherited overloads are overridden rather than left alone because an argument list of none is + /// resolved to the parameterless one of the base class rather than to the + /// above, which would leave the plainest call of the four the only one that is not refused before the + /// first render - the element references of an OTP input are bound once its inputs have been rendered, + /// and the inherited overload would ask the browser for an element that is not there yet. + /// + public override ValueTask FocusAsync() => FocusAsync(0); + + /// + /// A Boolean value indicating whether or not the browser should scroll + /// the document to bring the newly-focused element into view. + /// + public override ValueTask FocusAsync(bool preventScroll) + { + if (IsRendered is false || _inputRefs.Length == 0) return ValueTask.CompletedTask; + + return _inputRefs[0].FocusAsync(preventScroll); + } + [JSInvokable("SetValue")] @@ -503,6 +565,8 @@ protected override void RegisterCssClasses() }); ClassBuilder.Register(() => Vertical ? "bit-otp-vrt" : string.Empty); + + ClassBuilder.Register(() => FullWidth ? "bit-otp-fwi" : string.Empty); } protected override void RegisterCssStyles() @@ -522,8 +586,13 @@ protected override void OnInitialized() base.OnInitialized(); } + [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitOtpInputParams))] protected override void OnParametersSet() { + // Applied before anything below reads a parameter, so that the Length, the restrictions and the + // value all follow what the cascade filled in rather than what the component was left with. + CascadingParameters?.UpdateParameters(this); + // The Length is a plain parameter, so it can change at any time. Everything that is sized by it // has to follow, otherwise the render loop below would index past the end of these arrays. if (_length != NormalizedLength) @@ -531,6 +600,18 @@ protected override void OnParametersSet() ResizeInputs(); } + // The sentence that goes with the two states the component paints for a code that has left the + // page - the wait of a code being checked and the rejection that may come back - put into the live + // region while either of them is on and taken back out of it as they are cleared, so that the very + // same description is announced again when the next attempt is rejected too, and a second attempt + // rejected with another sentence is announced with that one. The busy state needs it as much as the + // error one does: aria-busy asks a screen reader to hold off on the changes inside the group, it + // never announces the wait itself. Assigning the text it already holds changes nothing in the DOM + // and is therefore not announced, which is what keeps a state that merely re-renders quiet. A + // DescriptionTemplate is markup rather than text, so there is nothing to copy into the region and + // the consumer keeps the announcement of it. + _statusMessage = (Invalid || IsLoading) && DescriptionTemplate is null ? Description : null; + // Narrowing the set of characters that the code may hold has to reach the characters that are // already in the inputs too, otherwise the component would keep showing (and reporting) a code // that it would now reject, and widening it has to give a code that was cut down on its way in a @@ -899,7 +980,8 @@ private async Task HandleOnInput(ChangeEventArgs e, int index) if (IsStaleIndex(index)) return; var oldValue = _inputValues[index]; - var newValue = e.Value?.ToString()?.Trim() ?? string.Empty; + var rawValue = e.Value?.ToString() ?? string.Empty; + var newValue = rawValue.Trim(); // What the input showed before this event, which is the masking text while a Mask is set. The // diff below has to subtract exactly that from the new value to end up with what was typed. @@ -940,7 +1022,7 @@ private async Task HandleOnInput(ChangeEventArgs e, int index) if (newValue.HasValue()) { - var rawDiff = DiffValues(oldRendered, newValue); + var rawDiff = DiffValues(oldRendered, newValue, Mask); if (rawDiff.Length > 1) { @@ -1015,6 +1097,17 @@ private async Task HandleOnInput(ChangeEventArgs e, int index) // The keydown handler owns this clear: it already wrote the resulting values (and ran the // auto shift) while this method was awaiting above, so nothing is written here. } + else if (pendingShift is false && rawValue.Length > 0) + { + // The input is holding whitespace and nothing else, which is what a pressed space bar leaves + // behind: the focused input is selected, so the space replaces the character that was in it. + // No code is ever made of whitespace, and taking the branch below would let an invisible + // keystroke delete a character the user had already typed, so it is refused the way any other + // rejected character is. + _inputValues[index] = oldValue; + + await OnInvalid.InvokeAsync((rawValue, index)); + } else if (pendingShift) { ShiftInputValues(index); @@ -1266,6 +1359,16 @@ private async Task CallOnFill() if (IsDisposed) return; await OnFill.InvokeAsync(value); + + // Submitted after the callback on purpose, so that whatever the consumer switches on in it (the + // IsLoading of the round trip above all) is already in place when the submit handler of the form runs. + if (AutoSubmit is false || IsDisposed || IsRendered is false) return; + + try + { + await _js.BitOtpInputSubmit(RootElement); + } + catch (JSDisconnectedException) { } // the circuit may already be gone at this point. } private bool IsAllowedValue(string value) => value.All(IsAllowedChar); @@ -1278,6 +1381,11 @@ private bool IsAllowedChar(char value) // with characters the user cannot see and hand the server a code it never issued. The control // characters are dropped for the very same reason. Neither of them is a character a code is ever // made of, so no input type and no pattern has to be consulted about them. + // Whitespace belongs to the very same set: a code is copied out of messages that wrap and space it, + // and a space typed into a box would fill it with a character that cannot be seen and cannot be told + // apart from an empty box. + if (char.IsWhiteSpace(value)) return false; + if (char.IsControl(value) || char.GetUnicodeCategory(value) is UnicodeCategory.Format) return false; // A character outside of the basic plane (an emoji above all) is a pair of chars rather than one, @@ -1333,9 +1441,9 @@ private string SanitizeValue(string? value) { if (value.HasNoValue()) return string.Empty; - // Codes are copied out of a message that often wraps or spaces them, so every kind of - // whitespace is dropped rather than the space character alone. - return new string([.. TransformValue(value!).Where(c => char.IsWhiteSpace(c) is false && IsAllowedChar(c))]); + // Every kind of whitespace is dropped by IsAllowedChar rather than the space character alone, which + // is what lets a code copied out of a message that wraps or spaces it still fill the inputs. + return new string([.. TransformValue(value!).Where(IsAllowedChar)]); } private string TransformPastedValue(string value) @@ -1390,11 +1498,21 @@ private static string ToAsciiDigits(string value) : c)]); } - private static string DiffValues(string oldValue, string newValue) + private static string DiffValues(string oldValue, string newValue, string? mask) { var oldLength = oldValue.Length; var newLength = newValue.Length; + // A box under a Mask shows the masking text whatever it holds, so an event reporting exactly that + // text wrote nothing. It is answered before the single character shortcut below, which would + // otherwise take a Mask of one character as the character that had just been typed and write the + // masking glyph into the code itself. A longer Mask needs no shortcut of its own: the event is the + // old value repeated, which the prefix test further down already diffs down to nothing. The same + // event with no Mask set is a real keystroke - the focus handler selects the character a box holds, + // so typing the very same character over it reports a value equal to the old one and still has to + // be written and still has to move the focus on. + if (newValue == oldValue && mask.HasValue()) return string.Empty; + if (newLength == 1) return newValue; if (newLength < oldLength) return newValue; diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss index 51145749fca..cf7daf7250b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss @@ -1,43 +1,89 @@ @import "../../../Styles/functions.scss"; +// Public CSS variables, read off the root and never declared here, so a value set on :root re-skins every +// otp input and one set on the Style of an instance re-skins that one alone: +// --bit-OtpInput-gap room between the boxes (default: spacing(1.25)) +// --bit-OtpInput-input-size width and height of every box (default: per size, $siz-ctrl-*) +// --bit-OtpInput-input-width width alone, for a box wider than it is tall (default: --bit-OtpInput-input-size) +// --bit-OtpInput-input-height height alone (default: --bit-OtpInput-input-size) +// --bit-OtpInput-font-family typeface of the whole component (default: $tg-font-family) +// --bit-OtpInput-font-size size of the code, the label and the hint (default: per size, from the type ramp) +// --bit-OtpInput-label-font-size size of the label above the inputs (default: the size of the code) +// --bit-OtpInput-label-font-weight weight of the label above the inputs (default: $tg-fw-semibold) +// --bit-OtpInput-description-font-size size of the helper text under the inputs (default: the ramp step below the code) +// --bit-OtpInput-font-weight weight of the character inside a box (default: $tg-fw-regular) +// --bit-OtpInput-radius corner radius of a box, and of the two ends +// of every merged group (default: $shp-radius-control) +// --bit-OtpInput-border-width thickness of a box's rule (default: $shp-border-width) +// --bit-OtpInput-color color of the typed character (default: $clr-fg-pri) +// --bit-OtpInput-background box background at rest (default: per variant) +// --bit-OtpInput-hover-background box background on hover (default: per variant) +// --bit-OtpInput-border-color box rule at rest (default: per variant) +// --bit-OtpInput-hover-border-color box rule on hover (default: per variant) +// --bit-OtpInput-filled-background background of a box that holds a character (default: the rest background) +// --bit-OtpInput-filled-border-color rule of a box that holds a character (default: the rest rule) +// --bit-OtpInput-focus-border-color box rule while focused (default: the Accent role's main color) +// --bit-OtpInput-focus-color focus ring color (default: the Accent role's focus color) +// --bit-OtpInput-placeholder-color hint character of an empty box (default: $clr-fg-ter) +// --bit-OtpInput-label-color label above the boxes (default: $clr-fg-pri) +// --bit-OtpInput-description-color helper text under the boxes (default: $clr-fg-sec) +// --bit-OtpInput-separator-color text drawn between the groups (default: $clr-fg-sec) +// --bit-OtpInput-invalid-color rule, helper text and bar in the error state (default: $clr-err) +// --bit-OtpInput-invalid-focus-color focus ring in the error state (default: $clr-err-focus) +// --bit-OtpInput-disabled-color character, label, helper text and separator +// when disabled (default: $clr-fg-dis) +// --bit-OtpInput-disabled-background box background when disabled (default: $clr-bg-dis) +// --bit-OtpInput-disabled-border-color box rule when disabled (default: $clr-brd-dis) +// --bit-OtpInput-loader-color the sweep of the IsLoading bar (default: the Accent role's main color) +// --bit-OtpInput-loader-background the track of the IsLoading bar (default: $clr-bg-sec) +// --bit-OtpInput-loader-height thickness of the IsLoading bar (default: $siz-track-sm) + .bit-otp { display: flex; width: fit-content; box-sizing: border-box; flex-direction: column; - font-family: $tg-font-family; - font-size: var(--bit-otp-fontsize); + //a code is the one piece of text in a form that is read character by character rather than as a word, so + //setting it in a tabular or a monospaced face - where a zero cannot be mistaken for an O - is a design + //decision worth making once for a whole application rather than per instance. + font-family: var(--bit-OtpInput-font-family, #{$tg-font-family}); + font-size: var(--bit-OtpInput-font-size, var(--bit-otp-fontsize)); } +//the label follows the size of the code by default, and carries a variable of its own so that a code drawn +//larger than its size class does not drag the caption above it along with it. The fallback is the inherit +//keyword rather than the variable the root reads, so a font-size written straight onto the root still +//reaches the label: a var() that falls back to a CSS-wide keyword computes as unset, which for an inherited +//property such as this one is the very same inherit it would have been without the variable. .bit-otp-lbl { margin: 0; display: block; - font-weight: $tg-fw-semibold; - color: $clr-fg-pri; - font-size: inherit; + font-size: var(--bit-OtpInput-label-font-size, inherit); + font-weight: var(--bit-OtpInput-label-font-weight, #{$tg-fw-semibold}); box-sizing: border-box; padding: spacing(0.625) 0; overflow-wrap: break-word; + color: var(--bit-OtpInput-label-color, #{$clr-fg-pri}); } -//the helper text under the inputs. It is a touch smaller than the code itself, so that the sentence -//explaining the code never outweighs the code it explains. +//the helper text under the inputs. It takes the step of the type ramp below the code itself, so that the +//sentence explaining the code never outweighs the code it explains. .bit-otp-dsc { margin: 0; display: block; max-width: 100%; - color: $clr-fg-sec; box-sizing: border-box; overflow-wrap: break-word; padding: spacing(0.625) 0 0; - font-size: calc(var(--bit-otp-fontsize) - #{spacing(0.5)}); + color: var(--bit-OtpInput-description-color, #{$clr-fg-sec}); + font-size: var(--bit-OtpInput-description-font-size, var(--bit-otp-dsc-fontsize)); } .bit-otp-iwr { display: flex; align-items: center; - gap: spacing(1.25); box-sizing: border-box; + gap: var(--bit-OtpInput-gap, #{spacing(1.25)}); flex-direction: var(--bit-otp-iwr-flexdirection); } @@ -46,8 +92,8 @@ .bit-otp-sep { line-height: 1; user-select: none; - color: $clr-fg-sec; pointer-events: none; + color: var(--bit-OtpInput-separator-color, #{$clr-fg-sec}); } //the code has been submitted and is being checked: an indeterminate bar under the inputs, as wide as the @@ -57,10 +103,10 @@ overflow: hidden; position: relative; box-sizing: border-box; - height: spacing(0.375); margin-top: spacing(0.625); border-radius: $shp-radius-sm; - background-color: $clr-bg-sec; + height: var(--bit-OtpInput-loader-height, #{$siz-track-sm}); + background-color: var(--bit-OtpInput-loader-background, #{$clr-bg-sec}); &::after { content: ""; @@ -69,7 +115,7 @@ position: absolute; border-radius: inherit; inset-inline-start: -33%; - background-color: var(--bit-otp-clr); + background-color: var(--bit-OtpInput-loader-color, var(--bit-otp-clr)); //the sweep is not a spinner, so it scales its own duration by the shared loop factor instead of //taking the spinner tokens: stretched under reduced motion, restored inside a ForceAnimation subtree. animation: calc(1.5s * #{$mot-loop-factor}) $mot-easing 0s infinite normal none running bit-otp-ldr-anm; @@ -86,6 +132,21 @@ } } +//the live region that announces the sentence a rejected code came back with. It is read rather than drawn, +//so it is taken out of the layout without being taken out of the accessibility tree the way display:none +//or visibility:hidden would. +.bit-otp-sts { + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + border-width: 0; + overflow: hidden; + white-space: nowrap; + position: absolute; + clip-path: inset(50%); +} + .bit-otp-inp { cursor: text; //a form control carries a padding of its own that is not the same on both sides in every engine, which @@ -95,20 +156,56 @@ //asked for explicitly, otherwise the code would be drawn in the default font of the browser while //the label above it follows the theme. font-size: inherit; - font-family: inherit; + //the family is read here rather than only inherited from the root, so that a font-family written onto + //an element between the root and the box (a `* { font-family: ... }` reset lands on the wrapper of the + //inputs too, and an inherited value always loses to one set on the element itself) cannot keep the + //public variable from reaching the one element that draws the code. The fallback is the inherit + //keyword, so a component that sets no variable of its own is left exactly where it was: following + //whatever it inherits. + font-family: var(--bit-OtpInput-font-family, inherit); text-align: center; - color: $clr-fg-pri; box-sizing: border-box; -moz-appearance: textfield; - width: var(--bit-otp-size); - height: var(--bit-otp-size); - border-radius: $shp-radius-control; - border-width: $shp-border-width; border-style: $shp-border-style; + color: var(--bit-OtpInput-color, #{$clr-fg-pri}); + //a form control does not inherit the weight of the page either, and the character in a box is the value + //the user typed rather than the label of a control, so it takes the regular step of the weight ramp. + font-weight: var(--bit-OtpInput-font-weight, #{$tg-fw-regular}); + border-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-width: var(--bit-OtpInput-border-width, #{$shp-border-width}); + width: var(--bit-OtpInput-input-width, var(--bit-OtpInput-input-size, var(--bit-otp-size))); + height: var(--bit-OtpInput-input-height, var(--bit-OtpInput-input-size, var(--bit-otp-size))); + //the two colors of a box are read from a single pair of variables here, and every variant declares what + //that pair holds, so a consumer overriding one of the public variables below re-skins every variant + //instead of having to repeat the override once per variant class. + border-color: var(--bit-OtpInput-border-color, var(--bit-otp-brd)); + background-color: var(--bit-OtpInput-background, var(--bit-otp-bg)); transition: border-color $mot-duration-short $mot-easing, background-color $mot-duration-short $mot-easing; &::placeholder { - color: $clr-fg-ter; + color: var(--bit-OtpInput-placeholder-color, #{$clr-fg-ter}); + } + + //a box that already holds a character, which is the hook for a row that shows its own progress. Both + //of its colors fall back to the ones of the rest state, so nothing changes until one of the two is set. + &.bit-otp-fld { + border-color: var(--bit-OtpInput-filled-border-color, var(--bit-OtpInput-border-color, var(--bit-otp-brd))); + background-color: var(--bit-OtpInput-filled-background, var(--bit-OtpInput-background, var(--bit-otp-bg))); + } + + //the hover rule is qualified so that it lights up nothing but a box the user can actually type in: + //never a disabled or a read-only component, and never over the accent color of the focused input. + &:enabled:read-write:hover:not(:focus-visible) { + border-color: var(--bit-OtpInput-hover-border-color, var(--bit-otp-brd-hover)); + background-color: var(--bit-OtpInput-hover-background, var(--bit-otp-bg-hover)); + } + + //declared after the filled block on purpose, and of the very same specificity as it, so that the accent + //of the focused box is never painted over by the rule of a box that happens to hold a character. Only + //the rule is taken over: a filled background is a part of the box rather than of its rest state, so it + //stays underneath the accent while the box is being typed in. + &:focus-visible { + border-color: var(--bit-OtpInput-focus-border-color, var(--bit-otp-clr)); } //the spin buttons of a number typed input would not fit in a single character wide box, and the @@ -117,60 +214,70 @@ &::-webkit-outer-spin-button { -webkit-appearance: none } + + //a one-time code is filled far more often than it is typed - that is what the one-time-code autofill, + //the WebOTP request and the password managers are all there for - so the colors a Chromium or a WebKit + //engine paints an auto filled control with are what the row would usually be seen in. They are declared + //with an internal rule no author declaration wins against, and in a dark scheme they are a pale blue box + //holding the light character of the theme, which is the code itself falling under the contrast minimum. + //Both halves are taken back: the character through the one property the engines do read back from the + //author, and the box by never letting the auto fill transition arrive, since the engine animates that + //color rather than painting it outright. The states are listed one by one because the engines match the + //auto filled control with a selector per state rather than with the bare one. + &:-webkit-autofill, + &:-webkit-autofill:hover, + &:-webkit-autofill:focus { + caret-color: var(--bit-OtpInput-color, #{$clr-fg-pri}); + -webkit-text-fill-color: var(--bit-OtpInput-color, #{$clr-fg-pri}); + transition: border-color $mot-duration-short $mot-easing, background-color 0s 10000s; + } } //Outline - the default: each input is a box drawn with the border color, filled with the page surface. .bit-otp-otl { - .bit-otp-inp { - border-color: $clr-brd-pri; - background-color: $clr-bg-pri; - - //the hover rule is qualified so that it lights up nothing but a box the user can actually type - //in: never a disabled or a read-only component, and never over the accent color of the focused - //input. The very same qualifiers are repeated in the invalid block below, which is what keeps a - //hovered box red while the code is in error. - &:enabled:read-write:hover:not(:focus-visible) { - border-color: $clr-brd-pri-hover; - } - - &:focus-visible { - border-color: var(--bit-otp-clr); - } - } + --bit-otp-bg: #{$clr-bg-pri}; + --bit-otp-bg-hover: var(--bit-OtpInput-background, var(--bit-otp-bg)); + --bit-otp-brd: #{$clr-brd-pri}; + --bit-otp-brd-hover: #{$clr-brd-pri-hover}; } //Fill: the box is painted with the secondary surface and carries no rule of its own, for a softer //looking field that still reads as a single character slot. .bit-otp-fil { - .bit-otp-inp { - border-color: transparent; - background-color: $clr-bg-sec; - - &:enabled:read-write:hover:not(:focus-visible) { - background-color: $clr-bg-sec-hover; - } - - &:focus-visible { - border-color: var(--bit-otp-clr); - } - } + --bit-otp-bg: #{$clr-bg-sec}; + --bit-otp-bg-hover: #{$clr-bg-sec-hover}; + --bit-otp-brd: transparent; + --bit-otp-brd-hover: var(--bit-OtpInput-border-color, var(--bit-otp-brd)); } //Text: only an underline is painted, for a code entry that should not outweigh the content around it. .bit-otp-txt { + --bit-otp-bg: transparent; + --bit-otp-bg-hover: var(--bit-OtpInput-background, var(--bit-otp-bg)); + --bit-otp-brd: #{$clr-brd-pri}; + --bit-otp-brd-hover: #{$clr-brd-pri-hover}; + .bit-otp-inp { border-radius: 0; - border-color: $clr-brd-pri; - background-color: transparent; - border-width: 0 0 $shp-border-width 0; + border-width: 0 0 var(--bit-OtpInput-border-width, #{$shp-border-width}) 0; + } +} - &:enabled:read-write:hover:not(:focus-visible) { - border-color: $clr-brd-pri-hover; - } +//FullWidth: the row stretches across whatever it is given and the boxes share it evenly, which is what +//keeps a long code from running off the side of a phone. The height stays the one of the size class, so +//the boxes only ever grow or shrink along the axis the code is laid out on. +.bit-otp-fwi { + width: 100%; - &:focus-visible { - border-color: var(--bit-otp-clr); - } + .bit-otp-inp { + min-width: 0; + flex: 1 1 0; + width: auto; + } + + &.bit-otp-vrt .bit-otp-inp { + flex: 0 0 auto; + width: 100%; } } @@ -222,65 +329,65 @@ .bit-otp-mrg:not(.bit-otp-vrt):not(.bit-otp-rvs) { .bit-otp-inp:not(.bit-otp-gst) { - margin-inline-start: calc(-1 * #{$shp-border-width}); + margin-inline-start: calc(-1 * var(--bit-OtpInput-border-width, #{$shp-border-width})); } .bit-otp-gst { - border-start-start-radius: $shp-radius-control; - border-end-start-radius: $shp-radius-control; + border-start-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-end-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } .bit-otp-gnd { - border-start-end-radius: $shp-radius-control; - border-end-end-radius: $shp-radius-control; + border-start-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-end-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } } .bit-otp-mrg.bit-otp-rvs:not(.bit-otp-vrt) { .bit-otp-inp:not(.bit-otp-gnd) { - margin-inline-end: calc(-1 * #{$shp-border-width}); + margin-inline-end: calc(-1 * var(--bit-OtpInput-border-width, #{$shp-border-width})); } .bit-otp-gnd { - border-start-start-radius: $shp-radius-control; - border-end-start-radius: $shp-radius-control; + border-start-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-end-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } .bit-otp-gst { - border-start-end-radius: $shp-radius-control; - border-end-end-radius: $shp-radius-control; + border-start-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-end-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } } .bit-otp-mrg.bit-otp-vrt:not(.bit-otp-rvs) { .bit-otp-inp:not(.bit-otp-gst) { - margin-block-start: calc(-1 * #{$shp-border-width}); + margin-block-start: calc(-1 * var(--bit-OtpInput-border-width, #{$shp-border-width})); } .bit-otp-gst { - border-start-start-radius: $shp-radius-control; - border-start-end-radius: $shp-radius-control; + border-start-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-start-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } .bit-otp-gnd { - border-end-start-radius: $shp-radius-control; - border-end-end-radius: $shp-radius-control; + border-end-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-end-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } } .bit-otp-mrg.bit-otp-vrt.bit-otp-rvs { .bit-otp-inp:not(.bit-otp-gnd) { - margin-block-end: calc(-1 * #{$shp-border-width}); + margin-block-end: calc(-1 * var(--bit-OtpInput-border-width, #{$shp-border-width})); } .bit-otp-gnd { - border-start-start-radius: $shp-radius-control; - border-start-end-radius: $shp-radius-control; + border-start-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-start-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } .bit-otp-gst { - border-end-start-radius: $shp-radius-control; - border-end-end-radius: $shp-radius-control; + border-end-start-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); + border-end-end-radius: var(--bit-OtpInput-radius, #{$shp-radius-control}); } } @@ -309,9 +416,18 @@ //the focus ring and the disabled/invalid overrides are declared after the variants on purpose: they are //as specific as the variant rules, so only the source order keeps a focused input from painting the //accent color over an error, or a disabled component from keeping its variant colors. -.bit-otp .bit-otp-inp { +//the Text variant is excluded so that it gets the underline treatment below instead: a ring drawn around a box +//that is only an underline would bring back the very frame the variant is there to leave out, and applying +//the ring only to take its shadow away again would still leave its forced-colors outline behind. +.bit-otp:not(.bit-otp-txt) .bit-otp-inp { + &:focus-visible { + @include focus-ring(var(--bit-OtpInput-focus-color, var(--bit-otp-clr-focus))); + } +} + +.bit-otp.bit-otp-txt .bit-otp-inp { &:focus-visible { - @include focus-ring(var(--bit-otp-clr-focus)); + @include focus-underline-ring(var(--bit-OtpInput-focus-color, var(--bit-otp-clr-focus))); } } @@ -319,25 +435,39 @@ .bit-otp-lbl, .bit-otp-dsc, .bit-otp-sep { - color: $clr-fg-dis; + color: var(--bit-OtpInput-disabled-color, #{$clr-fg-dis}); } .bit-otp-inp { - color: $clr-fg-dis; - border-color: $clr-brd-dis; - background-color: $clr-bg-dis; + color: var(--bit-OtpInput-disabled-color, #{$clr-fg-dis}); + border-color: var(--bit-OtpInput-disabled-border-color, #{$clr-brd-dis}); + background-color: var(--bit-OtpInput-disabled-background, #{$clr-bg-dis}); + //a box that was auto filled before the component was turned off - a code being submitted is disabled + //while the answer is awaited - still carries the fill color of the rule above, and a text fill wins + //over a color whatever the two selectors weigh, so the dimmed one has to be declared in kind. + -webkit-text-fill-color: var(--bit-OtpInput-disabled-color, #{$clr-fg-dis}); + + //and the auto fill states are listed one by one here as well, for the reason the rule up there + //lists them and for one more: a hovered or a focused auto filled box is matched by a selector of + //the very same weight as this block, so the dimming would be left resting on the order of the two + //rules alone, which a reordering of this file would quietly take away. + &:-webkit-autofill, + &:-webkit-autofill:hover, + &:-webkit-autofill:focus { + -webkit-text-fill-color: var(--bit-OtpInput-disabled-color, #{$clr-fg-dis}); + } //the hint of an empty box is a part of the field, so it has to be dimmed along with it instead of //staying the only thing in the component that still reads as enabled. &::placeholder { - color: $clr-fg-dis; + color: var(--bit-OtpInput-disabled-color, #{$clr-fg-dis}); } } //a disabled component is out of the flow the wait belongs to, so the bar is dimmed along with the rest //of it rather than being the one part that still looks live. .bit-otp-ldr::after { - background-color: $clr-fg-dis; + background-color: var(--bit-OtpInput-disabled-color, #{$clr-fg-dis}); } } @@ -346,27 +476,34 @@ //rather than as a hint standing next to it. It is also what keeps the failure from being carried by the //color of the boxes alone. .bit-otp-dsc { - color: $clr-err; + color: var(--bit-OtpInput-invalid-color, #{$clr-err}); } //a code that is being checked again after it came back rejected keeps the error state while it waits, //so the bar belongs to that state rather than staying the one part still painted with the accent. .bit-otp-ldr::after { - background-color: $clr-err; + background-color: var(--bit-OtpInput-invalid-color, #{$clr-err}); } .bit-otp-inp { - border-color: $clr-err; + border-color: var(--bit-OtpInput-invalid-color, #{$clr-err}); &:enabled:read-write:hover:not(:focus-visible) { - border-color: $clr-err; + border-color: var(--bit-OtpInput-invalid-color, #{$clr-err}); } &:focus-visible { - border-color: $clr-err; - @include focus-ring($clr-err-focus); + border-color: var(--bit-OtpInput-invalid-color, #{$clr-err}); } } + + &:not(.bit-otp-txt) .bit-otp-inp:focus-visible { + @include focus-ring(var(--bit-OtpInput-invalid-focus-color, #{$clr-err-focus})); + } + + &.bit-otp-txt .bit-otp-inp:focus-visible { + @include focus-underline-ring(var(--bit-OtpInput-invalid-focus-color, #{$clr-err-focus})); + } } @@ -412,17 +549,23 @@ } +//the box is a square of the control height of its size class, so a row of code boxes lines up with the +//other controls of the form it sits in, and the smallest one still clears the 24px minimum pointer target +//of WCAG 2.2 (SC 2.5.8). .bit-otp-sm { - --bit-otp-size: #{spacing(3.0)}; + --bit-otp-size: #{$siz-ctrl-sm}; --bit-otp-fontsize: #{$tg-fs-xs}; + --bit-otp-dsc-fontsize: #{$tg-fs-2xs}; } .bit-otp-md { - --bit-otp-size: #{spacing(3.75)}; + --bit-otp-size: #{$siz-ctrl-md}; --bit-otp-fontsize: #{$tg-fs-sm}; + --bit-otp-dsc-fontsize: #{$tg-fs-xs}; } .bit-otp-lg { - --bit-otp-size: #{spacing(4.5)}; + --bit-otp-size: #{$siz-ctrl-lg}; --bit-otp-fontsize: #{$tg-fs-md}; + --bit-otp-dsc-fontsize: #{$tg-fs-sm}; } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.ts b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.ts index 25918d93a08..61b367c0f19 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.ts +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.ts @@ -22,6 +22,20 @@ namespace BitBlazorUI { input?.select(); }, { signal }); + // The mouseup that ends a click puts the caret where it landed and drops the selection the + // handler above just made - in WebKit even when the click is the one that brought the focus in. + // That would leave the caret before the single character the box holds rather than over it, and + // a Backspace pressed there has nothing to delete, so a box clicked into to correct it would + // never clear. The default action is swallowed instead: there is no caret to place in a box + // holding one character, which is the very reason the character is selected to begin with. + root.addEventListener('mouseup', (e: MouseEvent) => { + const input = OtpInput.getInput(e); + if (!input) return; + + e.preventDefault(); + input.select(); + }, { signal }); + // The row of boxes reads as a single field, so the gaps between them (and the separators // sitting in those gaps) have to behave like a part of it rather than as dead space. A click // that misses a box lands on the input the typing is meant to carry on in, which is the first @@ -90,6 +104,18 @@ namespace BitBlazorUI { active.blur?.(); } + /** + * Submits the form the component sits in the way pressing Enter would. requestSubmit rather than + * submit, since only the former runs the constraint validation and raises the submit event, which + * is the one an EditForm listens to. + */ + public static submit(root: HTMLElement) { + const form = root?.closest('form'); + if (!form || typeof form.requestSubmit !== 'function') return; + + form.requestSubmit(); + } + public static dispose(id: string) { const ac = OtpInput.abortControllers[id]; if (!ac) return; @@ -112,7 +138,14 @@ namespace BitBlazorUI { private static writeCodeToClipboard(e: ClipboardEvent, root: HTMLElement): boolean { if (!OtpInput.getInput(e)) return false; if (!e.clipboardData) return false; - if (root.dataset.bitOtpNocopy === 'true') return false; + + // A code that is not shown is kept off the clipboard altogether rather than only being left to + // the browser: what the boxes hold is the masking character, and handing that over is neither + // of any use nor what the password input this reproduces would do. + if (root.dataset.bitOtpNocopy === 'true') { + e.preventDefault(); + return false; + } const code = Array.from(root.querySelectorAll('input.bit-otp-inp')) .map(i => i.value) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputJsRuntimeExtensions.cs index 59b184430fc..357dbd7d4cd 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputJsRuntimeExtensions.cs @@ -12,6 +12,11 @@ internal static ValueTask BitOtpInputBlur(this IJSRuntime jsRuntime, ElementRefe return jsRuntime.InvokeVoid("BitBlazorUI.OtpInput.blur", root); } + internal static ValueTask BitOtpInputSubmit(this IJSRuntime jsRuntime, ElementReference root) + { + return jsRuntime.InvokeVoid("BitBlazorUI.OtpInput.submit", root); + } + internal static ValueTask BitOtpInputDispose(this IJSRuntime jsRuntime, string id) { return jsRuntime.InvokeVoid("BitBlazorUI.OtpInput.dispose", id); diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputParams.cs new file mode 100644 index 00000000000..14cc77ba3d4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputParams.cs @@ -0,0 +1,473 @@ +namespace Bit.BlazorUI; + +/// +/// The parameters for component. +/// +public class BitOtpInputParams : BitInputBaseParams, IBitComponentParams +{ + /// + /// Represents the parameter name used to identify the cascading parameters within . + /// + /// + /// This constant is typically used when referencing or accessing the BitOtpInput 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(BitOtpInput)}"; + + + + public string Name => ParamName; + + + + /// + /// The accent color of the inputs, applied to the border and the focus ring of the focused input. + ///
+ /// . + ///
+ public BitColor? Accent { get; set; } + + /// + /// If true, the first input is auto focused. + ///
+ /// . + ///
+ public bool? AutoFocus { get; set; } + + /// + /// Enables auto shifting the indexes while clearing the inputs using Delete or Backspace. + ///
+ /// . + ///
+ public bool? AutoShift { get; set; } + + /// + /// Submits the form the component sits in as soon as the code is complete, the way pressing Enter would. + ///
+ /// . + ///
+ public bool? AutoSubmit { get; set; } + + /// + /// Removes the focus from the inputs as soon as the code is complete, which is what dismisses the + /// virtual keyboard of a phone once there is nothing left to type. + ///
+ /// . + ///
+ public bool? BlurOnFill { get; set; } + + /// + /// Custom CSS classes for different parts of the BitOtpInput. + ///
+ /// . + ///
+ public BitOtpInputClassStyles? Classes { get; set; } + + /// + /// The description (helper text) rendered under the inputs, which the group of the inputs references + /// through its aria-describedby. + ///
+ /// . + ///
+ public string? Description { get; set; } + + /// + /// Stretches the row of inputs across the available width and lets the inputs share it evenly. + ///
+ /// . + ///
+ public bool? FullWidth { get; set; } + + /// + /// The composite format of the aria-label rendered on each input, where {0} is the one based index + /// of the input and {1} is the Length. + ///
+ /// . + ///
+ public string? InputAriaLabelFormat { get; set; } + + /// + /// Sets the inputmode html attribute of the inputs, which is what decides the virtual keyboard that a + /// phone brings up without changing the element that is rendered. + ///
+ /// . + ///
+ public BitInputMode? InputMode { get; set; } + + /// + /// Paints the inputs with the error state without an EditContext taking part in it. + ///
+ /// . + ///
+ public bool? Invalid { get; set; } + + /// + /// Puts the component into the busy state of a code that has been submitted and is being checked, which + /// draws a progress bar under the inputs, holds the code still and announces the wait. + ///
+ /// . + ///
+ public bool? IsLoading { get; set; } + + /// + /// Label displayed above the inputs. + ///
+ /// . + ///
+ public string? Label { get; set; } + + /// + /// Length of the OTP or number of the inputs. + ///
+ /// . + ///
+ public int? Length { get; set; } + + /// + /// Turns every character of the code into its lower case form as it is typed or pasted. + ///
+ /// . + ///
+ public bool? Lowercase { get; set; } + + /// + /// The text rendered in place of every filled input, which hides the code without turning the inputs + /// into password inputs. + ///
+ /// . + ///
+ public string? Mask { get; set; } + + /// + /// Glues the inputs of each group together into a single field instead of leaving them standing next + /// to each other. + ///
+ /// . + ///
+ public bool? Merged { get; set; } + + /// + /// Turns the digits of the other numbering systems into their ASCII form as they are typed or pasted. + ///
+ /// . + ///
+ public bool? NormalizeDigits { get; set; } + + /// + /// Disables both the SMS auto fill of the OTP through the WebOTP API of the browser and the + /// one-time-code autofill of the inputs themselves. + ///
+ /// . + ///
+ public bool? NoSmsAutoFill { get; set; } + + /// + /// A function applied to a chunk of characters that reaches the component in one go (a paste, an SMS + /// auto fill, or a multi character input event) before anything else is done with it. Pulling the code + /// out of the message it was copied inside of is a rule of the application rather than of one field, + /// which is what makes it worth cascading. + ///
+ /// . + ///
+ public Func? PasteTransformer { get; set; } + + /// + /// A regular expression that every single character of the code has to match. + ///
+ /// . + ///
+ public string? Pattern { get; set; } + + /// + /// The hint text rendered in the empty inputs. + ///
+ /// . + ///
+ public string? Placeholder { get; set; } + + /// + /// Defines whether to render inputs in the opposite direction. + ///
+ /// . + ///
+ public bool? Reversed { get; set; } + + /// + /// The text rendered between the inputs, like a dash or a dot, to make a long code easier to read. + ///
+ /// . + ///
+ public string? Separator { get; set; } + + /// + /// The number of inputs of each group that the Separator is rendered between. + ///
+ /// . + ///
+ public int? SeparatorInterval { get; set; } + + /// + /// Keeps the code free of holes by pulling the focus, and any chunk that arrives at once, back to the + /// first input left to fill. + ///
+ /// . + ///
+ public bool? Sequential { get; set; } + + /// + /// Turns the whole component into a single stop of the tab order. + ///
+ /// . + ///
+ public bool? SingleTabStop { get; set; } + + /// + /// The size of the inputs. + ///
+ /// . + ///
+ public BitSize? Size { get; set; } + + /// + /// Custom CSS styles for different parts of the BitOtpInput. + ///
+ /// . + ///
+ public BitOtpInputClassStyles? Styles { get; set; } + + /// + /// Type of the inputs. + ///
+ /// . + ///
+ public BitInputType? Type { get; set; } + + /// + /// Turns every character of the code into its upper case form as it is typed or pasted. + ///
+ /// . + ///
+ public bool? Uppercase { get; set; } + + /// + /// The visual variant of the inputs, which decides how much of the frame around each input is painted. + ///
+ /// . + ///
+ public BitVariant? Variant { get; set; } + + /// + /// Defines whether to render inputs vertically. + ///
+ /// . + ///
+ 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(BitOtpInput bitOtpInput) + { + if (bitOtpInput is null) return; + + UpdateInputBaseParameters(bitOtpInput); + + if (Accent.HasValue && bitOtpInput.HasNotBeenSet(nameof(Accent))) + { + bitOtpInput.Accent = Accent.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (AutoFocus.HasValue && bitOtpInput.HasNotBeenSet(nameof(AutoFocus))) + { + bitOtpInput.AutoFocus = AutoFocus.Value; + } + + if (AutoShift.HasValue && bitOtpInput.HasNotBeenSet(nameof(AutoShift))) + { + bitOtpInput.AutoShift = AutoShift.Value; + } + + if (AutoSubmit.HasValue && bitOtpInput.HasNotBeenSet(nameof(AutoSubmit))) + { + bitOtpInput.AutoSubmit = AutoSubmit.Value; + } + + if (BlurOnFill.HasValue && bitOtpInput.HasNotBeenSet(nameof(BlurOnFill))) + { + bitOtpInput.BlurOnFill = BlurOnFill.Value; + } + + if (Classes is not null && bitOtpInput.HasNotBeenSet(nameof(Classes))) + { + bitOtpInput.Classes = Classes; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (Description.HasValue() && bitOtpInput.HasNotBeenSet(nameof(Description))) + { + bitOtpInput.Description = Description; + } + + if (FullWidth.HasValue && bitOtpInput.HasNotBeenSet(nameof(FullWidth))) + { + bitOtpInput.FullWidth = FullWidth.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (InputAriaLabelFormat.HasValue() && bitOtpInput.HasNotBeenSet(nameof(InputAriaLabelFormat))) + { + bitOtpInput.InputAriaLabelFormat = InputAriaLabelFormat; + } + + if (InputMode.HasValue && bitOtpInput.HasNotBeenSet(nameof(InputMode))) + { + bitOtpInput.InputMode = InputMode.Value; + } + + if (Invalid.HasValue && bitOtpInput.HasNotBeenSet(nameof(Invalid))) + { + bitOtpInput.Invalid = Invalid.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (IsLoading.HasValue && bitOtpInput.HasNotBeenSet(nameof(IsLoading))) + { + bitOtpInput.IsLoading = IsLoading.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (Label.HasValue() && bitOtpInput.HasNotBeenSet(nameof(Label))) + { + bitOtpInput.Label = Label; + } + + if (Length.HasValue && bitOtpInput.HasNotBeenSet(nameof(Length))) + { + bitOtpInput.Length = Length.Value; + } + + if (Lowercase.HasValue && bitOtpInput.HasNotBeenSet(nameof(Lowercase))) + { + bitOtpInput.Lowercase = Lowercase.Value; + } + + if (Mask.HasValue() && bitOtpInput.HasNotBeenSet(nameof(Mask))) + { + bitOtpInput.Mask = Mask; + } + + if (Merged.HasValue && bitOtpInput.HasNotBeenSet(nameof(Merged))) + { + bitOtpInput.Merged = Merged.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (NormalizeDigits.HasValue && bitOtpInput.HasNotBeenSet(nameof(NormalizeDigits))) + { + bitOtpInput.NormalizeDigits = NormalizeDigits.Value; + } + + if (NoSmsAutoFill.HasValue && bitOtpInput.HasNotBeenSet(nameof(NoSmsAutoFill))) + { + bitOtpInput.NoSmsAutoFill = NoSmsAutoFill.Value; + } + + if (PasteTransformer is not null && bitOtpInput.HasNotBeenSet(nameof(PasteTransformer))) + { + bitOtpInput.PasteTransformer = PasteTransformer; + } + + if (Pattern.HasValue() && bitOtpInput.HasNotBeenSet(nameof(Pattern))) + { + bitOtpInput.Pattern = Pattern; + } + + if (Placeholder.HasValue() && bitOtpInput.HasNotBeenSet(nameof(Placeholder))) + { + bitOtpInput.Placeholder = Placeholder; + } + + if (Reversed.HasValue && bitOtpInput.HasNotBeenSet(nameof(Reversed))) + { + bitOtpInput.Reversed = Reversed.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (Separator.HasValue() && bitOtpInput.HasNotBeenSet(nameof(Separator))) + { + bitOtpInput.Separator = Separator; + } + + if (SeparatorInterval.HasValue && bitOtpInput.HasNotBeenSet(nameof(SeparatorInterval))) + { + bitOtpInput.SeparatorInterval = SeparatorInterval.Value; + } + + if (Sequential.HasValue && bitOtpInput.HasNotBeenSet(nameof(Sequential))) + { + bitOtpInput.Sequential = Sequential.Value; + } + + if (SingleTabStop.HasValue && bitOtpInput.HasNotBeenSet(nameof(SingleTabStop))) + { + bitOtpInput.SingleTabStop = SingleTabStop.Value; + } + + if (Size.HasValue && bitOtpInput.HasNotBeenSet(nameof(Size))) + { + bitOtpInput.Size = Size.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (Styles is not null && bitOtpInput.HasNotBeenSet(nameof(Styles))) + { + bitOtpInput.Styles = Styles; + + bitOtpInput.StyleBuilder.Reset(); + } + + if (Type.HasValue && bitOtpInput.HasNotBeenSet(nameof(Type))) + { + bitOtpInput.Type = Type.Value; + } + + if (Uppercase.HasValue && bitOtpInput.HasNotBeenSet(nameof(Uppercase))) + { + bitOtpInput.Uppercase = Uppercase.Value; + } + + if (Variant.HasValue && bitOtpInput.HasNotBeenSet(nameof(Variant))) + { + bitOtpInput.Variant = Variant.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + + if (Vertical.HasValue && bitOtpInput.HasNotBeenSet(nameof(Vertical))) + { + bitOtpInput.Vertical = Vertical.Value; + + bitOtpInput.ClassBuilder.Reset(); + } + } +} 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..b5b0518debc --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Utils/Params/BitInputBaseParams.cs @@ -0,0 +1,64 @@ +namespace Bit.BlazorUI; + +/// +/// The parameters that every bit BlazorUI input component inherits from , +/// which the parameters class of an input derives from so that a cascade carries them +/// along with the ones the component declares itself. +/// +/// +/// Only the parameters that describe how a whole area of a form behaves are carried here. What identifies a +/// single field - its , its +/// , its and its +/// - is deliberately left out, since a value shared by every +/// input under the cascade is never what a consumer means. So is +/// , a dictionary the components write into, which they +/// would end up sharing a single instance of, and , which is read +/// while the parameters are still being set and so before a cascade has been applied. +/// +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 inherited input 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 + /// component itself. + /// + /// + /// The instance whose properties will be updated. Cannot be null. + /// + public void UpdateInputBaseParameters(BitInputBase bitInputBase) + { + if (bitInputBase is null) return; + + UpdateBaseParameters(bitInputBase); + + if (ReadOnly.HasValue && bitInputBase.HasNotBeenSetOnInput(nameof(ReadOnly))) + { + bitInputBase.ReadOnly = ReadOnly.Value; + + bitInputBase.ClassBuilder.Reset(); + } + + if (Required.HasValue && bitInputBase.HasNotBeenSetOnInput(nameof(Required))) + { + bitInputBase.Required = Required.Value; + + bitInputBase.ClassBuilder.Reset(); + } + } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor index 427eece08d0..f878100b304 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor @@ -1,61 +1,30 @@ -@page "/components/otpinput" +@page "/components/otpinput" @page "/components/otp-input" + Description="A one-time password (OTP) input for Blazor: single character boxes bound to one string, with auto advancing focus, keyboard editing, whole-code paste and copy, SMS autofill, auto submit, masking, grouping, busy and error states, and public CSS variables." />
- The component renders Length single character boxes (5 by default) and keeps their content in a - single string value. The focus moves on by itself as each box is filled, so the whole code is typed - without ever reaching for the Tab key. + Length boxes (5 by default) bound to one string. Typing advances the focus. Backspace clears the + box it is pressed in - on an empty box, the one before it - Delete clears in place, and the arrows, Home + and End walk the code.

- The keyboard follows the same rules a single input holding the whole code would: Backspace clears the - box it is pressed in and stays there, so the character can be retyped right away, and only once that - box is empty does it delete the character before it and follow it. Delete clears the current box - without moving. The arrow keys walk between the boxes, and Home and End jump to the first and the last - character of the code. -
-
-
- The mouse is treated the same way: focusing a box selects the character it holds, so typing replaces - it instead of appending to it, and a click that lands in the gap between two boxes, or on a separator, - is not swallowed either - it puts the caret in the box the typing is meant to carry on in, which is - the first one still empty, or the last one once the code is complete. The whole row therefore behaves - like the single field it represents rather than like a handful of unrelated boxes standing next to - each other. -
-
-
- IsEnabled and ReadOnly are the two ways to stop the user from editing the code: - a disabled component is dimmed and drops out of the tab order, while a read-only one still reads and - copies like normal text, which is what an already confirmed code should look like. - AutoFocus puts the caret in the first box still left to fill on the first render, so a - component seeded with a partial code carries on where the typing stopped instead of landing back on - its first character. AutoShift makes Backspace and Delete pull the remaining characters one - box to the left instead of leaving a hole in the middle of the code, and BlurOnFill drops the - focus as soon as the last box is filled, which is what gets the virtual keyboard of a phone out of - the way of the button below the code. -
-
-
- Sequential keeps the code free of holes: clicking a box that sits after the first empty one - moves the focus to that empty box instead, and a pasted chunk cannot land past it either, so the - code is always filled from its start onwards. It matters because the value is the characters of the - boxes joined together and an empty box contributes nothing to it, so a character typed into the - middle of an empty row would be reported as if it were the first one of the code. A complete code - is left alone, which keeps every one of its characters clickable and correctable. + AutoFocus lands in the first empty box. AutoShift pulls the rest of the code along + instead of leaving a hole. BlurOnFill drops the focus - and a phone's keyboard - on the last + character. Sequential pulls a click or a paste past the first empty box back to it.


Basic

@@ -80,27 +49,19 @@


Sequential (try clicking the last box)

- +
- Label renders a caption above the boxes. It is a real label element bound to the - first box, so clicking it moves the focus there, and the group of inputs is named after it for screen - readers. LabelTemplate replaces the text with arbitrary markup while keeping the same wiring, - and Required marks the label with an asterisk and the boxes as required for the browser and for - assistive technologies. + Label is a real label bound to the first box and names the group of boxes. + Required adds the asterisk and marks every box required.

- Description is the helper text under the boxes, and it is where the sentence that turns a row of - empty boxes into a question the user can answer belongs: where the code was sent, how long it stays - valid, or what to do when it never arrives. The group of boxes references it through - aria-describedby, so a screen reader announces it along with the name of the group instead - of leaving it as text that only sighted users get. It is deliberately attached to the group and not to - each box, which would otherwise repeat the whole sentence at every character of the code. - DescriptionTemplate replaces it with arbitrary markup, so a "resend the code" action or a - countdown put in there is announced with the group just the same. + Description is the helper text under the boxes - where the code was sent, how long it is valid. + The group references it, so it is announced once rather than at every box. The templates take markup + instead, such as a "resend" link.


Label:
@@ -125,12 +86,12 @@



Description:


-



DescriptionTemplate:


- + Didn't get it? @@ -142,39 +103,14 @@
- Type decides the kind of the underlying inputs and, with it, the virtual keyboard that the - mobile browsers bring up. Number asks for the numeric keypad and rejects anything that is not a - digit, whether it is typed or pasted, so a code copied as 123-456 still lands in the - boxes as 123456. It is deliberately not rendered as a native number input, which would - carry spin buttons, react to the mouse wheel and silently report an empty value for the characters a - number accepts but a code does not, such as e or -. - Password masks the characters with the bullet of the browser, which is what a code that stays - on screen for a while should do. -
-
-
- The same reasoning applies to Email and Url: those input types carry a constraint - validation that a single character can never satisfy, so a row of them would report itself as - permanently invalid to the browser and keep a plain HTML form from ever submitting. They are rendered - as text inputs as well and contribute only their keyboard, through the inputmode. - Tel is the exception and is rendered as it is, since it validates nothing. -
-
-
- InputMode asks for a keyboard on its own, without changing the element that is rendered or the - characters that are accepted. It defaults to the keyboard that matches the Type, so it is only - needed to ask for one the type does not imply: Tel on a code of digits brings up the telephone - keypad, whose keys are noticeably larger than the numeric ones on most Android keyboards, which makes - a code easier to hit on a small screen. + Type picks the accepted characters and the phone keyboard: Number accepts digits only, + typed or pasted, Password masks with the browser's bullet. Number, Email and Url render as text + inputs - their native elements would add spin buttons or a validation one character never passes.

- A code of digits is not always written in the digits of ASCII: a message in Persian carries it as - ۱۲۳۴۵۶, one in Arabic as ١٢٣٤٥٦, and a keyboard set to those languages types - them that way. NormalizeDigits turns the digits of every other numbering system into their - ASCII form as they are typed or pasted, which is what keeps such a code from being refused as if it - were not a number at all. The conversion happens before the Type and the Pattern decide - what to accept, and the value of the component is the ASCII form that a server expects. + InputMode asks for another keyboard without changing what is accepted, e.g. the larger + telephone keypad for digits. NormalizeDigits folds Persian, Arabic and other digits into ASCII.

@@ -191,25 +127,20 @@
Try pasting ۱۲۳۴۵۶ or ١٢٣٤٥٦.
- +
- Mask is the text that every filled box shows in place of the character it holds, which hides - the code without turning the boxes into password inputs, so the masking character is chosen rather - than dictated by the browser: a bullet, an asterisk, a star, even an emoji. Only the rendering - changes; the value of the component stays the code that was typed, and it is what binding, validation - and the callbacks all see. -
-
-
- A masked code is deliberately kept off the clipboard: copying from a box hands over the whole code - everywhere else, but here the boxes are showing a masking character rather than the code, and a - Password Type, which browsers refuse to let anyone copy from, is treated the - same way. Pasting into the boxes keeps working either way. + Placeholder is the hint of an empty box; one exactly Length long is spread one character + per box. Mask is what a filled box shows instead of its character - any text, even an emoji. + Neither touches the value, and a masked code is kept off the clipboard.

+ +

+ +



- +



@@ -217,24 +148,11 @@
Value: @maskValue
- +
- Pattern is a regular expression that every single character of the code has to match, - which narrows the code down to a set of characters that no input type covers on its own. A - character that does not match is rejected as it is typed, and the characters of a pasted code - that do not match are dropped instead of the whole paste being refused, so a code copied - together with the words around it still lands in the boxes. An expression that does not - compile is ignored rather than turning the component into a field that accepts nothing. -
-
-
- Uppercase is what keeps such a restriction from becoming a dead end: it turns every - character into its upper case form before the expression is applied, so a code that is printed in - upper case can be typed in either case, on a phone keyboard as much as on a desk one. - Lowercase is its mirror and follows exactly the same rules; Uppercase wins when both - are set, since asking for both at once is a contradiction rather than an order to apply. Both of - them reach a code that is assigned to the component as well as one that is typed or pasted into it, - so the boxes never show one casing while the value reports another. + Pattern is a regular expression every character has to match: a non-matching keystroke is + rejected, a pasted code only loses its non-matching characters. Uppercase and + Lowercase fold the case first, so a restricted pattern still accepts either case.

@@ -246,41 +164,11 @@
- +
- Placeholder puts a hint character in the boxes that are still empty, which makes the expected - shape of the code visible before anything is typed. A placeholder exactly as long as Length is - spread over the boxes one character each, so a mask like 000000 or - ABCDEF can be shown; any other value is repeated in every box as is. -
-
- -

- -
- - -
- Separator draws a piece of text between the boxes, the way a long code is usually printed in the - message that delivers it. It is purely decorative: it is hidden from screen readers, it does not take - the focus, and it is never rendered as a part of the value. A pasted code is filtered by the same rules - as a typed one, so the separators of a code copied with them in it are dropped when - Type is BitInputType.Number or a Pattern that rejects them is set, and are - taken as characters of the code otherwise. -
-
-
- SeparatorInterval decides how many boxes each group holds, which is what splits a long code - into the chunks it is read in. It defaults to 1, a separator between every pair of boxes; setting it - to 3 on a six character code renders a single separator in the middle and turns the row into - 123-456. -
-
-
- SeparatorTemplate replaces that text with arbitrary markup, an icon above all, and takes - precedence over Separator. Its context is the zero based index of the box the separator is - rendered before, so a single template can tell one separator of the row from another and render - something different at each of them. + Separator draws text between the boxes; SeparatorInterval sets how many boxes a group + holds (3 renders 123-456). SeparatorTemplate takes markup, with the index of the + next box as its context. Separators are decoration only: never focused, announced or part of the value.

@@ -298,13 +186,24 @@
- + +
+ How much frame a box carries: Outline (the default) a full rule, Fill a surface and no + rule, Text only an underline - its focus indicator is an underline too. +
+
+ +

+ +

+ +
+ +
- Vertical stacks the boxes in a column and Reversed renders them in the opposite order. - The arrow keys follow whatever layout is in effect: left and right walk a horizontal row, up and down - walk a vertical one, and both flip along with Reversed and with a right-to-left direction, so - the focus always moves the way the boxes look. Home and End are the exception on purpose: they address - the code rather than the layout, so they always land on the first and the last character of it. + Vertical stacks the boxes, Reversed flips their order; the arrow keys follow the layout, + while Home and End always reach the first and last character. FullWidth stretches the row + across its container and shares the width between the boxes.

@@ -314,32 +213,20 @@

+

+
+ +
+ +
- Merged glues the boxes together into a single field instead of leaving them standing next to - each other: the gaps between them are closed, the rule that two neighbouring boxes share is drawn - once rather than twice, and only the two ends of the row keep their rounding. What it draws is a - code printed in one box, which is how a code is printed on the card or in the message it comes - from, while every character keeps the input of its own that the typing, the autofill and the - screen readers need. -
-
-
- A Separator cuts the row into groups and the gluing follows those groups, so a six character - code with a SeparatorInterval of 3 is drawn as two joined boxes of three with the separator - between them. The separator keeps a little room of its own there, since closing the gaps would - otherwise leave a dash touching the boxes on either side of it. A group of a single box keeps all - four of its corners rounded, which is what a separator between every pair of boxes falls back to. -
-
-
- It composes with everything else about the layout: Vertical glues the boxes along the column - instead of the row, Reversed and a right-to-left direction keep the rounding at the two - visual ends of each group, and the Variant decides what is being joined - a row of filled - boxes, of outlined ones, or a single continuous underline. The focused box is drawn above the ones - it is glued to, so its ring is never clipped by them. + Merged glues the boxes of each group into one field: no gaps, shared rules drawn once, rounding + only at the ends. The groups are the ones the Separator makes, and it works with every + Variant, Vertical and Reversed.

@@ -358,125 +245,17 @@
- -
- The boxes are wrapped in a group that is named after the Label, or after - AriaLabel when there is none, so a screen reader announces the purpose of the code once instead - of once per box. Each box is then named after its own position, 1 of 6 by default, which is - what tells the user which character they are on. InputAriaLabelFormat is the composite format - behind that name, where {0} is the one based position and {1} is the - Length, and it is what localizes the announcement. -
-
-
- Description completes that picture: the group also carries an aria-describedby - pointing at the helper text under the boxes, so the sentence that says where the code was sent and - how long it is good for is announced along with the name of the group rather than being left as - text that only sighted users get. -
-
-
- SingleTabStop turns the whole component into one stop of the tab order: only the box holding - the first character stays reachable with the Tab key and the rest are left to the auto advancing - focus, the arrow keys and the mouse. A keyboard user then tabs past the code in one press instead of - six, the way a group of inputs that hold one single value is expected to behave. The boxes still - take the focus on a click and through FocusAsync, so nothing else about them changes. -
-
-
- TabIndex places the component itself somewhere in the tab order and is rendered on each box, - which is what puts a code entry ahead of the fields printed above it on a page whose reading order - and DOM order disagree. It composes with SingleTabStop rather than fighting it: a box that - was taken out of the tab order has no position left to be given, so only the reachable one carries - it. Use it sparingly, since a positive value pulls the component out of the natural order of the - document for every user, not only for the one it was meant to help. -
-
-
- The rest of the accessible behavior comes for free: Required marks every box as required for - the browser and for assistive technologies, a failing validation and the Invalid state add - aria-invalid to them, IsLoading marks the group with aria-busy so - that the wait for the answer of the server is announced rather than only drawn, the separators are - hidden from screen readers so the code is never read out with the dashes in it, and a disabled - component drops out of the tab order altogether. -
-
- -

- -

- -

- -

- -
- - -
- A one-time code is copied far more often than it is typed, so the whole code arrives in one go in - three different ways and all of them end up in the same place. Every box carries - autocomplete="one-time-code", which is what makes iOS and Safari offer the code that has - just arrived by SMS; on browsers that implement the WebOTP API the component asks the browser for that - code itself and fills the boxes as soon as the user allows it; and a paste anywhere in the row is - caught and spread over the boxes. NoSmsAutoFill turns the first two off for a code that never - arrives by SMS, such as one read from an authenticator app. -
-
-
- A pasted code is cleaned up before it lands: every kind of whitespace is dropped, so a code that wraps - or is spaced in the message it was copied from still fills the boxes, and so are the characters that - the Type or the Pattern rejects, which is what lets a code copied as - 123-456 or together with the words around it be pasted as is instead of being refused. - Uppercase applies to it too. -
-
-
- The characters that carry no glyph at all are dropped whatever the Type and the Pattern - say, since no code is ever made of them: the bidi marks that a message written in Persian or Arabic - carries around its digits, the zero width joiners of the scripts that need them, the byte order mark - that a few clipboards prepend, and the control characters. They are invisible, so letting them - through would fill the boxes with characters the user cannot see and hand the server a code it never - issued - a code that looks right on screen and is refused every single time. -
-
+
- Where the code lands depends on how much of it there is: one that fills the component always starts at - the first box, no matter which one received the paste, since anything else would drop its leading - characters, while a shorter one is inserted where it was pasted and the focus moves to the first box - still left to fill. A partial paste only replaces as many characters as it brings, so the rest of the - code around it is left alone, and a code longer than the boxes can hold keeps its first Length - characters. Sequential applies here too: it keeps a pasted chunk from landing past the first - empty box, which is the hole it exists to prevent. + A code arriving in one go - autocomplete="one-time-code", the WebOTP API or a paste - is + cleaned up before it lands: whitespace, invisible characters and whatever Type or + Pattern rejects are dropped. A full-length code starts at the first box wherever it was pasted.

- That per-character filtering is enough for a code of digits, where everything around the code is - thrown away by definition, but it cannot pull a code of letters out of the text it was copied inside - of: the letters of the words match the code just as well as the code does, so - your code is A1B2C3 would fill the boxes with YOURCODEI. - PasteTransformer is the way out. It is a function applied to a chunk of characters that - reaches the component in one go, before anything else is done with it, so a single regular - expression picks the code out of the sentence. Returning an empty string rejects the whole chunk, - which raises OnInvalid, and it is deliberately not applied to a single typed character. -
-
-
- The way back out is handled too. Each box is an input of its own, so copying from one of them would - hand over the single character it holds instead of the code the user meant; the whole code is put on - the clipboard instead, from whichever box the copy is made, which is what a single input holding the - code would have given. Cutting does the same and empties the boxes afterwards. A code that is not - shown is left alone: with a Mask or a Password type the boxes hold a masking character - rather than the code, and a password input refuses to be copied for exactly the same reason. -
-
-
- NoSmsAutoFill reaches the password managers as well. An autocomplete of - off is a request the browser extensions deliberately ignore, since it is what a site - refusing to work with them looks like, so the attributes that 1Password, LastPass, Bitwarden and - Dashlane read instead are rendered along with it. They are not rendered otherwise, because offering - the code from the authenticator vault that issued it is precisely what a password manager is for. + PasteTransformer runs over the whole chunk first, to pull a code of letters out of the message + around it; an empty result rejects the paste. Copying from any box copies the whole code, a cut clears + it. NoSmsAutoFill turns SMS and password-manager autofill off.

Try pasting 123 456, 12-34-56 or your code is 123456:
@@ -494,14 +273,10 @@
- +
- The value of the component is the whole code as a single string, not one value per box. Binding it - one-way with Value makes the boxes follow the model without letting the user change it, while - @@bind-Value keeps the two in sync in both directions: typing in the boxes updates the string, - and writing a new string into the model spreads it over the boxes. Setting the value to - null or to an empty string clears them all. DefaultValue is the uncontrolled - counterpart: it seeds the boxes on the first render and then leaves them to the user. + The value is the whole code as one string. Value alone is one-way, bind-Value two-way; + null or an empty string clears the boxes.

@@ -511,28 +286,11 @@
- -
- OnChange reports every change of the value, while OnFill fires the moment the last empty - box is filled, which is the callback to submit the code from - and, with it, the place to switch - IsLoading on while the answer of the server is awaited. It - is raised once per completed code, so retyping the same character over an already complete code does - not submit it twice, and it fires again after the code is edited into a different complete one. -
-
-
- OnInvalid is the other side of that coin: it fires when what was typed, pasted or auto filled - is rejected in full by the Type or the Pattern, so that nothing of it reaches the boxes, - and it receives the rejected text along with the index of the box that received it. Rejecting a - character is otherwise silent, which leaves the user typing into a box that never fills without being - told why, so this is the callback that turns it into a message, a shake of the row or the - Invalid state. A paste that only loses some of its characters, a code copied with the dashes - in it, still fills the boxes and is therefore not a rejection. -
-
+
- The remaining callbacks forward the raw DOM events of the individual boxes together with the index of - the box that raised them, which is what makes per-box behavior possible in the consuming code. + OnFill fires once per completed code - the place to submit it. OnInvalid fires when a + keystroke or a paste is rejected in full, with the rejected text and the box index. The rest forward + each box's DOM events along with its index.

@@ -566,23 +324,11 @@
Input index: @onPasteArgs?.Index
- +
- A reference to the component exposes FocusAsync, which focuses a specific box (the index is - clamped into the rendered range), BlurAsync, which drops the focus of whichever box holds it - and with it the virtual keyboard of a phone, Clear, which empties every box and the value at - once, and InputElements, the element references of the boxes themselves. Clearing and - re-focusing the first box is exactly what a "the code you entered is wrong, try again" branch needs - to do. -
-
-
- All of them are safe to call at any point in the lifetime of the component: focusing before the - first render does nothing rather than asking the browser for an element that is not there yet, - blurring does nothing when the focus is elsewhere on the page, and clearing does nothing while the - component is disabled or read-only. Clear is deliberately not blocked by - IsLoading, since the busy state belongs to the very - code that is about to be cleared and switched off again. + FocusAsync focuses a box (index clamped), BlurAsync drops the focus, Clear empties + the code, InputElements exposes the boxes. All are safe before the first render and do nothing + when there is nothing to do.

@@ -595,14 +341,11 @@
- +
- The component is a regular Blazor input, so it takes part in an EditForm like any other one: it - picks up the cascading EditContext, reports its value on every keystroke, renders the - error state on the boxes, and marks itself with aria-invalid for assistive technologies. - Data annotations such as [Required] and [MinLength] on the bound property are - all that is needed to validate the length of the code. Outside of an EditForm, Name - posts the whole code as one named field of a plain HTML form, not one field per box. + Works with EditForm and data annotations like any Blazor input, marking the boxes + aria-invalid on a failing validation. AutoSubmit submits the enclosing form the + moment the code is complete, validation included. Name posts the whole code as one field.


@@ -611,7 +354,7 @@ - +
@@ -626,75 +369,33 @@ }
-
- - -
- A one-time code is almost never wrong in a way a validator can see: it is the right length and it is - made of the right characters, and only the server that issued it knows that it has expired or does not - match. Invalid is what paints that answer back onto the boxes, without an - EditContext taking any part in it. It draws them in the error color, keeps them there - while the user reads the message, and marks them with aria-invalid so that the failure is - announced rather than only shown. -
-
-
- It is independent of the validation of an EditForm, which renders the very same state on its - own, so the two can be used together: data annotations catch a code that is too short before it is - ever sent, and Invalid catches the one that was sent and came back rejected. Clearing the boxes - and putting the caret back in the first of them, which is what Clear and FocusAsync do - together, is the usual companion to it. The wait in between the two, while the code is on its way to - the server, is what IsLoading is for. -
-
+

- Invalid paints the failure but does not say what it was, so it belongs together with - Description, which carries the sentence the server answered with. Because the group of boxes - references the description through aria-describedby, the reason is announced to a screen - reader user rather than only being drawn in red next to boxes they cannot see, and a colour alone is - never the only carrier of the message. A description that is referenced this way is announced when - the focus reaches the code, though, not at the moment the server answers; when the answer arrives - while the focus is elsewhere, put the sentence inside a DescriptionTemplate whose own markup - carries an aria-live="polite", so that it is read out as it changes. It is deliberately - not done for you, since a description holding a countdown or a "resend the code" link would then be - announced on every tick. + @if (autoSubmitted is false) + { + + + + + + + } + else + { + + Submitted on fill: @autoSubmitOtpInputModel.OtpValue + + }
-
- -
- - Clear & retry -
- +
- Entering a one-time code is a round trip: the last box is filled, the code is sent, and only the - answer of the server says whether it was the right one. IsLoading is the middle of that trip. - It draws an indeterminate progress bar under the boxes, so the wait is visible where the code is - rather than somewhere else on the page, and it marks the group of boxes with - aria-busy, which is what tells a screen reader that the answer is still on its way - instead of leaving the user wondering whether the code was received at all. -
-
-
- It also holds the code still while the answer travels, exactly the way ReadOnly does: nothing - can be typed, deleted, pasted or cut over a code that has already been submitted, and the component - stops asking the browser for the SMS code it is no longer waiting for. What it does not block is - your own Clear, since the busy state belongs to you: clearing the boxes is precisely what the - branch that turns IsLoading off and Invalid on is about to do. -
-
-
- The three states are meant to be used together and in this order: OnFill submits the code and - switches IsLoading on, the answer switches it back off, and a code that came back rejected - switches Invalid on along with the sentence the server answered with in the - Description. The bar follows the Accent color, so it belongs to the same form as the - boxes above it. + The round trip after OnFill. IsLoading draws a bar, holds the code still and marks the + group aria-busy while the server checks it. Invalid paints the error state a + validator cannot see - an expired or wrong code. While either is on, the Description is + announced through a live region.

- +
- Variant decides how much of a frame each box carries: Outline (the default) draws a full - rule around it, Fill paints it with a surface color and drops the rule, and Text keeps - only an underline, for a code entry that should not outweigh the fields around it. + The boxes form a group named by Label (or AriaLabel) and described by + Description; each box is announced by position, 1 of 6. InputAriaLabelFormat + localizes it - {0} is the position, {1} the Length. + SingleTabStop makes the whole code one tab stop.

- +
+ Separators are hidden from screen readers, IsLoading and Invalid are announced as they + happen, the boxes clear the 24px target size, and an autofilled box keeps the contrast of the theme. +
+
+

- +

- + +

+ +

+
- +
- Accent picks the color role that the focused box carries, on both its border and its focus ring, - so the component can follow the accent of the form it sits in. The error state of the validation always - wins over it, which keeps an invalid code recognizable whatever the accent is. + BitParams hands a BitOtpInputParams to every otp input below it - the place for rules + the server decides, such as Length, Type and PasteTransformer. They are defaults: + a parameter set on the component itself wins. Value, DefaultValue and Name are + never cascaded. +
+
+
+ + +
+ +
+ +
+
+
+
+ +
+
+ + +
+ Accent colors the focused box's border and ring and the IsLoading bar. The error state + always wins over it.

@@ -747,11 +481,9 @@
- +
- Size scales the boxes and the text inside them together, so the code stays centered and readable - at every size. Pick the one that matches the density of the surrounding form: the large size suits a - standalone verification screen, the small one a code entry tucked into a dense dialog. + Size scales the boxes and their text together, matching the control heights of the theme.

@@ -761,14 +493,10 @@
- +
- Style and Class reach the root element only, while Styles and Classes reach - every part of the component by name: the root, the label, the description, the wrapper of the inputs, - each input, the separator, the Loader drawn while IsLoading is on, plus the two state - slots Focused (the input that currently has the focus) and Filled (every input that - already holds a character), which are the hooks for a per-box appearance that follows the progress of - the typing. + Style and Class reach the root; Styles and Classes reach every part, plus + the Focused and Filled state slots of each box.


@@ -798,14 +526,40 @@ Focused = "custom-focused", Separator = "custom-separator" })" />
+

+
+ The public CSS variables inherit, so one set on + :root or an ancestor restyles every otp input below it, and one on Style restyles + a single instance. The filled pair paints the boxes that hold a character. +
+
+
+ +
+ +
+ +
+ +
+
+
Set once on an ancestor, inherited by every otp input inside it:
+
+
+ +
+ +
- +
- In a right-to-left direction the boxes are laid out from right to left and the arrow keys swap along - with them, so the left arrow still moves towards the box that sits on the visual left, which is the - one holding the next character. The value itself keeps its logical order: the first box is always the - first character of the code. + Right-to-left lays the boxes out from the right and swaps the arrow keys with them. The value keeps its + logical order.


diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor.cs index f1892d0202f..d96587e7c1a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web; namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Inputs.OtpInput; @@ -11,7 +11,7 @@ public partial class BitOtpInputDemo Name = "Accent", Type = "BitColor?", DefaultValue = "null", - Description = "The accent color of the inputs, applied to the border and the focus ring of the focused input. The error state of the validation still wins over it.", + Description = "The color of the focused input's border and focus ring, and of the loading bar. The error state wins over it.", LinkType = LinkType.Link, Href = "#color-enum", }, @@ -20,21 +20,28 @@ public partial class BitOtpInputDemo Name = "AutoFocus", Type = "bool", DefaultValue = "false", - Description = "If true, the first input left to fill is auto focused on the first render, so a component seeded with a partial code carries on where the typing stopped. A component that starts out disabled cannot take the focus, so it is focused on the first render that finds it enabled instead of losing the auto focus altogether.", + Description = "Focuses the first empty input on the first render that finds the component enabled.", }, new() { Name = "AutoShift", Type = "bool", DefaultValue = "false", - Description = "Enables auto shifting the indexes while clearing the inputs using Delete or Backspace, so the remaining characters move one input to the left instead of leaving a hole in the middle of the code.", + Description = "Shifts the rest of the code one input back when a character is cleared with Backspace or Delete, instead of leaving a hole.", + }, + new() + { + Name = "AutoSubmit", + Type = "bool", + DefaultValue = "false", + Description = "Submits the enclosing form (a plain form or an EditForm) right after OnFill, the way pressing Enter would: the form still validates first. Nothing happens outside of a form.", }, new() { Name = "BlurOnFill", Type = "bool", DefaultValue = "false", - Description = "Removes the focus from the inputs as soon as the code is complete, which is what dismisses the virtual keyboard of a phone once there is nothing left to type.", + Description = "Removes the focus from the inputs once the code is complete, which dismisses a phone's virtual keyboard.", }, new() { @@ -50,28 +57,35 @@ public partial class BitOtpInputDemo Name = "Description", Type = "string?", DefaultValue = "null", - Description = "The description (helper text) rendered under the inputs, which the group of the inputs references through its aria-describedby so that screen readers announce it along with the name of the group. It is where the sentence that turns a row of empty boxes into a question the user can answer belongs: where the code was sent, how long it is good for, or what a server that rejected it said.", + Description = "Helper text under the inputs, referenced by the group's aria-describedby. While Invalid or IsLoading is on it is also announced through a live region.", }, new() { Name = "DescriptionTemplate", Type = "RenderFragment?", DefaultValue = "null", - Description = "Custom template for the description (helper text) rendered under the inputs, which takes precedence over the Description. It is referenced the very same way, so a \"resend the code\" button or a countdown put in here is announced with the group as well.", + Description = "Custom template for the helper text, taking precedence over Description. It is described the same way but never copied into the live region.", + }, + new() + { + Name = "FullWidth", + Type = "bool", + DefaultValue = "false", + Description = "Stretches the row across its container and shares the width evenly between the inputs. The height stays the one of the Size.", }, new() { Name = "InputAriaLabelFormat", Type = "string?", DefaultValue = "null", - Description = "The composite format of the aria-label rendered on each input, where {0} is the one based index of the input and {1} is the Length. Set it to localize the position that screen readers announce for each input. The default is \"{0} of {1}\".", + Description = "Composite format of each input's aria-label: {0} is the one based position and {1} the Length. Defaults to \"{0} of {1}\".", }, new() { Name = "InputMode", Type = "BitInputMode?", DefaultValue = "null", - Description = "Sets the inputmode html attribute of the inputs, which is what decides the virtual keyboard that a phone brings up without changing the element that is rendered or the characters that are accepted. It defaults to the keyboard that matches the Type, so it is only needed to ask for a keyboard the type does not imply, like the telephone keypad (whose keys are larger than the numeric ones on most Android keyboards) for a code of digits.", + Description = "The inputmode attribute of the inputs, which picks the virtual keyboard without changing the accepted characters. Defaults to the one the Type implies.", LinkType = LinkType.Link, Href = "#input-mode-enum", }, @@ -80,21 +94,21 @@ public partial class BitOtpInputDemo Name = "Invalid", Type = "bool", DefaultValue = "false", - Description = "Paints the inputs with the error state without an EditContext taking part in it, which is what reports a code that the server has rejected (\"that code is not correct, try again\"): the failure only becomes known once the code has been submitted, so there is nothing for a validator to see. It also marks the inputs with aria-invalid, and a failing validation of an EditContext still shows the very same state on its own.", + Description = "Paints the error state and sets aria-invalid without an EditContext, e.g. for a code the server rejected. A failing validation shows the same state on its own.", }, new() { Name = "IsLoading", Type = "bool", DefaultValue = "false", - Description = "Puts the component into the busy state of a code that has been submitted and is being checked, which is the step between the OnFill and the answer that either lets the user through or sets the Invalid. It paints an indeterminate progress bar under the inputs, marks the group with aria-busy so that the wait is announced rather than only shown, and holds the code still the way the ReadOnly does, so that nothing can be typed, pasted or cut over a code whose answer is already on its way. The Clear of the consumer is deliberately not blocked by it.", + Description = "The busy state of a submitted code: draws a progress bar, marks the group aria-busy, announces the Description and holds the code still like ReadOnly. Clear is not blocked by it.", }, new() { Name = "Label", Type = "string?", DefaultValue = "null", - Description = "Label displayed above the inputs. It is rendered as a real label element bound to the first input and it also names the group of the inputs for assistive technologies.", + Description = "Label displayed above the inputs, bound to the first input and naming the group of inputs.", }, new() { @@ -108,147 +122,147 @@ public partial class BitOtpInputDemo Name = "Length", Type = "int", DefaultValue = "5", - Description = "Length of the OTP or number of the inputs. Values below 1 are clamped to 1, changing it at runtime keeps the characters of the inputs that survive the resize, and a value longer than the inputs can hold loses its extra characters instead of being reported as a value that is not shown.", + Description = "The number of inputs, which is the length of the code. Values below 1 are treated as 1.", }, new() { Name = "Lowercase", Type = "bool", DefaultValue = "false", - Description = "Turns every character of the code into its lower case form as it is typed or pasted, the mirror of the Uppercase and applied under the very same rules: before the Pattern is applied, so an expression restricted to lower case letters accepts an upper case keystroke, and to a code that is assigned to the component as much as to one that is typed into it. The Uppercase wins when both are set.", + Description = "Converts every character to lower case before the Pattern is applied. Uppercase wins when both are set.", }, new() { Name = "Mask", Type = "string?", DefaultValue = "null", - Description = "The text rendered in place of every filled input, which hides the code without turning the inputs into password inputs, so a masking character of its own (a bullet, an asterisk, an emoji) can be used. The value of the component stays the code that was typed.", + Description = "Text shown in place of every filled input's character. The value stays the typed code, and a masked code is kept off the clipboard.", }, new() { Name = "Merged", Type = "bool", DefaultValue = "false", - Description = "Glues the inputs of each group together into a single field instead of leaving them standing next to each other: the gaps between them are closed, the rule they share is drawn once, and only the two ends of every group keep their rounding, which is the look of a code printed in a single box. The groups are the ones the Separator makes, so a separator with a SeparatorInterval of 3 renders a six character code as two joined boxes of three.", + Description = "Glues the inputs of each group (the ones the Separator makes) into a single field with rounding only at its ends.", }, new() { Name = "NormalizeDigits", Type = "bool", DefaultValue = "false", - Description = "Turns the digits of the other numbering systems (the Persian ۰۱۲۳, the Arabic-Indic ٠١٢٣, the full width 0123 and the rest) into their ASCII form as they are typed or pasted, which is what lets a code that arrives in a message written in the language of the user be typed on the keyboard of that language rather than being rejected as if it were not a number at all. The conversion happens before the Pattern is applied and before the Type rejects what is not a digit, and the value of the component is the ASCII form that a server expects.", + Description = "Converts the digits of other numbering systems (Persian, Arabic-Indic, full width, ...) to ASCII before the Type and the Pattern are applied.", }, new() { Name = "NoSmsAutoFill", Type = "bool", DefaultValue = "false", - Description = "Disables both the SMS auto fill of the OTP through the WebOTP API of the browser and the one-time-code autofill of the inputs themselves. It also renders the attributes that keep the password manager extensions (1Password, LastPass, Bitwarden, Dashlane) from filling the inputs and from putting their badge over them, since an autocomplete of \"off\" is a request those extensions deliberately ignore.", + Description = "Turns off the WebOTP SMS auto fill, the one-time-code autocomplete and the password managers' autofill.", }, new() { Name = "OnFill", Type = "EventCallback", - Description = "Callback for when all of the inputs are filled. It is raised once per completed code, so an edit that keeps the very same code does not raise it again.", + Description = "Callback for when all of the inputs are filled, raised once per completed code.", }, new() { Name = "OnFocusIn", Type = "EventCallback<(FocusEventArgs Event, int Index)>", - Description = "onfocusin event callback for each input, receiving the event and the index of the input that raised it.", + Description = "onfocusin event callback for each input, with the index of the input.", }, new() { Name = "OnFocusOut", Type = "EventCallback<(FocusEventArgs Event, int Index)>", - Description = "onfocusout event callback for each input, receiving the event and the index of the input that raised it.", + Description = "onfocusout event callback for each input, with the index of the input.", }, new() { Name = "OnInput", Type = "EventCallback<(ChangeEventArgs Event, int Index)>", - Description = "oninput event callback for each input, receiving the event and the index of the input that raised it.", + Description = "oninput event callback for each input, with the index of the input.", }, new() { Name = "OnInvalid", Type = "EventCallback<(string Value, int Index)>", - Description = "Callback for when what was typed, pasted or auto filled is rejected in full by the Type or the Pattern, so that nothing of it reaches the inputs. It receives the rejected text along with the index of the input that received it, and it is what turns a silent rejection into a visible one. A paste that only loses some of its characters, like a code copied with the dashes in it, is not a rejection and does not raise it.", + Description = "Callback for when a keystroke, paste or auto fill is rejected in full by the Type, the Pattern or the PasteTransformer, with the rejected text and the index of the input. A paste that only loses some characters does not raise it.", }, new() { Name = "OnKeyDown", Type = "EventCallback<(KeyboardEventArgs Event, int Index)>", - Description = "onkeydown event callback for each input, receiving the event and the index of the input that raised it.", + Description = "onkeydown event callback for each input, with the index of the input.", }, new() { Name = "OnPaste", Type = "EventCallback<(ClipboardEventArgs Event, int Index)>", - Description = "onpaste event callback for each input, receiving the event and the index of the input that raised it.", + Description = "onpaste event callback for each input, with the index of the input.", }, new() { Name = "PasteTransformer", Type = "Func?", DefaultValue = "null", - Description = "A function applied to a chunk of characters that reaches the component in one go (a paste, an SMS auto fill, or a multi character input event) before anything else is done with it, which is what pulls the code out of the text it was copied inside of. The per character filtering of the Type and the Pattern cannot do that on its own for a code of letters, since the letters of the words around it match just as well as the ones of the code: \"your code is A1B2C3\" would fill the inputs with \"YOURCODEI\". Returning an empty string rejects the chunk, which raises OnInvalid. It is not applied to a single typed character, and an exception thrown out of it leaves the chunk untouched rather than breaking the input.", + Description = "Applied to a pasted or auto filled chunk before it is filtered, e.g. to pull the code out of the message around it. An empty result rejects the chunk; an exception leaves it untouched. Not applied to a single typed character.", }, new() { Name = "Pattern", Type = "string?", DefaultValue = "null", - Description = "A regular expression that every single character of the code has to match, which is what narrows the code down to a set of characters that no input type covers on its own, like upper case letters or hexadecimal digits. Characters that do not match are rejected while typing and dropped while pasting. An unusable expression is ignored rather than breaking the input.", + Description = "A regular expression every single character has to match. Non-matching characters are rejected when typed and dropped when pasted; an invalid expression is ignored.", }, new() { Name = "Placeholder", Type = "string?", DefaultValue = "null", - Description = "The hint text rendered in the empty inputs. A string as long as the Length is spread over the inputs one character each, any other value is rendered in every input as is.", + Description = "Hint text of the empty inputs. A string exactly Length long is spread one character per input; any other is shown in every input.", }, new() { Name = "Reversed", Type = "bool", DefaultValue = "false", - Description = "Defines whether to render inputs in the opposite direction. The arrow key navigation flips along with it.", + Description = "Renders the inputs in the opposite order. The arrow keys follow.", }, new() { Name = "Separator", Type = "string?", DefaultValue = "null", - Description = "The text rendered between the inputs, like a dash or a dot, to make a long code easier to read. It is hidden from assistive technologies and never becomes part of the value.", + Description = "Text rendered between the groups of inputs. It is hidden from assistive technologies and never part of the value.", }, new() { Name = "SeparatorInterval", Type = "int", DefaultValue = "1", - Description = "The number of inputs of each group that the Separator is rendered between, which is how a long code is split into the chunks it is usually printed in, like 123-456. The default is 1, meaning a separator between every pair of inputs. Values below 1 are treated as 1.", + Description = "The number of inputs in each group the Separator is rendered between, e.g. 3 for 123-456. Values below 1 are treated as 1.", }, new() { Name = "SeparatorTemplate", Type = "RenderFragment?", DefaultValue = "null", - Description = "Custom template rendered between the inputs in place of the Separator text, which is what puts an icon or any other markup between the groups of a code. The context is the zero based index of the input the separator is rendered before, so a template can tell one separator of the row from another. It takes precedence over the Separator.", + Description = "Custom template rendered in place of the Separator text, with the zero based index of the next input as its context.", }, new() { Name = "Sequential", Type = "bool", DefaultValue = "false", - Description = "Keeps the code free of holes: giving the focus to an input that sits after the first empty one, by clicking it or with an arrow key, moves the focus to that first empty input instead, and a chunk of characters that arrives at once (a paste or an auto fill) cannot land past it either, so the code is always filled from its start onwards. Without it a character typed into the middle of an empty row is reported as if it were the first one of the code, since the value is the characters of the inputs joined together and an empty input contributes nothing to it. A complete code is left alone, so any of its characters can still be clicked and corrected.", + Description = "Keeps the code free of holes: focusing, or pasting into, an input past the first empty one lands on that first empty input instead. A complete code is left editable anywhere.", }, new() { Name = "SingleTabStop", Type = "bool", DefaultValue = "false", - Description = "Turns the whole component into a single stop of the tab order: only the input holding the first character of the code is reachable with the Tab key and the rest are left to the auto advancing focus, the arrow keys and the mouse. Tabbing out of the code then lands on the element after it rather than on its next character.", + Description = "Makes the whole component a single tab stop: only the first input is reachable with Tab.", }, new() { @@ -273,7 +287,7 @@ public partial class BitOtpInputDemo Name = "Type", Type = "BitInputType?", DefaultValue = "null", - Description = "Type of the inputs, which also decides the virtual keyboard of the mobile browsers. The Number type asks for the numeric keypad and rejects every character that is not a digit, whether it is typed or pasted, without rendering a native number input (which would carry spin buttons and report an empty value for characters like e or -). The Email and the Url types are rendered as text inputs for the same reason, since the constraint validation they carry can never be satisfied by a single character and would keep a plain html form from submitting; only the keyboard they ask for is kept.", + Description = "Type of the inputs, deciding the accepted characters and the virtual keyboard. Number accepts digits only; Number, Email and Url render as text inputs.", LinkType = LinkType.Link, Href = "#input-type-enum", }, @@ -282,14 +296,14 @@ public partial class BitOtpInputDemo Name = "Uppercase", Type = "bool", DefaultValue = "false", - Description = "Turns every character of the code into its upper case form as it is typed or pasted, which is what lets a code that is printed in upper case be typed in either case. The conversion happens before the Pattern is applied, so an expression restricted to upper case letters accepts a lower case keystroke instead of rejecting it.", + Description = "Converts every character to upper case before the Pattern is applied.", }, new() { Name = "Variant", Type = "BitVariant?", DefaultValue = "null", - Description = "The visual variant of the inputs, which decides how much of the frame around each input is painted: a full fill, only an outline, or just an underline.", + Description = "The visual variant of the inputs: Outline (default), Fill or Text (underline only).", LinkType = LinkType.Link, Href = "#variant-enum", }, @@ -298,7 +312,7 @@ public partial class BitOtpInputDemo Name = "Vertical", Type = "bool", DefaultValue = "false", - Description = "Defines whether to render inputs vertically. The arrow key navigation follows the layout.", + Description = "Renders the inputs vertically. The arrow keys follow.", }, ]; @@ -549,31 +563,249 @@ public partial class BitOtpInputDemo } ]; + private readonly List componentCssVariables = + [ + new() + { + Name = "--bit-OtpInput-gap", + DefaultValue = "0.625rem", + Description = "Room between the inputs. Merged closes it to 0 whatever this holds.", + }, + new() + { + Name = "--bit-OtpInput-input-size", + DefaultValue = "--bit-siz-ctrl-sm / --bit-siz-ctrl-md / --bit-siz-ctrl-lg, per Size", + Description = "Width and height of every input, which is a square of the control height of its size class. FullWidth overrides the width alone.", + }, + new() + { + Name = "--bit-OtpInput-input-width", + DefaultValue = "--bit-OtpInput-input-size", + Description = "Width of an input on its own, for a box wider than it is tall.", + }, + new() + { + Name = "--bit-OtpInput-input-height", + DefaultValue = "--bit-OtpInput-input-size", + Description = "Height of an input on its own.", + }, + new() + { + Name = "--bit-OtpInput-font-family", + DefaultValue = "--bit-tpg-font-family", + Description = "Typeface of the whole component, which is where a tabular or monospaced face for the code is set - the one piece of text in a form that is read character by character.", + }, + new() + { + Name = "--bit-OtpInput-font-size", + DefaultValue = "--bit-tpg-fs-xs / --bit-tpg-fs-sm / --bit-tpg-fs-md, per Size", + Description = "Size of the code, inherited by the label and the placeholder.", + }, + new() + { + Name = "--bit-OtpInput-label-font-size", + DefaultValue = "--bit-OtpInput-font-size", + Description = "Size of the label above the inputs, which follows the size of the code unless it is set on its own.", + }, + new() + { + Name = "--bit-OtpInput-label-font-weight", + DefaultValue = "--bit-tpg-fw-semibold", + Description = "Weight of the label above the inputs.", + }, + new() + { + Name = "--bit-OtpInput-description-font-size", + DefaultValue = "--bit-tpg-fs-2xs / --bit-tpg-fs-xs / --bit-tpg-fs-sm, per Size", + Description = "Size of the helper text under the inputs, one step of the type ramp below the code.", + }, + new() + { + Name = "--bit-OtpInput-font-weight", + DefaultValue = "--bit-tpg-fw-regular", + Description = "Weight of the character inside an input.", + }, + new() + { + Name = "--bit-OtpInput-radius", + DefaultValue = "--bit-shp-radius-control", + Description = "Corner radius of an input, and of the two ends of every group while Merged is on. The Text variant squares them off whatever this holds.", + }, + new() + { + Name = "--bit-OtpInput-border-width", + DefaultValue = "--bit-shp-border-width", + Description = "Thickness of an input's rule, and with it the overlap that glues two Merged inputs together.", + }, + new() + { + Name = "--bit-OtpInput-color", + DefaultValue = "--bit-clr-fg-pri", + Description = "Color of the typed character.", + }, + new() + { + Name = "--bit-OtpInput-background", + DefaultValue = "Per Variant: --bit-clr-bg-pri (Outline), --bit-clr-bg-sec (Fill), transparent (Text)", + Description = "Input background at rest, and the fallback of the hover and filled backgrounds below.", + }, + new() + { + Name = "--bit-OtpInput-hover-background", + DefaultValue = "Per Variant: the rest background (Outline, Text), --bit-clr-bg-sec-hover (Fill)", + Description = "Input background while hovered, on an input that is neither disabled nor read-only.", + }, + new() + { + Name = "--bit-OtpInput-border-color", + DefaultValue = "Per Variant: --bit-clr-brd-pri (Outline, Text), transparent (Fill)", + Description = "Input rule at rest, and the fallback of the hover and filled rules below.", + }, + new() + { + Name = "--bit-OtpInput-hover-border-color", + DefaultValue = "Per Variant: --bit-clr-brd-pri-hover (Outline, Text), the rest rule (Fill)", + Description = "Input rule while hovered.", + }, + new() + { + Name = "--bit-OtpInput-filled-background", + DefaultValue = "--bit-OtpInput-background", + Description = "Background of an input that already holds a character, which is what turns the row into its own progress indicator. It has no parameter behind it.", + }, + new() + { + Name = "--bit-OtpInput-filled-border-color", + DefaultValue = "--bit-OtpInput-border-color", + Description = "Rule of an input that already holds a character.", + }, + new() + { + Name = "--bit-OtpInput-focus-border-color", + DefaultValue = "The Accent role's main color", + Description = "Input rule while focused.", + }, + new() + { + Name = "--bit-OtpInput-focus-color", + DefaultValue = "The Accent role's focus color", + Description = "Color of the keyboard focus ring.", + }, + new() + { + Name = "--bit-OtpInput-placeholder-color", + DefaultValue = "--bit-clr-fg-ter", + Description = "Hint character of an empty input.", + }, + new() + { + Name = "--bit-OtpInput-label-color", + DefaultValue = "--bit-clr-fg-pri", + Description = "Label above the inputs.", + }, + new() + { + Name = "--bit-OtpInput-description-color", + DefaultValue = "--bit-clr-fg-sec", + Description = "Helper text under the inputs, outside the error state.", + }, + new() + { + Name = "--bit-OtpInput-separator-color", + DefaultValue = "--bit-clr-fg-sec", + Description = "Text drawn between the groups of the code.", + }, + new() + { + Name = "--bit-OtpInput-invalid-color", + DefaultValue = "--bit-clr-err", + Description = "Input rule, helper text and loading bar while Invalid is on or a validation is failing.", + }, + new() + { + Name = "--bit-OtpInput-invalid-focus-color", + DefaultValue = "--bit-clr-err-focus", + Description = "Focus ring color in the error state.", + }, + new() + { + Name = "--bit-OtpInput-disabled-color", + DefaultValue = "--bit-clr-fg-dis", + Description = "Character, placeholder, label, helper text, separator and loading bar when IsEnabled is false.", + }, + new() + { + Name = "--bit-OtpInput-disabled-background", + DefaultValue = "--bit-clr-bg-dis", + Description = "Input background when IsEnabled is false.", + }, + new() + { + Name = "--bit-OtpInput-disabled-border-color", + DefaultValue = "--bit-clr-brd-dis", + Description = "Input rule when IsEnabled is false.", + }, + new() + { + Name = "--bit-OtpInput-loader-color", + DefaultValue = "The Accent role's main color", + Description = "The sweep of the bar drawn while IsLoading is on. The error and disabled states paint it with their own color instead.", + }, + new() + { + Name = "--bit-OtpInput-loader-background", + DefaultValue = "--bit-clr-bg-sec", + Description = "The track the sweep of the loading bar travels along.", + }, + new() + { + Name = "--bit-OtpInput-loader-height", + DefaultValue = "--bit-siz-track-sm", + Description = "Thickness of the loading bar.", + }, + ]; + private readonly List componentPublicMembers = [ new() { Name = "InputElements", Type = "ElementReference[]", - Description = "The ElementReferences to the input elements of the BitOtpInput. The inherited InputElement, which every input component carries a single one of, stands for the input holding the first character of the code.", + Description = "The ElementReferences to the input elements of the BitOtpInput. The inherited InputElement is the first of them.", }, new() { Name = "BlurAsync", Type = "() => ValueTask", - Description = "Removes the focus from the input of the BitOtpInput that currently holds it, which is what dismisses the virtual keyboard of a phone. Nothing happens when the focus is somewhere else on the page, so a component that filled itself in the background never takes it away from what the user is doing.", + Description = "Removes the focus from the input that holds it, dismissing a phone's virtual keyboard. Does nothing when the focus is elsewhere on the page.", }, new() { Name = "Clear", Type = "() => Task", - Description = "Clears the value of all of the inputs of the BitOtpInput. It does nothing while the component is disabled or read-only.", + Description = "Clears all of the inputs and the value. Does nothing while the component is disabled or read-only.", }, new() { Name = "FocusAsync", Type = "(int index = 0) => ValueTask", - Description = "Gives focus to a specific input element of the BitOtpInput. The index is clamped into the range of the rendered inputs, and calling it before the component has rendered does nothing rather than asking the browser for an element that is not there yet.", + Description = "Focuses the input at the given index, clamped into range. The inherited FocusAsync() and FocusAsync(bool preventScroll) focus the first input. Does nothing before the first render.", + } + ]; + + + + private readonly BitOtpInputParams[] otpInputParams = + [ + new() + { + Length = 6, + Separator = "-", + SeparatorInterval = 3, + Type = BitInputType.Number, + Variant = BitVariant.Fill, + NormalizeDigits = true, + PasteTransformer = v => System.Text.RegularExpressions.Regex.Match(v, @"\p{Nd}{6}").Value, } ]; @@ -625,30 +857,18 @@ private void HandleInvalidSubmit() formIsValidSubmit = false; } - private bool invalidState; - private BitOtpInput? invalidOtpInput; - private string invalidDescription = "Enter the 6 digit code we sent you. Try 123456."; - private void HandleInvalidDemoFill(string? value) + private bool autoSubmitted; + private ValidationOtpInputModel autoSubmitOtpInputModel = new(); + private async Task HandleAutoSubmit() { - // Only the server that issued the code knows whether it is the right one, so the error state is - // set from the answer it gives rather than from a validator, and the answer itself goes into the - // description, which the group of the inputs is described by. - invalidState = value != "123456"; + autoSubmitted = true; - invalidDescription = invalidState - ? "That code is not correct or has expired. Try 123456." - : "That code is correct."; - } - - private async Task HandleInvalidDemoRetry() - { - invalidState = false; - invalidDescription = "Enter the 6 digit code we sent you. Try 123456."; + await Task.Delay(3000); - if (invalidOtpInput is null) return; + autoSubmitted = false; + autoSubmitOtpInputModel = new(); - await invalidOtpInput.Clear(); - await invalidOtpInput.FocusAsync(); + StateHasChanged(); } private bool isLoading; @@ -684,437 +904,4 @@ private async Task HandleLoadingDemoRetry() await loadingOtpInput.Clear(); await loadingOtpInput.FocusAsync(); } - - - - private readonly string example1RazorCode = @" - - - - - - - - - - - - - - -"; - - private readonly string example2RazorCode = @" - - - - - - - - Custom label - - - - - - - - - - - - Didn't get it? - Send it again - - -"; - - private readonly string example3RazorCode = @" - - - - - - -
Value: @normalizeDigitsValue
"; - private readonly string example3CsharpCode = @" -private string? normalizeDigitsValue;"; - - private readonly string example4RazorCode = @" - - - - - - - -
Value: @maskValue
"; - private readonly string example4CsharpCode = @" -private string? maskValue;"; - - private readonly string example5RazorCode = @" - - - - - - -"; - - private readonly string example6RazorCode = @" - - -"; - - private readonly string example7RazorCode = @" - - - - - - - - - - - - -"; - - private readonly string example8RazorCode = @" - - - -"; - - private readonly string example9RazorCode = @" - - - - - - - - - - -"; - - private readonly string example10RazorCode = @" - - - - - - - - -"; - - private readonly string example11RazorCode = @" - -
Value: @pasteValue
- - Regex.Match(v, ""[A-Za-z0-9]{6}"").Value)"" - @bind-Value=""transformedPasteValue"" /> -
Value: @transformedPasteValue
- -"; - private readonly string example11CsharpCode = @" -private string? pasteValue; -private string? transformedPasteValue;"; - - private readonly string example12RazorCode = @" - - - - -"; - private readonly string example12CsharpCode = @" -private string? oneWayValue; -private string? twoWayValue;"; - - private readonly string example13RazorCode = @" - onChangeValue = v"" /> -
OnChange value: @onChangeValue
- - onFillValue = v"" /> -
OnFill value: @onFillValue
- - onInvalidArgs = args"" /> -
Rejected: @onInvalidArgs?.Value
-
Input index: @onInvalidArgs?.Index
- - onFocusInArgs = args"" /> -
Focus type: @onFocusInArgs?.Event.Type
-
Input index: @onFocusInArgs?.Index
- - onFocusOutArgs = args"" /> -
Focus type: @onFocusOutArgs?.Event.Type
-
Input index: @onFocusOutArgs?.Index
- - onInputArgs = args"" /> -
Value: @onInputArgs?.Event.Value
-
Input index: @onInputArgs?.Index
- - onKeyDownArgs = args"" /> -
Key & Code: [@onKeyDownArgs?.Event.Key] [@onKeyDownArgs?.Event.Code]
-
Input index: @onKeyDownArgs?.Index
- - onPasteArgs = args"" /> -
Focus type: @onPasteArgs?.Event.Type
-
Input index: @onPasteArgs?.Index
"; - private readonly string example13CsharpCode = @" -private string? onChangeValue; -private string? onFillValue; -private (string Value, int Index)? onInvalidArgs; -private (FocusEventArgs Event, int Index)? onFocusInArgs; -private (FocusEventArgs Event, int Index)? onFocusOutArgs; -private (ChangeEventArgs Event, int Index)? onInputArgs; -private (KeyboardEventArgs Event, int Index)? onKeyDownArgs; -private (ClipboardEventArgs Event, int Index)? onPasteArgs;"; - - private readonly string example14RazorCode = @" - - - - apiOtpInput?.FocusAsync(0)"">Focus first - apiOtpInput?.FocusAsync(5)"">Focus last - apiOtpInput?.BlurAsync()"">Blur - Clear -"; - private readonly string example14CsharpCode = @" -private BitOtpInput? apiOtpInput; - -private async Task HandleClearClick() -{ - if (apiOtpInput is null) return; - - await apiOtpInput.Clear(); - await apiOtpInput.FocusAsync(); -}"; - - private readonly string example15RazorCode = @" - - - - - - - validationOtpInputModel.OtpValue"" /> - - Submit -"; - private readonly string example15CsharpCode = @" -public class ValidationOtpInputModel -{ - [Required(ErrorMessage = ""The OTP value is required."")] - [MinLength(6, ErrorMessage = ""Minimum length is 6."")] - public string OtpValue { get; set; } -} - -private ValidationOtpInputModel validationOtpInputModel = new(); - -private void HandleValidSubmit() { } -private void HandleInvalidSubmit() { }"; - - private readonly string example16RazorCode = @" - - - - Clear & retry -"; - private readonly string example16CsharpCode = @" -private bool invalidState; -private BitOtpInput? invalidOtpInput; -private string invalidDescription = ""Enter the 6 digit code we sent you. Try 123456.""; - -private void HandleInvalidDemoFill(string? value) -{ - // Only the server that issued the code knows whether it is the right one, so the error state is - // set from the answer it gives rather than from a validator, and the answer itself goes into the - // description, which the group of the inputs is described by. - invalidState = value != ""123456""; - - invalidDescription = invalidState - ? ""That code is not correct or has expired. Try 123456."" - : ""That code is correct.""; -} - -private async Task HandleInvalidDemoRetry() -{ - invalidState = false; - invalidDescription = ""Enter the 6 digit code we sent you. Try 123456.""; - - if (invalidOtpInput is null) return; - - await invalidOtpInput.Clear(); - await invalidOtpInput.FocusAsync(); -}"; - - private readonly string example17RazorCode = @" - - - - Clear & retry -"; - private readonly string example17CsharpCode = @" -private bool isLoading; -private bool loadingInvalid; -private BitOtpInput? loadingOtpInput; -private string loadingDescription = ""Enter the 6 digit code we sent you. Try 123456.""; - -private async Task HandleLoadingDemoFill(string? value) -{ - // The code is on its way to the server, which is what the component says while the answer is - // awaited: the boxes are held still and the wait is announced along with the group. - isLoading = true; - loadingInvalid = false; - loadingDescription = ""Checking the code…""; - - await Task.Delay(2000); - - isLoading = false; - loadingInvalid = value != ""123456""; - - loadingDescription = loadingInvalid - ? ""That code is not correct or has expired. Try 123456."" - : ""That code is correct.""; -} - -private async Task HandleLoadingDemoRetry() -{ - isLoading = false; - loadingInvalid = false; - loadingDescription = ""Enter the 6 digit code we sent you. Try 123456.""; - - if (loadingOtpInput is null) return; - - await loadingOtpInput.Clear(); - await loadingOtpInput.FocusAsync(); -}"; - - private readonly string example18RazorCode = @" - - -"; - - private readonly string example19RazorCode = @" - - - - - - - -"; - - private readonly string example20RazorCode = @" - - -"; - - private readonly string example21RazorCode = @" - - - - - - - - - - -"; - - private readonly string example22RazorCode = @" - - -"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor.samples.cs new file mode 100644 index 00000000000..bbefe34de39 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/OtpInput/BitOtpInputDemo.razor.samples.cs @@ -0,0 +1,490 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Inputs.OtpInput; + +public partial class BitOtpInputDemo +{ + private readonly string example1RazorCode = @" + + + + + + + + + + + + + + +"; + + private readonly string example2RazorCode = @" + + + + + + + + Custom label + + + + + + + + + + + + Didn't get it? + Send it again + + +"; + + private readonly string example3RazorCode = @" + + + + + + +
Value: @normalizeDigitsValue
"; + private readonly string example3CsharpCode = @" +private string? normalizeDigitsValue;"; + + private readonly string example4RazorCode = @" + + + + + + + + + + + +
Value: @maskValue
"; + private readonly string example4CsharpCode = @" +private string? maskValue;"; + + private readonly string example5RazorCode = @" + + + + + + +"; + + private readonly string example6RazorCode = @" + + + + + + + + + + + + +"; + + private readonly string example7RazorCode = @" + + +"; + + private readonly string example8RazorCode = @" + + + + + +
+ + + +
"; + + private readonly string example9RazorCode = @" + + + + + + + + + + +"; + + private readonly string example10RazorCode = @" + +
Value: @pasteValue
+ + Regex.Match(v, ""[A-Za-z0-9]{6}"").Value)"" + @bind-Value=""transformedPasteValue"" /> +
Value: @transformedPasteValue
+ +"; + private readonly string example10CsharpCode = @" +private string? pasteValue; +private string? transformedPasteValue;"; + + private readonly string example11RazorCode = @" + + + + +"; + private readonly string example11CsharpCode = @" +private string? oneWayValue; +private string? twoWayValue;"; + + private readonly string example12RazorCode = @" + onChangeValue = v"" /> +
OnChange value: @onChangeValue
+ + onFillValue = v"" /> +
OnFill value: @onFillValue
+ + onInvalidArgs = args"" /> +
Rejected: @onInvalidArgs?.Value
+
Input index: @onInvalidArgs?.Index
+ + onFocusInArgs = args"" /> +
Focus type: @onFocusInArgs?.Event.Type
+
Input index: @onFocusInArgs?.Index
+ + onFocusOutArgs = args"" /> +
Focus type: @onFocusOutArgs?.Event.Type
+
Input index: @onFocusOutArgs?.Index
+ + onInputArgs = args"" /> +
Value: @onInputArgs?.Event.Value
+
Input index: @onInputArgs?.Index
+ + onKeyDownArgs = args"" /> +
Key & Code: [@onKeyDownArgs?.Event.Key] [@onKeyDownArgs?.Event.Code]
+
Input index: @onKeyDownArgs?.Index
+ + onPasteArgs = args"" /> +
Focus type: @onPasteArgs?.Event.Type
+
Input index: @onPasteArgs?.Index
"; + private readonly string example12CsharpCode = @" +private string? onChangeValue; +private string? onFillValue; +private (string Value, int Index)? onInvalidArgs; +private (FocusEventArgs Event, int Index)? onFocusInArgs; +private (FocusEventArgs Event, int Index)? onFocusOutArgs; +private (ChangeEventArgs Event, int Index)? onInputArgs; +private (KeyboardEventArgs Event, int Index)? onKeyDownArgs; +private (ClipboardEventArgs Event, int Index)? onPasteArgs;"; + + private readonly string example13RazorCode = @" + + + + apiOtpInput?.FocusAsync(0)"">Focus first + apiOtpInput?.FocusAsync(5)"">Focus last + apiOtpInput?.BlurAsync()"">Blur + Clear +"; + private readonly string example13CsharpCode = @" +private BitOtpInput? apiOtpInput; + +private async Task HandleClearClick() +{ + if (apiOtpInput is null) return; + + await apiOtpInput.Clear(); + await apiOtpInput.FocusAsync(); +}"; + + private readonly string example14RazorCode = @" + + +@if (formIsValidSubmit is false) +{ + + + + + validationOtpInputModel.OtpValue"" /> + + Submit + +} +else +{ + + The form submitted successfully. + +} + + +@if (autoSubmitted is false) +{ + + + + + autoSubmitOtpInputModel.OtpValue"" /> + +} +else +{ + + Submitted on fill: @autoSubmitOtpInputModel.OtpValue + +}"; + private readonly string example14CsharpCode = @" +public class ValidationOtpInputModel +{ + [Required(ErrorMessage = ""The OTP value is required."")] + [MinLength(6, ErrorMessage = ""Minimum length is 6."")] + public string OtpValue { get; set; } +} + +private bool formIsValidSubmit; +private ValidationOtpInputModel validationOtpInputModel = new(); + +private void HandleValidSubmit() +{ + formIsValidSubmit = true; +} + +private void HandleInvalidSubmit() +{ + formIsValidSubmit = false; +} + + +private bool autoSubmitted; +private ValidationOtpInputModel autoSubmitOtpInputModel = new(); + +private void HandleAutoSubmit() +{ + autoSubmitted = true; +}"; + + private readonly string example15RazorCode = @" + + + + Clear & retry +"; + private readonly string example15CsharpCode = @" +private bool isLoading; +private bool loadingInvalid; +private BitOtpInput? loadingOtpInput; +private string loadingDescription = ""Enter the 6 digit code we sent you. Try 123456.""; + +private async Task HandleLoadingDemoFill(string? value) +{ + isLoading = true; + loadingInvalid = false; + loadingDescription = ""Checking the code…""; + + await Task.Delay(2000); // the server checking the code + + isLoading = false; + loadingInvalid = value != ""123456""; + + loadingDescription = loadingInvalid + ? ""That code is not correct or has expired. Try 123456."" + : ""That code is correct.""; +} + +private async Task HandleLoadingDemoRetry() +{ + isLoading = false; + loadingInvalid = false; + loadingDescription = ""Enter the 6 digit code we sent you. Try 123456.""; + + if (loadingOtpInput is null) return; + + await loadingOtpInput.Clear(); + await loadingOtpInput.FocusAsync(); +}"; + + private readonly string example16RazorCode = @" + + + + + + + + +"; + + private readonly string example17RazorCode = @" + + + + + + + + +"; + private readonly string example17CsharpCode = @" +private readonly BitOtpInputParams[] otpInputParams = +[ + new() + { + Length = 6, + Separator = ""-"", + SeparatorInterval = 3, + Type = BitInputType.Number, + Variant = BitVariant.Fill, + NormalizeDigits = true, + PasteTransformer = v => System.Text.RegularExpressions.Regex.Match(v, @""\p{Nd}{6}"").Value, + } +];"; + + private readonly string example18RazorCode = @" + + + + + + + +"; + + private readonly string example19RazorCode = @" + + +"; + + private readonly string example20RazorCode = @" + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
"; + + private readonly string example21RazorCode = @" + + +"; +} 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..6bb602322c2 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 @@ -37,7 +37,7 @@ public partial class MainLayout new() { Text = "FileInput", Url = "/components/fileinput", AdditionalUrls = ["/components/file-input"] }, 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 = "OtpInput", Url = "/components/otpinput", AdditionalUrls = ["/components/otp-input"], Description = "PinInput, VerificationCode, OneTimePassword" }, new() { Text = "Rating", Url = "/components/rating" }, new() { Text = "SearchBox", Url = "/components/searchbox", AdditionalUrls = ["/components/search-box"], Data = "AutoComplete" }, new() { Text = "Slider", Url = "/components/slider", Description = "Range" }, diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/OtpInput/BitOtpInputTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/OtpInput/BitOtpInputTests.cs index 16daf2ea791..747fe5c880d 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/OtpInput/BitOtpInputTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/OtpInput/BitOtpInputTests.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; @@ -1041,6 +1043,89 @@ public async Task BitOtpInputShouldKeepTheCharacterWhenAnInputEventChangesNothin Assert.AreEqual("••", com.FindAll(".bit-otp-inp")[0].GetAttribute("value")); } + [TestMethod] + public async Task BitOtpInputShouldNotWriteASingleCharacterMaskIntoTheCode() + { + // The very same event as above with a Mask of one character, which is what a mask usually is: the + // masking glyph is the whole value the input reports, so the single character shortcut of the diff + // used to take it for a typed character and write it into the code. + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 2); + parameters.Add(p => p.Mask, "●"); + parameters.Add(p => p.DefaultValue, "1"); + }); + + await com.FindAll(".bit-otp-inp")[0].InputAsync(new ChangeEventArgs { Value = "●" }); + + Assert.AreEqual("1", com.Instance.Value); + Assert.AreEqual("●", com.FindAll(".bit-otp-inp")[0].GetAttribute("value")); + } + + [TestMethod] + public async Task BitOtpInputShouldStillReplaceACharacterTypedOverAMaskedInput() + { + // The guard above must not swallow the keystroke that replaces a masked character: focusing an input + // selects what it holds, so what the browser reports is the new character on its own. + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 2); + parameters.Add(p => p.Mask, "●"); + parameters.Add(p => p.DefaultValue, "12"); + }); + + await com.FindAll(".bit-otp-inp")[0].InputAsync(new ChangeEventArgs { Value = "9" }); + + Assert.AreEqual("92", com.Instance.Value); + } + + [TestMethod] + public async Task BitOtpInputShouldTakeACharacterTypedOverTheSameCharacter() + { + // Focusing an input selects the character it holds, so typing that very same character over it is + // reported as a value equal to the one the input was already showing. With no Mask set that is a + // keystroke like any other: it has to be written and the focus has to move on. The guard answering + // an unchanged masked value must not reach it, or a code re-typed over a code that starts with the + // same digit would lose that digit and shift the rest of itself one input to the left. + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 2); + parameters.Add(p => p.DefaultValue, "19"); + }); + + var focusCount = Context.JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Count; + + await com.FindAll(".bit-otp-inp")[0].InputAsync(new ChangeEventArgs { Value = "1" }); + + Assert.AreEqual("19", com.Instance.Value); + Assert.AreEqual(focusCount + 1, Context.JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Count); + } + + [TestMethod] + public async Task BitOtpInputShouldWalkThroughACodeTypedOverTheSameCode() + { + // The whole of what a swallowed keystroke costs: the focus is what carries the next character to the + // next input, so a code typed over the very same code has to advance once per input but the last - + // otherwise the character after the swallowed one lands in the input that kept the focus, and the + // rest of the code shifts one input to the left. + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 4); + parameters.Add(p => p.DefaultValue, "1234"); + }); + + var focusCount = Context.JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Count; + + var code = "1234"; + for (var index = 0; index < code.Length; index++) + { + await com.FindAll(".bit-otp-inp")[index].InputAsync(new ChangeEventArgs { Value = code[index].ToString() }); + } + + Assert.AreEqual("1234", com.Instance.Value); + Assert.AreEqual(focusCount + 3, Context.JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Count); + } + [TestMethod] public void BitOtpInputShouldGroupTheSeparatorsBySeparatorInterval() { @@ -2251,6 +2336,38 @@ public async Task BitOtpInputBlurAsyncShouldBlurTheFocusedInput() Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.OtpInput.blur"].Count); } + [TestMethod] + public async Task BitOtpInputFocusAsyncShouldDoNothingBeforeTheFirstRender() + { + // The element references are bound once the inputs have been rendered, so every one of the four + // overloads has to be refused until then - including the two inherited ones, which is what an + // argument list of none and one of a single bool are resolved to. + var otpInput = new BitOtpInput(); + + await otpInput.FocusAsync(); + await otpInput.FocusAsync(true); + await otpInput.FocusAsync(0); + await otpInput.FocusAsync(3); + + Assert.AreEqual(0, Context.JSInterop.Invocations["Blazor._internal.domWrapper.focus"].Count); + } + + [TestMethod] + public async Task BitOtpInputFocusAsyncShouldLandOnTheFirstInputWhateverOverloadIsCalled() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 3); + }); + + await com.InvokeAsync(() => com.Instance.FocusAsync()); + await com.InvokeAsync(() => com.Instance.FocusAsync(true)); + + // The inherited InputElement stands for the input holding the first character of the code, so the + // two overloads of the base class address the very same element that FocusAsync(0) does. + Assert.AreEqual(com.Instance.InputElements[0], com.Instance.InputElement); + } + [TestMethod, DataRow(true), DataRow(false) @@ -2602,4 +2719,468 @@ public async Task BitOtpInputShouldNotApplyTheMaskToThePlaceholderOfAnEmptyInput Assert.IsTrue(string.IsNullOrEmpty(inputs[1].GetAttribute("value"))); Assert.AreEqual("0", inputs[1].GetAttribute("placeholder")); } + + [TestMethod] + public async Task BitOtpInputShouldRejectWhitespaceInsteadOfClearingTheInput() + { + // The focused input is selected, so a pressed space bar replaces the character that was in it. No + // code is made of whitespace, so it must be refused rather than allowed to delete that character. + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 3); + parameters.Add(p => p.DefaultValue, "12"); + }); + + (string Value, int Index)? invalidArgs = null; + com.Render(parameters => + { + parameters.Add(p => p.OnInvalid, args => invalidArgs = args); + }); + + await com.FindAll(".bit-otp-inp")[1].InputAsync(new ChangeEventArgs { Value = " " }); + + Assert.AreEqual("12", com.Instance.Value); + Assert.AreEqual("2", com.FindAll(".bit-otp-inp")[1].GetAttribute("value")); + Assert.AreEqual(" ", invalidArgs?.Value); + Assert.AreEqual(1, invalidArgs?.Index); + } + + [TestMethod] + public async Task BitOtpInputShouldStillClearAnInputOnAnEmptyInputEvent() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 3); + parameters.Add(p => p.DefaultValue, "123"); + }); + + await com.FindAll(".bit-otp-inp")[1].InputAsync(new ChangeEventArgs { Value = "" }); + + Assert.AreEqual("13", com.Instance.Value); + } + + [TestMethod] + public void BitOtpInputShouldNotRenderAnAriaLabelOnItsGenericRootElement() + { + // aria-label is prohibited on a generic element and dropped by assistive technologies, so the name + // of the code belongs on the group of the inputs instead of being rendered in both places. + var com = RenderComponent(parameters => + { + parameters.Add(p => p.AriaLabel, "One time code"); + }); + + Assert.IsFalse(com.Find(".bit-otp").HasAttribute("aria-label")); + Assert.AreEqual("One time code", com.Find(".bit-otp-iwr").GetAttribute("aria-label")); + } + + [TestMethod] + public void BitOtpInputShouldAnnounceTheDescriptionAsTheInvalidStateTurnsOn() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Description, "That code is not correct."); + }); + + var status = com.Find(".bit-otp-sts"); + + // The region is rendered from the start and left empty, since one added with its text already in + // it is not announced by most screen readers. + Assert.AreEqual("status", status.GetAttribute("role")); + Assert.AreEqual("polite", status.GetAttribute("aria-live")); + Assert.AreEqual(string.Empty, status.TextContent); + + com.Render(parameters => parameters.Add(p => p.Invalid, true)); + + Assert.AreEqual("That code is not correct.", com.Find(".bit-otp-sts").TextContent); + + com.Render(parameters => parameters.Add(p => p.Invalid, false)); + + Assert.AreEqual(string.Empty, com.Find(".bit-otp-sts").TextContent); + } + + [TestMethod] + public void BitOtpInputShouldAnnounceTheDescriptionWhileTheCodeIsBeingChecked() + { + // aria-busy asks a screen reader to hold off on the changes inside the group, it never announces + // the wait itself, so the sentence that says the code is being checked has to reach the live region + // the very same way the sentence of a rejected code does. + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Description, "Enter the code we sent you."); + }); + + Assert.AreEqual(string.Empty, com.Find(".bit-otp-sts").TextContent); + + com.Render(parameters => + { + parameters.Add(p => p.IsLoading, true); + parameters.Add(p => p.Description, "Checking the code…"); + }); + + Assert.AreEqual("Checking the code…", com.Find(".bit-otp-sts").TextContent); + + // The answer came back rejected: the busy state goes and the error state arrives, so the region + // keeps announcing and moves on to the sentence of the rejection. + com.Render(parameters => + { + parameters.Add(p => p.IsLoading, false); + parameters.Add(p => p.Invalid, true); + parameters.Add(p => p.Description, "That code is not correct."); + }); + + Assert.AreEqual("That code is not correct.", com.Find(".bit-otp-sts").TextContent); + + // Neither state is on any more, so the region goes quiet and the next rejection is announced again + // even when it comes back with the very same sentence. + com.Render(parameters => + { + parameters.Add(p => p.Invalid, false); + }); + + Assert.AreEqual(string.Empty, com.Find(".bit-otp-sts").TextContent); + } + + [TestMethod] + public void BitOtpInputShouldLeaveTheAnnouncementOfADescriptionTemplateToItsOwnMarkup() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Description, "That code is not correct."); + parameters.Add(p => p.DescriptionTemplate, (RenderFragment)(builder => builder.AddContent(0, "Resend in 30s"))); + parameters.Add(p => p.Invalid, true); + }); + + Assert.AreEqual(string.Empty, com.Find(".bit-otp-sts").TextContent); + } + + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitOtpInputFullWidthTest(bool fullWidth) + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.FullWidth, fullWidth); + }); + + var bitOtpInput = com.Find(".bit-otp"); + + Assert.AreEqual(fullWidth, bitOtpInput.ClassList.Contains("bit-otp-fwi")); + } + + [TestMethod] + public void BitOtpInputParamsShouldHaveCorrectParamName() + { + var paramName = BitOtpInputParams.ParamName; + var expectedName = $"{nameof(BitParams)}.{nameof(BitOtpInput)}"; + + Assert.AreEqual(expectedName, paramName); + } + + [TestMethod] + public void BitOtpInputParamsShouldImplementIBitComponentParams() + { + var @params = new BitOtpInputParams(); + + Assert.IsInstanceOfType(@params); + Assert.AreEqual(BitOtpInputParams.ParamName, @params.Name); + } + + [TestMethod] + public void BitOtpInputShouldApplyCascadingParametersFromBitParams() + { + var paramsList = new List + { + new BitOtpInputParams + { + Length = 6, + Label = "Cascaded label", + Accent = BitColor.Success, + Size = BitSize.Large, + Variant = BitVariant.Fill, + Merged = true, + Vertical = true, + FullWidth = true, + Placeholder = "0", + SingleTabStop = true, + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + var root = com.Find(".bit-otp"); + + Assert.IsTrue(root.ClassList.Contains("bit-otp-suc")); + Assert.IsTrue(root.ClassList.Contains("bit-otp-lg")); + Assert.IsTrue(root.ClassList.Contains("bit-otp-fil")); + Assert.IsTrue(root.ClassList.Contains("bit-otp-mrg")); + Assert.IsTrue(root.ClassList.Contains("bit-otp-vrt")); + Assert.IsTrue(root.ClassList.Contains("bit-otp-fwi")); + + Assert.AreEqual("Cascaded label", com.Find(".bit-otp-lbl").TextContent.Trim()); + + var inputs = com.FindAll(".bit-otp-inp"); + + Assert.AreEqual(6, inputs.Count); + Assert.AreEqual("0", inputs[0].GetAttribute("placeholder")); + Assert.AreEqual("-1", inputs[1].GetAttribute("tabindex")); + } + + [TestMethod] + public void BitOtpInputDirectParametersShouldOverrideCascadingParameters() + { + var paramsList = new List + { + new BitOtpInputParams + { + Length = 6, + Accent = BitColor.Success, + Size = BitSize.Large, + Variant = BitVariant.Fill, + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitOtpInput.Length), 4); + builder.AddAttribute(2, nameof(BitOtpInput.Accent), BitColor.Error); + builder.CloseComponent(); + }); + }); + + var root = com.Find(".bit-otp"); + + // What the component wrote for itself wins over the cascade. + Assert.AreEqual(4, com.FindAll(".bit-otp-inp").Count); + Assert.IsTrue(root.ClassList.Contains("bit-otp-err")); + + // What it left unset is still filled in from the cascade, parameter by parameter. + Assert.IsTrue(root.ClassList.Contains("bit-otp-lg")); + Assert.IsTrue(root.ClassList.Contains("bit-otp-fil")); + } + + [TestMethod] + public async Task BitOtpInputShouldApplyTheCascadedRestrictionsToTheTypedCharacters() + { + // The rules that decide which characters a code may hold are exactly what a BitParams is there to + // carry, so they have to reach the filtering and not only the rendering. + var paramsList = new List + { + new BitOtpInputParams + { + Length = 4, + Uppercase = true, + Pattern = "^[A-Z]$", + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + var otpInput = com.FindComponent(); + + await otpInput.FindAll(".bit-otp-inp")[0].InputAsync(new ChangeEventArgs { Value = "a" }); + + Assert.AreEqual("A", otpInput.Instance.Value); + + await otpInput.FindAll(".bit-otp-inp")[1].InputAsync(new ChangeEventArgs { Value = "1" }); + + Assert.AreEqual("A", otpInput.Instance.Value); + } + + [TestMethod] + public void BitOtpInputShouldApplyTheCascadedParametersOfTheInputBase() + { + // ReadOnly and Required are declared by BitInputBase rather than by the component, and they are taken + // out of the ParameterView before either of the sets that HasNotBeenSet reads is filled, so a cascade + // reaching them is what this covers. + var paramsList = new List + { + new BitOtpInputParams + { + Length = 4, + ReadOnly = true, + Required = true, + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + var root = com.Find(".bit-otp"); + + Assert.IsTrue(root.ClassList.Contains("bit-otp-rdl")); + Assert.IsTrue(root.ClassList.Contains("bit-otp-req")); + + var inputs = com.FindAll(".bit-otp-inp"); + + Assert.AreEqual(4, inputs.Count); + + for (int i = 0; i < inputs.Count; i++) + { + Assert.IsTrue(inputs[i].HasAttribute("readonly")); + Assert.IsTrue(inputs[i].HasAttribute("required")); + } + } + + [TestMethod] + public void BitOtpInputShouldKeepItsOwnInputBaseParametersOverTheCascadedOnes() + { + // The whole point of the third assigned-parameter set: a component that writes ReadOnly="false" for + // itself has written it, so the cascade must not turn it back on. + var paramsList = new List + { + new BitOtpInputParams + { + ReadOnly = true, + Required = true, + } + }; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, paramsList); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(BitOtpInput.ReadOnly), false); + builder.AddAttribute(2, nameof(BitOtpInput.Required), false); + builder.CloseComponent(); + }); + }); + + var root = com.Find(".bit-otp"); + + Assert.IsFalse(root.ClassList.Contains("bit-otp-rdl")); + Assert.IsFalse(root.ClassList.Contains("bit-otp-req")); + Assert.IsFalse(com.Find(".bit-otp-inp").HasAttribute("readonly")); + Assert.IsFalse(com.Find(".bit-otp-inp").HasAttribute("required")); + } + + [TestMethod] + public void BitOtpInputShouldNotCascadeTheValueOfASingleField() + { + // Value and DefaultValue identify one field, so they are deliberately not part of the params class: + // a code shared by every input under the cascade is never what a consumer means. (The Name of the + // component is not checked here because the params class carries a Name of its own, the discriminator + // that IBitComponentParams is resolved by.) + var properties = typeof(BitOtpInputParams).GetProperties(); + + Assert.IsNull(Array.Find(properties, p => p.Name == nameof(BitOtpInput.Value))); + Assert.IsNull(Array.Find(properties, p => p.Name == nameof(BitOtpInput.DefaultValue))); + } + + [TestMethod] + public void BitOtpInputParamsShouldCarryEveryPlainParameterOfTheComponent() + { + // A parameter added to the component without its counterpart here is one a BitParams cascade silently + // ignores. Callbacks, templates and the cascade itself are the ones deliberately left out. + var paramsProperties = typeof(BitOtpInputParams).GetProperties().Select(p => p.Name).ToHashSet(); + + var missing = typeof(BitOtpInput).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(p => p.GetCustomAttribute() is not null) + .Where(p => p.PropertyType.Name.StartsWith("EventCallback") is false) + .Where(p => p.PropertyType.Name.StartsWith("RenderFragment") is false) + .Select(p => p.Name) + .Where(n => paramsProperties.Contains(n) is false) + .ToList(); + + CollectionAssert.AreEqual(new List(), missing, string.Join(", ", missing)); + } + + [TestMethod] + public async Task BitOtpInputShouldSubmitTheFormOnFillWhenAutoSubmitIsEnabled() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 2); + parameters.Add(p => p.AutoSubmit, true); + }); + + await com.FindAll(".bit-otp-inp")[0].InputAsync(new ChangeEventArgs { Value = "1" }); + Assert.AreEqual(0, Context.JSInterop.Invocations["BitBlazorUI.OtpInput.submit"].Count); + + await com.FindAll(".bit-otp-inp")[1].InputAsync(new ChangeEventArgs { Value = "2" }); + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.OtpInput.submit"].Count); + + // Retyping a character of the complete code keeps the very same code, which is not submitted twice. + await com.FindAll(".bit-otp-inp")[1].InputAsync(new ChangeEventArgs { Value = "2" }); + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.OtpInput.submit"].Count); + } + + [TestMethod] + public async Task BitOtpInputShouldSubmitAfterTheOnFillCallback() + { + var submitsSeenByOnFill = -1; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 1); + parameters.Add(p => p.AutoSubmit, true); + parameters.Add(p => p.OnFill, (string? _) => submitsSeenByOnFill = Context.JSInterop.Invocations["BitBlazorUI.OtpInput.submit"].Count); + }); + + await com.Find(".bit-otp-inp").InputAsync(new ChangeEventArgs { Value = "1" }); + + Assert.AreEqual(0, submitsSeenByOnFill); + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.OtpInput.submit"].Count); + } + + [TestMethod] + public async Task BitOtpInputShouldNotSubmitOnFillWithoutAutoSubmit() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Length, 1); + }); + + await com.Find(".bit-otp-inp").InputAsync(new ChangeEventArgs { Value = "1" }); + + Assert.AreEqual(0, Context.JSInterop.Invocations["BitBlazorUI.OtpInput.submit"].Count); + } + + [TestMethod] + public async Task BitOtpInputShouldTakeAutoSubmitFromTheCascade() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Parameters, new List { new BitOtpInputParams { Length = 1, AutoSubmit = true } }); + parameters.AddChildContent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + }); + + await com.Find(".bit-otp-inp").InputAsync(new ChangeEventArgs { Value = "1" }); + + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.OtpInput.submit"].Count); + } }