Skip to content

feat: Add FluentNumberInput<TValue> component with demo and tests#4668

Open
AClerbois wants to merge 5 commits intomicrosoft:dev-v5from
AClerbois:users/aclerbois/dev-v5/numeric-input-component
Open

feat: Add FluentNumberInput<TValue> component with demo and tests#4668
AClerbois wants to merge 5 commits intomicrosoft:dev-v5from
AClerbois:users/aclerbois/dev-v5/numeric-input-component

Conversation

@AClerbois
Copy link
Copy Markdown
Collaborator

@AClerbois AClerbois commented Apr 1, 2026

Summary

Adds a new FluentNumberInput<TValue> component with full JS interop support, demo documentation, and unit tests.

Changes

Component (src/Core/Components/NumberInput/)

  • FluentNumberInput<TValue> — numeric input wrapping <fluent-text-input type="number"> inside FluentField
  • Supports all .NET numeric types: byte, sbyte, short, ushort, int, uint, long, ulong, float, double, decimal
  • Invariant-culture formatting/parsing (HTML spec compliance)
  • JS interop to apply step/min/max to the inner shadow-DOM <input>, with re-application on parameter changes

JS Interop (src/Core.Scripts/)

  • NumberInput.ts — helper to set step/min/max on the inner control
  • Registered in ExportedMethods.ts

Demo documentation (examples/Demo/.../NumberInput/)

  • Documentation page with examples: Numeric Types, Culture, Appearance, States, Prefix/Suffix
  • API section auto-generated via {{ API Type=FluentNumberInput<int> }}

Tests (tests/Core/Components/NumberInput/)

  • Unit tests covering rendering, binding, parsing, culture-aware parsing, disabled/readonly states, templates, and label
  • Uses both Verify snapshot and MarkupMatches patterns

Closes #4667

Copilot AI review requested due to automatic review settings April 1, 2026 16:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new FluentNumberInput<TValue> component (with JS interop support and tests) and introduces a demo documentation page for it, including an auto-generated API section.

Changes:

  • Added FluentNumberInput<TValue> (Razor/C#) with invariant-culture formatting/parsing and JS interop to apply step/min/max to the inner <input>.
  • Added unit tests for rendering, binding/parsing, and culture-related parsing/formatting behavior.
  • Added demo docs + examples for the new component, including an ## API FluentNumberInput section with {{ API Type=FluentNumberInput<int> }}.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/Core/Components/NumberInput/FluentNumberInputTests.razor New unit tests covering rendering, binding, and culture parsing.
src/Core/Components/NumberInput/FluentNumberInput.razor.css Adds width/max-width styling consistency with other inputs.
src/Core/Components/NumberInput/FluentNumberInput.razor.cs Implements the new component logic and JS interop calls.
src/Core/Components/NumberInput/FluentNumberInput.razor Adds the Razor markup wrapping <fluent-text-input type="number"> in FluentField.
src/Core.Scripts/src/ExportedMethods.ts Exposes the new NumberInput JS namespace on window.Microsoft.FluentUI....
src/Core.Scripts/src/Components/NumberInput/NumberInput.ts Adds helper to set step/min/max on the inner control.
examples/Demo/FluentUI.Demo.Client/Documentation/Components/NumberInput/FluentNumberInput.md New documentation page including API generation directive.
examples/Demo/FluentUI.Demo.Client/Documentation/Components/NumberInput/Examples/NumberInput*.razor New demo examples for common scenarios (types, culture, min/max/step, etc.).
AGENTS.md Updates the repo structure description (minor wording change).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@AClerbois AClerbois changed the title docs: Add API section for FluentNumberInput component documentation feat: Add FluentNumberInput<TValue> component with demo and tests Apr 1, 2026
- Revert AGENTS.md change (unrelated to this PR)
- Fix Min/Max/Step to reapply when parameters change (not only on firstRender)
- Add Wrap=true to Appearance example for responsive layout
- Simplify Culture example, move docs to .md file
- Delete redundant examples (Default, Immediate, MinMaxStep)
- Update FluentNumberInput.md to remove deleted example references
- Add Verify snapshot test for default int rendering
@AClerbois AClerbois requested a review from dvoituron April 1, 2026 18:22
@MarvinKlein1508 MarvinKlein1508 self-requested a review April 1, 2026 19:45
}

result = default;
validationErrorMessage = $"The '{DisplayName ?? "Unknown Bound Field"}' field is not valid.";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be in the localization files

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added NumberInput_InvalidValue key to LanguageResource.resx with value "The {0} field is not a valid number." and the error message now uses Localizer[Localization.LanguageResource.NumberInput_InvalidValue].

