From 9f04cd02f709ac130b9b1806a7ee82698e683da2 Mon Sep 17 00:00:00 2001 From: msynk Date: Tue, 22 Sep 2026 08:06:28 +0330 Subject: [PATCH 1/7] Improve theme infra of BitOtpInput #13327 --- .../Inputs/OtpInput/BitOtpInput.razor | 16 +- .../Inputs/OtpInput/BitOtpInput.razor.cs | 74 ++- .../Inputs/OtpInput/BitOtpInput.scss | 267 ++++++---- .../Inputs/OtpInput/BitOtpInputParams.cs | 460 ++++++++++++++++++ .../Inputs/OtpInput/BitOtpInputDemo.razor | 408 +++++++++------- .../Inputs/OtpInput/BitOtpInputDemo.razor.cs | 388 +++++++++++---- .../Inputs/OtpInput/BitOtpInputTests.cs | 246 ++++++++++ 7 files changed, 1480 insertions(+), 379 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputParams.cs diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.razor index fa782a2a665..182a752385f 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. *@
} + @* The answer of a server that rejected the code arrives while the focus is usually nowhere near the + boxes, so the error state alone reaches nobody who cannot see it: 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 that moment. 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 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..c962e4501ef 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. /// @@ -76,6 +90,8 @@ 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 is on it also sits in the live region of the component, so a rejection is + /// announced at once rather than waiting for the focus to come back to the code. /// [Parameter] public string? Description { get; set; } @@ -86,6 +102,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 +134,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; } @@ -503,6 +532,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 +553,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 +567,15 @@ protected override void OnParametersSet() ResizeInputs(); } + // The sentence the server answered with, put into the live region while the error state is on and + // taken back out of it as the state is 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. 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 && 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 +944,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. @@ -1015,6 +1061,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); @@ -1278,6 +1335,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 +1395,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) diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss index 51145749fca..864058541e8 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInput.scss @@ -1,43 +1,84 @@ @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-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-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); + 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); 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 +87,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 +98,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 +110,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 +127,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 @@ -97,18 +153,48 @@ font-size: inherit; 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 @@ -121,56 +207,49 @@ //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 +301,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}); } } @@ -311,7 +390,7 @@ //accent color over an error, or a disabled component from keeping its variant colors. .bit-otp .bit-otp-inp { &:focus-visible { - @include focus-ring(var(--bit-otp-clr-focus)); + @include focus-ring(var(--bit-OtpInput-focus-color, var(--bit-otp-clr-focus))); } } @@ -319,25 +398,25 @@ .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}); //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,25 +425,25 @@ //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}); + @include focus-ring(var(--bit-OtpInput-invalid-focus-color, #{$clr-err-focus})); } } } @@ -412,17 +491,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/BitOtpInputParams.cs b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputParams.cs new file mode 100644 index 00000000000..228c5764ae7 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI/Components/Inputs/OtpInput/BitOtpInputParams.cs @@ -0,0 +1,460 @@ +namespace Bit.BlazorUI; + +/// +/// The parameters for component. +/// +public class BitOtpInputParams : BitComponentBaseParams, 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; } + + /// + /// 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. + ///
+ /// . + ///
+ 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; + + UpdateBaseParameters(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 (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/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..105724531f2 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 @@ -3,13 +3,14 @@ + Description="A one-time password (OTP) input for Blazor: a row of single character boxes with auto advancing focus, full keyboard navigation, character masking, a regular expression restricting the code, case folding, digit normalization across numbering systems, grouping separators, copy and paste of the whole code, SMS auto fill through the WebOTP API, an accessible group with a described helper text, a busy state for a code that is being checked, an error state for one the server has rejected, a full width layout for narrow screens, cascading defaults through BitParams, and public CSS variables for everything the parameters do not cover." /> @@ -21,41 +22,32 @@

- 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 whole row behaves like the single field it stands for rather than like a handful of unrelated + boxes. Backspace clears the box it is pressed in and stays there, so the character can be retyped at + once, and only on an already empty box does it delete the character before it and follow it; Delete + clears without moving; the arrow keys walk between the boxes and Home and End jump to the first and + last character of the code. Focusing a box selects what it holds, so typing replaces rather than + appends - which is also why the space bar is refused like any other character no code is made of, + instead of wiping one out invisibly. A click that lands in a gap or on a separator is not swallowed + either: it puts the caret in the box the typing is meant to carry on in.

- 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 code being edited: a disabled + component is dimmed and drops out of the tab order, a read-only one still reads and copies like text, + which is what an already confirmed code should look like. AutoFocus puts the caret in the first + box still left to fill, so a component seeded with a partial code carries on where the typing stopped. + AutoShift makes Backspace and Delete pull the remaining characters one box along instead of + leaving a hole, and BlurOnFill drops the focus on the last character, which gets the virtual + keyboard of a phone out of the way of the button below the code.

- 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. + Sequential keeps the code free of holes: clicking a box past the first empty one moves the + focus to that empty box instead, and a pasted chunk cannot land past it either. It matters because + the value is the characters of the boxes joined together and an empty box contributes nothing, 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, so every one of its characters stays clickable and correctable.


Basic

@@ -191,7 +183,20 @@
Try pasting ۱۲۳۴۵۶ or ١٢٣٤٥٦.
- + +
+ The two ends of what a box can show: Placeholder is the hint in a box that is still empty and + Mask is what a box that has been filled shows in place of the character it holds. Neither of + them touches the value. +
+
+
+ Placeholder 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 shape + like 000000 or ABCDEF can be drawn; any other value is repeated in every + box as is. +
+
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 @@ -207,9 +212,13 @@ same way. Pasting into the boxes keeps working either way.

+ +

+ +



- +



@@ -246,20 +255,7 @@
- -
- 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 @@ -298,7 +294,7 @@ - +
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 @@ -307,6 +303,14 @@ the code rather than the layout, so they always land on the first and the last character of it.

+
+ FullWidth stretches the row across the width it is given and lets the boxes share it evenly, + instead of drawing them at the fixed width of their Size. 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 + around it on a sign-in form. Only the axis the code is laid out on grows: the boxes keep the height + of their size class, and a Vertical row stretches each box across the column instead. +
+


@@ -314,9 +318,16 @@

+

+
+ +
+ +
- +
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 @@ -358,38 +369,34 @@ - -
- 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. + The boxes are wrapped in a group named after the Label, or after AriaLabel + when there is none, so the purpose of the code is announced once instead of once per box, and + described by the Description under them, so the sentence saying where the code was sent is + announced with it rather than being left to sighted users. Each box is then named after its own + position, 1 of 6 by default; InputAriaLabelFormat is the composite format behind that + name - {0} the one based position, {1} the Length - and is what + localizes it.

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. + the first character stays reachable with Tab and the rest are left to the auto advancing focus, the + arrow keys and the mouse, so a keyboard user tabs past the code in one press instead of six. The + boxes still take the focus on a click and through FocusAsync. TabIndex places the + component itself somewhere in the tab order and composes with it rather than fighting it: a box + taken out of the 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 document order for every user.

- 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 answer of a server that rejected the code arrives while the focus is usually nowhere near the + boxes, and aria-invalid is only announced once the focus lands on one of them. The + component therefore carries a live region of its own, which holds the Description while + Invalid is on - the status message that WCAG 2.2 SC 4.1.3 asks for. Only text is announced + this way: a DescriptionTemplate is markup, so a countdown or a "resend" link put in one keeps + whatever aria-live you give it and nothing more.

@@ -413,70 +420,57 @@ Description="Enter the code from the text message we sent to +1 555 0100." /> - -
- 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. + A one-time code is copied far more often than it is typed, and all three ways it arrives in one go + end in the same place: autocomplete="one-time-code" on every box, which is what makes + iOS and Safari offer the code that just arrived by SMS; the WebOTP API, which the component asks the + browser for itself where it exists; and a paste anywhere in the row.

- 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. + An arriving code is cleaned up before it lands. Whitespace and the characters that the Type or + the Pattern rejects are dropped rather than the whole paste being refused, so a code copied as + 123-456, wrapped over two lines, or surrounded by the words of the message still fills + the boxes; Uppercase, Lowercase and NormalizeDigits apply to it too. The + characters that carry no glyph at all go whatever the Type and the Pattern say - the + bidi marks around the digits of a Persian or Arabic message, zero width joiners, the byte order mark + a few clipboards prepend, the control characters - since letting an invisible character through hands + the server a code the user cannot see and it never issued.

- 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. + Where the code lands depends on its length: one that fills the component starts at the first box + whichever one received it, since anything else would drop its leading characters; a shorter one is + inserted where it was pasted, replaces only as many characters as it brings and moves the focus to + the first box left to fill; a longer one keeps its first Length characters. Sequential + applies here as well and keeps a pasted chunk from landing past the first empty box.

- 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. + Per-character filtering cannot pull a code of letters out of the text around it - the letters + of the words match just as well, so your code is A1B2C3 would fill the boxes with + YOURCODEI. PasteTransformer is the way out: a function applied to the whole chunk + before anything else is done with it, so one regular expression picks the code out of the sentence. + Returning an empty string rejects the chunk and raises OnInvalid; a single typed character + never reaches it.

- 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. + The way back out is handled too. Each box is an input of its own, so a copy would otherwise hand over + the single character it holds; the whole code goes on the clipboard instead, from whichever box the + copy is made, and a cut does the same and empties the boxes afterwards. A code that is not shown is + left alone, with a Mask or a Password Type, for the same reason a password input + refuses to be copied at all.

- 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. + NoSmsAutoFill turns the SMS routes off for a code that never arrives by SMS, such as one read + from an authenticator app, and reaches the password managers with them. An autocomplete + of off is a request the extensions deliberately ignore - it is what a site refusing to + work with them looks like - so the attributes 1Password, LastPass, Bitwarden and Dashlane read + instead are rendered along with it. They are not rendered otherwise, since offering the code from the + vault that issued it is precisely what a password manager is for.

Try pasting 123 456, 12-34-56 or your code is 123456:
@@ -494,11 +488,11 @@
- +
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, + 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. @@ -511,11 +505,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 + 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.
@@ -566,7 +560,7 @@
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 @@ -581,7 +575,7 @@ 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 + IsLoading, since the busy state belongs to the very code that is about to be cleared and switched off again.

@@ -595,7 +589,7 @@
- +
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 @@ -628,73 +622,50 @@
- +
- 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. + Entering a one-time code is a round trip, and these are its two halves. A one-time code is almost + never wrong in a way a validator can see - it is the right length and made of the right characters, + and only the server that issued it knows that it has expired or does not match - so the wait for + that answer and the answer itself are both states of the component rather than of an + EditContext.

- 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. + IsLoading is the middle of the 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, marks the group + with aria-busy so it is announced rather than only drawn, and holds the code still + 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.

- 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. -
-
- -
- - 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. + Invalid is the answer that came back rejected, painted onto the boxes in the error color and + marked with aria-invalid. It is independent of the validation of an EditForm, + which renders the very same state on its own, so the two work together: data annotations catch a code + that is too short before it is ever sent, Invalid catches the one that was sent and refused. + Clearing the boxes and putting the caret back in the first of them, which is what Clear and + FocusAsync do together, is its usual companion.

- 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. + 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. That sentence is both drawn + under the boxes and announced: the group references it through aria-describedby, and while + Invalid is on it also sits in the component's own live region, so the rejection reaches a + screen reader user right away instead of waiting for the focus to come back to the code. + Colour is therefore never the only carrier of the message. Only the plain Description is + announced this way; a DescriptionTemplate is markup rather than text, so a countdown or a + "resend the code" link put in one keeps whatever aria-live you give it and nothing more.

- The three states are meant to be used together and in this order: OnFill submits the code and + The order the three are used in is the order of the round trip: 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. + switches Invalid on along with the sentence to show for it. The bar follows the Accent + color, and keeps to the error color while a rejected code is being checked again.

- +
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 @@ -723,7 +694,39 @@ - + +
+ BitParams carries a BitOtpInputParams down to every otp input under it, so a sign-in + flow sets the shape of its codes once - the length, the accepted characters, the keyboard, the + layout - instead of repeating it on every screen that asks for one. What it carries is a default + and not an override: a component that writes a parameter for itself keeps its own value, and only + what it left unset is filled in from the cascade, parameter by parameter. +
+
+
+ It is the natural home of the rules that belong to the application rather than to one field: + Length and Type, which the server that issues the code decides, Pattern and + Uppercase, NormalizeDigits for the numbering systems your users write in, and + PasteTransformer, which is the one regular expression that pulls your codes out of the + messages they arrive in. +
+
+
+ + +
+ +
+ +
+
+
+
+ +
+
+ +
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 @@ -747,7 +750,7 @@ - +
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 @@ -761,7 +764,7 @@ - +
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, @@ -798,9 +801,42 @@ Focused = "custom-focused", Separator = "custom-separator" })" />
+

