diff --git a/localization/de.json b/localization/de.json index 6c2c77dddaf..b35e87d2a9f 100644 --- a/localization/de.json +++ b/localization/de.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Das Feld {0} muss eine Zahl sein." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Das Feld {0} muss eine Zahl sein." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Ja", "ButtonNoText": "Nein", diff --git a/localization/es.json b/localization/es.json index 47fbc140ca9..d00078ac86f 100644 --- a/localization/es.json +++ b/localization/es.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "El campo {0} debe ser un número." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "El campo {0} debe ser un número." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sí", "ButtonNoText": "No", diff --git a/localization/it.json b/localization/it.json index c94983047d9..2b4d6669470 100644 --- a/localization/it.json +++ b/localization/it.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Il campo {0} deve essere un numero." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Il campo {0} deve essere un numero." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sì", "ButtonNoText": "No", diff --git a/localization/km-KH.json b/localization/km-KH.json index 92c92e067d2..be18274346b 100644 --- a/localization/km-KH.json +++ b/localization/km-KH.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "ទិន្នន័យ {0} ត្រូវ​តែ​ជា​លេខ។" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "ទិន្នន័យ {0} ត្រូវ​តែ​ជា​លេខ។" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "បាទ/ចាស៎", "ButtonNoText": "ទេ", diff --git a/localization/pt-BR.json b/localization/pt-BR.json index 1691acc82c6..5b26fd0668c 100644 --- a/localization/pt-BR.json +++ b/localization/pt-BR.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "O campo {0} deve ser um número." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "O campo {0} deve ser um número." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sim", "ButtonNoText": "Não", diff --git a/localization/pt.json b/localization/pt.json index f9deaab3207..b295e8c559a 100644 --- a/localization/pt.json +++ b/localization/pt.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "O campo {0} deve ser um número." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "O campo {0} deve ser um número." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Sim", "ButtonNoText": "Não", diff --git a/localization/ru-RU.json b/localization/ru-RU.json index c84135954c6..5a06500b52b 100644 --- a/localization/ru-RU.json +++ b/localization/ru-RU.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Поле {0} должно быть числом." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Поле {0} должно быть числом." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Да", "ButtonNoText": "Нет", diff --git a/localization/th-TH.json b/localization/th-TH.json index 8492e394c2a..6cb85f59a5f 100644 --- a/localization/th-TH.json +++ b/localization/th-TH.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "ฟิลด์ {0} ต้องเป็นตัวเลข" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "ฟิลด์ {0} ต้องเป็นตัวเลข" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "ใช่", "ButtonNoText": "ไม่ใช่", diff --git a/localization/uk-UA.json b/localization/uk-UA.json index 25d2f8488ce..02f9abb8ab9 100644 --- a/localization/uk-UA.json +++ b/localization/uk-UA.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "Поле {0} має бути числом." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "Поле {0} має бути числом." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Так", "ButtonNoText": "Ні", diff --git a/localization/zh-TW.json b/localization/zh-TW.json index 2473b581046..7622f7ce3ff 100644 --- a/localization/zh-TW.json +++ b/localization/zh-TW.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "{0}欄位值必須為 Number 類型" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "{0}欄位值必須為 Number 類型" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "確認", "ButtonNoText": "取消", diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor new file mode 100644 index 00000000000..19b0f95d2ca --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -0,0 +1,44 @@ +@page "/input-currency" + +@inject IStringLocalizer Localizer + +

@Localizer["InputCurrencyTitle"]

+

@Localizer["InputCurrencyDescription"]

+

@((MarkupString)Localizer["InputCurrencyMemo"].Value)

