Blazor Custom Input Number Component

Blazor Custom Input Number Component

Introduction

Recently I have started learning Blazor

As a beginner, in this new .Net web framework, I ran into a simple problem very quickly.

How to enable to user of a web application to enter and view numerical values in the format that is most convenient for him?

After studying documentation, I have learned that it is necessary to create custom component, that will replace the basic input component of type number, with extended functionalities such as:

  • Entering and displaying numbers in user-defined number format
  • Preventing user to enter anything else but a number value
  • Show appropriate message to user if value is required
  • Change color of component border based on validity of entered value

It took me a while to develop this custom component that resolves above prerequisites. The image above shows how the applied component looks like with entered numbers in different culture formats. For beginners like me, at the beginning of this article is a link to download the complete project with an example of how to use the component. Download it and study the code thoroughly. For advanced users, below is the program code of the component.

@using System.Globalization
@using System.Numerics
@using System.Text.RegularExpressions

<input class="form-control @TextAllign @Border"
       type="text"
       inputmode="numeric"
       id=@ID
       name=@Name
       disabled=@Disabled
       step=@(Step==decimal.Zero ? "any" : Step?.ToString())
       min=@MinValue?.ToString()
       max=@MaxValue?.ToString()
       value=@_stringNumberValue
       placeholder=@Placeholder
       autocomplete=@AutoComplete
       required=@ShowStandardRequiredMessage
       @onchange=@OnValueChange
       @onkeydown=@OnKeyDown @onkeydown:preventDefault=@_preventDefault
       @oninput=@OnInputValue
       @onpaste=@OnPaste />


@if( string.IsNullOrWhiteSpace( _stringNumberValue ) && Required == true )
{
    <div class="validation-message">@RequiredValueValidationMessage</div>
}

