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

@Localizer["InputCurrencyTitle"]

+

@Localizer["InputCurrencyDescription"]

+

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

+ + +
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ + +
@((MarkupString)Localizer["InputCurrencyCultureDesc"].Value)
+
+
+ +
+
+ +
+
+
+ + + + + + + + + + + + + + diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs new file mode 100644 index 00000000000..8bff8a81e44 --- /dev/null +++ b/src/BootstrapBlazor.Server/Components/Samples/InputCurrencies.razor.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Server.Components.Samples; + +/// +/// InputCurrencies 组件 +/// InputCurrencies component +/// +public partial class InputCurrencies +{ + private decimal _value = 1234.56m; + + private decimal _value2 = 2m; + + private bool _showIsoCurrencySymbol; + private bool _showClearIcon; +} diff --git a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs index a1f831f0029..a41936e123c 100644 --- a/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs +++ b/src/BootstrapBlazor.Server/Components/Samples/InputNumbers.razor.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the Apache 2.0 License // See the LICENSE file in the project root for more information. // Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone diff --git a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs index 7f52c8503b0..2a60a35c3e7 100644 --- a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs +++ b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs @@ -446,6 +446,11 @@ void AddForm(DemoMenuItem item) Url = "input-number" }, new() + { + Text = Localizer["BootstrapInputCurrency"], + Url = "input-currency" + }, + new() { Text = Localizer["BootstrapInputGroup"], Url = "input-group" diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index 780ed5a6a19..92f351ede69 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -174,6 +174,7 @@ "BootstrapInput": "BootstrapInput", "BootstrapInputGroup": "BootstrapInputGroup", "BootstrapInputNumber": "BootstrapInputNumber", + "BootstrapInputCurrency": "BootstrapInputCurrency", "BootstrapLabel": "Labels", "Breadcrumb": "Breadcrumb", "Breakpoints": "Breakpoints", @@ -2771,6 +2772,22 @@ "IntersectionObserverIntro": "By setting IsIntersectionObserver=\"true\" to enable lazy loading, the image will not be loaded when it is in the invisible area, and will be loaded only when it is about to be visible.", "IntersectionObserverTitle": "Intersection Observer" }, + "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { + "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", + "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.", + "InputCurrencyMemo": "This component is actually an extension of the InputNumbers component, designed to meet the need to display formatted numeric values such as currency and percentages in the UI.", + "InputCurrencyNormalIntro": "Number numeric type displays a text box, automatically bringing up the numeric keyboard on mobile devices", + "InputCurrencyNormalTitle": "Basic usage", + "InputCurrencyPercentIntro": "You can use Formatter or FormatString to achieve percentage display", + "InputCurrencyPercentTitle": "Percentage usage", + "InputCurrencyRangeIntro": "Set Max and Min to control the numeric range 1-10", + "InputCurrencyRangeTitle": "Range limitation usage", + "InputCurrencyTitle": "InputCurrency" + }, "BootstrapBlazor.Server.Components.Samples.InputGroups": { "InputGroupsCheckboxIntro": "Add Checkbox or CheckboxList to the InputGroup", "InputGroupsCheckboxTitle": "Checkbox", @@ -6067,7 +6084,6 @@ "Picture.Required": "{0} is required" }, "BootstrapBlazor.Server.Components.Samples.ValidateForms": { - "AsyncValidationError": "The user name already exists.", "AsyncValidationUserName": "User name", "ChangeButtonText": "Change", "CustomValidationFormComment1": "Add mailbox validation rules", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index 40735fdcb2c..9106b4430f0 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -174,6 +174,7 @@ "BootstrapInput": "输入框 BootstrapInput", "BootstrapInputGroup": "输入组 BootstrapInputGroup", "BootstrapInputNumber": "数字框 BootstrapInputNumber", + "BootstrapInputCurrency": "货币框 BootstrapInputCurrency", "BootstrapLabel": "表单标签", "Breadcrumb": "面包屑 Breadcrumb", "Breakpoints": "断点阈值", @@ -2771,6 +2772,22 @@ "IntersectionObserverIntro": "通过设置 IsIntersectionObserver=\"true\" 开启懒加载特性,当图片在不可见区域时不加载图片,当图片即将可见时才开始加载图片", "IntersectionObserverTitle": "懒加载" }, + "BootstrapBlazor.Server.Components.Samples.InputCurrencies": { + "InputCurrencyCultureDesc": "指定巴西葡萄牙语,例如:pt-BR;注意千分符为 点号,小数点符号为 逗号;可以通过 Decimals 指定小数位数", + "InputCurrencyCultureIntro": "自行指定文化信息,例如:en-US、zh-CN", + "InputCurrencyCultureTitle": "指定文化名称", + "InputCurrencyCustomizeIntro": "使用 Formatter 回调来实现自定义格式用法", + "InputCurrencyCustomizeTitle": "自定义格式用法", + "InputCurrencyDescription": "仅允许输入标准的数字值,并提供格式化功能,例如:输入12345.78,千分位显示:12,345.78;货币显示:¥12,345.78;百分比显示:12,345.78%", + "InputCurrencyMemo": "本组件,实际上是 InputNumbers 组件的扩展,以满足有需要在 UI 中显示货币、百分比等格式化数值的需求", + "InputCurrencyNormalIntro": "Number 数值类型显示文本框,移动端自动弹出数字键盘", + "InputCurrencyNormalTitle": "基础用法", + "InputCurrencyPercentIntro": "可使用 FormatterFormatString 来实现百分比显示", + "InputCurrencyPercentTitle": "百分比用法", + "InputCurrencyRangeIntro": "设置 Max Min 来控制数值区间范围 1-10", + "InputCurrencyRangeTitle": "区间限制用法", + "InputCurrencyTitle": "InputCurrency 组件" + }, "BootstrapBlazor.Server.Components.Samples.InputGroups": { "InputGroupsCheckboxIntro": "往 InputGroup 里面增加 Checkbox 或者 CheckboxList", "InputGroupsCheckboxTitle": "复选框组合", @@ -6067,7 +6084,6 @@ "Picture.Required": "上传文件不能为空" }, "BootstrapBlazor.Server.Components.Samples.ValidateForms": { - "AsyncValidationError": "用户名已存在", "AsyncValidationUserName": "用户名", "ChangeButtonText": "更改组件", "CustomValidationFormComment1": "增加邮箱验证规则", diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index 4dd9a807ec2..7e5ffbef0cc 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -104,6 +104,7 @@ "iframe": "IFrames", "image-viewer": "ImageViewers", "input-number": "InputNumbers", + "input-currency": "InputCurrencies", "input": "Inputs", "input-group": "InputGroups", "ip": "Ips", @@ -406,6 +407,7 @@ "BootstrapInputEventBase", "BootstrapInputGroup", "BootstrapInputGroupIcon", + "BootstrapInputCurrency", "BootstrapInputNumber", "BootstrapInputNumberBase", "Button", diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor new file mode 100644 index 00000000000..34fc35d481a --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor @@ -0,0 +1,39 @@ +@namespace BootstrapBlazor.Components +@inherits BootstrapInputBase + +@if (IsShowLabel) +{ + +} + +
+
+ @if (SymbolTemplate != null) + { + @SymbolTemplate + } + else + { + @_currencySymbol + } +
+ @if (IsClearable && !IsDisabled && !Readonly) + { +
+ @RenderInput + +
+ } + else + { + @RenderInput + } +
+ +@code { + RenderFragment RenderInput => + @; +} diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs new file mode 100644 index 00000000000..e8b0a6af352 --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.cs @@ -0,0 +1,242 @@ +// 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.Collections.Concurrent; +using System.Globalization; + +namespace BootstrapBlazor.Components; + +/// +/// BootstrapInputCurrency 组件 +/// BootstrapInputCurrency component +/// +[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 + /// + [Parameter] + public bool Readonly { get; set; } + + /// + /// 获得/设置 最小值 + /// Gets or sets Minimum Value + /// + [Parameter] + public decimal? Min { get; set; } + + /// + /// 获得/设置 最大值 + /// Gets or sets Maximum Value + /// + [Parameter] + public decimal? Max { get; set; } + + /// + /// 获得/设置 数值解析及格式字符串使用的文化信息,默认 null 未设置使用当前 UI 文化信息 + /// Gets or sets the culture used to parse and format numeric values. Default is null, which uses the current UI culture + /// + [Parameter] + public string? CultureName { get; set; } + + /// + /// 获得/设置小数位数,默认 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 ShowIsoCurrencySymbol { get; set; } + + /// + /// 获得/设置 货币符号模板 + /// Gets or sets the currency symbol template + /// + [Parameter] + public RenderFragment? SymbolTemplate { get; set; } + + /// + /// 获得/设置 清空文本框时的回调方法,默认为 null + /// Gets or sets the callback method when clearing text box. Default is null + /// + [Parameter] + public Func? OnClear { get; set; } + + /// + /// 获得/设置 是否显示清空小按钮,默认为 false + /// Gets or sets whether to show clear button. Default is false + /// + [Parameter] + public bool IsClearable { get; set; } + + /// + /// 获得/设置 清空小按钮图标,默认为 null + /// Gets or sets the clear button icon. Default is null + /// + [Parameter] + public string? ClearIcon { get; set; } + + [Inject] + [NotNull] + private IIconTheme? IconTheme { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + private string? ReadonlyString => Readonly ? "true" : null; + + private string? ClearableIconString => CssBuilder.Default("form-control-clear-icon") + .AddClass(ClearIcon) + .Build(); + + private string? _currencySymbol; + + private string? InputClassString => CssBuilder.Default("bb-input-currencyinput form-control") + .AddClass(CssClass).AddClass(ValidCss) + .AddClass($"border-{Color.ToDescriptionString()}", Color != Color.None) + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + private CultureInfo _currentCultureInfo = CultureInfo.CurrentUICulture; + + private string InputId => $"{Id}_input"; + + /// + /// + /// + /// + protected override string? GetInputId() => InputId; + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + ClearIcon ??= IconTheme.GetIconByKey(ComponentIcons.InputClearIcon); + ParsingErrorMessage ??= Localizer[nameof(ParsingErrorMessage)]; + + if (Decimals is < 0 or > 28) + { + throw new ArgumentOutOfRangeException(nameof(Decimals), Decimals, "DecimalPlaces must be between 0 and 28."); + } + + _currentCultureInfo = string.IsNullOrEmpty(CultureName) ? CultureInfo.CurrentUICulture : CultureInfo.GetCultureInfo(CultureName); + _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)) + { + 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); + } + + /// + /// + /// + 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 override string? FormatValueAsString(decimal value) + { + 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() + { + CurrentValue = Math.Clamp(CurrentValue, Min ?? decimal.MinValue, Max ?? decimal.MaxValue); + await base.OnBlur(); + } + + private async Task OnClickClear() + { + if (OnClear != null) + { + await OnClear(Value); + } + + CurrentValue = default; + } +} 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`) +} diff --git a/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss new file mode 100644 index 00000000000..84ddab62a28 --- /dev/null +++ b/src/BootstrapBlazor/Components/InputCurrency/BootstrapInputCurrency.razor.scss @@ -0,0 +1,12 @@ +.bb-input-currency { + input[type=number]::-webkit-outer-spin-button, + input[type=number]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + + input[type=number] { + -moz-appearance: textfield; + } +} diff --git a/src/BootstrapBlazor/Locales/en.json b/src/BootstrapBlazor/Locales/en.json index a20e0ca9a8c..bba7c671b16 100644 --- a/src/BootstrapBlazor/Locales/en.json +++ b/src/BootstrapBlazor/Locales/en.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "The {0} field must be a number." }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "The {0} field must be a number." + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "Yes", "ButtonNoText": "No", diff --git a/src/BootstrapBlazor/Locales/zh.json b/src/BootstrapBlazor/Locales/zh.json index 3690d2b9253..8b70a11853e 100644 --- a/src/BootstrapBlazor/Locales/zh.json +++ b/src/BootstrapBlazor/Locales/zh.json @@ -78,6 +78,9 @@ "BootstrapBlazor.Components.BootstrapInputNumber": { "ParsingErrorMessage": "{0}字段值必须为 Number 类型" }, + "BootstrapBlazor.Components.BootstrapInputCurrency": { + "ParsingErrorMessage": "{0}字段值必须为 Number 类型" + }, "BootstrapBlazor.Components.ResultDialogOption": { "ButtonYesText": "确认", "ButtonNoText": "取消", diff --git a/src/BootstrapBlazor/wwwroot/scss/components.scss b/src/BootstrapBlazor/wwwroot/scss/components.scss index 90e6b6864fb..c0bc6c4502b 100644 --- a/src/BootstrapBlazor/wwwroot/scss/components.scss +++ b/src/BootstrapBlazor/wwwroot/scss/components.scss @@ -1,4 +1,4 @@ -@use "root.scss"; +@use "root.scss"; @use "../../Components/Affix/Affix.razor.scss"; @use "../../Components/Alert/Alert.razor.scss"; @use "../../Components/AnchorLink/AnchorLink.razor.scss"; @@ -57,6 +57,7 @@ @use "../../Components/Input/BootstrapInputGroup.razor.scss"; @use "../../Components/Input/FloatingLabel.razor.scss"; @use "../../Components/Input/OtpInput.razor.scss"; +@use "../../Components/InputCurrency/BootstrapInputCurrency.razor.scss"; @use "../../Components/IntersectionObserver/LoadMore.razor.scss"; @use "../../Components/IpAddress/IpAddress.razor.scss"; @use "../../Components/Layout/Layout.razor.scss"; diff --git a/test/UnitTest/Components/InputCurrencyTest.cs b/test/UnitTest/Components/InputCurrencyTest.cs new file mode 100644 index 00000000000..a1ebc001b3f --- /dev/null +++ b/test/UnitTest/Components/InputCurrencyTest.cs @@ -0,0 +1,257 @@ +// 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; + +namespace UnitTest.Components; + +public class InputCurrencyTest : BootstrapBlazorTestBase +{ + [Fact] + public async Task RenderAttributes_Ok() + { + 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 = cut.Find("input"); + Assert.Equal("true", input.GetAttribute("readonly")); + 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")); + + // 调用 focus 触发 GetInputId 方法 + await cut.InvokeAsync(() => cut.Instance.FocusAsync()); + } + + [Fact] + public void FormatValue_Ok() + { + var culture = CultureInfo.GetCultureInfo("en-US"); + var cut = Context.Render(pb => + { + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); + }); + + Assert.Equal("1,234.50", cut.Find("input").GetAttribute("value")); + cut.Contains("
$
"); + + cut.Render(pb => + { + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); + pb.Add(a => a.Decimals, 3); + }); + Assert.Equal("1,234.500", cut.Find("input").GetAttribute("value")); + + cut.Render(pb => + { + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); + pb.Add(a => a.FormatString, "0.0"); + }); + Assert.Equal("1234.5", cut.Find("input").GetAttribute("value")); + + cut.Render(pb => + { + pb.Add(a => a.Value, 1234.5m); + pb.Add(a => a.CultureName, culture.Name); + pb.Add(a => a.Formatter, value => $"{value + 1:0.00}"); + }); + Assert.Equal("1235.50", cut.Find("input").GetAttribute("value")); + } + + [Theory] + [InlineData("en-US", "USD")] + [InlineData("zh-CN", "CNY")] + [InlineData("en", "USD")] + public void ShowIsoCurrencySymbol_Ok(string cultureName, string expected) + { + var cut = Context.Render(pb => + { + pb.Add(a => a.CultureName, cultureName); + pb.Add(a => a.ShowIsoCurrencySymbol, true); + }); + + 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() + { + var cut = Context.Render(pb => + { + pb.Add(a => a.SymbolTemplate, builder => builder.AddContent(0, "Custom")); + }); + + Assert.Equal("Custom", cut.Find(".input-group-text").TextContent); + } + + [Fact] + public async Task CultureParsing_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.CultureName, "de-DE"); + }); + + await cut.InvokeAsync(() => cut.Find("input").Change("2.345,75")); + + Assert.Equal(2345.75m, value); + Assert.Equal("2.345,75", cut.Find("input").GetAttribute("value")); + } + + [Fact] + public async Task MinMax_Ok() + { + var cut = Context.Render(pb => + { + pb.Add(a => a.Value, 5m); + pb.Add(a => a.Min, 10m); + }); + + await cut.InvokeAsync(() => cut.Find("input").Blur()); + Assert.Equal(10m, cut.Instance.Value); + + cut.Render(pb => + { + pb.Add(a => a.Value, 15m); + pb.Add(a => a.Max, 10m); + }); + await cut.InvokeAsync(() => cut.Find("input").Blur()); + Assert.Equal(10m, cut.Instance.Value); + } + + [Fact] + public async Task Clearable_Ok() + { + decimal? clearedValue = null; + var changedValue = 12.34m; + var cut = Context.Render(pb => + { + 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 => + { + clearedValue = value; + return Task.CompletedTask; + }); + }); + + 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.IsClearable, true); + pb.Add(a => a.Readonly, true); + }); + Assert.Empty(cut.FindAll(".form-control-clear-icon")); + + cut.Render(pb => + { + pb.Add(a => a.IsClearable, true); + pb.Add(a => a.IsDisabled, true); + }); + Assert.Empty(cut.FindAll(".form-control-clear-icon")); + } + + [Theory] + [InlineData(-1)] + [InlineData(29)] + public void DecimalsOutOfRange_Throws(int decimals) + { + Assert.Throws(() => Context.Render(pb => + { + pb.Add(a => a.Decimals, decimals); + })); + } + + [Fact] + public async Task OnBlurAsync_Ok() + { + decimal? value = null; + var cut = Context.Render(pb => + { + pb.Add(a => a.Value, 10m); + pb.Add(a => a.OnBlurAsync, v => + { + value = v; + return Task.CompletedTask; + }); + }); + + await cut.InvokeAsync(() => cut.Find("input").Blur()); + Assert.Equal(10m, value); + } + + [Fact] + public async Task ValidateForm_Ok() + { + var model = new CurrencyModel { Amount = 10m }; + var cut = Context.Render(pb => + { + pb.Add(a => a.Model, model); + pb.AddChildContent(builder => + { + 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))); + }); + }); + + 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(() => cut.Find("input").Change("5")); + Assert.Equal(5m, model.Amount); + Assert.True(await cut.InvokeAsync(() => cut.Instance.ValidateAsync(CancellationToken.None))); + } + + private class CurrencyModel + { + [Range(1, 10)] + public decimal Amount { get; set; } + } +}