From 94ebc58668ed4b0fc28d77821270e296c4d89c9f Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sat, 5 Sep 2026 15:45:19 +0800 Subject: [PATCH 01/27] =?UTF-8?q?feat(InputNumber):=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E6=95=B0=E5=AD=97=E6=A0=BC=E5=BC=8F=E5=8C=96=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor:支持数字格式化显示,例如:$123,252.12 feat:增加文化信息参数 feat:增加Parser,以便与Formatter成配套使用;即Formatter可以输出任意格式,Parser负责反向 --- .../Components/Samples/InputNumbers.razor | 51 ++++++------ .../Components/Samples/InputNumbers.razor.cs | 27 ++++++- src/BootstrapBlazor.Server/appsettings.json | 6 +- .../InputNumber/BootstrapInputNumber.razor | 4 +- .../InputNumber/BootstrapInputNumber.razor.cs | 31 ++++++- .../Extensions/ObjectExtensions.cs | 57 +++++++++++++ test/UnitTest/Components/InputNumberTest.cs | 80 +++++++++++++++++++ .../Extensions/ObjectExtensionsTest.cs | 29 +++++++ 8 files changed, 251 insertions(+), 34 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor index d7e0a5be2e1..d19b8a6dd82 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor @@ -5,7 +5,7 @@

@Localizer["InputNumbersDescription"]

-
@@ -19,25 +19,27 @@
- +*@
- +
- +
- +
-
+ @*
+
@@ -48,11 +50,11 @@
-
+
*@
-

@Localizer["InputNumbersShowButtonDescription"]

@@ -67,9 +69,9 @@
-
+ *@ -
@@ -111,12 +113,12 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersStep0.1"]

-
+ -

@Localizer["InputNumbersColorDescription1"]

+

@Localizer["InputNumbersColorDescription1"]

@@ -161,7 +163,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersColorDescription2"]

-
+
@@ -190,10 +192,10 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
+
- +
@@ -202,13 +204,13 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
- +*@
-
+ @*
@@ -225,23 +227,25 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
- +
-
+
*@
- +
- +
-
@@ -252,7 +256,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
+*@ + diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs index a1f831f0029..323a033a871 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs @@ -1,8 +1,11 @@ -// 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 +using System.Globalization; +using System.Text.RegularExpressions; + namespace BootstrapBlazor.Server.Components.Samples; /// @@ -51,9 +54,27 @@ public sealed partial class InputNumbers private double? InputNullableValue { get; set; } = 12.01; - private double InputValue { get; set; } = 12.01; + private decimal InputValue { get; set; } = 12.01m; - private int? BindInputNullableValue { get; set; } = 2; + private double BindInputNullableValue { get; set; } = 2; private int BindInputValue { get; set; } = 2; + + private string Format1(double value) + { + double.TryParse("$235,623.235", NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out var number); + double.TryParse("¥1235,623.235", NumberStyles.Currency, CultureInfo.GetCultureInfo("zh-CN"), out var number1); + return $"$ {value.ToString("n0")}"; + } + + private string Format2(decimal value) + { + return $"{value.ToString("n2")}%"; + } + + private (bool Success, decimal Value) Parser1(string arg1, CultureInfo info) + { + var ret = (true, decimal.Parse(arg1.Replace("%", ""), info)); + return ret; + } } diff --git a/src/BootstrapBlazor.Server/appsettings.json b/src/BootstrapBlazor.Server/appsettings.json index 695457f9d91..7f18fdb96c5 100644 --- a/src/BootstrapBlazor.Server/appsettings.json +++ b/src/BootstrapBlazor.Server/appsettings.json @@ -50,9 +50,9 @@ "Short": "1", "Int": "1", "Long": "1", - "Float": "0.1", - "Double": "0.01", - "Decimal": "0.01" + "Float": "any", + "Double": "any", + "Decimal": "any" }, "ConnectionHubOptions": { "Enable": true, diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor index 61e479c3ea5..b6960d717b5 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @typeparam TValue @inherits BootstrapInputNumberBase @@ -25,5 +25,5 @@ else @code { RenderFragment RenderInput => - @; + @; } diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs index 0b73672aaec..c221f3f01f9 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs @@ -93,6 +93,20 @@ public partial class BootstrapInputNumber [Parameter] public string? PlusIcon { get; set; } + /// + /// 获得/设置 数值解析使用的文化信息 + /// Gets or sets the culture used to parse numeric values + /// + [Parameter] + public CultureInfo CultureInfo { get; set; } = CultureInfo.CurrentCulture; + + /// + /// 获得/设置 自定义数值解析回调方法 + /// Gets or sets the callback used to parse custom numeric values + /// + [Parameter] + public Func? Parser { get; set; } + [Inject] [NotNull] private IStringLocalizer>? Localizer { get; set; } @@ -201,9 +215,9 @@ private string GetStepString() return StepString; } - private static TValue ParseValue(string value) + private TValue ParseValue(string value) { - return value.TryConvertTo(out var ret) + return value.TryConvertTo(CultureInfo, out var ret) ? ret : throw new InvalidOperationException($"Unsupported type {typeof(TValue)}"); } @@ -338,7 +352,18 @@ protected override bool TryParseValueFromString(string value, [MaybeNullWhen(fal } else { - ret = base.TryParseValueFromString(value, out result, out validationErrorMessage); + if (Parser != null) + { + var parsedValue = Parser(value, CultureInfo); + ret = parsedValue.Success; + result = ret ? parsedValue.Value! : default; + } + else + { + ret = value.TryConvertTo(CultureInfo, out result); + } + + validationErrorMessage = ret ? null : FormatParsingErrorMessage(); if (ret && UseInputEvent) { _lastInputValueString = value; diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 4a9eecf76cc..03a786044bd 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -206,6 +206,63 @@ 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, + TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + _ => 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/test/UnitTest/Components/InputNumberTest.cs b/test/UnitTest/Components/InputNumberTest.cs index 4458f3b3579..3a088758658 100644 --- a/test/UnitTest/Components/InputNumberTest.cs +++ b/test/UnitTest/Components/InputNumberTest.cs @@ -108,6 +108,86 @@ public void Formatter_Ok() cut.InvokeAsync(() => input.Change("")); } + [Fact] + public async Task Formatter_Culture_Ok() + { + var value = 2m; + 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.Formatter, v => v.ToString("C0", culture)); + }); + + var input = cut.Find("input"); + Assert.Equal("$2", input.GetAttribute("value")); + + await cut.InvokeAsync(() => input.Change("$ 3")); + Assert.Equal(3m, value); + } + + [Fact] + public async Task Parser_Success_Culture_Ok() + { + var value = 2; + CultureInfo? parserCulture = null; + var culture = CultureInfo.GetCultureInfo("fr-FR"); + 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.UseInputEvent, true); + pb.Add(a => a.Parser, (text, currentCulture) => + { + parserCulture = currentCulture; + return (true, text.Length); + }); + }); + + var input = cut.Find("input"); + await cut.InvokeAsync(() => input.Input("custom")); + + Assert.Equal(6, value); + Assert.Same(culture, parserCulture); + Assert.Equal("custom", input.GetAttribute("value")); + } + + [Fact] + public async Task Parser_Failure_DoesNotFallback_Ok() + { + var value = 2; + 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.Parser, (_, _) => (false, default)); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("3")); + + Assert.Equal(2, value); + } + + [Fact] + public async Task Parser_Null_UsesDefaultConversion_Ok() + { + var value = 2m; + 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, CultureInfo.GetCultureInfo("en-US")); + pb.Add(a => a.Parser, null); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("$ 3")); + + Assert.Equal(3m, value); + } + [Fact] public void Formatter_Null() { diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index 387a135524a..ca8e634fe34 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -164,6 +164,35 @@ public static void TryConvertTo_Generic() Assert.True(result); } + [Fact] + public static void TryConvertTo_GenericCulture() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + + Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); + Assert.Equal(2d, doubleValue); + + Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); + Assert.Equal(1234.50m, decimalValue); + + Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); + Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); + + Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); + Assert.Equal(2m, nullableDecimalValue); + + Assert.True("1,234".TryConvertTo(culture, out var integerValue)); + Assert.Equal(1234, integerValue); + + Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); + Assert.Equal(ulong.MaxValue, unsignedValue); + + Assert.True("false".TryConvertTo(culture, out var booleanValue)); + Assert.False(booleanValue); + + Assert.False("not-a-number".TryConvertTo(culture, out _)); + } + [Theory] [InlineData(100f, "100 B")] [InlineData(1024f, "1.0 KB")] From c4513469875b69e8cea8c1820dc5970e97e84bb5 Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sat, 5 Sep 2026 18:12:31 +0800 Subject: [PATCH 02/27] =?UTF-8?q?test:=E8=A1=A5=E5=85=85=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=AE=8C=E5=96=84=E4=BB=A3=E7=A0=81=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/ObjectExtensions.cs | 3 +- .../Components/TableNumberFilterTest.cs | 2 +- .../Extensions/ObjectExtensionsTest.cs | 133 +++++++++++++++--- 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 03a786044bd..380cc8ca98c 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -249,8 +249,7 @@ public static bool TryConvertTo(this string? source, CultureInfo culture 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, - TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - _ => null + _ => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null }; if (converted != null) diff --git a/test/UnitTest/Components/TableNumberFilterTest.cs b/test/UnitTest/Components/TableNumberFilterTest.cs index 206bb4c5ae7..d7fee064711 100644 --- a/test/UnitTest/Components/TableNumberFilterTest.cs +++ b/test/UnitTest/Components/TableNumberFilterTest.cs @@ -17,7 +17,7 @@ public async Task OnFilterAsync_Ok() pb.Add(a => a.IsHeaderRow, true); }); - var input = cut.Find("[type=\"number\"]"); + var input = cut.Find("[inputmode=\"decimal\"]"); await cut.InvokeAsync(() => input.Change("1")); var action = cut.Find(".dropdown-item"); diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index ca8e634fe34..543b1b605d5 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -165,32 +165,121 @@ public static void TryConvertTo_Generic() } [Fact] - public static void TryConvertTo_GenericCulture() + 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"); - Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); - Assert.Equal(2d, doubleValue); + 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); - Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); - Assert.Equal(1234.50m, decimalValue); + var frFr = CultureInfo.GetCultureInfo("fr-FR"); + AssertConversion(1234.5m.ToString("N2", frFr), frFr, 1234.5m); + } - Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); - Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); + [Fact] + public static void TryConvertTo_GenericCulture_PreservesPrecision() + { + var culture = CultureInfo.GetCultureInfo("en-US"); - Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); - Assert.Equal(2m, nullableDecimalValue); + AssertConversion("$12,345,678,901,234,567,890.123456789", culture, 12345678901234567890.123456789m); + AssertConversion("18,446,744,073,709,551,615", culture, ulong.MaxValue); + } - Assert.True("1,234".TryConvertTo(culture, out var integerValue)); - Assert.Equal(1234, integerValue); + [Fact] + public static void TryConvertTo_GenericCulture_NonNumericUsesBindConverter() + { + var culture = CultureInfo.InvariantCulture; + var guid = Guid.NewGuid(); - Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); - Assert.Equal(ulong.MaxValue, unsignedValue); + AssertConversion(guid.ToString(), culture, guid); - Assert.True("false".TryConvertTo(culture, out var booleanValue)); - Assert.False(booleanValue); + Assert.False("not-a-date".TryConvertTo(culture, out var invalidDate)); + Assert.Equal(default, invalidDate); - Assert.False("not-a-number".TryConvertTo(culture, out _)); + var deDe = CultureInfo.GetCultureInfo("de-DE"); + AssertConversion("31.12.2025", deDe, new DateTime(2025, 12, 31)); } [Theory] @@ -401,6 +490,18 @@ private interface MockInterface string? Name { get; set; } } + 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 class MockComplexObject { public Foo? Foo { get; set; } From d955dc988b45afccf1c143dd9d5454b05e0996cc Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 12:51:08 +0800 Subject: [PATCH 03/27] =?UTF-8?q?Revert=20"test:=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=E5=AE=8C=E5=96=84=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=A6=86=E7=9B=96=E7=8E=87"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit c4513469875b69e8cea8c1820dc5970e97e84bb5. --- .../Extensions/ObjectExtensions.cs | 3 +- .../Components/TableNumberFilterTest.cs | 2 +- .../Extensions/ObjectExtensionsTest.cs | 133 +++--------------- 3 files changed, 19 insertions(+), 119 deletions(-) diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 380cc8ca98c..03a786044bd 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -249,7 +249,8 @@ public static bool TryConvertTo(this string? source, CultureInfo culture 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 + TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, + _ => null }; if (converted != null) diff --git a/test/UnitTest/Components/TableNumberFilterTest.cs b/test/UnitTest/Components/TableNumberFilterTest.cs index d7fee064711..206bb4c5ae7 100644 --- a/test/UnitTest/Components/TableNumberFilterTest.cs +++ b/test/UnitTest/Components/TableNumberFilterTest.cs @@ -17,7 +17,7 @@ public async Task OnFilterAsync_Ok() pb.Add(a => a.IsHeaderRow, true); }); - var input = cut.Find("[inputmode=\"decimal\"]"); + var input = cut.Find("[type=\"number\"]"); await cut.InvokeAsync(() => input.Change("1")); var action = cut.Find(".dropdown-item"); diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index 543b1b605d5..ca8e634fe34 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -165,121 +165,32 @@ public static void TryConvertTo_Generic() } [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() + public static void TryConvertTo_GenericCulture() { 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); + Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); + Assert.Equal(2d, doubleValue); - var frFr = CultureInfo.GetCultureInfo("fr-FR"); - AssertConversion(1234.5m.ToString("N2", frFr), frFr, 1234.5m); - } + Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); + Assert.Equal(1234.50m, decimalValue); - [Fact] - public static void TryConvertTo_GenericCulture_PreservesPrecision() - { - var culture = CultureInfo.GetCultureInfo("en-US"); + Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); + Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); - AssertConversion("$12,345,678,901,234,567,890.123456789", culture, 12345678901234567890.123456789m); - AssertConversion("18,446,744,073,709,551,615", culture, ulong.MaxValue); - } + Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); + Assert.Equal(2m, nullableDecimalValue); - [Fact] - public static void TryConvertTo_GenericCulture_NonNumericUsesBindConverter() - { - var culture = CultureInfo.InvariantCulture; - var guid = Guid.NewGuid(); + Assert.True("1,234".TryConvertTo(culture, out var integerValue)); + Assert.Equal(1234, integerValue); - AssertConversion(guid.ToString(), culture, guid); + Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); + Assert.Equal(ulong.MaxValue, unsignedValue); - Assert.False("not-a-date".TryConvertTo(culture, out var invalidDate)); - Assert.Equal(default, invalidDate); + Assert.True("false".TryConvertTo(culture, out var booleanValue)); + Assert.False(booleanValue); - var deDe = CultureInfo.GetCultureInfo("de-DE"); - AssertConversion("31.12.2025", deDe, new DateTime(2025, 12, 31)); + Assert.False("not-a-number".TryConvertTo(culture, out _)); } [Theory] @@ -490,18 +401,6 @@ private interface MockInterface string? Name { get; set; } } - 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 class MockComplexObject { public Foo? Foo { get; set; } From 082cec6f3fa158207f8b21037055bc3acdd0fcc4 Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 12:51:14 +0800 Subject: [PATCH 04/27] =?UTF-8?q?Revert=20"feat(InputNumber):=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E6=95=B0=E5=AD=97=E6=A0=BC=E5=BC=8F=E5=8C=96=E6=98=BE?= =?UTF-8?q?=E7=A4=BA"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 94ebc58668ed4b0fc28d77821270e296c4d89c9f. --- .../Components/Samples/InputNumbers.razor | 51 ++++++------ .../Components/Samples/InputNumbers.razor.cs | 27 +------ src/BootstrapBlazor.Server/appsettings.json | 6 +- .../InputNumber/BootstrapInputNumber.razor | 4 +- .../InputNumber/BootstrapInputNumber.razor.cs | 31 +------ .../Extensions/ObjectExtensions.cs | 57 ------------- test/UnitTest/Components/InputNumberTest.cs | 80 ------------------- .../Extensions/ObjectExtensionsTest.cs | 29 ------- 8 files changed, 34 insertions(+), 251 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor index d19b8a6dd82..d7e0a5be2e1 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor @@ -5,7 +5,7 @@