@code
{
    /// <summary>
    /// This component allows to User
    /// to enter any number from the set of real numbers
    /// within decimal min max range.
    /// </summary>

    #region-   Culture  Parameters -

    /// <summary>
    /// Used for formating number value.
    /// If not set , gets CurrenCulture from current system settings.
    /// </summary>
    [Parameter]
    public CultureInfo Culture { get; set; } = CultureInfo.CurrentCulture;
    #endregion

    #region-   Number Value Parameters   -

    /// <summary>
    /// Event is raised when number value is changed.
    /// </summary>
    [Parameter]
    public EventCallback<decimal?> NumberValueChanged { get; set; }

    /// <summary>
    /// Helper
    /// </summary>
    private decimal? _numberValue = null;

    /// <summary>
    /// Entered or selected decimal value.
    /// </summary>
    [Parameter]
    public decimal? NumberValue
    {
        get => _numberValue;
        set
        {
            if( _numberValue != value )
            {
                _numberValue = value;

                if( _numberValue != null )
                {
                    _stringNumberValue = _numberValue?.ToString( NumberFormat, Culture );
                }
                else
                {
                    _stringNumberValue = null;
                }

                // Set current string value
                _currentNumberValue = _stringNumberValue;

                // Invoke the delegate passing changed value
                if( NumberValueChanged.HasDelegate )
                {
                    NumberValueChanged.InvokeAsync( value );
                }
            }
        }
    }

    /// <summary>
    /// String representation of
    /// entered or selected decimal value.
    /// This value is formated and displayed to User.
    /// Initially set to null.
    /// </summary>
    private string? _stringNumberValue { get; set; } = null;

    #endregion

    #region-   Atribute Parameters   -

    /// <summary>
    /// Component ID
    /// </summary>
    public string? ID { get; set; } = Guid.NewGuid().ToString();

    /// <summary>
    /// Conmponent Name
    /// </summary>
    public string? Name { get; set; } = Guid.NewGuid().ToString();

    /// <summary>
    /// Flag for enable / disable component.
    /// Initially set to false,  component is enabled.
    /// </summary>
    [Parameter]
    public bool? Disabled { get; set; } = false;

    /// <summary>
    /// Step , increase / decrease step value for up / down selection arrows.
    /// Initially set to decimal.Zero => "any" , meaning that User can enter any
    /// decimal value in between Min and Max value ,
    /// number of decimal places are set in parameter NumberFormat or DecimalPlaces.
    /// If step is set to decimal.One => "1.00" ,
    /// User can practically enter only Integer values.
    /// </summary>
    [Parameter]
    public decimal? Step { get; set; } = decimal.One;

    /// <summary>
    /// Minimal value that can be entered or selected ,
    /// initially set to decimal.MinValue
    /// </summary>
    [Parameter]
    public decimal? MinValue { get; set; } = decimal.MinValue;

    /// <summary>
    /// Maximum value that can be entered or selected ,
    /// initially set to decimal.MaxValue
    /// </summary>
    [Parameter]
    public decimal? MaxValue { get; set; } = decimal.MaxValue;

    /// <summary>
    /// Helper
    /// </summary>
    private string? _numberFormat = null;

    /// <summary>
    /// String representation of number format,
    /// initially set to N format.
    /// If this parameter is set after parameter DecimalPlaces
    /// overrides number of decimal places set by DecimalPlaces.
    /// </summary>
    [Parameter]
    public string? NumberFormat
    {
        get
        {
            if( _numberFormat == null )
            {
                _numberFormat = "N";
            }

            return _numberFormat;
        }
        set
        {
            if( _numberFormat != value )
            {

                _numberFormat = value;

                if( string.IsNullOrWhiteSpace( _numberFormat ) )
                {
                    _numberFormat = "N";
                }
                else if( !string.IsNullOrWhiteSpace( _numberFormat ) && _numberFormat.Length == 1 )
                {
                    if( _numberFormat.StartsWith( "N" ) || _numberFormat.StartsWith( "F" ) )
                    {
                        _numberFormat += DecimalPlaces.ToString();
                    }
                    else
                    {
                        _numberFormat = "N";
                    }
                }
                else if( !string.IsNullOrWhiteSpace( _numberFormat ) && _numberFormat.Length > 1 )
                {
                    if( _numberFormat.StartsWith( "N" ) || _numberFormat.StartsWith( "F" ) )
                    {
                        bool ok = int.TryParse(_numberFormat.Substring(1), out int dp );

                        if( ok )
                        {
                            DecimalPlaces = dp;
                        }
                        else
                        {
                            DecimalPlaces = null;
                        }
                    }
                    else if( _numberFormat.Contains( "." ) )
                    {
                        DecimalPlaces = _numberFormat.Split( "." )[1].Length;
                    }
                    else
                    {
                        _numberFormat = "N";
                    }
                }
            }
        }
    }

    /// <summary>
    /// Helper
    /// </summary>
    private int? _decimalPlaces = null;

    /// <summary>
    /// Number of decimal places,
    /// initially set to default value
    /// for selected Culture Number Format.
    /// If this parammeter is set after parameter NumberFormat
    /// Overrides number of decimal places set by NumberFormat.
    /// </summary>
    [Parameter]
    public int? DecimalPlaces
    {
        get
        {
            if( _decimalPlaces == null )
            {
                _decimalPlaces = Culture.NumberFormat.NumberDecimalDigits;
            }

            return _decimalPlaces;
        }
        set
        {
            if( _decimalPlaces != value )
            {
                _decimalPlaces = value;

                if( !string.IsNullOrWhiteSpace( NumberFormat ) &&
                    (NumberFormat.StartsWith( "N" ) || NumberFormat.StartsWith( "F" )) )
                {
                    NumberFormat = NumberFormat[0] + _decimalPlaces.ToString();
                }
            }
        }
    }

    /// <summary>
    /// Align text inside component text box.
    /// Possible values : text-start , text-center , text-end.
    /// Initially set to text-center.
    /// </summary>
    [Parameter]
    public string? TextAllign { get; set; } = "text-center";

    /// <summary>
    /// Is Required Value flag.
    /// </summary>
    [Parameter]
    public bool? Required { get; set; } = false;

    /// <summary>
    /// Set color of border according to value of Required flag.
    /// If Required, border-danger or border-success according to entered value.
    /// If not Required standard blue.
    /// </summary>
    private string? Border => Required != null
                              ?
                                    Required == true
                                    ? NumberValue == null
                                            ? "border-3 border-danger"
                                            : "border-2 border-success"
                                    : "border-1 border-primary"
                              : string.Empty;

    /// <summary>
    /// Flag for showing Standard Required Pop Up Message for Requiered Value.
    /// Initially set to false, value is not required.
    /// </summary>
    [Parameter]
    public bool? ShowStandardRequiredMessage { get; set; } = false;

    /// <summary>
    /// Validation Message for Requiered Value.
    /// Initially set to "Value in this field is required."
    /// </summary>
    [Parameter]
    public string? RequiredValueValidationMessage { get; set; } =
        "Value in this field is required.";

    /// <summary>
    /// Flag for preventing acceptance of Enter key.
    /// Initially set to false, Enter key is allowed.
    /// </summary>
    [Parameter]
    public bool? PreventEnter { get; set; } = false;

    /// <summary>
    /// Placeholder text.
    /// Initially set to null.
    /// </summary>
    [Parameter]
    public string? Placeholder { get; set; } = null;

    /// <summary>
    /// Flag for enable / disable autocomplete of component,
    /// initially set to false ,  autocomplete Off.
    /// </summary>
    [Parameter]
    public string? AutoComplete { get; set; } = "off";

    #endregion

    #region-   On event handlers   -
    /// <summary>
    /// Regex pattern for allowed number value and format
    /// </summary>
    private Regex numberValuePattern
    {
        get
        {
            string rx = $"^[+-]?(\\d+([{_allowedDecimalSeparatorKey}]\\d+)?)$";

            return new Regex( rx );
        }
    }

    /// <summary>
    /// Regex pattern for allowed current number value and format
    /// </summary>
    private Regex currentNumberValuePattern
    {
        get
        {
            var rx = $"^[-+]?((\\d{{1,{_allowedNumberGroupSizes[0]}}}([{_allowedNumberGroupSeparatorKey}](\\d{{0,{_allowedNumberGroupSizes[0]}}}))*)|" +
                     $"\\d*)([{_allowedDecimalSeparatorKey}]\\d{{0,{DecimalPlaces}}})?$";

            return new Regex( rx );
        }
    }

    /// <summary>
    /// Regex pattern for forbiden number value format
    /// </summary>
    private Regex forbiddenNumberValuePattern
    {
        get
        {
            string rx = string.Empty;
            if( _allowedNumberGroupSizes.Length == 1 )
            {
                rx = $"([{_allowedNumberGroupSeparatorKey}](\\d{{0,{_allowedNumberGroupSizes[0] - 1}}})[{_allowedNumberGroupSeparatorKey}{_allowedDecimalSeparatorKey}])|" +
                     $"([+-][{_allowedNumberGroupSeparatorKey}{_allowedDecimalSeparatorKey}]|" +
                     $"^[{_allowedNumberGroupSeparatorKey}{_allowedDecimalSeparatorKey}])";
            }
            else if( _allowedNumberGroupSizes.Length == 2 )
            {
                rx = $"((\\d{{3}})[{_allowedNumberGroupSeparatorKey}])|" +
                     $"([{_allowedNumberGroupSeparatorKey}](\\d{{0,{_allowedNumberGroupSizes[1] - 1}}})[{_allowedNumberGroupSeparatorKey}{_allowedDecimalSeparatorKey}])|" +
                     $"([{_allowedNumberGroupSeparatorKey}](\\d{{0,{_allowedNumberGroupSizes[0] - 1}}})[{_allowedDecimalSeparatorKey}])|" +
                     $"([+-][{_allowedNumberGroupSeparatorKey}{_allowedDecimalSeparatorKey}]|" +
                     $"^[{_allowedNumberGroupSeparatorKey}{_allowedDecimalSeparatorKey}])";
            }

            return new Regex( rx );
        }
    }


    /// <summary>
    /// Task checks if receved string parameter
    /// is valid decimal value. 
    /// If value is valid, returns decimal value parsed from string
    /// and if not returns null.
    /// </summary>
    /// <param name="number"></param>
    private async Task<decimal?> ReturnValidDecimalNullableValue( string? number )
    {
        // Check if number is null
        if( string.IsNullOrWhiteSpace( number ) )
        {
            return null;
        }

        // Remove space
        number = number.Trim();

        // Remove number group separator
        number = number.Replace( _allowedNumberGroupSeparatorKey, string.Empty );

        // Check if number is null
        if( string.IsNullOrWhiteSpace( number ) )
        {
            return null;
        }

        if( !numberValuePattern.IsMatch( number ) )
        {
            return null;
        }


        string integerPart = string.Empty;
        string decimalPart = string.Empty;

        if( number.Contains( _allowedDecimalSeparatorKey ) )
        {
            string sign = string.Empty;

            if( number.StartsWith( _allowedNegativeSignKey ) ||
                number.StartsWith( _allowedPositiveSignKey ) )
            {
                sign = number.Substring( 0, 1 );

                number = number.Remove( 0, 1 );
            }

            string[] numberParts = number.Split(_allowedDecimalSeparatorKey);

            integerPart = sign + (numberParts[0].Count() == 0 ? "0" : numberParts[0]);
            decimalPart = numberParts[1].Count() == 0 ? "0" : numberParts[1];

            // Set correct number of decimal places
            if( decimalPart.Length > DecimalPlaces.GetValueOrDefault() )
            {
                decimalPart = decimalPart.Substring( 0, DecimalPlaces.GetValueOrDefault() );
            }

            // Set new value to number
            number = integerPart + _allowedDecimalSeparatorKey + decimalPart;
        }
        else
        {
            integerPart = number;
        }

        BigInteger max = (BigInteger)MaxValue.GetValueOrDefault();
        BigInteger min = (BigInteger)MinValue.GetValueOrDefault();

        BigInteger integerNumber = BigInteger.Parse(integerPart);

        // Check if number is inside Min Max Range
        if( integerNumber > max )
        {
            return MaxValue;
        }

        if( integerNumber < min )
        {
            return MinValue;
        }

        // Return parsed number value
        return decimal.Parse( number,
                              NumberStyles.Number,
                              Culture.NumberFormat );
    }

    /// <summary>
    /// Task is called over component attribute @onchange
    /// when component gets focus,
    /// User enters value
    /// and component loses focus.
    /// </summary>
    /// <param name="e"></param>
    private async Task OnValueChange( ChangeEventArgs e )
    {
        await SetChangedValue( e.Value?.ToString() );

        return;
    }

    /// <summary>
    /// Task is called every time when
    /// there is need to set new Number Value.
    /// </summary>
    /// <param name="value"></param>
    private async Task SetChangedValue( string? value )
    {
        // Set changed value
        decimal? changedValue = null;

        changedValue = await ReturnValidDecimalNullableValue( value );

        if( changedValue != null )
        {
            // Refresh / redraw input field with new value
            // If User has entered same value
            // empty input field with Zero or One
            if( changedValue == NumberValue )
            {
                if( NumberValue != decimal.Zero )
                {
                    NumberValue = decimal.Zero;
                }
                else
                {
                    NumberValue = decimal.One;
                }

                // Yield to refresh
                await Task.Yield();

            }
        }
        else
        {
            // Refresh / redraw input field with new value
            NumberValue = decimal.Zero;

            // Yield to refresh
            await Task.Yield();
        }

        // Set new value
        NumberValue = changedValue;

        return;
    }

    #region-   Allowed Keys   -
    /// <summary>
    /// String contains user selected culture number decimal separator.
    /// </summary>
    private string _allowedDecimalSeparatorKey
    {
        get
        {
            return Culture.NumberFormat.NumberDecimalSeparator;
        }
    }

    /// <summary>
    /// String contains user selected culture number group separator.
    /// </summary>
    private string _allowedNumberGroupSeparatorKey
    {
        get
        {
            return Culture.NumberFormat.NumberGroupSeparator;
        }
    }

    /// <summary>
    /// String contains user selected culture number group sizes list.
    /// </summary>
    private int[] _allowedNumberGroupSizes
    {
        get
        {
            return Culture.NumberFormat.NumberGroupSizes;
        }
    }

    /// <summary>
    /// String contains user selected culture number positive sign.
    /// </summary>
    private string _allowedPositiveSignKey
    {
        get
        {
            return Culture.NumberFormat.PositiveSign;
        }
    }

    /// <summary>
    /// String contains user selected culture number negative sign.
    /// </summary>
    private string _allowedNegativeSignKey
    {
        get
        {
            return Culture.NumberFormat.NegativeSign;
        }
    }

    /// <summary>
    /// String contains name of Enter key
    /// </summary>
    private string _allowedEnterKey
    {
        get
        {
            return "Enter";
        }
    }
    #endregion

    /// <summary>
    /// Current string value entered into component.
    /// </summary>
    private string? _currentNumberValue = null;

    /// <summary>
    /// Event handler is called over component attribute @oninput
    /// when component gets focus, enters edit mode,
    /// and User hit any key, after @onkeydown event.
    /// </summary>
    /// <param name="e"></param>
    private async Task OnInputValue( ChangeEventArgs e )
    {
        string? value = e.Value?.ToString();

        if( currentNumberValuePattern.IsMatch( value ) && !forbiddenNumberValuePattern.IsMatch( value ) )
        {
            _currentNumberValue = value;
            _stringNumberValue = _currentNumberValue;
        }
        else
        {
            if( _stringNumberValue == _currentNumberValue )
            {
                if( _stringNumberValue != decimal.Zero.ToString() )
                {
                    _stringNumberValue = decimal.Zero.ToString();
                }
                else
                {
                    _stringNumberValue = decimal.One.ToString();
                }

                // Yield to refresh
                await Task.Yield();
            }

            _stringNumberValue = _currentNumberValue;

        }

        if( _paste )
        {
            await SetChangedValue( _currentNumberValue );

            _paste = false;
        }

        if( _currentNumberValue?.Length == 0 )
        {
            await SetChangedValue( _currentNumberValue );
        }
    }


    /// <summary>
    /// Flag for raised @onpaste event.
    /// </summary>
    private bool _paste = false;

    /// <summary>
    /// Event handler is called over component attribute @onpaste
    /// when user paste some content to component.
    /// </summary>
    /// <param name="e"></param>
    private void OnPaste( ClipboardEventArgs e )
    {
        _paste = true;
    }


    /// <summary>
    /// Flag for preventing input of some entered keys
    /// over @onkeydown:preventDefault event
    /// </summary>
    private bool _preventDefault = false;

    /// <summary>
    /// Event handler is called over component attribute @onkeydown
    /// when component gets focus, enters edit mode,
    /// and User hit any key, this event is raised
    /// before @oninput event.
    /// </summary>
    /// <param name="e"></param>
    private void OnKeyDown( KeyboardEventArgs e )
    {
        // Prevent input of not allowed Keys
        _preventDefault = false;

        // Prevent Enter key
        if( e.Key == _allowedEnterKey )
        {
            _preventDefault = PreventEnter ?? false;

            return;
        }

        // On arrow up,
        // increase value by step value
        if( e.Key == "ArrowUp" )
        {
            if( NumberValue == null )
            {
                NumberValue = 0M;
            }

            else if( NumberValue <= MaxValue - Step )
            {
                NumberValue += Step;
            }

            return;
        }

        // On arrow down,
        // decrease value by step value
        if( e.Key == "ArrowDown" )
        {
            if( NumberValue == null )
            {
                NumberValue = 0M;
            }

            else if( NumberValue >= MinValue + Step )
            {
                NumberValue -= Step;
            }

            return;
        }

        return;
    }
    #endregion
}

Conclusion

This component is far from a perfect solution and should be viewed as such.


Similar Articles