mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-02-04 05:35:44 +00:00
649 lines
22 KiB
C#
649 lines
22 KiB
C#
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Web;
|
|
using Microsoft.Extensions.Primitives;
|
|
using Microsoft.JSInterop;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
using System.Linq;
|
|
using System.Globalization;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace Radzen.Blazor
|
|
{
|
|
/// <summary>
|
|
/// A numeric input component that allows users to enter numbers with optional increment/decrement buttons and value constraints.
|
|
/// RadzenNumeric supports various numeric types, formatting, min/max validation, step increments, and culture-specific number display.
|
|
/// Provides up/down arrow buttons for incrementing/decrementing the value by a specified step amount.
|
|
/// Supports min/max constraints that are enforced during input and stepping, formatted value display using standard .NET format strings,
|
|
/// and can be configured with or without the up/down buttons. Handles overflow protection and respects the numeric type's natural limits.
|
|
/// </summary>
|
|
/// <typeparam name="TValue">The numeric type of the value. Supports int, long, short, byte, float, double, decimal and their nullable variants.</typeparam>
|
|
/// <example>
|
|
/// Basic integer numeric input with constraints:
|
|
/// <code>
|
|
/// <RadzenNumeric @bind-Value=@quantity TValue="int" Min="1" Max="100" Step="1" />
|
|
/// </code>
|
|
/// Decimal input with custom formatting:
|
|
/// <code>
|
|
/// <RadzenNumeric @bind-Value=@price TValue="decimal" Min="0" Format="c" Placeholder="Enter price" />
|
|
/// </code>
|
|
/// Nullable numeric without increment buttons:
|
|
/// <code>
|
|
/// <RadzenNumeric @bind-Value=@optionalValue TValue="int?" ShowUpDown="false" Placeholder="Optional" />
|
|
/// </code>
|
|
/// </example>
|
|
public partial class RadzenNumeric<TValue> : FormComponentWithAutoComplete<TValue>
|
|
{
|
|
/// <summary>
|
|
/// Gets or sets whether the component should update the bound value immediately as the user types (oninput event),
|
|
/// rather than waiting for the input to lose focus (onchange event).
|
|
/// This enables real-time value updates but may trigger more frequent change events.
|
|
/// </summary>
|
|
/// <value><c>true</c> for immediate updates; <c>false</c> for deferred updates. Default is <c>false</c>.</value>
|
|
[Parameter]
|
|
public bool Immediate { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets additional HTML attributes to be applied to the underlying input element.
|
|
/// This allows passing custom attributes like data-* attributes, aria-* attributes, or other HTML attributes directly to the input.
|
|
/// </summary>
|
|
/// <value>A dictionary of custom HTML attributes.</value>
|
|
[Parameter]
|
|
public IReadOnlyDictionary<string, object>? InputAttributes { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets input reference.
|
|
/// </summary>
|
|
protected ElementReference input;
|
|
|
|
/// <inheritdoc />
|
|
protected override string GetComponentCssClass()
|
|
{
|
|
return GetClassList("rz-numeric").ToString();
|
|
}
|
|
|
|
string GetInputCssClass()
|
|
{
|
|
var textAlignName = Enum.GetName<TextAlign>(TextAlign)?.ToLowerInvariant() ?? "left";
|
|
return GetClassList("rz-numeric-input")
|
|
.Add("rz-inputtext")
|
|
.Add($"rz-text-align-{textAlignName}")
|
|
.ToString();
|
|
}
|
|
|
|
private string GetOnInput()
|
|
{
|
|
var minArg = Min.HasValue ? Min.Value.ToString(CultureInfo.InvariantCulture) : "null";
|
|
var maxArg = Max.HasValue ? Max.Value.ToString(CultureInfo.InvariantCulture) : "null";
|
|
string isNull = IsNullable.ToString().ToLowerInvariant();
|
|
return (Min != null || Max != null) ? $@"Radzen.numericOnInput(event, {minArg}, {maxArg}, {isNull})" : "";
|
|
}
|
|
|
|
private string GetOnPaste()
|
|
{
|
|
var minArg = Min.HasValue ? Min.Value.ToString(CultureInfo.InvariantCulture) : "null";
|
|
var maxArg = Max.HasValue ? Max.Value.ToString(CultureInfo.InvariantCulture) : "null";
|
|
|
|
return (Min != null || Max != null) ? $@"Radzen.numericOnPaste(event, {minArg}, {maxArg})" : "";
|
|
}
|
|
|
|
bool? isNullable;
|
|
bool IsNullable
|
|
{
|
|
get
|
|
{
|
|
if (isNullable == null)
|
|
{
|
|
isNullable = typeof(TValue).IsGenericType && typeof(TValue).GetGenericTypeDefinition() == typeof(Nullable<>);
|
|
}
|
|
|
|
return isNullable.Value;
|
|
}
|
|
}
|
|
|
|
private bool IsNumericType(object? value) => value switch
|
|
{
|
|
sbyte => true,
|
|
byte => true,
|
|
short => true,
|
|
ushort => true,
|
|
int => true,
|
|
uint => true,
|
|
long => true,
|
|
ulong => true,
|
|
float => true,
|
|
double => true,
|
|
decimal => true,
|
|
_ => false
|
|
};
|
|
|
|
#if NET7_0_OR_GREATER
|
|
private TNum SumFloating<TNum>(TNum value1, TNum value2)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(value1);
|
|
ArgumentNullException.ThrowIfNull(value2);
|
|
|
|
var decimalValue1 = (decimal)Convert.ChangeType(value1, TypeCode.Decimal, Culture);
|
|
var decimalValue2 = (decimal)Convert.ChangeType(value2, TypeCode.Decimal, Culture);
|
|
|
|
return (TNum)Convert.ChangeType(decimalValue1 + decimalValue2, typeof(TNum), Culture);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Use native numeric type to process the step up/down while checking for possible overflow errors
|
|
/// and clamping to Min/Max values
|
|
/// </summary>
|
|
/// <typeparam name="TNum"></typeparam>
|
|
/// <param name="valueToUpdate"></param>
|
|
/// <param name="stepUp"></param>
|
|
/// <param name="decimalStep"></param>
|
|
/// <returns></returns>
|
|
private TNum UpdateValueWithStepNumeric<TNum>(TNum valueToUpdate, bool stepUp, decimal decimalStep)
|
|
where TNum : struct, System.Numerics.INumber<TNum>, System.Numerics.IMinMaxValue<TNum>
|
|
{
|
|
var step = TNum.CreateSaturating(decimalStep);
|
|
|
|
if (stepUp && (TNum.MaxValue - step) < valueToUpdate)
|
|
{
|
|
return valueToUpdate;
|
|
}
|
|
if (!stepUp && (TNum.MinValue + step) > valueToUpdate)
|
|
{
|
|
return valueToUpdate;
|
|
}
|
|
|
|
TNum newValue = default(TNum);
|
|
|
|
if (typeof(TNum) == typeof(double) || typeof(TNum) == typeof(double?) ||
|
|
typeof(TNum) == typeof(float) || typeof(TNum) == typeof(float?))
|
|
{
|
|
newValue = SumFloating(valueToUpdate, (stepUp ? step : -step));
|
|
}
|
|
else
|
|
{
|
|
newValue = valueToUpdate + (stepUp ? step : -step);
|
|
}
|
|
|
|
if (Max.HasValue && newValue > TNum.CreateSaturating(Max.Value)
|
|
|| Min.HasValue && newValue < TNum.CreateSaturating(Min.Value)
|
|
|| object.Equals(Value, newValue))
|
|
{
|
|
return valueToUpdate;
|
|
}
|
|
|
|
return newValue;
|
|
}
|
|
#endif
|
|
|
|
async System.Threading.Tasks.Task UpdateValueWithStep(bool stepUp)
|
|
{
|
|
if (Disabled || ReadOnly)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var step = string.IsNullOrEmpty(Step) || Step == "any" ? 1 : decimal.Parse(Step.Replace(",", ".", StringComparison.Ordinal), System.Globalization.CultureInfo.InvariantCulture);
|
|
TValue? newValue;
|
|
|
|
#if NET7_0_OR_GREATER
|
|
if (IsNumericType(Value))
|
|
{
|
|
// cannot call UpdateValueWithStepNumeric directly because TValue is not value type constrained
|
|
Func<dynamic?, bool, decimal, dynamic> dynamicWrapper = (dynamic? value, bool stepUp, decimal step)
|
|
=> UpdateValueWithStepNumeric(value, stepUp, step);
|
|
|
|
newValue = dynamicWrapper(Value, stepUp, step);
|
|
}
|
|
else
|
|
#endif
|
|
{
|
|
var valueToUpdate = ConvertToDecimal(Value);
|
|
|
|
var newValueToUpdate = valueToUpdate + (stepUp ? step : -step);
|
|
|
|
if (Max.HasValue && newValueToUpdate > Max.Value || Min.HasValue && newValueToUpdate < Min.Value || object.Equals(Value, newValueToUpdate))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if ((typeof(TValue) == typeof(byte) || typeof(TValue) == typeof(byte?)) && (newValueToUpdate < 0 || newValueToUpdate > 255))
|
|
{
|
|
return;
|
|
}
|
|
|
|
newValue = ConvertFromDecimal(newValueToUpdate);
|
|
}
|
|
|
|
if(object.Equals(newValue, Value))
|
|
return;
|
|
|
|
Value = newValue!;
|
|
|
|
await ValueChanged.InvokeAsync(Value);
|
|
if (FieldIdentifier.FieldName != null) { EditContext?.NotifyFieldChanged(FieldIdentifier); }
|
|
await Change.InvokeAsync(Value);
|
|
|
|
StateHasChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the value.
|
|
/// </summary>
|
|
/// <value>The value.</value>
|
|
[Parameter]
|
|
public override TValue? Value
|
|
{
|
|
get
|
|
{
|
|
return _value;
|
|
}
|
|
set
|
|
{
|
|
if (!EqualityComparer<TValue>.Default.Equals(value, _value))
|
|
{
|
|
_value = value;
|
|
}
|
|
|
|
stringValue = $"{value}";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the formatted value.
|
|
/// </summary>
|
|
/// <value>The formatted value.</value>
|
|
protected string? FormattedValue
|
|
{
|
|
get
|
|
{
|
|
if (_value != null)
|
|
{
|
|
if (Format != null)
|
|
{
|
|
if (_value is IFormattable formattable)
|
|
{
|
|
return formattable.ToString(Format, Culture);
|
|
}
|
|
decimal decimalValue = ConvertToDecimal(_value);
|
|
return decimalValue.ToString(Format, Culture);
|
|
}
|
|
return _value.ToString();
|
|
}
|
|
else
|
|
{
|
|
return stringValue;
|
|
}
|
|
}
|
|
set
|
|
{
|
|
_ = InternalValueChanged(value);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a value indicating whether this instance has value.
|
|
/// </summary>
|
|
/// <value><c>true</c> if this instance has value; otherwise, <c>false</c>.</value>
|
|
public override bool HasValue
|
|
{
|
|
get
|
|
{
|
|
return Value != null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the format.
|
|
/// </summary>
|
|
/// <value>The format.</value>
|
|
[Parameter]
|
|
public string? Format { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the step.
|
|
/// </summary>
|
|
/// <value>The step.</value>
|
|
[Parameter]
|
|
public string? Step { get; set; }
|
|
|
|
private bool IsInteger()
|
|
{
|
|
var type = typeof(TValue).IsGenericType ? typeof(TValue).GetGenericArguments()[0] : typeof(TValue);
|
|
|
|
switch (Type.GetTypeCode(type))
|
|
{
|
|
case TypeCode.Byte:
|
|
case TypeCode.SByte:
|
|
case TypeCode.UInt16:
|
|
case TypeCode.UInt32:
|
|
case TypeCode.UInt64:
|
|
case TypeCode.Int16:
|
|
case TypeCode.Int32:
|
|
case TypeCode.Int64:
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether is read only.
|
|
/// </summary>
|
|
/// <value><c>true</c> if is read only; otherwise, <c>false</c>.</value>
|
|
[Parameter]
|
|
public bool ReadOnly { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets the maximum allowed text length.
|
|
/// </summary>
|
|
/// <value>The maximum length.</value>
|
|
[Parameter]
|
|
public long? MaxLength { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether up down buttons are shown.
|
|
/// </summary>
|
|
/// <value><c>true</c> if up down buttons are shown; otherwise, <c>false</c>.</value>
|
|
[Parameter]
|
|
public bool ShowUpDown { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the text align.
|
|
/// </summary>
|
|
/// <value>The text align.</value>
|
|
[Parameter]
|
|
public TextAlign TextAlign { get; set; } = TextAlign.Left;
|
|
|
|
/// <summary>
|
|
/// Handles the change event.
|
|
/// </summary>
|
|
/// <param name="args">The <see cref="ChangeEventArgs"/> instance containing the event data.</param>
|
|
protected async System.Threading.Tasks.Task OnChange(ChangeEventArgs args)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(args);
|
|
stringValue = $"{args.Value}";
|
|
await InternalValueChanged(args.Value);
|
|
}
|
|
|
|
string? stringValue;
|
|
async Task SetValue(string? value)
|
|
{
|
|
stringValue = value;
|
|
await InternalValueChanged(value);
|
|
}
|
|
|
|
private string RemoveNonNumericCharacters(object value)
|
|
{
|
|
string valueStr = value as string ?? $"{value}";
|
|
|
|
valueStr = NormalizeDigits(valueStr);
|
|
|
|
if (!string.IsNullOrEmpty(Format))
|
|
{
|
|
string formattedStringWithoutPlaceholder = Format.Replace("#", "", StringComparison.Ordinal).Trim();
|
|
|
|
if (valueStr.Contains(Format, StringComparison.Ordinal))
|
|
{
|
|
string currencyDecimalSeparator = Culture.NumberFormat.CurrencyDecimalSeparator;
|
|
|
|
string[] splitFormatString = formattedStringWithoutPlaceholder.Split(currencyDecimalSeparator);
|
|
string[] splitValueString = valueStr.Split(currencyDecimalSeparator);
|
|
int lengthDifference = splitValueString[0].Length - splitFormatString[0].Length;
|
|
formattedStringWithoutPlaceholder = formattedStringWithoutPlaceholder.PadLeft(formattedStringWithoutPlaceholder.Length + lengthDifference, '0');
|
|
}
|
|
|
|
valueStr = valueStr.Replace(formattedStringWithoutPlaceholder, "", StringComparison.Ordinal);
|
|
}
|
|
|
|
return new string(valueStr.Where(c => char.IsDigit(c) || char.IsPunctuation(c)).ToArray()).Replace("%", "", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static string NormalizeDigits(string input)
|
|
{
|
|
if (string.IsNullOrEmpty(input)) return input;
|
|
|
|
var sb = new System.Text.StringBuilder(input.Length);
|
|
foreach (var ch in input)
|
|
{
|
|
if (char.GetUnicodeCategory(ch) == System.Globalization.UnicodeCategory.DecimalDigitNumber)
|
|
{
|
|
var numeric = (int)char.GetNumericValue(ch); // 0..9
|
|
if (numeric >= 0 && numeric <= 9)
|
|
{
|
|
sb.Append((char)('0' + numeric));
|
|
continue;
|
|
}
|
|
}
|
|
sb.Append(ch);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Gets or sets the function which returns TValue from string.
|
|
/// </summary>
|
|
[Parameter]
|
|
public Func<string, TValue>? ConvertValue { get; set; }
|
|
|
|
private async Task InternalValueChanged(object? value)
|
|
{
|
|
TValue? newValue = default(TValue);
|
|
try
|
|
{
|
|
if (value is TValue typedValue)
|
|
{
|
|
newValue = typedValue;
|
|
}
|
|
else if (ConvertValue != null)
|
|
{
|
|
newValue = ConvertValue($"{value}");
|
|
}
|
|
else if (value != null)
|
|
{
|
|
BindConverter.TryConvertTo<TValue>(RemoveNonNumericCharacters(value), Culture, out TValue? convertedValue);
|
|
newValue = convertedValue;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
newValue = default(TValue)!;
|
|
}
|
|
|
|
if (newValue != null)
|
|
{
|
|
newValue = ApplyMinMax(newValue);
|
|
}
|
|
|
|
stringValue = $"{newValue}";
|
|
|
|
if (EqualityComparer<TValue>.Default.Equals(Value, newValue))
|
|
{
|
|
if (JSRuntime != null)
|
|
{
|
|
await JSRuntime.InvokeAsync<string>("Radzen.setInputValue", input, FormattedValue);
|
|
}
|
|
return;
|
|
}
|
|
|
|
Value = newValue;
|
|
if (!ValueChanged.HasDelegate && JSRuntime != null)
|
|
{
|
|
await JSRuntime.InvokeAsync<string>("Radzen.setInputValue", input, FormattedValue);
|
|
}
|
|
|
|
await ValueChanged.InvokeAsync(Value);
|
|
if (FieldIdentifier.FieldName != null) { EditContext?.NotifyFieldChanged(FieldIdentifier); }
|
|
await Change.InvokeAsync(Value);
|
|
}
|
|
|
|
private TValue? ApplyMinMax(TValue? newValue)
|
|
{
|
|
if (Max == null && Min == null || newValue == null)
|
|
{
|
|
return newValue;
|
|
}
|
|
|
|
if (newValue is IComparable<decimal> c)
|
|
{
|
|
if (Max != null && c.CompareTo(Max.Value) > 0)
|
|
return ConvertFromDecimal(Max.Value);
|
|
if (Min != null && c.CompareTo(Min.Value) < 0)
|
|
return ConvertFromDecimal(Min.Value);
|
|
return newValue;
|
|
}
|
|
|
|
decimal? newValueAsDecimal;
|
|
try
|
|
{
|
|
newValueAsDecimal = ConvertToDecimal(newValue);
|
|
}
|
|
catch
|
|
{
|
|
newValueAsDecimal = default;
|
|
}
|
|
|
|
if (newValueAsDecimal > Max)
|
|
{
|
|
newValueAsDecimal = Max.Value;
|
|
}
|
|
|
|
if (newValueAsDecimal < Min)
|
|
{
|
|
newValueAsDecimal = Min.Value;
|
|
}
|
|
return ConvertFromDecimal(newValueAsDecimal);
|
|
}
|
|
|
|
private decimal ConvertToDecimal(TValue? input)
|
|
{
|
|
if (input == null)
|
|
return default;
|
|
|
|
var converter = TypeDescriptor.GetConverter(typeof(TValue));
|
|
if (converter.CanConvertTo(typeof(decimal)))
|
|
{
|
|
var converted = converter.ConvertTo(null, Culture, input, typeof(decimal));
|
|
return converted != null ? (decimal)converted : decimal.Zero;
|
|
}
|
|
try
|
|
{
|
|
var changed = ConvertType.ChangeType(input, typeof(decimal), Culture);
|
|
return changed != null ? (decimal)changed : decimal.Zero;
|
|
}
|
|
catch
|
|
{
|
|
return decimal.Zero;
|
|
}
|
|
}
|
|
|
|
private TValue? ConvertFromDecimal(decimal? input)
|
|
{
|
|
if (input == null)
|
|
return default(TValue?);
|
|
|
|
var converter = TypeDescriptor.GetConverter(typeof(TValue));
|
|
if (converter.CanConvertFrom(typeof(decimal)))
|
|
{
|
|
var result = converter.ConvertFrom(null, Culture, input);
|
|
return result != null ? (TValue)result : default(TValue)!;
|
|
}
|
|
|
|
var changeTypeResult = ConvertType.ChangeType(input, typeof(TValue), Culture);
|
|
return changeTypeResult != null ? (TValue)changeTypeResult : default(TValue)!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines the minimum value.
|
|
/// </summary>
|
|
/// <value>The minimum value.</value>
|
|
[Parameter]
|
|
public decimal? Min { get; set; }
|
|
|
|
/// <summary>
|
|
/// Determines the maximum value.
|
|
/// </summary>
|
|
/// <value>The maximum value.</value>
|
|
[Parameter]
|
|
public decimal? Max { get; set; }
|
|
|
|
/// <inheritdoc />
|
|
public override async Task SetParametersAsync(ParameterView parameters)
|
|
{
|
|
bool minChanged = parameters.DidParameterChange(nameof(Min), Min);
|
|
bool maxChanged = parameters.DidParameterChange(nameof(Max), Max);
|
|
|
|
await base.SetParametersAsync(parameters);
|
|
|
|
if (minChanged && IsJSRuntimeAvailable)
|
|
{
|
|
await InternalValueChanged(Value);
|
|
}
|
|
|
|
if (maxChanged && IsJSRuntimeAvailable)
|
|
{
|
|
await InternalValueChanged(Value);
|
|
}
|
|
}
|
|
|
|
bool preventKeyPress;
|
|
async Task OnKeyPress(KeyboardEventArgs args)
|
|
{
|
|
var key = args.Code != null ? args.Code : args.Key;
|
|
|
|
if (key == "ArrowUp" || key == "ArrowDown")
|
|
{
|
|
preventKeyPress = true;
|
|
|
|
if (key == "ArrowUp")
|
|
{
|
|
await UpdateValueWithStep(true);
|
|
}
|
|
else
|
|
{
|
|
await UpdateValueWithStep(false);
|
|
}
|
|
|
|
preventKeyPress = false;
|
|
}
|
|
else if (Immediate && args.Key.Length == 1 && char.IsDigit(args.Key[0]) && !args.CtrlKey && !args.AltKey && !args.ShiftKey)
|
|
{
|
|
preventKeyPress = true;
|
|
|
|
if (JSRuntime != null)
|
|
{
|
|
var value = await JSRuntime.InvokeAsync<string>("Radzen.getInputValue", input);
|
|
await SetValue(value);
|
|
}
|
|
|
|
preventKeyPress = false;
|
|
}
|
|
else
|
|
{
|
|
preventKeyPress = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the up button aria-label attribute.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string UpAriaLabel { get; set; } = "Up";
|
|
|
|
/// <summary>
|
|
/// Gets or sets the down button aria-label attribute.
|
|
/// </summary>
|
|
[Parameter]
|
|
public string DownAriaLabel { get; set; } = "Down";
|
|
|
|
/// <summary>
|
|
/// Sets the focus on the input element.
|
|
/// </summary>
|
|
public override async ValueTask FocusAsync()
|
|
{
|
|
await input.FocusAsync();
|
|
}
|
|
}
|
|
}
|