@Localizer["InputNumbersDescription"]

-@*
@@ -19,27 +19,25 @@
-*@ +
- +
- +
- +
- @*
-
+
@@ -50,11 +48,11 @@
-
*@ +
-@*

@Localizer["InputNumbersShowButtonDescription"]

@@ -69,9 +67,9 @@
-
*@ + -@*
@@ -113,12 +111,12 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersStep0.1"]

-
+ -

@Localizer["InputNumbersColorDescription1"]

+

@Localizer["InputNumbersColorDescription1"]

@@ -163,7 +161,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se

@Localizer["InputNumbersColorDescription2"]

-
+
@@ -192,10 +190,10 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
+
- +
@@ -204,13 +202,13 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-*@ +
- @*
+
@@ -227,25 +225,23 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
- +
-
*@ +
- +
- +
-@*
@@ -256,7 +252,7 @@ private IOptions<BootstrapBlazorOptions>? BootstrapBlazorOptions { get; se
-
*@ + - diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs index 323a033a871..a1f831f0029 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs @@ -1,11 +1,8 @@ -// 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 -using System.Globalization; -using System.Text.RegularExpressions; - namespace BootstrapBlazor.Server.Components.Samples; /// @@ -54,27 +51,9 @@ public sealed partial class InputNumbers private double? InputNullableValue { get; set; } = 12.01; - private decimal InputValue { get; set; } = 12.01m; + private double InputValue { get; set; } = 12.01; - private double BindInputNullableValue { get; set; } = 2; + private int? BindInputNullableValue { get; set; } = 2; private int BindInputValue { get; set; } = 2; - - private string Format1(double value) - { - double.TryParse("$235,623.235", NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out var number); - double.TryParse("¥1235,623.235", NumberStyles.Currency, CultureInfo.GetCultureInfo("zh-CN"), out var number1); - return $"$ {value.ToString("n0")}"; - } - - private string Format2(decimal value) - { - return $"{value.ToString("n2")}%"; - } - - private (bool Success, decimal Value) Parser1(string arg1, CultureInfo info) - { - var ret = (true, decimal.Parse(arg1.Replace("%", ""), info)); - return ret; - } } diff --git a/src/BootstrapBlazor.Server/appsettings.json b/src/BootstrapBlazor.Server/appsettings.json index 7f18fdb96c5..695457f9d91 100644 --- a/src/BootstrapBlazor.Server/appsettings.json +++ b/src/BootstrapBlazor.Server/appsettings.json @@ -50,9 +50,9 @@ "Short": "1", "Int": "1", "Long": "1", - "Float": "any", - "Double": "any", - "Decimal": "any" + "Float": "0.1", + "Double": "0.01", + "Decimal": "0.01" }, "ConnectionHubOptions": { "Enable": true, diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor index b6960d717b5..61e479c3ea5 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @typeparam TValue @inherits BootstrapInputNumberBase @@ -25,5 +25,5 @@ else @code { RenderFragment RenderInput => - @; + @; } diff --git a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs index c221f3f01f9..0b73672aaec 100644 --- a/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs +++ b/src/BootstrapBlazor/Components/InputNumber/BootstrapInputNumber.razor.cs @@ -93,20 +93,6 @@ public partial class BootstrapInputNumber [Parameter] public string? PlusIcon { get; set; } - /// - /// 获得/设置 数值解析使用的文化信息 - /// Gets or sets the culture used to parse numeric values - /// - [Parameter] - public CultureInfo CultureInfo { get; set; } = CultureInfo.CurrentCulture; - - /// - /// 获得/设置 自定义数值解析回调方法 - /// Gets or sets the callback used to parse custom numeric values - /// - [Parameter] - public Func? Parser { get; set; } - [Inject] [NotNull] private IStringLocalizer>? Localizer { get; set; } @@ -215,9 +201,9 @@ private string GetStepString() return StepString; } - private TValue ParseValue(string value) + private static TValue ParseValue(string value) { - return value.TryConvertTo(CultureInfo, out var ret) + return value.TryConvertTo(out var ret) ? ret : throw new InvalidOperationException($"Unsupported type {typeof(TValue)}"); } @@ -352,18 +338,7 @@ protected override bool TryParseValueFromString(string value, [MaybeNullWhen(fal } else { - if (Parser != null) - { - var parsedValue = Parser(value, CultureInfo); - ret = parsedValue.Success; - result = ret ? parsedValue.Value! : default; - } - else - { - ret = value.TryConvertTo(CultureInfo, out result); - } - - validationErrorMessage = ret ? null : FormatParsingErrorMessage(); + ret = base.TryParseValueFromString(value, out result, out validationErrorMessage); if (ret && UseInputEvent) { _lastInputValueString = value; diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 03a786044bd..4a9eecf76cc 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -206,63 +206,6 @@ 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, - TypeCode.Decimal => decimal.TryParse(source, NumberStyles.Any, culture, out var value) ? value : null, - _ => 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/test/UnitTest/Components/InputNumberTest.cs b/test/UnitTest/Components/InputNumberTest.cs index 3a088758658..4458f3b3579 100644 --- a/test/UnitTest/Components/InputNumberTest.cs +++ b/test/UnitTest/Components/InputNumberTest.cs @@ -108,86 +108,6 @@ public void Formatter_Ok() cut.InvokeAsync(() => input.Change("")); } - [Fact] - public async Task Formatter_Culture_Ok() - { - var value = 2m; - 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.Formatter, v => v.ToString("C0", culture)); - }); - - var input = cut.Find("input"); - Assert.Equal("$2", input.GetAttribute("value")); - - await cut.InvokeAsync(() => input.Change("$ 3")); - Assert.Equal(3m, value); - } - - [Fact] - public async Task Parser_Success_Culture_Ok() - { - var value = 2; - CultureInfo? parserCulture = null; - var culture = CultureInfo.GetCultureInfo("fr-FR"); - 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.UseInputEvent, true); - pb.Add(a => a.Parser, (text, currentCulture) => - { - parserCulture = currentCulture; - return (true, text.Length); - }); - }); - - var input = cut.Find("input"); - await cut.InvokeAsync(() => input.Input("custom")); - - Assert.Equal(6, value); - Assert.Same(culture, parserCulture); - Assert.Equal("custom", input.GetAttribute("value")); - } - - [Fact] - public async Task Parser_Failure_DoesNotFallback_Ok() - { - var value = 2; - 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.Parser, (_, _) => (false, default)); - }); - - await cut.InvokeAsync(() => cut.Find("input").Change("3")); - - Assert.Equal(2, value); - } - - [Fact] - public async Task Parser_Null_UsesDefaultConversion_Ok() - { - var value = 2m; - 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, CultureInfo.GetCultureInfo("en-US")); - pb.Add(a => a.Parser, null); - }); - - await cut.InvokeAsync(() => cut.Find("input").Change("$ 3")); - - Assert.Equal(3m, value); - } - [Fact] public void Formatter_Null() { diff --git a/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index ca8e634fe34..387a135524a 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -164,35 +164,6 @@ public static void TryConvertTo_Generic() Assert.True(result); } - [Fact] - public static void TryConvertTo_GenericCulture() - { - var culture = CultureInfo.GetCultureInfo("en-US"); - - Assert.True("$ 2".TryConvertTo(culture, out var doubleValue)); - Assert.Equal(2d, doubleValue); - - Assert.True("$1,234.50".TryConvertTo(culture, out var decimalValue)); - Assert.Equal(1234.50m, decimalValue); - - Assert.True("$12,345,678,901,234,567,890.123456789".TryConvertTo(culture, out var preciseDecimalValue)); - Assert.Equal(12345678901234567890.123456789m, preciseDecimalValue); - - Assert.True("$ 2".TryConvertTo(culture, out var nullableDecimalValue)); - Assert.Equal(2m, nullableDecimalValue); - - Assert.True("1,234".TryConvertTo(culture, out var integerValue)); - Assert.Equal(1234, integerValue); - - Assert.True("18,446,744,073,709,551,615".TryConvertTo(culture, out var unsignedValue)); - Assert.Equal(ulong.MaxValue, unsignedValue); - - Assert.True("false".TryConvertTo(culture, out var booleanValue)); - Assert.False(booleanValue); - - Assert.False("not-a-number".TryConvertTo(culture, out _)); - } - [Theory] [InlineData(100f, "100 B")] [InlineData(1024f, "1.0 KB")] From 270cd5335b52f3358e475a2cfbbe31661d1a5f0f Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 18:28:27 +0800 Subject: [PATCH 05/27] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9EInputCurrency?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat:新增InputCurrency组件,输入数值后进行格式化显示的组件 doc:新增InputCurrency组件对应文档 test:补充组件单元测试 --- localization/de.json | 3 + localization/es.json | 3 + localization/it.json | 3 + localization/km-KH.json | 3 + localization/pt-BR.json | 3 + localization/pt.json | 3 + localization/ru-RU.json | 3 + localization/th-TH.json | 3 + localization/uk-UA.json | 3 + localization/zh-TW.json | 3 + .../Components/Samples/InputCurrency.razor | 73 +++ .../Components/Samples/InputCurrency.razor.cs | 56 ++ .../Samples/InputCurrency.razor.css | 7 + .../Components/Samples/InputNumbers.razor.cs | 2 +- src/BootstrapBlazor.Server/Locales/en-US.json | 17 +- src/BootstrapBlazor.Server/Locales/zh-CN.json | 17 +- src/BootstrapBlazor.Server/docs.json | 1 + .../BootstrapInputCurrency.razor | 17 + .../BootstrapInputCurrency.razor.cs | 333 ++++++++++++ .../Extensions/ObjectExtensions.cs | 56 ++ src/BootstrapBlazor/Locales/en.json | 3 + src/BootstrapBlazor/Locales/zh.json | 3 + test/UnitTest/Components/InputCurrencyTest.cs | 491 ++++++++++++++++++ .../Extensions/ObjectExtensionsTest.cs | 130 +++++ 24 files changed, 1233 insertions(+), 3 deletions(-) create mode 100644 src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor create mode 100644 src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs create mode 100644 src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css create mode 100644 src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor create mode 100644 src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs create mode 100644 test/UnitTest/Components/InputCurrencyTest.cs 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/InputCurrency.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor new file mode 100644 index 00000000000..44cf2001727 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor @@ -0,0 +1,73 @@ +@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/InputCurrency.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs new file mode 100644 index 00000000000..c830cf20f66 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs @@ -0,0 +1,56 @@ +// 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.Globalization; + +namespace BootstrapBlazor.Server.Components.Samples; + +public partial class InputCurrency +{ + /// + /// BindValue + /// + public double BindValue { get; set; } = 5; + + /// + /// + /// + public decimal BindDecimalValue { get; set; } = 1898.78m; + + /// + /// + /// + public double BindMaxMinValue { get; set; } = 9.23; + + /// + /// + /// + public double? NullableValue { get; set; } = 5; + + private string Format1(double value) + { + double.TryParse("$235,623.235", NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out var number); + double.TryParse("¥1235,623.235", NumberStyles.Currency, CultureInfo.GetCultureInfo("zh-CN"), out var number1); + return $"$ {value.ToString("n0")}"; + } + private string Format(double? value) + { + if (value != null) + return $"$ {value?.ToString("n0")}"; + else + return null; + } + + private string FormatCustomize(decimal value) + { + return $"RMB {value.ToString("n2")}"; + } + + private (bool Success, decimal Value) Parser(string arg1, CultureInfo info) + { + var ret = (true, decimal.Parse(arg1.Replace("%", ""), info)); + return ret; + } +} diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css new file mode 100644 index 00000000000..f13df052c95 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css @@ -0,0 +1,7 @@ +.demo-input-currency { + width: 200px; +} + + .demo-input-currency > ::deep .form-control { + width: inherit; + } 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/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 780ed5a6a19..4e6f32a7465 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -2771,6 +2771,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.InputCurrency": { + "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 +6083,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..3981912a05d 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -2771,6 +2771,22 @@ "IntersectionObserverIntro": "通过设置 IsIntersectionObserver=\"true\" 开启懒加载特性,当图片在不可见区域时不加载图片,当图片即将可见时才开始加载图片", "IntersectionObserverTitle": "懒加载" }, + "BootstrapBlazor.Server.Components.Samples.InputCurrency": { + "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 +6083,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..3d4a710849f 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": "InputCurrency", "input": "Inputs", "input-group": "InputGroups", "ip": "Ips", diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor new file mode 100644 index 00000000000..225677568fc --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -0,0 +1,17 @@ +@namespace BootstrapBlazor.Components +@typeparam TValue +@inherits BootstrapInputNumberBase + +@if (IsShowLabel) +{ + +} +@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..1087ec8bb49 --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -0,0 +1,333 @@ +// 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; +using System.Text; + +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 string? Min { get; set; } + + /// + /// 获得/设置 最大值 + /// Gets or sets Maximum Value + /// + [Parameter] + public string? Max { get; set; } + + /// + /// 获得/设置 数值解析及格式字符串使用的文化信息 + /// Gets or sets the culture used to parse and format numeric values + /// + [Parameter] + public CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; + + /// + /// 获得/设置 自定义数值解析回调方法。解析成功后由组件格式化显示值 + /// Gets or sets the custom numeric parser. The component formats the value after successful parsing + /// + [Parameter] + public Func? Parser { get; set; } + + + [Inject] + [NotNull] + private IStringLocalizer>? Localizer { get; set; } + + private string? ReadonlyString => Readonly ? "true" : null; + + /// + /// 获得 文本框样式 + /// Get Text Box Style + /// + protected string? InputClassString => CssBuilder.Default("form-control") + .AddClass(CssClass).AddClass(ValidCss) + .AddClass($"border-{Color.ToDescriptionString()}", Color != Color.None) + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + private string? InputModeString => IsDecimalType() ? "decimal" : "numeric"; + + + private string? _lastInputValueString; + + private bool _manualInput; + + /// + /// + /// + protected override void OnInitialized() + { + base.OnInitialized(); + if (UseInputEvent) + { + _lastInputValueString ??= Value?.ToString(); + } + } + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; + + if (Value is null) + { + _lastInputValueString = ""; + } + + if (UseInputEvent && !_manualInput) + { + _lastInputValueString = GetFormatString(Value); + } + } + + /// + /// + /// + /// + protected override void OnAfterRender(bool firstRender) + { + base.OnAfterRender(firstRender); + + if (_manualInput) + { + _manualInput = false; + } + } + + /// + /// + /// + protected override string? FormatParsingErrorMessage() => string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayText); + + /// + /// + /// + protected override string? FormatValueAsString(TValue? value) => UseInputEvent ? _lastInputValueString : GetFormatString(value); + + private string? GetFormatString(TValue? value) => Formatter != null + ? Formatter.Invoke(value) + : (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable + ? formattable.ToString(FormatString, CultureInfo) + : InternalFormat(value)); + + /// + /// InternalFormat 方法 + /// InternalFormat Method + /// + /// + /// + protected virtual string? InternalFormat(TValue? value) => value switch + { + null => null, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"Unsupported type {value!.GetType()}") + }; + + private TValue ParseValue(string value) + { + return value.TryConvertTo(CultureInfo, out var ret) + ? ret + : throw new InvalidOperationException($"Unsupported type {typeof(TValue)}"); + } + + private bool IsDecimalType() + { + // 检查是否允许带小数点数据类型 + var type = ValueType; + return type == typeof(float) || type == typeof(double) || type == typeof(decimal); + } + + /// + /// + /// + protected override async Task OnBlur() + { + if (!PreviousParsingAttemptFailed) + { + CurrentValue = SetMax(SetMin(Value)); + } + else + { + CurrentValue = default!; + } + + if (IsNullable() && string.IsNullOrEmpty(CurrentValueAsString)) + { + // set component value empty + await InvokeVoidAsync("clear", Id); + } + + if (OnBlurAsync != null) + { + await OnBlurAsync(Value); + } + } + + private TValue? SetMin(TValue? val) + { + if (!string.IsNullOrEmpty(Min) && val != null) + { + var min = ParseValue(Min); + if (Comparer.Default.Compare(val, min) < 0) + { + val = min; + } + } + return val; + } + + private TValue? SetMax(TValue? val) + { + if (!string.IsNullOrEmpty(Max) && val != null) + { + var max = ParseValue(Max); + if (Comparer.Default.Compare(val, max) > 0) + { + val = max; + } + } + return val; + } + + private string NormalizeValue(string value) + { + return Formatter != null || !string.IsNullOrEmpty(FormatString) + ? NormalizeNumericValue(value) + : value; + } + + private string NormalizeNumericValue(string value) + { + var format = CultureInfo.NumberFormat; + var decimalSeparators = new[] { format.NumberDecimalSeparator, format.CurrencyDecimalSeparator } + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct() + .ToArray(); + var tokens = new[] + { + format.CurrencySymbol, + format.NumberGroupSeparator, + format.CurrencyGroupSeparator, + format.NegativeSign, + format.PositiveSign + } + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct() + .OrderByDescending(s => s.Length) + .ToArray(); + + var builder = new StringBuilder(value.Length); + var hasDecimalSeparator = false; + for (var index = 0; index < value.Length;) + { + var decimalSeparator = Array.Find(decimalSeparators, separator => value.AsSpan(index).StartsWith(separator, StringComparison.Ordinal)); + if (decimalSeparator != null) + { + if (!hasDecimalSeparator) + { + builder.Append(decimalSeparator); + hasDecimalSeparator = true; + } + index += decimalSeparator.Length; + continue; + } + + var token = Array.Find(tokens, candidate => value.AsSpan(index).StartsWith(candidate, StringComparison.Ordinal)); + if (token != null) + { + builder.Append(token); + index += token.Length; + continue; + } + + var character = value[index]; + if (char.IsDigit(character) || char.IsWhiteSpace(character) || character is '(' or ')') + { + builder.Append(character); + } + else if (IsDecimalType() && character is 'e' or 'E' + && index > 0 + && index + 1 < value.Length + && char.IsDigit(value[index - 1]) + && (char.IsDigit(value[index + 1]) || value[index + 1] is '+' or '-')) + { + builder.Append(character); + } + index++; + } + return builder.ToString(); + } + + /// + /// + /// + /// + /// + /// + protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out TValue result, out string? validationErrorMessage) + { + bool ret; + if (string.IsNullOrEmpty(value)) + { + result = default; + validationErrorMessage = null; + + // nullable data type do not run here + _lastInputValueString = result!.ToString(); + ret = true; + } + else + { + var normalizedValue = NormalizeValue(value); + if (Parser != null) + { + var parsedValue = Parser(normalizedValue, CultureInfo); + ret = parsedValue.Success; + result = ret ? parsedValue.Value! : default; + } + else + { + ret = normalizedValue.TryConvertTo(CultureInfo, out result); + } + + validationErrorMessage = ret ? null : FormatParsingErrorMessage(); + + if (ret && UseInputEvent) + { + _lastInputValueString = value; + } + } + + if (UseInputEvent) + { + _manualInput = true; + } + return ret; + } +} 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/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs new file mode 100644 index 00000000000..c222a04c1fb --- /dev/null +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -0,0 +1,491 @@ +// 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("1e-3", 0.001)] + [InlineData("1e", 1)] + [InlineData("e1", 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; + 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; + return (shouldSucceed, shouldSucceed ? text.Length : default); + }); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("custom")); + Assert.Equal(6, value); + Assert.Same(culture, receivedCulture); + + shouldSucceed = false; + await cut.InvokeAsync(() => cut.Find("input").Change("123")); + Assert.Equal(6, 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("t2"); + }); + Assert.Equal(1, model.Count); + valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); + Assert.False(valid); + + await cut.InvokeAsync(() => + { + input.Change("2"); + }); + 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("t2"); + }); + Assert.Equal(1, model.Count); + + await cut.InvokeAsync(() => + { + input.Change("2"); + }); + 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; } From 1ec833f8c1af7d907ff9db8ce749825e973365fb Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Sun, 6 Sep 2026 19:00:05 +0800 Subject: [PATCH 06/27] =?UTF-8?q?refactor(InputCurrency):=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=95=B0=E6=8D=AE=E6=B8=85=E6=B4=97=E6=A0=87=E5=87=86?= =?UTF-8?q?=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor:重构数据清洗标准化逻辑 test:调整单元测试 --- .../BootstrapInputCurrency.razor.cs | 7 ---- test/UnitTest/Components/InputCurrencyTest.cs | 34 ++++++++----------- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 1087ec8bb49..78fb94e6854 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -216,13 +216,6 @@ protected override async Task OnBlur() } private string NormalizeValue(string value) - { - return Formatter != null || !string.IsNullOrEmpty(FormatString) - ? NormalizeNumericValue(value) - : value; - } - - private string NormalizeNumericValue(string value) { var format = CultureInfo.NumberFormat; var decimalSeparators = new[] { format.NumberDecimalSeparator, format.CurrencyDecimalSeparator } diff --git a/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs index c222a04c1fb..5e8f7913faa 100644 --- a/test/UnitTest/Components/InputCurrencyTest.cs +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -156,9 +156,11 @@ public async Task CultureFormattingAndParsing_Ok() [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; @@ -200,6 +202,7 @@ 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 => { @@ -209,17 +212,20 @@ public async Task Parser_SuccessFailureAndCulture_Ok() pb.Add(a => a.Parser, (text, currentCulture) => { receivedCulture = currentCulture; + receivedText = text; return (shouldSucceed, shouldSucceed ? text.Length : default); }); }); - await cut.InvokeAsync(() => cut.Find("input").Change("custom")); - Assert.Equal(6, value); + 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("123")); - Assert.Equal(6, value); + await cut.InvokeAsync(() => cut.Find("input").Change("456custom")); + Assert.Equal("456", receivedText); + Assert.Equal(3, value); } [Fact] @@ -392,18 +398,12 @@ await cut.InvokeAsync(() => var valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); Assert.False(valid); - await cut.InvokeAsync(() => - { - input.Change("t2"); - }); + 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("2"); - }); + await cut.InvokeAsync(() => input.Change("t2")); Assert.Equal(2, model.Count); valid = await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None)); @@ -432,16 +432,10 @@ await cut.InvokeAsync(() => }); Assert.Equal(1, model.Count); - await cut.InvokeAsync(() => - { - input.Change("t2"); - }); + await cut.InvokeAsync(() => input.Change("test")); Assert.Equal(1, model.Count); - await cut.InvokeAsync(() => - { - input.Change("2"); - }); + await cut.InvokeAsync(() => input.Change("t2")); Assert.Equal(2, model.Count); } From fe20827cb2393cf29807305d8a4bc618c1dc9deb Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Tue, 8 Sep 2026 08:59:59 +0800 Subject: [PATCH 07/27] =?UTF-8?q?doc:=E8=A1=A5=E5=85=A8=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc:补全文档路径 --- .../Extensions/MenusLocalizerExtensions.cs | 5 +++++ src/BootstrapBlazor.Server/Locales/en-US.json | 1 + src/BootstrapBlazor.Server/Locales/zh-CN.json | 1 + src/BootstrapBlazor.Server/docs.json | 1 + 4 files changed, 8 insertions(+) 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 4e6f32a7465..5c1cc46b8fc 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", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index 3981912a05d..13c23d9e6e9 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": "断点阈值", diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index 3d4a710849f..377038bca73 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -407,6 +407,7 @@ "BootstrapInputEventBase", "BootstrapInputGroup", "BootstrapInputGroupIcon", + "BootstrapInputCurrency", "BootstrapInputNumber", "BootstrapInputNumberBase", "Button", From 560858dc23074eff263f76169f9573ce522f9400 Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Tue, 8 Sep 2026 09:27:16 +0800 Subject: [PATCH 08/27] =?UTF-8?q?doc:=E6=96=87=E6=A1=A3=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc:文档示例错误修正 --- .../Components/Samples/InputCurrency.razor | 14 +++++++------- .../Components/Samples/InputCurrency.razor.cs | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor index 44cf2001727..03b4764162f 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor @@ -10,7 +10,7 @@ Introduction="@Localizer["InputCurrencyNormalIntro"]" Name="Normal">
- +
@@ -19,14 +19,14 @@ Name="Culture">
- +
@((MarkupString)Localizer["InputCurrencyCultureIntro2"].Value)
- +
@@ -39,10 +39,10 @@ Name="Percent">
- +
- +
@@ -52,7 +52,7 @@ Name="Customize">
- +
@@ -65,7 +65,7 @@ Name="Range">
- +
diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs index c830cf20f66..4f8c487b72f 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs @@ -19,6 +19,11 @@ public partial class InputCurrency ///
public decimal BindDecimalValue { get; set; } = 1898.78m; + /// + /// + /// + public decimal BindPercentValue { get; set; } = 35.356m; + /// /// /// From c96f9c96fa1ab1bc4a3640b79cee0863b564759c Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 7 Sep 2026 13:10:36 +0800 Subject: [PATCH 09/27] =?UTF-8?q?refactor:=20=E6=9B=B4=E6=94=B9=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B=E6=96=87=E4=BB=B6=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor --- .../{InputCurrency.razor => InputCurrencies.razor} | 10 +++++----- ...InputCurrency.razor.cs => InputCurrencies.razor.cs} | 2 +- ...putCurrency.razor.css => InputCurrencies.razor.css} | 0 src/BootstrapBlazor.Server/Locales/en-US.json | 2 +- src/BootstrapBlazor.Server/Locales/zh-CN.json | 2 +- src/BootstrapBlazor.Server/docs.json | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) rename src/BootstrapBlazor.Server/Components/Samples/{InputCurrency.razor => InputCurrencies.razor} (82%) rename src/BootstrapBlazor.Server/Components/Samples/{InputCurrency.razor.cs => InputCurrencies.razor.cs} (97%) rename src/BootstrapBlazor.Server/Components/Samples/{InputCurrency.razor.css => InputCurrencies.razor.css} (100%) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor similarity index 82% rename from src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor rename to src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor index 03b4764162f..0c818e903ff 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -1,6 +1,6 @@ @page "/input-currency" -@inject IStringLocalizer Localizer +@inject IStringLocalizer Localizer

@Localizer["InputCurrencyTitle"]

@Localizer["InputCurrencyDescription"]

@@ -10,7 +10,7 @@ Introduction="@Localizer["InputCurrencyNormalIntro"]" Name="Normal">
- +
@@ -39,7 +39,7 @@ Name="Percent">
- +
@@ -52,7 +52,7 @@ Name="Customize">
- +
@@ -65,7 +65,7 @@ Name="Range">
- +
diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs similarity index 97% rename from src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs rename to src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs index 4f8c487b72f..e120907ab20 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs @@ -7,7 +7,7 @@ namespace BootstrapBlazor.Server.Components.Samples; -public partial class InputCurrency +public partial class InputCurrencies { /// /// BindValue diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.css similarity index 100% rename from src/BootstrapBlazor.Server/Components/Samples/InputCurrency.razor.css rename to src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.css diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 5c1cc46b8fc..4ae6bffcf36 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -2772,7 +2772,7 @@ "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.InputCurrency": { + "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", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index 13c23d9e6e9..c93bb081f67 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -2772,7 +2772,7 @@ "IntersectionObserverIntro": "通过设置 IsIntersectionObserver=\"true\" 开启懒加载特性,当图片在不可见区域时不加载图片,当图片即将可见时才开始加载图片", "IntersectionObserverTitle": "懒加载" }, - "BootstrapBlazor.Server.Components.Samples.InputCurrency": { + "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { "InputCurrencyCultureIntro": "自行指定文化信息,例如:en-US、zh-CN", "InputCurrencyCultureIntro2": "指定巴西葡萄牙语,例如:pt-BR", "InputCurrencyCultureTitle": "指文化用法", diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index 377038bca73..7e5ffbef0cc 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -104,7 +104,7 @@ "iframe": "IFrames", "image-viewer": "ImageViewers", "input-number": "InputNumbers", - "input-currency": "InputCurrency", + "input-currency": "InputCurrencies", "input": "Inputs", "input-group": "InputGroups", "ip": "Ips", From 31cb3289fb395cb64ae928b3483cf6bb7a314dfc Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 7 Sep 2026 13:27:10 +0800 Subject: [PATCH 10/27] =?UTF-8?q?refactor:=20=E5=A2=9E=E5=8A=A0=20IsCleara?= =?UTF-8?q?ble=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapInputCurrency.razor | 22 +++++++++-- .../BootstrapInputCurrency.razor.cs | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor index 225677568fc..c5b25aaed9a 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -6,12 +6,26 @@ { } -@RenderInput + +@if (IsClearable) +{ +
+ @RenderInput + @if (!IsDisabled && !Readonly) + { + + } +
+} +else +{ + @RenderInput +} @code { RenderFragment RenderInput => @; + placeholder="@PlaceHolder" class="@InputClassString" + readonly="@ReadonlyString" disabled="@Disabled" + @bind-value="@CurrentValueAsString" @bind-value:event="@EventString" @onblur="@OnBlur" />; } diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 78fb94e6854..2618bf96a04 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -50,6 +50,30 @@ public partial class BootstrapInputCurrency [Parameter] public Func? Parser { 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] @@ -57,6 +81,10 @@ public partial class BootstrapInputCurrency private string? ReadonlyString => Readonly ? "true" : null; + private string? ClearableIconString => CssBuilder.Default("form-control-clear-icon") + .AddClass(ClearIcon) + .Build(); + /// /// 获得 文本框样式 /// Get Text Box Style @@ -93,6 +121,7 @@ protected override void OnParametersSet() { base.OnParametersSet(); + ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.InputClearIcon); ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; if (Value is null) @@ -323,4 +352,13 @@ protected override bool TryParseValueFromString(string value, [MaybeNullWhen(fal } return ret; } + + private async Task OnClickClear() + { + if (OnClear != null) + { + await OnClear(Value); + } + CurrentValueAsString = ""; + } } From c8f2e7d4123b5b5e471baf4c55c2626c564667bb Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 7 Sep 2026 13:27:42 +0800 Subject: [PATCH 11/27] =?UTF-8?q?refactor:=20=E9=87=8D=E6=96=B0=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20CultureInfo=20=E9=BB=98=E8=AE=A4=E5=80=BC=E4=B8=BA?= =?UTF-8?q?=20null=20=E4=BD=BF=E7=94=A8=E5=BD=93=E5=89=8D=20UI=20=E6=96=87?= =?UTF-8?q?=E5=8C=96=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InputCurrency/BootstrapInputCurrency.razor.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 2618bf96a04..15005e06f27 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -37,11 +37,11 @@ public partial class BootstrapInputCurrency public string? Max { get; set; } /// - /// 获得/设置 数值解析及格式字符串使用的文化信息 - /// Gets or sets the culture used to parse and format numeric values + /// 获得/设置 数值解析及格式字符串使用的文化信息,默认 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 CultureInfo CultureInfo { get; set; } = CultureInfo.InvariantCulture; + public CultureInfo? CultureInfo { get; set; } /// /// 获得/设置 自定义数值解析回调方法。解析成功后由组件格式化显示值 @@ -108,6 +108,7 @@ public partial class BootstrapInputCurrency protected override void OnInitialized() { base.OnInitialized(); + if (UseInputEvent) { _lastInputValueString ??= Value?.ToString(); @@ -124,6 +125,9 @@ protected override void OnParametersSet() ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.InputClearIcon); ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; + CultureInfo ??= CultureInfo.CurrentUICulture; + FormatString ??= "C"; + if (Value is null) { _lastInputValueString = ""; From 9e950f0461c5f8ae4b448308cc93814bc4ce75f5 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 8 Sep 2026 17:39:45 +0800 Subject: [PATCH 12/27] =?UTF-8?q?refactor:=20=E9=87=8D=E6=96=B0=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapInputCurrency.razor | 14 +- .../BootstrapInputCurrency.razor.cs | 269 ++---------------- .../BootstrapInputCurrency.razor.scss | 17 ++ 3 files changed, 56 insertions(+), 244 deletions(-) create mode 100644 src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor index c5b25aaed9a..05c2c149303 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -1,6 +1,5 @@ @namespace BootstrapBlazor.Components -@typeparam TValue -@inherits BootstrapInputNumberBase +@inherits BootstrapInputBase @if (IsShowLabel) { @@ -24,8 +23,11 @@ else @code { RenderFragment RenderInput => - @; + @
+ +
; } diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 15005e06f27..17f79e834c0 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Localization; using System.Globalization; -using System.Text; namespace BootstrapBlazor.Components; @@ -13,7 +12,7 @@ namespace BootstrapBlazor.Components; /// BootstrapInputCurrency 组件 /// BootstrapInputCurrency component ///
-public partial class BootstrapInputCurrency +public partial class BootstrapInputCurrency { /// /// 获得/设置 是否为只读,默认为 false @@ -27,35 +26,35 @@ public partial class BootstrapInputCurrency /// Gets or sets Minimum Value /// [Parameter] - public string? Min { get; set; } + public decimal? Min { get; set; } /// /// 获得/设置 最大值 /// Gets or sets Maximum Value /// [Parameter] - public string? Max { get; set; } + 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 CultureInfo? CultureInfo { get; set; } + public string? CultureName { get; set; } /// - /// 获得/设置 自定义数值解析回调方法。解析成功后由组件格式化显示值 - /// Gets or sets the custom numeric parser. The component formats the value after successful parsing + /// 获得/设置 是否显示 ISO 货币符号,默认 false 不显示 + /// Gets or sets whether to show ISO currency symbol. Default is false /// [Parameter] - public Func? Parser { get; set; } + 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; } + public Func? OnClear { get; set; } /// /// 获得/设置 是否显示清空小按钮,默认为 false @@ -77,7 +76,7 @@ public partial class BootstrapInputCurrency [Inject] [NotNull] - private IStringLocalizer>? Localizer { get; set; } + private IStringLocalizer? Localizer { get; set; } private string? ReadonlyString => Readonly ? "true" : null; @@ -85,35 +84,21 @@ public partial class BootstrapInputCurrency .AddClass(ClearIcon) .Build(); - /// - /// 获得 文本框样式 - /// Get Text Box Style - /// - protected string? InputClassString => CssBuilder.Default("form-control") + 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? InputModeString => IsDecimalType() ? "decimal" : "numeric"; - - - private string? _lastInputValueString; - - private bool _manualInput; + private string? InputStyleString => string.IsNullOrEmpty(_currencySymbol) ? null : CssBuilder.Default() + .AddClass($"--bb-input-currency-symbol-length: {_currencySymbol.Length};") + .AddClass($"--bb-input-currency-symbol: '{_currencySymbol}';") + .Build(); - /// - /// - /// - protected override void OnInitialized() - { - base.OnInitialized(); - if (UseInputEvent) - { - _lastInputValueString ??= Value?.ToString(); - } - } + private CultureInfo? _currentCultureInfo; /// /// @@ -125,32 +110,23 @@ protected override void OnParametersSet() ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.InputClearIcon); ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; - CultureInfo ??= CultureInfo.CurrentUICulture; - FormatString ??= "C"; - - if (Value is null) + if (string.IsNullOrEmpty(CultureName)) { - _lastInputValueString = ""; - } + _currentCultureInfo = CultureInfo.CurrentUICulture; - if (UseInputEvent && !_manualInput) + } + else { - _lastInputValueString = GetFormatString(Value); + _currentCultureInfo = CultureInfo.GetCultureInfo(CultureName); } + + _currencySymbol = IsIsoSymbol ? GetIsoCurrencySymbol(_currentCultureInfo.Name) : _currentCultureInfo.NumberFormat.CurrencySymbol; } - /// - /// - /// - /// - protected override void OnAfterRender(bool firstRender) + private static string GetIsoCurrencySymbol(string cultureName) { - base.OnAfterRender(firstRender); - - if (_manualInput) - { - _manualInput = false; - } + var regionInfo = new RegionInfo(cultureName); + return regionInfo.ISOCurrencySymbol; } /// @@ -158,63 +134,15 @@ protected override void OnAfterRender(bool firstRender) /// protected override string? FormatParsingErrorMessage() => string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayText); - /// - /// - /// - protected override string? FormatValueAsString(TValue? value) => UseInputEvent ? _lastInputValueString : GetFormatString(value); - - private string? GetFormatString(TValue? value) => Formatter != null - ? Formatter.Invoke(value) - : (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable - ? formattable.ToString(FormatString, CultureInfo) - : InternalFormat(value)); - - /// - /// InternalFormat 方法 - /// InternalFormat Method - /// - /// - /// - protected virtual string? InternalFormat(TValue? value) => value switch - { - null => null, - IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), - _ => throw new InvalidOperationException($"Unsupported type {value!.GetType()}") - }; - - private TValue ParseValue(string value) - { - return value.TryConvertTo(CultureInfo, out var ret) - ? ret - : throw new InvalidOperationException($"Unsupported type {typeof(TValue)}"); - } - - private bool IsDecimalType() - { - // 检查是否允许带小数点数据类型 - var type = ValueType; - return type == typeof(float) || type == typeof(double) || type == typeof(decimal); - } - /// /// /// protected override async Task OnBlur() { - if (!PreviousParsingAttemptFailed) - { - CurrentValue = SetMax(SetMin(Value)); - } - else - { - CurrentValue = default!; - } - - if (IsNullable() && string.IsNullOrEmpty(CurrentValueAsString)) - { - // set component value empty - await InvokeVoidAsync("clear", Id); - } + var max = Max ?? decimal.MaxValue; + var min = Min ?? decimal.MinValue; + var val = CurrentValue ?? 0; + CurrentValue = Math.Clamp(val, min, max); if (OnBlurAsync != null) { @@ -222,141 +150,6 @@ protected override async Task OnBlur() } } - private TValue? SetMin(TValue? val) - { - if (!string.IsNullOrEmpty(Min) && val != null) - { - var min = ParseValue(Min); - if (Comparer.Default.Compare(val, min) < 0) - { - val = min; - } - } - return val; - } - - private TValue? SetMax(TValue? val) - { - if (!string.IsNullOrEmpty(Max) && val != null) - { - var max = ParseValue(Max); - if (Comparer.Default.Compare(val, max) > 0) - { - val = max; - } - } - return val; - } - - private string NormalizeValue(string value) - { - var format = CultureInfo.NumberFormat; - var decimalSeparators = new[] { format.NumberDecimalSeparator, format.CurrencyDecimalSeparator } - .Where(s => !string.IsNullOrEmpty(s)) - .Distinct() - .ToArray(); - var tokens = new[] - { - format.CurrencySymbol, - format.NumberGroupSeparator, - format.CurrencyGroupSeparator, - format.NegativeSign, - format.PositiveSign - } - .Where(s => !string.IsNullOrEmpty(s)) - .Distinct() - .OrderByDescending(s => s.Length) - .ToArray(); - - var builder = new StringBuilder(value.Length); - var hasDecimalSeparator = false; - for (var index = 0; index < value.Length;) - { - var decimalSeparator = Array.Find(decimalSeparators, separator => value.AsSpan(index).StartsWith(separator, StringComparison.Ordinal)); - if (decimalSeparator != null) - { - if (!hasDecimalSeparator) - { - builder.Append(decimalSeparator); - hasDecimalSeparator = true; - } - index += decimalSeparator.Length; - continue; - } - - var token = Array.Find(tokens, candidate => value.AsSpan(index).StartsWith(candidate, StringComparison.Ordinal)); - if (token != null) - { - builder.Append(token); - index += token.Length; - continue; - } - - var character = value[index]; - if (char.IsDigit(character) || char.IsWhiteSpace(character) || character is '(' or ')') - { - builder.Append(character); - } - else if (IsDecimalType() && character is 'e' or 'E' - && index > 0 - && index + 1 < value.Length - && char.IsDigit(value[index - 1]) - && (char.IsDigit(value[index + 1]) || value[index + 1] is '+' or '-')) - { - builder.Append(character); - } - index++; - } - return builder.ToString(); - } - - /// - /// - /// - /// - /// - /// - protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out TValue result, out string? validationErrorMessage) - { - bool ret; - if (string.IsNullOrEmpty(value)) - { - result = default; - validationErrorMessage = null; - - // nullable data type do not run here - _lastInputValueString = result!.ToString(); - ret = true; - } - else - { - var normalizedValue = NormalizeValue(value); - if (Parser != null) - { - var parsedValue = Parser(normalizedValue, CultureInfo); - ret = parsedValue.Success; - result = ret ? parsedValue.Value! : default; - } - else - { - ret = normalizedValue.TryConvertTo(CultureInfo, out result); - } - - validationErrorMessage = ret ? null : FormatParsingErrorMessage(); - - if (ret && UseInputEvent) - { - _lastInputValueString = value; - } - } - - if (UseInputEvent) - { - _manualInput = true; - } - return ret; - } - private async Task OnClickClear() { if (OnClear != null) 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; + } +} From 735a0e521ea34c69d1b1854f5a9bd07b508e8b46 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 8 Sep 2026 17:40:00 +0800 Subject: [PATCH 13/27] =?UTF-8?q?chore:=20=E5=A2=9E=E5=8A=A0=20scss=20?= =?UTF-8?q?=E6=BA=90=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/BootstrapBlazor/wwwroot/scss/components.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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"; From 0858339f804d1baa48789be4075a4f70d5262e93 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 8 Sep 2026 17:51:45 +0800 Subject: [PATCH 14/27] =?UTF-8?q?doc:=20=E6=9B=B4=E6=96=B0=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Samples/InputCurrencies.razor | 55 +++++------------- .../Samples/InputCurrencies.razor.cs | 56 +++---------------- .../Samples/InputCurrencies.razor.css | 7 --- 3 files changed, 20 insertions(+), 98 deletions(-) delete mode 100644 src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.css diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor index 0c818e903ff..19b0f95d2ca 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -9,53 +9,28 @@ +
+
+
+ +
+
+
- +
+
@((MarkupString)Localizer["InputCurrencyCultureIntro2"].Value)
- -
-
- -
-
@((MarkupString)Localizer["InputCurrencyCultureIntro2"].Value)
-
- -
-
- -
-
-
- - -
-
- +
- -
-
-
- - -
-
- -
-
- +
@@ -63,11 +38,7 @@ -
-
- -
-
+
- + diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs index e120907ab20..45e55c0a1a7 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs @@ -3,59 +3,17 @@ // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone -using System.Globalization; - namespace BootstrapBlazor.Server.Components.Samples; +/// +/// InputCurrencies 组件 +/// InputCurrencies component +/// public partial class InputCurrencies { - /// - /// BindValue - /// - public double BindValue { get; set; } = 5; - - /// - /// - /// - public decimal BindDecimalValue { get; set; } = 1898.78m; - - /// - /// - /// - public decimal BindPercentValue { get; set; } = 35.356m; - - /// - /// - /// - public double BindMaxMinValue { get; set; } = 9.23; - - /// - /// - /// - public double? NullableValue { get; set; } = 5; - - private string Format1(double value) - { - double.TryParse("$235,623.235", NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out var number); - double.TryParse("¥1235,623.235", NumberStyles.Currency, CultureInfo.GetCultureInfo("zh-CN"), out var number1); - return $"$ {value.ToString("n0")}"; - } - private string Format(double? value) - { - if (value != null) - return $"$ {value?.ToString("n0")}"; - else - return null; - } + private decimal _value = 1234.56m; - private string FormatCustomize(decimal value) - { - return $"RMB {value.ToString("n2")}"; - } + private decimal? _value2 = 2m; - private (bool Success, decimal Value) Parser(string arg1, CultureInfo info) - { - var ret = (true, decimal.Parse(arg1.Replace("%", ""), info)); - return ret; - } + private bool _useIsoSymbol; } diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.css b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.css deleted file mode 100644 index f13df052c95..00000000000 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.css +++ /dev/null @@ -1,7 +0,0 @@ -.demo-input-currency { - width: 200px; -} - - .demo-input-currency > ::deep .form-control { - width: inherit; - } From a66851694cb0d777fafce1b0afc99d1635659bb8 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 8 Sep 2026 17:51:55 +0800 Subject: [PATCH 15/27] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20style=20?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/InputCurrency/BootstrapInputCurrency.razor | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor index 05c2c149303..9ff4248a8cb 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -23,8 +23,8 @@ else @code { RenderFragment RenderInput => - @
- + Date: Wed, 9 Sep 2026 10:34:02 +0800 Subject: [PATCH 16/27] =?UTF-8?q?refactor(InputCurrency):=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E7=BB=84=E4=BB=B6=E8=BE=93=E5=85=A5=E5=8F=8A=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=E6=98=BE=E7=A4=BA=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor:重构组件输入及格式化显示逻辑 refactor:保留IsIsoSymbol参数伪类显示,以便满足,只想显示货币符号,并不想将数字格式化的用户群体 doc:修改文档部份示例,以测试文化格式化后的情况 --- .../Components/Samples/InputCurrencies.razor | 2 +- .../BootstrapInputCurrency.razor | 16 +++++- .../BootstrapInputCurrency.razor.cs | 53 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor index 19b0f95d2ca..179d75374b8 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -30,7 +30,7 @@
- +
diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor index 9ff4248a8cb..f526063ac0f 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -18,11 +18,25 @@ } else { - @RenderInput + @if (IsIsoSymbol) + { + @RenderSymbolInput + } + else + { + @RenderInput + } } @code { RenderFragment RenderInput => + @; + + RenderFragment RenderSymbolInput => @
_isEditing ? "number" : "text"; + private bool _isEditing; + + private string? _cultureValue + { + set + { + if (string.IsNullOrEmpty(value)) + { + CurrentValue = null; + } + else if (decimal.TryParse(value, NumberStyles.Any, _currentCultureInfo, out var result)) + { + CurrentValue = result; + } + } + get + { + if (_isEditing) + { + return CurrentValue?.ToString(); + } + else + return IsIsoSymbol ? CurrentValue?.ToString() : GetFormatString(CurrentValue); + } + } + + /// + /// + /// + protected override string? FormatValueAsString(decimal? value) => IsIsoSymbol ? CurrentValue?.ToString() : GetFormatString(value); + + private string? GetFormatString(decimal? value) => Formatter != null + ? Formatter.Invoke(value) + : (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable + ? formattable.ToString(FormatString, _currentCultureInfo) + : value?.ToString()); + /// /// /// @@ -121,6 +159,10 @@ protected override void OnParametersSet() } _currencySymbol = IsIsoSymbol ? GetIsoCurrencySymbol(_currentCultureInfo.Name) : _currentCultureInfo.NumberFormat.CurrencySymbol; + if (IsIsoSymbol) + { + _cultureValue = CurrentValue?.ToString(); + } } private static string GetIsoCurrencySymbol(string cultureName) @@ -134,6 +176,16 @@ private static string GetIsoCurrencySymbol(string cultureName) ///
protected override string? FormatParsingErrorMessage() => string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayText); + /// + /// + /// + /// + protected Task OnFocus() + { + _isEditing = true; + return Task.CompletedTask; + } + /// /// /// @@ -142,6 +194,7 @@ protected override async Task OnBlur() var max = Max ?? decimal.MaxValue; var min = Min ?? decimal.MinValue; var val = CurrentValue ?? 0; + _isEditing = false; CurrentValue = Math.Clamp(val, min, max); if (OnBlurAsync != null) From 1b7d41f5638b3984473aebe4cab8ca399a14e318 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 10 Sep 2026 12:21:27 +0800 Subject: [PATCH 17/27] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapInputCurrency.razor | 48 +++---- .../BootstrapInputCurrency.razor.cs | 134 ++++++++---------- .../BootstrapInputCurrency.razor.scss | 19 +-- 3 files changed, 85 insertions(+), 116 deletions(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor index f526063ac0f..4a1f00c29ef 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -1,47 +1,39 @@ @namespace BootstrapBlazor.Components -@inherits BootstrapInputBase +@inherits BootstrapInputBase @if (IsShowLabel) { } -@if (IsClearable) -{ -
- @RenderInput - @if (!IsDisabled && !Readonly) +
+
+ @if (SymbolTemplate != null) { - + @SymbolTemplate + } + else + { + @_currencySymbol }
-} -else -{ - @if (IsIsoSymbol) + @if (IsClearable && !IsDisabled && !Readonly) { - @RenderSymbolInput +
+ @RenderInput + +
} else { @RenderInput - } -} + } +
@code { RenderFragment RenderInput => - @; - - RenderFragment RenderSymbolInput => - @
- -
; + @; } diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 440ae4fdf17..5f57e6205d1 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -42,12 +42,26 @@ public partial class BootstrapInputCurrency [Parameter] public string? CultureName { get; set; } + /// + /// 获得/设置小数位数,默认 null,使用当前货币文化的小数位数。设置 FormatStringFormatter 时本参数不生效 + /// Gets or sets the number of decimal places. The default is null, which uses the current currency culture. This parameter is ignored when FormatString or Formatter is set + /// + [Parameter] + public int? Decimals { get; set; } + /// /// 获得/设置 是否显示 ISO 货币符号,默认 false 不显示 /// Gets or sets whether to show ISO currency symbol. Default is false /// [Parameter] - public bool IsIsoSymbol { get; set; } + public bool ShowIsoCurrencySymbol { get; set; } + + /// + /// 获得/设置 货币符号模板 + /// Gets or sets the currency symbol template + /// + [Parameter] + public RenderFragment? SymbolTemplate { get; set; } /// /// 获得/设置 清空文本框时的回调方法,默认为 null @@ -92,51 +106,7 @@ public partial class BootstrapInputCurrency .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; - - private string? InputType => _isEditing ? "number" : "text"; - private bool _isEditing; - - private string? _cultureValue - { - set - { - if (string.IsNullOrEmpty(value)) - { - CurrentValue = null; - } - else if (decimal.TryParse(value, NumberStyles.Any, _currentCultureInfo, out var result)) - { - CurrentValue = result; - } - } - get - { - if (_isEditing) - { - return CurrentValue?.ToString(); - } - else - return IsIsoSymbol ? CurrentValue?.ToString() : GetFormatString(CurrentValue); - } - } - - /// - /// - /// - protected override string? FormatValueAsString(decimal? value) => IsIsoSymbol ? CurrentValue?.ToString() : GetFormatString(value); - - private string? GetFormatString(decimal? value) => Formatter != null - ? Formatter.Invoke(value) - : (!string.IsNullOrEmpty(FormatString) && value is IFormattable formattable - ? formattable.ToString(FormatString, _currentCultureInfo) - : value?.ToString()); + private CultureInfo _currentCultureInfo = CultureInfo.CurrentUICulture; /// /// @@ -148,59 +118,70 @@ protected override void OnParametersSet() ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.InputClearIcon); ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; - if (string.IsNullOrEmpty(CultureName)) - { - _currentCultureInfo = CultureInfo.CurrentUICulture; - - } - else + if (Decimals is < 0 or > 28) { - _currentCultureInfo = CultureInfo.GetCultureInfo(CultureName); + throw new ArgumentOutOfRangeException(nameof(Decimals), Decimals, "DecimalPlaces must be between 0 and 28."); } - _currencySymbol = IsIsoSymbol ? GetIsoCurrencySymbol(_currentCultureInfo.Name) : _currentCultureInfo.NumberFormat.CurrencySymbol; - if (IsIsoSymbol) - { - _cultureValue = CurrentValue?.ToString(); - } + _currentCultureInfo = string.IsNullOrEmpty(CultureName) ? CultureInfo.CurrentUICulture : CultureInfo.GetCultureInfo(CultureName); + _currencySymbol = ShowIsoCurrencySymbol ? GetIsoCurrencySymbol(_currentCultureInfo) : _currentCultureInfo.NumberFormat.CurrencySymbol; } - private static string GetIsoCurrencySymbol(string cultureName) + private static string GetIsoCurrencySymbol(CultureInfo culture) { - var regionInfo = new RegionInfo(cultureName); - return regionInfo.ISOCurrencySymbol; + if (string.IsNullOrEmpty(culture.Name)) + { + return culture.NumberFormat.CurrencySymbol; + } + + var specificCulture = culture.IsNeutralCulture ? CultureInfo.CreateSpecificCulture(culture.Name) : culture; + return new RegionInfo(specificCulture.Name).ISOCurrencySymbol; } /// /// /// - protected override string? FormatParsingErrorMessage() => string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayText); + protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out decimal result, out string? validationErrorMessage) + { + var ret = decimal.TryParse(value, NumberStyles.Currency, _currentCultureInfo, out result); + validationErrorMessage = ret ? null : FormatParsingErrorMessage(); + if (!ret) + { + result = default; + } + + return ret; + } /// /// /// + /// /// - protected Task OnFocus() + protected override string? FormatValueAsString(decimal value) { - _isEditing = true; - return Task.CompletedTask; + if (Formatter != null) + { + return Formatter(value); + } + + var decimalPlaces = Decimals ?? _currentCultureInfo.NumberFormat.CurrencyDecimalDigits; + var format = string.IsNullOrEmpty(FormatString) ? $"N{decimalPlaces}" : FormatString; + return value.ToString(format, _currentCultureInfo); } + /// + /// + /// + 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; - _isEditing = false; - CurrentValue = Math.Clamp(val, min, max); - - if (OnBlurAsync != null) - { - await OnBlurAsync(Value); - } + CurrentValue = Math.Clamp(CurrentValue, Min ?? decimal.MinValue, Max ?? decimal.MaxValue); + await base.OnBlur(); } private async Task OnClickClear() @@ -209,6 +190,7 @@ private async Task OnClickClear() { await OnClear(Value); } - CurrentValueAsString = ""; + + CurrentValue = default; } } diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss index d71c5b5b653..84ddab62a28 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss @@ -1,17 +1,12 @@ .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; + input[type=number]::-webkit-outer-spin-button, + input[type=number]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; } - .form-control { - padding-inline-start: calc(var(--bb-input-currency-symbol-length, 0) * 1em + var(--bb-input-currency-padding-x)) !important; + + input[type=number] { + -moz-appearance: textfield; } } From a6a43b60b5d46a180e347d445536a966d23a1097 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 10 Sep 2026 12:21:36 +0800 Subject: [PATCH 18/27] =?UTF-8?q?doc:=20=E5=A2=9E=E5=8A=A0=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Samples/InputCurrencies.razor | 15 ++++++++++----- .../Components/Samples/InputCurrencies.razor.cs | 5 +++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor index 179d75374b8..ddad73a1c90 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -12,12 +12,17 @@
- + +
+
+
- + +
@@ -25,12 +30,12 @@ Introduction="@Localizer["InputCurrencyCultureIntro"]" Name="Culture">
@((MarkupString)Localizer["InputCurrencyCultureIntro2"].Value)
-
+
- +
- +
diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs index 45e55c0a1a7..8bff8a81e44 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs @@ -13,7 +13,8 @@ public partial class InputCurrencies { private decimal _value = 1234.56m; - private decimal? _value2 = 2m; + private decimal _value2 = 2m; - private bool _useIsoSymbol; + private bool _showIsoCurrencySymbol; + private bool _showClearIcon; } From c1347da83dd448096e6a3debe8455ad9748a7b3f Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 10 Sep 2026 12:24:09 +0800 Subject: [PATCH 19/27] =?UTF-8?q?doc:=20=E6=9B=B4=E6=96=B0=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/BootstrapBlazor.Server/Locales/en-US.json | 6 +++--- src/BootstrapBlazor.Server/Locales/zh-CN.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 4ae6bffcf36..f0eeea7bb47 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -2773,9 +2773,9 @@ "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", + "InputCurrencyCultureIntro": "Specify culture information, for example: en-US, zh-CN", + "InputCurrencyCultureIntro2": "Specify Brazilian Portuguese, for example: pt-BR", + "InputCurrencyCultureTitle": "Specify culture name", "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.", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index c93bb081f67..8b3d214f297 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -2773,9 +2773,9 @@ "IntersectionObserverTitle": "懒加载" }, "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { - "InputCurrencyCultureIntro": "自行指定文化信息,例如:en-US、zh-CN", - "InputCurrencyCultureIntro2": "指定巴西葡萄牙语,例如:pt-BR", - "InputCurrencyCultureTitle": "指文化用法", + "InputCurrencyCultureIntro": "自行指定文化信息,例如:en-US、zh-CN", + "InputCurrencyCultureIntro2": "指定巴西葡萄牙语,例如:pt-BR", + "InputCurrencyCultureTitle": "指定文化名称", "InputCurrencyCustomizeIntro": "使用 Formatter 回调来实现自定义格式用法", "InputCurrencyCustomizeTitle": "自定义格式用法", "InputCurrencyDescription": "仅允许输入标准的数字值,并提供格式化功能,例如:输入12345.78,千分位显示:12,345.78;货币显示:¥12,345.78;百分比显示:12,345.78%", From 18911739e03e34ac998226f9f3a25998b13f1e0e Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 10 Sep 2026 12:29:49 +0800 Subject: [PATCH 20/27] =?UTF-8?q?doc:=20=E6=9B=B4=E6=96=B0=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Samples/InputCurrencies.razor | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor index ddad73a1c90..a6988562686 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -40,6 +40,16 @@
+ + + + + + + + From 2bd93fe1f33adfda6f1fe15d782e6b78af86a6f6 Mon Sep 17 00:00:00 2001 From: Tony-ST0754 <6914529@qq.com> Date: Thu, 10 Sep 2026 12:48:54 +0800 Subject: [PATCH 21/27] =?UTF-8?q?doc:=E6=96=87=E6=A1=A3=E4=B8=80=E5=A4=84?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E9=94=99=E8=AF=AF=E7=BA=A0=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc:文档一处标记错误纠正 --- .../Components/Samples/InputCurrencies.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor index a6988562686..58e572566e6 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -42,7 +42,7 @@ + Name="SymbolTemplate"> From 5deee4d404dc88643ef11a1cbae7ef68901723d7 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 11 Sep 2026 11:53:43 +0800 Subject: [PATCH 22/27] =?UTF-8?q?doc:=20=E6=9B=B4=E6=96=B0=E7=A4=BA?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Samples/InputCurrencies.razor | 6 +++--- src/BootstrapBlazor.Server/Locales/en-US.json | 2 +- src/BootstrapBlazor.Server/Locales/zh-CN.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor index 58e572566e6..c59ad4efd17 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor @@ -28,11 +28,11 @@ -
@((MarkupString)Localizer["InputCurrencyCultureIntro2"].Value)
+ Name="CultureName"> +
@((MarkupString)Localizer["InputCurrencyCultureDesc"].Value)
- +
diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index f0eeea7bb47..92f351ede69 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -2773,8 +2773,8 @@ "IntersectionObserverTitle": "Intersection Observer" }, "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { + "InputCurrencyCultureDesc": "Specify Brazilian Portuguese, for example: pt-BR; note that the thousand separator is a dot and the decimal separator is a comma; you can specify the number of decimal places using the Decimals parameter", "InputCurrencyCultureIntro": "Specify culture information, for example: en-US, zh-CN", - "InputCurrencyCultureIntro2": "Specify Brazilian Portuguese, for example: pt-BR", "InputCurrencyCultureTitle": "Specify culture name", "InputCurrencyCustomizeIntro": "Use the Formatter callback to achieve custom format usage", "InputCurrencyCustomizeTitle": "Custom format usage", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index 8b3d214f297..9106b4430f0 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -2773,8 +2773,8 @@ "IntersectionObserverTitle": "懒加载" }, "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { + "InputCurrencyCultureDesc": "指定巴西葡萄牙语,例如:pt-BR;注意千分符为 点号,小数点符号为 逗号;可以通过 Decimals 指定小数位数", "InputCurrencyCultureIntro": "自行指定文化信息,例如:en-US、zh-CN", - "InputCurrencyCultureIntro2": "指定巴西葡萄牙语,例如:pt-BR", "InputCurrencyCultureTitle": "指定文化名称", "InputCurrencyCustomizeIntro": "使用 Formatter 回调来实现自定义格式用法", "InputCurrencyCustomizeTitle": "自定义格式用法", From d57c1e5c96a5e3efb51921d61ceeaec7e36147ca Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 11 Sep 2026 14:04:02 +0800 Subject: [PATCH 23/27] =?UTF-8?q?refactor:=20=E5=A2=9E=E5=8A=A0=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E8=BF=87=E6=BB=A4=E9=9D=9E=E6=95=B0=E5=AD=97=E5=AD=97?= =?UTF-8?q?=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapInputCurrency.razor | 4 +- .../BootstrapInputCurrency.razor.cs | 45 +++++++- .../BootstrapInputCurrency.razor.js | 105 ++++++++++++++++++ 3 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.js diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor index 4a1f00c29ef..34fc35d481a 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -6,7 +6,7 @@ } -
+
@if (SymbolTemplate != null) { @@ -32,7 +32,7 @@ @code { RenderFragment RenderInput => - @; diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 5f57e6205d1..450db7abf5d 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -12,6 +12,7 @@ namespace BootstrapBlazor.Components; /// BootstrapInputCurrency 组件 /// BootstrapInputCurrency component ///
+[BootstrapModuleAutoLoader("InputCurrency/BootstrapInputCurrency.razor.js", JSObjectReference = true)] public partial class BootstrapInputCurrency { /// @@ -68,7 +69,7 @@ public partial class BootstrapInputCurrency /// Gets or sets the callback method when clearing text box. Default is null /// [Parameter] - public Func? OnClear { get; set; } + public Func? OnClear { get; set; } /// /// 获得/设置 是否显示清空小按钮,默认为 false @@ -100,7 +101,7 @@ public partial class BootstrapInputCurrency private string? _currencySymbol; - private string? InputClassString => CssBuilder.Default("form-control") + private string? InputClassString => CssBuilder.Default("bb-input-currencyinput form-control") .AddClass(CssClass).AddClass(ValidCss) .AddClass($"border-{Color.ToDescriptionString()}", Color != Color.None) .AddClassFromAttributes(AdditionalAttributes) @@ -108,6 +109,14 @@ public partial class BootstrapInputCurrency private CultureInfo _currentCultureInfo = CultureInfo.CurrentUICulture; + private string InputId => $"{Id}_input"; + + /// + /// + /// + /// + protected override string? GetInputId() => InputId; + /// /// /// @@ -127,6 +136,38 @@ protected override void OnParametersSet() _currencySymbol = ShowIsoCurrencySymbol ? GetIsoCurrencySymbol(_currentCultureInfo) : _currentCultureInfo.NumberFormat.CurrencySymbol; } + /// + /// + /// + protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, GetAllowedInputTokens()); + + /// + /// + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (!firstRender) + { + await InvokeVoidAsync("update", Id, GetAllowedInputTokens()); + } + } + + private string[] GetAllowedInputTokens() + { + var format = _currentCultureInfo.NumberFormat; + return + [ + format.NumberDecimalSeparator, + format.NumberGroupSeparator, + format.CurrencyDecimalSeparator, + format.CurrencyGroupSeparator, + format.PositiveSign, + format.NegativeSign + ]; + } + private static string GetIsoCurrencySymbol(CultureInfo culture) { if (string.IsNullOrEmpty(culture.Name)) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.js b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.js new file mode 100644 index 00000000000..7a64aca67d3 --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.js @@ -0,0 +1,105 @@ +import Data from "../../modules/data.js" +import EventHandler from "../../modules/event-handler.js" +import * as Input from "../Input/BootstrapInput.razor.js" + +export const focus = Input.focus +export const clear = Input.clear +export const handleKeyUp = Input.handleKeyUp +export const select = Input.select +export const selectAllByFocus = Input.selectAllByFocus +export const selectAllByEnter = Input.selectAllByEnter + +const sanitize = (value, tokens) => { + let result = '' + let index = 0 + + while (index < value.length) { + const character = value[index] + if (character >= '0' && character <= '9') { + result += character + index++ + continue + } + + const token = tokens.find(token => value.startsWith(token, index)) + if (token) { + result += token + index += token.length + } + else { + index++ + } + } + + return result +} + +const normalizeTokens = tokens => [...new Set(tokens)] + .filter(token => token) + .sort((left, right) => right.length - left.length) + +const sanitizeInput = state => { + const { el, tokens } = state + const value = sanitize(el.value, tokens) + if (value === el.value) { + return + } + + const selectionStart = el.selectionStart ?? el.value.length + const caret = sanitize(el.value.substring(0, selectionStart), tokens).length + el.value = value + el.setSelectionRange(caret, caret) +} + +export function init(id, tokens) { + const el = document.getElementById(`${id}_input`) + if (!el) { + return + } + + const state = { el, tokens: normalizeTokens(tokens), isComposing: false } + Data.set(id, state) + + EventHandler.on(el, 'beforeinput', e => { + if (state.isComposing || e.isComposing) { + e.preventDefault(); + return; + } + + if (e.inputType?.startsWith('insert') && e.data && sanitize(e.data, state.tokens) !== e.data) { + e.preventDefault() + } + }) + EventHandler.on(el, 'input', e => { + if (!e.isComposing) { + sanitizeInput(state) + } + }) + EventHandler.on(el, 'compositionstart', () => { + state.isComposing = true + }) + EventHandler.on(el, 'compositionend', () => { + state.isComposing = false + sanitizeInput(state) + }) +} + +export function update(id, tokens) { + const state = Data.get(id) + if (state) { + state.tokens = normalizeTokens(tokens) + } +} + +export function dispose(id) { + const state = Data.get(id) + Data.remove(id) + + if (state) { + EventHandler.off(state.el, 'beforeinput') + EventHandler.off(state.el, 'input') + EventHandler.off(state.el, 'compositionstart') + EventHandler.off(state.el, 'compositionend') + } + Input.dispose(`${id}_input`) +} From 0920e4f499812c87cf994395bd687328b48a505c Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 11 Sep 2026 14:28:55 +0800 Subject: [PATCH 24/27] =?UTF-8?q?test:=20=E6=9B=B4=E6=96=B0=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapInputCurrency.razor.cs | 8 +- test/UnitTest/Components/InputCurrencyTest.cs | 476 +++++------------- 2 files changed, 115 insertions(+), 369 deletions(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index 450db7abf5d..c458c857d28 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -170,13 +170,7 @@ private string[] GetAllowedInputTokens() private static string GetIsoCurrencySymbol(CultureInfo culture) { - if (string.IsNullOrEmpty(culture.Name)) - { - return culture.NumberFormat.CurrencySymbol; - } - - var specificCulture = culture.IsNeutralCulture ? CultureInfo.CreateSpecificCulture(culture.Name) : culture; - return new RegionInfo(specificCulture.Name).ISOCurrencySymbol; + return new RegionInfo(culture.Name).ISOCurrencySymbol; } /// diff --git a/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs index 5e8f7913faa..29762c4400e 100644 --- a/test/UnitTest/Components/InputCurrencyTest.cs +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -5,481 +5,233 @@ 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() + public async Task RenderAttributes_Ok() { - var integer = Context.Render>(pb => + var cut = Context.Render(pb => { pb.Add(a => a.Readonly, true); pb.Add(a => a.Color, Color.Danger); + pb.Add(a => a.PlaceHolder, "Amount"); pb.Add(a => a.AdditionalAttributes, new Dictionary { ["data-test"] = "currency" }); }); - var input = integer.Find("input"); + + var input = cut.Find("input"); Assert.Equal("true", input.GetAttribute("readonly")); - Assert.Equal("numeric", input.GetAttribute("inputmode")); - Assert.Equal("currency", input.GetAttribute("data-test")); + Assert.Equal("decimal", input.GetAttribute("inputmode")); + Assert.Equal("Amount", input.GetAttribute("placeholder")); Assert.Contains("border-danger", input.ClassList); + Assert.Equal("currency", cut.Find(".bb-input-currency").GetAttribute("data-test")); - 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()); + // 调用 focus 触发 GetInputId 方法 + await cut.InvokeAsync(() => cut.Instance.FocusAsync()); } [Fact] - public void ValidateForm() + public void FormatValue_Ok() { - var foo = new Cat() { Count = 20 }; - var cut = Context.Render(pb => + var culture = CultureInfo.GetCultureInfo("en-US"); + 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))); - }); + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); }); - cut.Contains("class=\"form-label\""); - var input = cut.Find("input"); - cut.InvokeAsync(() => input.Change("")); + Assert.Equal("1,234.50", cut.Find("input").GetAttribute("value")); + cut.Contains("
$
"); - 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 => + cut.Render(pb => { - pb.Add(a => a.Value, 10.01m); - pb.Add(a => a.Formatter, v => $"{v + 1}"); + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); + pb.Add(a => a.Decimals, 3); }); - var input = cut.Find("input"); - Assert.Equal("11.01", input.GetAttribute("value")); + Assert.Equal("1,234.500", cut.Find("input").GetAttribute("value")); cut.Render(pb => { - pb.Add(a => a.Formatter, null); - pb.Add(a => a.FormatString, "#0.0"); + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); + pb.Add(a => a.FormatString, "0.0"); }); - Assert.Equal("10.0", input.GetAttribute("value")); - - input = cut.Find("input"); - cut.InvokeAsync(() => input.Change("")); - } + Assert.Equal("1234.5", cut.Find("input").GetAttribute("value")); - [Fact] - public async Task CultureFormattingAndParsing_Ok() - { - var culture = CultureInfo.GetCultureInfo("de-DE"); - var value = 1234.5m; - var cut = Context.Render>(pb => + cut.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"); + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); + pb.Add(a => a.Formatter, value => $"{value + 1:0.00}"); }); - 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")); + Assert.Equal("1235.50", 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() + [InlineData("en-US", "USD")] + [InlineData("zh-CN", "CNY")] + [InlineData("en", "USD")] + public void ShowIsoCurrencySymbol_Ok(string cultureName, string expected) { - var value = 0; - var cut = Context.Render>(pb => + 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); + pb.Add(a => a.CultureName, cultureName); + pb.Add(a => a.ShowIsoCurrencySymbol, true); }); - await cut.InvokeAsync(() => cut.Find("input").Change("12abc")); - - Assert.Equal(12, value); + Assert.Equal(expected, cut.Find(".input-group-text").TextContent); } [Fact] - public async Task Parser_SuccessFailureAndCulture_Ok() + public void SymbolTemplate_Ok() { - var value = 1; - var culture = CultureInfo.GetCultureInfo("fr-FR"); - CultureInfo? receivedCulture = null; - string? receivedText = null; - var shouldSucceed = true; - var cut = Context.Render>(pb => + 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); - }); + pb.Add(a => a.SymbolTemplate, builder => builder.AddContent(0, "Custom")); }); - 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); + Assert.Equal("Custom", cut.Find(".input-group-text").TextContent); } [Fact] - public async Task UseInputEvent_FormatterPreservesInput_Ok() + public async Task CultureParsing_Ok() { var value = 0m; - var cut = Context.Render>(pb => + 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")); + pb.Add(a => a.CultureName, "de-DE"); }); - await cut.InvokeAsync(() => cut.Find("input").Input("$1,234.50abc")); + await cut.InvokeAsync(() => cut.Find("input").Change("2.345,75")); - Assert.Equal(1234.50m, value); - Assert.Equal("$1,234.50abc", cut.Find("input").GetAttribute("value")); + Assert.Equal(2345.75m, value); + Assert.Equal("2.345,75", cut.Find("input").GetAttribute("value")); } [Fact] - public void Formatter_Null() + public async Task MinMax_Ok() { - var cut = Context.Render>(pb => + var cut = Context.Render(pb => { - pb.Add(a => a.FormatString, "d2"); + pb.Add(a => a.Value, 5m); + pb.Add(a => a.Min, 10m); }); - cut.Contains("value=\"\""); - } - [Fact] - public void Formatter_Error() - { - Assert.ThrowsAny(() => Context.Render()); - } + await cut.InvokeAsync(() => cut.Find("input").Blur()); + Assert.Equal(10m, cut.Instance.Value); - [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 => + cut.Render(pb => { - builder.Add(a => a.OnBlurAsync, v => - { - blur = true; - return Task.CompletedTask; - }); + pb.Add(a => a.Value, 15m); + pb.Add(a => a.Max, 10m); }); - var input = cut.Find("input"); - await cut.InvokeAsync(() => { input.Blur(); }); - Assert.True(blur); + await cut.InvokeAsync(() => cut.Find("input").Blur()); + Assert.Equal(10m, cut.Instance.Value); } [Fact] - public async Task OnValueChanged_Ok() + public async Task Clearable_Ok() { - int? changedValue = null; - var cut = Context.Render>(pb => + decimal? clearedValue = null; + var changedValue = 12.34m; + var cut = Context.Render(pb => { - pb.Add(a => a.OnValueChanged, value => + pb.Add(a => a.Value, changedValue); + pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => changedValue = v)); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.ClearIcon, "test-clear-icon"); + pb.Add(a => a.OnClear, value => { - changedValue = value; + clearedValue = 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); + var icon = cut.Find(".form-control-clear-icon"); + Assert.Contains("test-clear-icon", icon.ClassList); + await cut.InvokeAsync(() => icon.Click()); + Assert.Equal(12.34m, clearedValue); + Assert.Equal(0m, changedValue); cut.Render(pb => { - pb.Add(a => a.Value, 15); - pb.Add(a => a.Min, null); - pb.Add(a => a.Max, "10"); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.Readonly, true); }); - input = cut.Find("input"); - await cut.InvokeAsync(() => input.Blur()); - Assert.Equal(10, cut.Instance.Value); + Assert.Empty(cut.FindAll(".form-control-clear-icon")); cut.Render(pb => { - pb.Add(a => a.Value, 5); - pb.Add(a => a.Min, "0"); - pb.Add(a => a.Max, "10"); + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.IsDisabled, true); }); - input = cut.Find("input"); - await cut.InvokeAsync(() => input.Blur()); - Assert.Equal(5, cut.Instance.Value); + Assert.Empty(cut.FindAll(".form-control-clear-icon")); } - [Fact] - public async Task InvalidMin_Throws_Ok() + [Theory] + [InlineData(-1)] + [InlineData(29)] + public void DecimalsOutOfRange_Throws(int decimals) { - var cut = Context.Render>(pb => + Assert.Throws(() => 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); + pb.Add(a => a.Decimals, decimals); + })); } [Fact] - public async Task Validate_Ok() + public async Task OnBlurAsync_Ok() { - var model = new Foo() { Count = 1 }; - var cut = Context.Render(pb => + decimal? value = null; + var cut = Context.Render(pb => { - pb.Add(a => a.Model, model); - pb.AddChildContent>(builder => + pb.Add(a => a.Value, 10m); + pb.Add(a => a.OnBlurAsync, v => { - 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))); + value = v; + return Task.CompletedTask; }); }); - 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); + await cut.InvokeAsync(() => cut.Find("input").Blur()); + Assert.Equal(10m, value); } [Fact] - public async Task TryParseValueFromString_Ok() + public async Task ValidateForm_Ok() { - var model = new Foo() { Count = 1 }; - var cut = Context.Render>(pb => + var model = new CurrencyModel { Amount = 10m }; + var cut = Context.Render(pb => { - pb.Add(a => a.Value, 1); - pb.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => + pb.Add(a => a.Model, model); + pb.AddChildContent(builder => { - 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"); + builder.Add(a => a.Value, model.Amount); + builder.Add(a => a.ValueChanged, EventCallback.Factory.Create(this, v => model.Amount = v)); + builder.Add(a => a.ValueExpression, Utility.GenerateValueExpression(model, nameof(CurrencyModel.Amount), typeof(decimal))); + }); }); - Assert.Equal(1, model.Count); - await cut.InvokeAsync(() => input.Change("test")); - Assert.Equal(1, model.Count); + await cut.InvokeAsync(() => cut.Find("input").Change("invalid")); + Assert.Equal(10m, model.Amount); + Assert.False(await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None))); - await cut.InvokeAsync(() => input.Change("t2")); - Assert.Equal(2, model.Count); + await cut.InvokeAsync(() => cut.Find("input").Change("5")); + Assert.Equal(5m, model.Amount); + Assert.True(await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None))); } - private class Cat + private class CurrencyModel { [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(""); - } + public decimal Amount { get; set; } } } From d03a0a8b6e20bec746b145632060db080a7ed3fc Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 11 Sep 2026 14:33:43 +0800 Subject: [PATCH 25/27] =?UTF-8?q?revert:=20=E6=92=A4=E9=94=80=E6=9B=B4?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Extensions/ObjectExtensions.cs | 56 -------- .../Extensions/ObjectExtensionsTest.cs | 130 ------------------ 2 files changed, 186 deletions(-) diff --git a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs index 380cc8ca98c..4a9eecf76cc 100644 --- a/src/BootstrapBlazor/Extensions/ObjectExtensions.cs +++ b/src/BootstrapBlazor/Extensions/ObjectExtensions.cs @@ -206,62 +206,6 @@ 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/test/UnitTest/Extensions/ObjectExtensionsTest.cs b/test/UnitTest/Extensions/ObjectExtensionsTest.cs index e4ac33637e0..387a135524a 100644 --- a/test/UnitTest/Extensions/ObjectExtensionsTest.cs +++ b/test/UnitTest/Extensions/ObjectExtensionsTest.cs @@ -164,124 +164,6 @@ 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")] @@ -485,18 +367,6 @@ 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; } From fbd393aff91cc09b5518733c0b0efbc74de8b304 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 11 Sep 2026 14:41:14 +0800 Subject: [PATCH 26/27] =?UTF-8?q?refactor:=20=E5=A2=9E=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E5=8C=96=E7=BC=93=E5=AD=98=E6=8F=90=E9=AB=98=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InputCurrency/BootstrapInputCurrency.razor.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs index c458c857d28..e8b0a6af352 100644 --- a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -4,6 +4,7 @@ // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone using Microsoft.Extensions.Localization; +using System.Collections.Concurrent; using System.Globalization; namespace BootstrapBlazor.Components; @@ -15,6 +16,8 @@ namespace BootstrapBlazor.Components; [BootstrapModuleAutoLoader("InputCurrency/BootstrapInputCurrency.razor.js", JSObjectReference = true)] public partial class BootstrapInputCurrency { + private static readonly ConcurrentDictionary _isoCurrencySymbols = []; + /// /// 获得/设置 是否为只读,默认为 false /// Gets or sets whether readonly. Default is false @@ -170,7 +173,15 @@ private string[] GetAllowedInputTokens() private static string GetIsoCurrencySymbol(CultureInfo culture) { - return new RegionInfo(culture.Name).ISOCurrencySymbol; + if (string.IsNullOrEmpty(culture.Name)) + { + return culture.NumberFormat.CurrencySymbol; + } + + var cultureName = culture.IsNeutralCulture + ? CultureInfo.CreateSpecificCulture(culture.Name).Name + : culture.Name; + return _isoCurrencySymbols.GetOrAdd(cultureName, static name => new RegionInfo(name).ISOCurrencySymbol); } /// From a2ad6e36af379398a4790ac5ab9c1d3c206cec51 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 11 Sep 2026 14:42:57 +0800 Subject: [PATCH 27/27] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E6=97=A0?= =?UTF-8?q?=E5=9C=B0=E5=8C=BA=E6=96=87=E5=8C=96=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/UnitTest/Components/InputCurrencyTest.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs index 29762c4400e..a1ebc001b3f 100644 --- a/test/UnitTest/Components/InputCurrencyTest.cs +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -88,6 +88,26 @@ public void ShowIsoCurrencySymbol_Ok(string cultureName, string expected) Assert.Equal(expected, cut.Find(".input-group-text").TextContent); } + [Fact] + public void ShowIsoCurrencySymbol_InvariantCulture_Ok() + { + var originalCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture; + var cut = Context.Render(pb => + { + pb.Add(a => a.ShowIsoCurrencySymbol, true); + }); + + Assert.Equal(CultureInfo.InvariantCulture.NumberFormat.CurrencySymbol, cut.Find(".input-group-text").TextContent); + } + finally + { + CultureInfo.CurrentUICulture = originalCulture; + } + } + [Fact] public void SymbolTemplate_Ok() {