+
+ What the parameters do not cover, the public + CSS variables do. They are read off the root and + they inherit, so a value on :root or on any ancestor re-skins every code entry below + it, and one on the Style of an instance re-skins that one alone - without a selector against + the internal class names, which are not part of the API. +
+
+
+ --bit-OtpInput-filled-background and --bit-OtpInput-filled-border-color are the pair + with no parameter behind them: they paint the boxes that already hold a character, which is what + turns the row into its own progress indicator. +
+
+
+ +
+ +
+ +
+
+
Set once on an ancestor, inherited by every code entry 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 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..392bd83b99d 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 @@ -50,14 +50,21 @@ 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 = "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. While Invalid is on it also sits in the live region of the component, so a rejection is announced at once rather than waiting for the focus to come back to the code.", }, 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 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. It is markup rather than text, so it is never copied into the live region that announces a rejected code: whatever aria-live it carries is its own.", + }, + new() + { + Name = "FullWidth", + Type = "bool", + DefaultValue = "false", + Description = "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 Size. Only the axis the code is laid out on grows: the inputs keep the height of their size class, and a Vertical row stretches each input across the column instead.", }, new() { @@ -80,7 +87,7 @@ 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 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, puts the Description into the live region of the component so that the rejection is announced at the moment it arrives, and a failing validation of an EditContext still shows the very same state on its own.", }, new() { @@ -549,6 +556,196 @@ 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-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-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() @@ -579,6 +776,22 @@ public partial class BitOtpInputDemo + 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, "[0-9]{6}").Value, + } + ]; + + + private string? normalizeDigitsValue; private string? maskValue; @@ -625,32 +838,6 @@ 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) - { - // 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 bool isLoading; private bool loadingInvalid; private BitOtpInput? loadingOtpInput; @@ -744,9 +931,13 @@ private async Task HandleLoadingDemoRetry() private string? normalizeDigitsValue;"; private readonly string example4RazorCode = @" + + + + - + @@ -765,11 +956,6 @@ private async Task HandleLoadingDemoRetry() "; private readonly string example6RazorCode = @" - - -"; - - private readonly string example7RazorCode = @" @@ -784,13 +970,20 @@ private async Task HandleLoadingDemoRetry() "; - private readonly string example8RazorCode = @" + private readonly string example7RazorCode = @" -"; + - private readonly string example9RazorCode = @" +
+ + + +
"; + + private readonly string example8RazorCode = @" "; - private readonly string example10RazorCode = @" + private readonly string example9RazorCode = @" @@ -818,7 +1011,7 @@ private async Task HandleLoadingDemoRetry() "; - private readonly string example11RazorCode = @" + private readonly string example10RazorCode = @"
Value: @pasteValue
@@ -828,21 +1021,21 @@ private async Task HandleLoadingDemoRetry()
Value: @transformedPasteValue
"; - private readonly string example11CsharpCode = @" + private readonly string example10CsharpCode = @" private string? pasteValue; private string? transformedPasteValue;"; - private readonly string example12RazorCode = @" + private readonly string example11RazorCode = @" "; - private readonly string example12CsharpCode = @" + private readonly string example11CsharpCode = @" private string? oneWayValue; private string? twoWayValue;"; - private readonly string example13RazorCode = @" + private readonly string example12RazorCode = @" onChangeValue = v"" />
OnChange value: @onChangeValue
@@ -872,7 +1065,7 @@ private async Task HandleLoadingDemoRetry() onPasteArgs = args"" />
Focus type: @onPasteArgs?.Event.Type
Input index: @onPasteArgs?.Index
"; - private readonly string example13CsharpCode = @" + private readonly string example12CsharpCode = @" private string? onChangeValue; private string? onFillValue; private (string Value, int Index)? onInvalidArgs; @@ -882,7 +1075,7 @@ private async Task HandleLoadingDemoRetry() private (KeyboardEventArgs Event, int Index)? onKeyDownArgs; private (ClipboardEventArgs Event, int Index)? onPasteArgs;"; - private readonly string example14RazorCode = @" + private readonly string example13RazorCode = @" @@ -891,7 +1084,7 @@ private async Task HandleLoadingDemoRetry() apiOtpInput?.BlurAsync()"">Blur Clear "; - private readonly string example14CsharpCode = @" + private readonly string example13CsharpCode = @" private BitOtpInput? apiOtpInput; private async Task HandleClearClick() @@ -902,7 +1095,7 @@ private async Task HandleClearClick() await apiOtpInput.FocusAsync(); }"; - private readonly string example15RazorCode = @" + private readonly string example14RazorCode = @" - - - - - - validationOtpInputModel.OtpValue"" /> - - Submit -"; - 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 ValidationOtpInputModel validationOtpInputModel = new(); - -private void HandleValidSubmit() { } -private void HandleInvalidSubmit() { }"; - - 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) -{ - // 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 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/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/Tests/Bit.BlazorUI.Tests/Components/Inputs/OtpInput/BitOtpInputTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Inputs/OtpInput/BitOtpInputTests.cs index 0b27877c82c..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; @@ -3094,4 +3096,91 @@ public void BitOtpInputShouldNotCascadeTheValueOfASingleField() 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); + } }