+ + +
+
+
+ +
+
+
+
+ +
+
+ + +
@((MarkupString)Localizer["InputCurrencyCultureIntro2"].Value)
+
+
+ +
+
+ +
+
+
+ + + + + + diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs new file mode 100644 index 00000000000..45e55c0a1a7 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Server.Components.Samples; + +/// +/// InputCurrencies 组件 +/// InputCurrencies component +/// +public partial class InputCurrencies +{ + private decimal _value = 1234.56m; + + private decimal? _value2 = 2m; + + private bool _useIsoSymbol; +} diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs index a1f831f0029..a41936e123c 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the Apache 2.0 License // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone diff --git a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs index 7f52c8503b0..2a60a35c3e7 100644 --- a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs +++ b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs @@ -446,6 +446,11 @@ void AddForm(DemoMenuItem item) Url = "input-number" }, new() + { + Text = Localizer["BootstrapInputCurrency"], + Url = "input-currency" + }, + new() { Text = Localizer["BootstrapInputGroup"], Url = "input-group" diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 780ed5a6a19..4ae6bffcf36 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -174,6 +174,7 @@ "BootstrapInput": "BootstrapInput", "BootstrapInputGroup": "BootstrapInputGroup", "BootstrapInputNumber": "BootstrapInputNumber", + "BootstrapInputCurrency": "BootstrapInputCurrency", "BootstrapLabel": "Labels", "Breadcrumb": "Breadcrumb", "Breakpoints": "Breakpoints", @@ -2771,6 +2772,22 @@ "IntersectionObserverIntro": "By setting IsIntersectionObserver=\"true\" to enable lazy loading, the image will not be loaded when it is in the invisible area, and will be loaded only when it is about to be visible.", "IntersectionObserverTitle": "Intersection Observer" }, + "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { + "InputCurrencyCultureIntro": "Specify culture information, for example: en-US, zh-CN", + "InputCurrencyCultureIntro2": "Specify Brazilian Portuguese, for example: pt-BR", + "InputCurrencyCultureTitle": "Culture-specific usage", + "InputCurrencyCustomizeIntro": "Use the Formatter callback to achieve custom format usage", + "InputCurrencyCustomizeTitle": "Custom format usage", + "InputCurrencyDescription": "Only allows standard numeric values and provides formatting capabilities, for example: entering 12345.78 displays as 12,345.78 with thousand separators; as ¥12,345.78 for currency; and as 12,345.78% for percentage.", + "InputCurrencyMemo": "This component is actually an extension of the InputNumbers component, designed to meet the need to display formatted numeric values such as currency and percentages in the UI.", + "InputCurrencyNormalIntro": "Number numeric type displays a text box, automatically bringing up the numeric keyboard on mobile devices", + "InputCurrencyNormalTitle": "Basic usage", + "InputCurrencyPercentIntro": "You can use Formatter or FormatString to achieve percentage display", + "InputCurrencyPercentTitle": "Percentage usage", + "InputCurrencyRangeIntro": "Set Max and Min to control the numeric range 1-10", + "InputCurrencyRangeTitle": "Range limitation usage", + "InputCurrencyTitle": "InputCurrency" + }, "BootstrapBlazor.Server.Components.Samples.InputGroups": { "InputGroupsCheckboxIntro": "Add Checkbox or CheckboxList to the InputGroup", "InputGroupsCheckboxTitle": "Checkbox", @@ -6067,7 +6084,6 @@ "Picture.Required": "{0} is required" }, "BootstrapBlazor.Server.Components.Samples.ValidateForms": { - "AsyncValidationError": "The user name already exists.", "AsyncValidationUserName": "User name", "ChangeButtonText": "Change", "CustomValidationFormComment1": "Add mailbox validation rules", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index 40735fdcb2c..c93bb081f67 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -174,6 +174,7 @@ "BootstrapInput": "输入框 BootstrapInput", "BootstrapInputGroup": "输入组 BootstrapInputGroup", "BootstrapInputNumber": "数字框 BootstrapInputNumber", + "BootstrapInputCurrency": "货币框 BootstrapInputCurrency", "BootstrapLabel": "表单标签", "Breadcrumb": "面包屑 Breadcrumb", "Breakpoints": "断点阈值", @@ -2771,6 +2772,22 @@ "IntersectionObserverIntro": "通过设置 IsIntersectionObserver=\"true\" 开启懒加载特性,当图片在不可见区域时不加载图片,当图片即将可见时才开始加载图片", "IntersectionObserverTitle": "懒加载" }, + "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { + "InputCurrencyCultureIntro": "自行指定文化信息,例如:en-US、zh-CN", + "InputCurrencyCultureIntro2": "指定巴西葡萄牙语,例如:pt-BR", + "InputCurrencyCultureTitle": "指文化用法", + "InputCurrencyCustomizeIntro": "使用 Formatter 回调来实现自定义格式用法", + "InputCurrencyCustomizeTitle": "自定义格式用法", + "InputCurrencyDescription": "仅允许输入标准的数字值,并提供格式化功能,例如:输入12345.78,千分位显示:12,345.78;货币显示:¥12,345.78;百分比显示:12,345.78%", + "InputCurrencyMemo": "本组件,实际上是 InputNumbers 组件的扩展,以满足有需要在 UI 中显示货币、百分比等格式化数值的需求", + "InputCurrencyNormalIntro": "Number 数值类型显示文本框,移动端自动弹出数字键盘", + "InputCurrencyNormalTitle": "基础用法", + "InputCurrencyPercentIntro": "可使用 FormatterFormatString 来实现百分比显示", + "InputCurrencyPercentTitle": "百分比用法", + "InputCurrencyRangeIntro": "设置 Max Min 来控制数值区间范围 1-10", + "InputCurrencyRangeTitle": "区间限制用法", + "InputCurrencyTitle": "InputCurrency 组件" + }, "BootstrapBlazor.Server.Components.Samples.InputGroups": { "InputGroupsCheckboxIntro": "往 InputGroup 里面增加 Checkbox 或者 CheckboxList", "InputGroupsCheckboxTitle": "复选框组合", @@ -6067,7 +6084,6 @@ "Picture.Required": "上传文件不能为空" }, "BootstrapBlazor.Server.Components.Samples.ValidateForms": { - "AsyncValidationError": "用户名已存在", "AsyncValidationUserName": "用户名", "ChangeButtonText": "更改组件", "CustomValidationFormComment1": "增加邮箱验证规则", diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index 4dd9a807ec2..7e5ffbef0cc 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -104,6 +104,7 @@ "iframe": "IFrames", "image-viewer": "ImageViewers", "input-number": "InputNumbers", + "input-currency": "InputCurrencies", "input": "Inputs", "input-group": "InputGroups", "ip": "Ips", @@ -406,6 +407,7 @@ "BootstrapInputEventBase", "BootstrapInputGroup", "BootstrapInputGroupIcon", + "BootstrapInputCurrency", "BootstrapInputNumber", "BootstrapInputNumberBase", "Button", diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor new file mode 100644 index 00000000000..9ff4248a8cb --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -0,0 +1,33 @@ +@namespace BootstrapBlazor.Components +@inherits BootstrapInputBase + +@if (IsShowLabel) +{ + +} + +@if (IsClearable) +{ +
+ @RenderInput + @if (!IsDisabled && !Readonly) + { + + } +
+} +else +{ + @RenderInput +} + +@code { + RenderFragment RenderInput => + @
+ +
; +} diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs new file mode 100644 index 00000000000..17f79e834c0 --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -0,0 +1,161 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Localization; +using System.Globalization; + +namespace BootstrapBlazor.Components; + +/// +/// BootstrapInputCurrency 组件 +/// BootstrapInputCurrency component +/// +public partial class BootstrapInputCurrency +{ + /// + /// 获得/设置 是否为只读,默认为 false + /// Gets or sets whether readonly. Default is false + /// + [Parameter] + public bool Readonly { get; set; } + + /// + /// 获得/设置 最小值 + /// Gets or sets Minimum Value + /// + [Parameter] + public decimal? Min { get; set; } + + /// + /// 获得/设置 最大值 + /// Gets or sets Maximum Value + /// + [Parameter] + public decimal? Max { get; set; } + + /// + /// 获得/设置 数值解析及格式字符串使用的文化信息,默认 null 未设置使用当前 UI 文化信息 + /// Gets or sets the culture used to parse and format numeric values. Default is null, which uses the current UI culture + /// + [Parameter] + public string? CultureName { get; set; } + + /// + /// 获得/设置 是否显示 ISO 货币符号,默认 false 不显示 + /// Gets or sets whether to show ISO currency symbol. Default is false + /// + [Parameter] + public bool IsIsoSymbol { get; set; } + + /// + /// 获得/设置 清空文本框时的回调方法,默认为 null + /// Gets or sets the callback method when clearing text box. Default is null + /// + [Parameter] + public Func? OnClear { get; set; } + + /// + /// 获得/设置 是否显示清空小按钮,默认为 false + /// Gets or sets whether to show clear button. Default is false + /// + [Parameter] + public bool IsClearable { get; set; } + + /// + /// 获得/设置 清空小按钮图标,默认为 null + /// Gets or sets the clear button icon. Default is null + /// + [Parameter] + public string? ClearIcon { get; set; } + + [Inject] + [NotNull] + private IIconTheme? IconTheme { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + private string? ReadonlyString => Readonly ? "true" : null; + + private string? ClearableIconString => CssBuilder.Default("form-control-clear-icon") + .AddClass(ClearIcon) + .Build(); + + private string? _currencySymbol; + + private string? InputClassString => CssBuilder.Default("form-control") + .AddClass(CssClass).AddClass(ValidCss) + .AddClass($"border-{Color.ToDescriptionString()}", Color != Color.None) + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + private string? InputStyleString => string.IsNullOrEmpty(_currencySymbol) ? null : CssBuilder.Default() + .AddClass($"--bb-input-currency-symbol-length: {_currencySymbol.Length};") + .AddClass($"--bb-input-currency-symbol: '{_currencySymbol}';") + .Build(); + + + private CultureInfo? _currentCultureInfo; + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.InputClearIcon); + ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; + + if (string.IsNullOrEmpty(CultureName)) + { + _currentCultureInfo = CultureInfo.CurrentUICulture; + + } + else + { + _currentCultureInfo = CultureInfo.GetCultureInfo(CultureName); + } + + _currencySymbol = IsIsoSymbol ? GetIsoCurrencySymbol(_currentCultureInfo.Name) : _currentCultureInfo.NumberFormat.CurrencySymbol; + } + + private static string GetIsoCurrencySymbol(string cultureName) + { + var regionInfo = new RegionInfo(cultureName); + return regionInfo.ISOCurrencySymbol; + } + + /// + /// + /// + protected override string? FormatParsingErrorMessage() => string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayText); + + /// + /// + /// + protected override async Task OnBlur() + { + var max = Max ?? decimal.MaxValue; + var min = Min ?? decimal.MinValue; + var val = CurrentValue ?? 0; + CurrentValue = Math.Clamp(val, min, max); + + if (OnBlurAsync != null) + { + await OnBlurAsync(Value); + } + } + + private async Task OnClickClear() + { + if (OnClear != null) + { + await OnClear(Value); + } + CurrentValueAsString = ""; + } +} diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss new file mode 100644 index 00000000000..d71c5b5b653 --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss @@ -0,0 +1,17 @@ +.bb-input-currency { + position: relative; + --bb-input-currency-padding-x: 0.75rem; + + &::before { + content: var(--bb-input-currency-symbol, ''); + position: absolute; + top: 50%; + left: var(--bb-input-currency-padding-x); + transform: translateY(-50%); + pointer-events: none; + } + + .form-control { + padding-inline-start: calc(var(--bb-input-currency-symbol-length, 0) * 1em + var(--bb-input-currency-padding-x)) !important; + } +} diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 4a9eecf76cc..380cc8ca98c 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -206,6 +206,62 @@ public static bool TryConvertTo(this string? source, [MaybeNullWhen(fals return ret; } + /// + /// 使用指定文化尝试将字符串表示的值转换为指定类型 + /// Tries to convert the string representation of a value to a specified type using the specified culture + /// + /// + /// + /// + /// + public static bool TryConvertTo(this string? source, CultureInfo culture, [MaybeNullWhen(false)] out TValue val) + { + var type = Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue); + if (type == typeof(string)) + { + val = (TValue)(object)source!; + return true; + } + + if (source == null) + { + val = default!; + return true; + } + + if (source.Length == 0 || !type.IsNumber()) + { + var value = type == typeof(bool) + ? (object)source.Equals("true", StringComparison.CurrentCultureIgnoreCase) + : source; + return BindConverter.TryConvertTo(value, culture, out val); + } + + object? converted = Type.GetTypeCode(type) switch + { + TypeCode.SByte => sbyte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Byte => byte.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int16 => short.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt16 => ushort.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int32 => int.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt32 => uint.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Int64 => long.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.UInt64 => ulong.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Single => float.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + TypeCode.Double => double.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + _ => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null + }; + + if (converted != null) + { + val = (TValue)converted; + return true; + } + + val = default; + return false; + } + /// /// 将文件大小格式化为带有适当单位的字符串 /// Formats the file size into a string with appropriate units diff --git a/src/BootstrapBlazor/Locales/en.json b/src/BootstrapBlazor/Locales/en.json index a20e0ca9a8c..bba7c671b16 100644 --- a/src/BootstrapBlazor/Locales/en.json +++ b/src/BootstrapBlazor/Locales/en.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "The {0} field must be a number." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "The {0} field must be a number." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Yes", "ButtonNoText": "No", diff --git a/src/BootstrapBlazor/Locales/zh.json b/src/BootstrapBlazor/Locales/zh.json index 3690d2b9253..8b70a11853e 100644 --- a/src/BootstrapBlazor/Locales/zh.json +++ b/src/BootstrapBlazor/Locales/zh.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "{0}字段值必须为 Number 类型" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "{0}字段值必须为 Number 类型" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "确认", "ButtonNoText": "取消", diff --git a/src/BootstrapBlazor/wwwroot/scss/components.scss b/src/BootstrapBlazor/wwwroot/scss/components.scss index 90e6b6864fb..c0bc6c4502b 100644 --- a/src/BootstrapBlazor/wwwroot/scss/components.scss +++ b/src/BootstrapBlazor/wwwroot/scss/components.scss @@ -1,4 +1,4 @@ -@use "root.scss"; +@use "root.scss"; @use "../../Components/Affix/Affix.razor.scss"; @use "../../Components/Alert/Alert.razor.scss"; @use "../../Components/AnchorLink/AnchorLink.razor.scss"; @@ -57,6 +57,7 @@ @use "../../Components/Input/BootstrapInputGroup.razor.scss"; @use "../../Components/Input/FloatingLabel.razor.scss"; @use "../../Components/Input/OtpInput.razor.scss"; +@use "../../Components/InputCurrency/BootstrapInputCurrency.razor.scss"; @use "../../Components/IntersectionObserver/LoadMore.razor.scss"; @use "../../Components/IpAddress/IpAddress.razor.scss"; @use "../../Components/Layout/Layout.razor.scss"; diff --git a/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs new file mode 100644 index 00000000000..5e8f7913faa --- /dev/null +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -0,0 +1,485 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Reflection; + +namespace UnitTest.Components; + +public class InputCurrencyTest : BootstrapBlazorTestBase +{ + [Theory] + [InlineData(null)] + [InlineData(0.0)] + public async Task OnInput_Ok(double? v) + { + double? value = 0.0; + var cut = Context.Render>(builder => + { + builder.Add(a => a.Value, v); + builder.Add(a => a.UseInputEvent, true); + builder.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => + { + value = v; + })); + }); + var input = cut.Find("input"); + await cut.InvokeAsync(() => + { + input.Input("0.0"); + }); + cut.Contains("value=\"0.0\""); + + await cut.InvokeAsync(() => + { + input.Input("0.01"); + }); + cut.Contains("value=\"0.01\""); + } + + [Fact] + public void RenderAttributes_Ok() + { + var integer = Context.Render>(pb => + { + pb.Add(a => a.Readonly, true); + pb.Add(a => a.Color, Color.Danger); + pb.Add(a => a.AdditionalAttributes, new Dictionary + { + ["data-test"] = "currency" + }); + }); + var input = integer.Find("input"); + Assert.Equal("true", input.GetAttribute("readonly")); + Assert.Equal("numeric", input.GetAttribute("inputmode")); + Assert.Equal("currency", input.GetAttribute("data-test")); + Assert.Contains("border-danger", input.ClassList); + + var decimalInput = Context.Render>(); + Assert.Equal("decimal", decimalInput.Find("input").GetAttribute("inputmode")); + Assert.Null(decimalInput.Find("input").GetAttribute("readonly")); + } + + [Fact] + public void OnBlur_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Min, "0"); + pb.Add(a => a.Max, "10"); + }); + cut.Contains("min=\"0\""); + cut.Contains("max=\"10\""); + + var input = cut.Find("input"); + cut.InvokeAsync(() => input.Blur()); + } + + [Fact] + public void ValidateForm() + { + var foo = new Cat() { Count = 20 }; + var cut = Context.Render(pb => + { + pb.Add(a => a.Model, foo); + pb.AddChildContent>(pb => + { + pb.Add(a => a.Value, foo.Count); + pb.Add(a => a.ValueExpression, Utility.GenerateValueExpression(foo, nameof(Cat.Count), typeof(int))); + }); + }); + cut.Contains("class=\"form-label\""); + + var input = cut.Find("input"); + cut.InvokeAsync(() => input.Change("")); + + var form = cut.Find("form"); + cut.InvokeAsync(() => form.Submit()); + cut.Contains("is-invalid"); + } + + [Fact] + public void InvalidOperationException_Error() + { + Assert.ThrowsAny(() => Context.Render>()); + } + + [Fact] + public void Formatter_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 10.01m); + pb.Add(a => a.Formatter, v => $"{v + 1}"); + }); + var input = cut.Find("input"); + Assert.Equal("11.01", input.GetAttribute("value")); + + cut.Render(pb => + { + pb.Add(a => a.Formatter, null); + pb.Add(a => a.FormatString, "#0.0"); + }); + Assert.Equal("10.0", input.GetAttribute("value")); + + input = cut.Find("input"); + cut.InvokeAsync(() => input.Change("")); + } + + [Fact] + public async Task CultureFormattingAndParsing_Ok() + { + var culture = CultureInfo.GetCultureInfo("de-DE"); + var value = 1234.5m; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.FormatString, "C2"); + }); + var input = cut.Find("input"); + Assert.Equal(value.ToString("C2", culture), input.GetAttribute("value")); + + await cut.InvokeAsync(() => input.Change("2.345,75 €")); + + Assert.Equal(2345.75m, value); + Assert.Equal(value.ToString("C2", culture), cut.Find("input").GetAttribute("value")); + } + + [Theory] + [InlineData("12345abc", 12345)] + [InlineData("-1,234.50 USD", -1234.50)] + [InlineData("1.2.3", 1.23)] + [InlineData("(1,234.50)", -1234.50)] + [InlineData("1e+3", 1000)] + [InlineData("1E3", 1000)] + [InlineData("1e-3", 0.001)] + [InlineData("1e", 1)] + [InlineData("e1", 1)] + [InlineData("1ea", 1)] + public async Task FormattedInput_Normalizes_Ok(string source, double expected) + { + var value = 0d; + var culture = CultureInfo.GetCultureInfo("en-US"); + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.FormatString, "0.##"); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change(source)); + + Assert.Equal(expected, value); + Assert.Equal(expected.ToString("0.##", culture), cut.Find("input").GetAttribute("value")); + } + + [Fact] + public async Task FormattedInteger_Normalizes_Ok() + { + var value = 0; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.FormatString, "N0"); + pb.Add(a => a.CultureInfo, CultureInfo.InvariantCulture); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("12abc")); + + Assert.Equal(12, value); + } + + [Fact] + public async Task Parser_SuccessFailureAndCulture_Ok() + { + var value = 1; + var culture = CultureInfo.GetCultureInfo("fr-FR"); + CultureInfo? receivedCulture = null; + string? receivedText = null; + var shouldSucceed = true; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.CultureInfo, culture); + pb.Add(a => a.Parser, (text, currentCulture) => + { + receivedCulture = currentCulture; + receivedText = text; + return (shouldSucceed, shouldSucceed ? text.Length : default); + }); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("123custom")); + Assert.Equal(3, value); + Assert.Equal("123", receivedText); + Assert.Same(culture, receivedCulture); + + shouldSucceed = false; + await cut.InvokeAsync(() => cut.Find("input").Change("456custom")); + Assert.Equal("456", receivedText); + Assert.Equal(3, value); + } + + [Fact] + public async Task UseInputEvent_FormatterPreservesInput_Ok() + { + var value = 0m; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => value = v)); + pb.Add(a => a.UseInputEvent, true); + pb.Add(a => a.FormatString, "N2"); + pb.Add(a => a.CultureInfo, CultureInfo.GetCultureInfo("en-US")); + }); + + await cut.InvokeAsync(() => cut.Find("input").Input("$1,234.50abc")); + + Assert.Equal(1234.50m, value); + Assert.Equal("$1,234.50abc", cut.Find("input").GetAttribute("value")); + } + + [Fact] + public void Formatter_Null() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.FormatString, "d2"); + }); + cut.Contains("value=\"\""); + } + + [Fact] + public void Formatter_Error() + { + Assert.ThrowsAny(() => Context.Render()); + } + + [Fact] + public async Task Nullable_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 5); + }); + var input = cut.Find("input"); + await cut.InvokeAsync(() => + { + input.Change("1+2"); + input.Blur(); + }); + Assert.Null(cut.Instance.Value); + } + + [Fact] + public async Task OnBlurAsync_Ok() + { + var blur = false; + var cut = Context.Render>(builder => + { + builder.Add(a => a.OnBlurAsync, v => + { + blur = true; + return Task.CompletedTask; + }); + }); + var input = cut.Find("input"); + await cut.InvokeAsync(() => { input.Blur(); }); + Assert.True(blur); + } + + [Fact] + public async Task OnValueChanged_Ok() + { + int? changedValue = null; + var cut = Context.Render>(pb => + { + pb.Add(a => a.OnValueChanged, value => + { + changedValue = value; + return Task.CompletedTask; + }); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("12")); + + Assert.Equal(12, changedValue); + } + + [Fact] + public async Task MinMax_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 5); + pb.Add(a => a.Min, "10"); + }); + + var input = cut.Find("input"); + await cut.InvokeAsync(() => input.Blur()); + Assert.Equal(10, cut.Instance.Value); + + cut.Render(pb => + { + pb.Add(a => a.Value, 15); + pb.Add(a => a.Min, null); + pb.Add(a => a.Max, "10"); + }); + input = cut.Find("input"); + await cut.InvokeAsync(() => input.Blur()); + Assert.Equal(10, cut.Instance.Value); + + cut.Render(pb => + { + pb.Add(a => a.Value, 5); + pb.Add(a => a.Min, "0"); + pb.Add(a => a.Max, "10"); + }); + input = cut.Find("input"); + await cut.InvokeAsync(() => input.Blur()); + Assert.Equal(5, cut.Instance.Value); + } + + [Fact] + public async Task InvalidMin_Throws_Ok() + { + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 1); + pb.Add(a => a.Min, "invalid"); + }); + + await Assert.ThrowsAsync(() => cut.InvokeAsync(() => cut.Find("input").Blur())); + } + + [Fact] + public void UnsignedType_Bind_Ok() + { + AssertInputNumberValueChanged(1, "2", 2); + AssertInputNumberValueChanged(1, "2", 2); + AssertInputNumberValueChanged(1, "2", 2); + AssertInputNumberValueChanged(1, "2", 2); + } + + [Fact] + public async Task Validate_Ok() + { + var model = new Foo() { Count = 1 }; + var cut = Context.Render(pb => + { + pb.Add(a => a.Model, model); + pb.AddChildContent>(builder => + { + builder.Add(a => a.Value, model.Count); + builder.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => + { + model.Count = v; + })); + builder.Add(a => a.ValueExpression, Utility.GenerateValueExpression(model, nameof(model.Count), typeof(int))); + }); + }); + var input = cut.Find(".form-control"); + + // 更改成非法数值 测试 CurrentValueAsString 赋值逻辑 + await cut.InvokeAsync(() => + { + input.Change("t"); + }); + Assert.Equal(1, model.Count); + + var valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); + Assert.False(valid); + + await cut.InvokeAsync(() => input.Change("test")); + Assert.Equal(1, model.Count); + valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); + Assert.False(valid); + + await cut.InvokeAsync(() => input.Change("t2")); + Assert.Equal(2, model.Count); + + valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); + Assert.True(valid); + } + + [Fact] + public async Task TryParseValueFromString_Ok() + { + var model = new Foo() { Count = 1 }; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, 1); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => + { + model.Count = v; + })); + pb.Add(a => a.ValueExpression, Utility.GenerateValueExpression(model, nameof(model.Count), typeof(int))); + }); + var input = cut.Find(".form-control"); + + // 更改成非法数值 测试 CurrentValueAsString 赋值逻辑 + await cut.InvokeAsync(() => + { + input.Change("t"); + }); + Assert.Equal(1, model.Count); + + await cut.InvokeAsync(() => input.Change("test")); + Assert.Equal(1, model.Count); + + await cut.InvokeAsync(() => input.Change("t2")); + Assert.Equal(2, model.Count); + } + + private class Cat + { + [Range(1, 10)] + public int Count { get; set; } + } + + private void AssertInputNumberValueChanged(TValue value, string inputValue, TValue expected) + { + var currentValue = value; + var cut = Context.Render>(pb => + { + pb.Add(a => a.Value, value); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => currentValue = v)); + }); + + var input = cut.Find(".form-control"); + cut.InvokeAsync(() => input.Change(inputValue)); + Assert.Equal(expected, currentValue); + } + + private class MockInputNumber : BootstrapInputCurrency + { + public override Task SetParametersAsync(ParameterView parameters) + { + parameters.SetParameterProperties(this); + + OnInitialized(); + + return Task.CompletedTask; + } + + protected override string? InternalFormat(string? value) + { + return base.InternalFormat(value); + } + + protected override void OnInitialized() + { + base.OnInitialized(); + + InternalFormat(""); + } + } +} diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index 387a135524a..e4ac33637e0 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -164,6 +164,124 @@ public static void TryConvertTo_Generic() Assert.True(result); } + [Fact] + public static void TryConvertTo_GenericCulture_StringNullAndEmpty() + { + var culture = CultureInfo.InvariantCulture; + + Assert.True("test".TryConvertTo(culture, out var text)); + Assert.Equal("test", text); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullText)); + Assert.Null(nullText); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullInteger)); + Assert.Equal(0, nullInteger); + + Assert.True(((string?)null).TryConvertTo(culture, out var nullNullableInteger)); + Assert.Null(nullNullableInteger); + + Assert.False(string.Empty.TryConvertTo(culture, out var emptyInteger)); + Assert.Equal(0, emptyInteger); + + Assert.True(string.Empty.TryConvertTo(culture, out var emptyNullableInteger)); + Assert.Null(emptyNullableInteger); + } + + [Theory] + [InlineData("true", true)] + [InlineData("TRUE", true)] + [InlineData("false", false)] + [InlineData("False", false)] + public static void TryConvertTo_GenericCulture_Boolean(string source, bool expected) + { + Assert.True(source.TryConvertTo(CultureInfo.InvariantCulture, out var actual)); + Assert.Equal(expected, actual); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumericBoundaries() + { + var culture = CultureInfo.InvariantCulture; + + AssertConversion("-128", culture, sbyte.MinValue); + AssertConversion("255", culture, byte.MaxValue); + AssertConversion("-32768", culture, short.MinValue); + AssertConversion("65535", culture, ushort.MaxValue); + AssertConversion("-2147483648", culture, int.MinValue); + AssertConversion("4294967295", culture, uint.MaxValue); + AssertConversion("-9223372036854775808", culture, long.MinValue); + AssertConversion("18446744073709551615", culture, ulong.MaxValue); + AssertConversion("-3.4028235E+38", culture, float.MinValue); + AssertConversion("1.7976931348623157E+308", culture, double.MaxValue); + AssertConversion("79228162514264337593543950335", culture, decimal.MaxValue); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumericFailuresReturnDefault() + { + var culture = CultureInfo.InvariantCulture; + + AssertConversionFails("-129", culture); + AssertConversionFails("256", culture); + AssertConversionFails("-32769", culture); + AssertConversionFails("65536", culture); + AssertConversionFails("-2147483649", culture); + AssertConversionFails("4294967296", culture); + AssertConversionFails("-9223372036854775809", culture); + AssertConversionFails("18446744073709551616", culture); + AssertConversionFails("not-a-number", culture); + AssertConversionFails("not-a-number", culture); + AssertConversionFails("79228162514264337593543950336", culture); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NullableNumeric() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + AssertConversion("$ 2", culture, 2m); + AssertConversion("1,234", culture, 1234); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NumberFormats() + { + var enUs = CultureInfo.GetCultureInfo("en-US"); + AssertConversion("$ 2", enUs, 2d); + AssertConversion("$1,234.50", enUs, 1234.50m); + + var deDe = CultureInfo.GetCultureInfo("de-DE"); + AssertConversion("1.234,5", deDe, 1234.5d); + + var frFr = CultureInfo.GetCultureInfo("fr-FR"); + AssertConversion(1234.5m.ToString("N2", frFr), frFr, 1234.5m); + } + + [Fact] + public static void TryConvertTo_GenericCulture_PreservesPrecision() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + AssertConversion("$12,345,678,901,234,567,890.123456789", culture, 12345678901234567890.123456789m); + AssertConversion("18,446,744,073,709,551,615", culture, ulong.MaxValue); + } + + [Fact] + public static void TryConvertTo_GenericCulture_NonNumericUsesBindConverter() + { + var culture = CultureInfo.InvariantCulture; + var guid = Guid.NewGuid(); + + AssertConversion(guid.ToString(), culture, guid); + + Assert.False("not-a-date".TryConvertTo(culture, out var invalidDate)); + Assert.Equal(default, invalidDate); + + var deDe = CultureInfo.GetCultureInfo("de-DE"); + AssertConversion("31.12.2025", deDe, new DateTime(2025, 12, 31)); + } + [Theory] [InlineData(100f, "100 B")] [InlineData(1024f, "1.0 KB")] @@ -367,6 +485,18 @@ public void CreateInstance_Ok() Assert.Null(bar.Bar); } + private static void AssertConversion(string source, CultureInfo culture, TValue expected) + { + Assert.True(source.TryConvertTo(culture, out var actual)); + Assert.Equal(expected, actual); + } + + private static void AssertConversionFails(string source, CultureInfo culture) + { + Assert.False(source.TryConvertTo(culture, out var actual)); + Assert.Equal(default, actual); + } + private interface MockInterface { string? Name { get; set; }