/// </summary>
protected override string? FormatValueAsString(TValue value)
{
return Convert.ToString(value, CultureInfo.InvariantCulture);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should use BindConverter here to avoid boxing.

protected override string? FormatValueAsString(TValue? value)
    {
        // Avoiding a cast to IFormattable to avoid boxing.
        switch (value)
        {
            case null:
                return null;

            case int @int:
                return BindConverter.FormatValue(@int, CultureInfo.InvariantCulture);

            case long @long:
                return BindConverter.FormatValue(@long, CultureInfo.InvariantCulture);

            case short @short:
                return BindConverter.FormatValue(@short, CultureInfo.InvariantCulture);

            case float @float:
                return BindConverter.FormatValue(@float, CultureInfo.InvariantCulture);

            case double @double:
                return BindConverter.FormatValue(@double, CultureInfo.InvariantCulture);

            case decimal @decimal:
                return BindConverter.FormatValue(@decimal, CultureInfo.InvariantCulture);

            default:
                throw new InvalidOperationException($"Unsupported type {value.GetType()}");
        }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed differently: replaced the IComparable<TValue> constraint with INumber<TValue> (from System.Numerics, available since .NET 7). Since INumber<T> inherits IFormattable, we now call value.ToString(null, Culture) directly — no boxing occurs because TValue is constrained to struct. This avoids the large switch on concrete types while covering all numeric types.

{
// Normalize comma to dot to handle edge cases where a browser
// might send the locale-formatted decimal separator.
var normalized = value?.Replace(',', '.');
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that this is necessary. The native InputNumber component from Blazor just does it like this:

protected override bool TryParseValueFromString(string? value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string? validationErrorMessage)
    {
        if (BindConverter.TryConvertTo<TValue>(value, CultureInfo.InvariantCulture, out result))
        {
            validationErrorMessage = null;
            return true;
        }
        else
        {
            validationErrorMessage = string.Format(CultureInfo.InvariantCulture, ParsingErrorMessage, DisplayName ?? FieldIdentifier.FieldName);
            return false;
        }
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Replaced BindConverter.TryConvertTo with TValue.TryParse(normalized, Culture, out var parsedValue) — a static abstract method from IParsable<TSelf> (inherited by INumber<TValue>). Simpler and no trimming annotation needed.

/// <exception cref="InvalidOperationException">Thrown when TValue is not a supported numeric type.</exception>
public FluentNumberInput(LibraryConfiguration configuration) : base(configuration)
{
if (typeof(TValue) != typeof(byte) &&
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably check for nullable types as well.

 var targetType = Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by switching the constraint to where TValue : struct, INumber<TValue>. This provides compile-time safety — only numeric types are accepted. The 11-type runtime check and Nullable.GetUnderlyingType are no longer needed since the compiler enforces the constraint.

@inherits FluentUITestContext
@code
{
public FluentNumberInputTests()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add test cases for null as well? In v4 this component was quite buggy with null.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good remark, I will do

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests for invalid input ("abc") and empty input (""), which verify the value remains unchanged when parsing fails. With the struct constraint, TValue itself cannot be null (e.g. int defaults to 0). Total: 24 tests, 95.5% coverage.

@using System.Globalization

<FluentStack Orientation="Orientation.Vertical" VerticalGap="10">
<FluentNumberInput TValue="double"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You must add a Culture (type CultureInfo) parameter to allow a specific format when the browser or server uses a different culture. E.g. https://localhost:7197/DateTime/Calendar#culture

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added [Parameter] public CultureInfo Culture { get; set; } = CultureInfo.InvariantCulture; — used in FormatValueAsString, TryParseValueFromString, and FormatNullableValue.

Label="@($"Culture: {CultureInfo.CurrentCulture.Name}")"
Step="0.01"
Min="0"
Max="9999.99"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the minimum and maximum values are not met, the bound value must be set to the minimum or maximum value, not to the specified value.
See an example here: https://www.mudblazor.com/components/numericfield

Image

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Values are now clamped to Min/Max bounds in TryParseValueFromString. Added tests: Clamp_AboveMax, Clamp_BelowMin, Clamp_WithinRange, Clamp_Double_AboveMax, and Clamp_Double_BelowMin.

Min="0"
Max="9999.99"
Placeholder="0.00"
@bind-Value="@price" />
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why, but decimal characters aren't accepted in this component, like in the input element by default.
Example:

  1. Using . or , in the FluentNumberInput
  2. Using the same keys in <input type="number" step="0.01" min="0" max="999" @bind-value="@price" />

Image

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dvoituron I can't even write , or . into this fields. How did you put the , there?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe because my Windows system is set to use a comma… and the “Group+Decimal” symbols are accepted in the “input” field?

{CBE6D7CD-A8FF-4017-9492-39B19CE39840}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AClerbois it looks like the underlying web-component is not yet supporting the number type. The only way for us to get this to work is switching the input type to text.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a limitation of the <fluent-text-input type="number"> web component — it doesn't properly support decimal character input like a native <input type="number"> does. As @MarvinKlein1508 mentioned, switching to type="text" may be the only workaround. This would require changes to the Razor template and JS interop. Should we address this in a follow-up PR?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AClerbois I'm not sure about a follow-up PR. Right now this component is pretty much unusable for me when I need to enter and decimal numbers. It only works for full numbers for me.

I can't enter 1.5 or 1,5. My locale is de-de

- Replace struct+IComparable constraint with INumber<TValue> for compile-time numeric type safety
- Use TValue.TryParse (via IParsable) instead of BindConverter.TryConvertTo
- Use IFormattable.ToString instead of Convert.ToString to avoid boxing
- Add Culture parameter (defaults to InvariantCulture) for formatting/parsing
- Clamp parsed values to Min/Max bounds
- Localize parsing error message via LanguageResource.NumberInput_InvalidValue
- Add tests: invalid input, empty input, double clamping (24 total, 95.5% coverage)
@AClerbois
Copy link
Copy Markdown
Collaborator Author

AClerbois commented Apr 3, 2026 via email

…port

The HTML input type=number blocks locale-specific decimal separators
(e.g. comma in fr-FR). Switching to type=text with inputmode=decimal
preserves the mobile numeric keyboard while allowing all decimal
characters to be entered.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants