From 8724349a5b285aede03f5cbdbc489ee1f644f876 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 14:03:00 +0000 Subject: [PATCH 1/2] Upgrade to .NET 10; refactor component for perf, accessibility, and robustness. Co-authored-by: erik --- .github/workflows/deploy-demo.yml | 4 +- .github/workflows/nuget-publish.yml | 8 +- .../BlazorFastRollingNumbers.Demo.csproj | 6 +- .../BlazorFastRollingNumbers.Tests.csproj | 8 +- .../RollingNumberTests.cs | 28 +++- .../BlazorFastRollingNumber.razor | 129 +-------------- .../BlazorFastRollingNumber.razor.cs | 153 ++++++++++++++++++ .../BlazorFastRollingNumber.razor.css | 2 +- .../BlazorFastRollingNumbers.csproj | 8 +- BlazorFastRollingNumbers/Easing.cs | 2 +- 10 files changed, 206 insertions(+), 142 deletions(-) create mode 100644 BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.cs diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index dd16dd3..e5b9d98 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -24,7 +24,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Restore dependencies run: dotnet restore BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj @@ -38,7 +38,7 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: 'BlazorFastRollingNumbers.Demo/bin/Release/net9.0/publish/wwwroot' + path: 'BlazorFastRollingNumbers.Demo/bin/Release/net10.0/publish/wwwroot' deploy: needs: build diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 20081cb..533940f 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -32,7 +32,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Restore library dependencies run: dotnet restore BlazorFastRollingNumbers/BlazorFastRollingNumbers.csproj @@ -83,7 +83,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Restore dependencies run: dotnet restore BlazorFastRollingNumbers/BlazorFastRollingNumbers.csproj @@ -169,7 +169,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Publish to NuGet.org run: | @@ -215,7 +215,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: '9.0.x' + dotnet-version: '10.0.x' - name: Publish to GitHub Packages run: | diff --git a/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj b/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj index 69c060d..83e72ab 100644 --- a/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj +++ b/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false @@ -11,8 +11,8 @@ - - + + diff --git a/BlazorFastRollingNumbers.Tests/BlazorFastRollingNumbers.Tests.csproj b/BlazorFastRollingNumbers.Tests/BlazorFastRollingNumbers.Tests.csproj index b191695..bedafe3 100644 --- a/BlazorFastRollingNumbers.Tests/BlazorFastRollingNumbers.Tests.csproj +++ b/BlazorFastRollingNumbers.Tests/BlazorFastRollingNumbers.Tests.csproj @@ -1,19 +1,19 @@ - + - net9.0 + net10.0 enable enable false - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all diff --git a/BlazorFastRollingNumbers.Tests/RollingNumberTests.cs b/BlazorFastRollingNumbers.Tests/RollingNumberTests.cs index 4850c77..86dccf7 100644 --- a/BlazorFastRollingNumbers.Tests/RollingNumberTests.cs +++ b/BlazorFastRollingNumbers.Tests/RollingNumberTests.cs @@ -7,7 +7,7 @@ namespace BlazorFastRollingNumbers.Tests; public class BlazorFastRollingNumberTests : TestContext { [Fact] - public void BlazorFastBlazorFastRollingNumber_RendersPositiveNumber() + public void BlazorFastRollingNumber_RendersPositiveNumber() { // Arrange & Act var cut = RenderComponent(parameters => parameters @@ -67,6 +67,19 @@ public void BlazorFastRollingNumber_RespectsMinimumDigits() Assert.Equal(3, paddingCount); } + [Fact] + public void BlazorFastRollingNumber_ClampsVeryLargeMinimumDigits() + { + // Arrange & Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.Value, 5) + .Add(p => p.MinimumDigits, 1000)); + + // Assert + var digits = cut.FindAll(".bfrn__digit"); + Assert.Equal(32, digits.Count); + } + [Fact] public void BlazorFastRollingNumber_UpdatesWhenValueChanges() { @@ -228,6 +241,19 @@ public void BlazorFastRollingNumber_NoAnimationWhenValueUnchanged() Assert.Equal(initialStyle, newStyle); } + [Fact] + public void BlazorFastRollingNumber_RendersAriaLabelWhenProvided() + { + // Arrange & Act + var cut = RenderComponent(parameters => parameters + .Add(p => p.Value, 42) + .Add(p => p.AriaLabel, "Score: 42")); + + // Assert + var wrapper = cut.Find(".bfrn"); + Assert.Equal("Score: 42", wrapper.GetAttribute("aria-label")); + } + [Fact] public void BlazorFastRollingNumber_SupportsCustomEasing() { diff --git a/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor b/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor index 6f7a767..7ebb2f8 100644 --- a/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor +++ b/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor @@ -1,12 +1,13 @@ @namespace BlazorFastRollingNumbers -@using System.Buffers -@using System.Runtime.CompilerServices -@using System.Runtime.InteropServices - - @foreach (var item in _digitData) + + @for (var i = 0; i < _digitCount; i++) { - + var item = _digitData[i]; + } - -@code { - [Parameter] - public int Value { get; set; } - - [Parameter] - public int MinimumDigits { get; set; } - - [Parameter] - public string Duration { get; set; } = "1s"; - - [Parameter] - public Easing Easing { get; set; } = Easing.Ease; - - [Parameter] - public string? CssClass { get; set; } - - private DigitData[] _digitData = []; - private int _lastValue = int.MaxValue; - private int _lastMinDigits = -1; - - protected override void OnParametersSet() - { - // Only recompute if value or min digits changed - if (_lastValue == Value && _lastMinDigits == MinimumDigits) - return; - - _lastValue = Value; - _lastMinDigits = MinimumDigits; - - var targetSize = Math.Max(MinimumDigits, GetDigitCount(Value)); - - // Reuse array if same size - if (_digitData.Length != targetSize) - _digitData = new DigitData[targetSize]; - - PopulateDigitData(Value, targetSize, _digitData); - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int GetTransformOffset(char digit) - { - // Branchless computation for digits 0-9 - if ((uint)(digit - '0') <= 9) - return (digit - '0') * -10; - - return digit switch - { - '\u200B' => 10, - '-' => -100, - _ => 0 - }; - } - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static int GetDigitCount(int value) - { - if (value == 0) return 1; - if (value == int.MinValue) return 11; - - var absValue = Math.Abs(value); - - // Optimized digit count without log10 - int digits = 1; - if (absValue >= 100000000) { digits += 8; absValue /= 100000000; } - if (absValue >= 10000) { digits += 4; absValue /= 10000; } - if (absValue >= 100) { digits += 2; absValue /= 100; } - if (absValue >= 10) digits++; - - return value < 0 ? digits + 1 : digits; - } - - [MethodImpl(MethodImplOptions.AggressiveOptimization)] - private static void PopulateDigitData(int value, int targetSize, Span destination) - { - const char ZeroWidthSpace = '\u200B'; - - Span buffer = stackalloc char[11]; - - if (!value.TryFormat(buffer, out int written)) - { - destination[0] = new DigitData(ZeroWidthSpace, 10, 0); - return; - } - - var padding = targetSize - written; - - // Fill padding - for (int i = 0; i < padding; i++) - { - destination[i] = new DigitData(ZeroWidthSpace, 10, i); - } - - // Copy actual digits - for (int i = 0; i < written; i++) - { - var ch = buffer[i]; - destination[padding + i] = new DigitData(ch, GetTransformOffset(ch), padding + i); - } - } - - private readonly struct DigitData - { - public readonly char Char; - public readonly int Offset; - public readonly int Index; - - [MethodImpl(MethodImplOptions.AggressiveInlining)] - public DigitData(char ch, int offset, int index) - { - Char = ch; - Offset = offset; - Index = index; - } - } -} diff --git a/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.cs b/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.cs new file mode 100644 index 0000000..f15d0b5 --- /dev/null +++ b/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.cs @@ -0,0 +1,153 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Components; + +namespace BlazorFastRollingNumbers; + +public partial class BlazorFastRollingNumber : ComponentBase +{ + // int.MinValue ("-2147483648") is 11 chars. Cap MinDigits to keep allocations bounded. + private const int MaxMinimumDigits = 32; + private const int MaxIntChars = 11; + private const int BufferSize = MaxMinimumDigits > MaxIntChars ? MaxMinimumDigits : MaxIntChars; + + [Parameter] + public int Value { get; set; } + + /// + /// Minimum number of characters to render, including a possible '-' sign. + /// Values < 0 are treated as 0. Values > are clamped. + /// + [Parameter] + public int MinimumDigits { get; set; } + + /// + /// CSS transition duration (e.g., "0.5s", "500ms"). + /// + [Parameter] + public string Duration { get; set; } = "1s"; + + [Parameter] + public Easing Easing { get; set; } = Easing.Ease; + + [Parameter] + public string? CssClass { get; set; } + + /// + /// Optional accessible label for the rendered number. + /// If null, no aria-label is emitted (to avoid per-render string allocations). + /// + [Parameter] + public string? AriaLabel { get; set; } + + /// + /// Optional aria-live politeness setting (e.g. "polite" / "assertive"). + /// + [Parameter] + public string? AriaLive { get; set; } + + private readonly DigitData[] _digitData = new DigitData[BufferSize]; + private int _digitCount; + + private int _lastValue = int.MaxValue; + private int _lastMinDigitsEffective = -1; + + protected override void OnParametersSet() + { + var minDigitsEffective = ClampMinDigits(MinimumDigits); + + // Only recompute if value or effective min digits changed + if (_lastValue == Value && _lastMinDigitsEffective == minDigitsEffective) + return; + + _lastValue = Value; + _lastMinDigitsEffective = minDigitsEffective; + + var targetSize = Math.Max(minDigitsEffective, GetDigitCount(Value)); + _digitCount = targetSize; + + PopulateDigitData(Value, targetSize, _digitData); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int ClampMinDigits(int value) + { + if (value <= 0) return 0; + return value <= MaxMinimumDigits ? value : MaxMinimumDigits; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int GetTransformOffset(char digit) + { + // Branchless computation for digits 0-9 + if ((uint)(digit - '0') <= 9) + return (digit - '0') * -10; + + return digit switch + { + '\u200B' => 10, + '-' => -100, + _ => 0 + }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int GetDigitCount(int value) + { + if (value == 0) return 1; + if (value == int.MinValue) return 11; + + var absValue = Math.Abs(value); + + // Optimized digit count without log10 + var digits = 1; + if (absValue >= 100000000) { digits += 8; absValue /= 100000000; } + if (absValue >= 10000) { digits += 4; absValue /= 10000; } + if (absValue >= 100) { digits += 2; absValue /= 100; } + if (absValue >= 10) digits++; + + return value < 0 ? digits + 1 : digits; + } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private static void PopulateDigitData(int value, int targetSize, Span destination) + { + const char ZeroWidthSpace = '\u200B'; + + Span buffer = stackalloc char[MaxIntChars]; + + if (!value.TryFormat(buffer, out var written)) + { + destination[0] = new DigitData(ZeroWidthSpace, 10); + return; + } + + var padding = targetSize - written; + + // Fill padding + for (var i = 0; i < padding; i++) + { + destination[i] = new DigitData(ZeroWidthSpace, 10); + } + + // Copy actual digits + for (var i = 0; i < written; i++) + { + var ch = buffer[i]; + destination[padding + i] = new DigitData(ch, GetTransformOffset(ch)); + } + } + + private readonly struct DigitData + { + public readonly char Char; + public readonly int Offset; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public DigitData(char ch, int offset) + { + Char = ch; + Offset = offset; + } + } +} + diff --git a/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.css b/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.css index e5c6e40..ea84649 100644 --- a/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.css +++ b/BlazorFastRollingNumbers/BlazorFastRollingNumber.razor.css @@ -36,7 +36,7 @@ align-items: center; justify-content: center; flex-direction: column; - transition: transform var(--roll-duration, 1s); + transition: transform var(--roll-duration, 1s) var(--roll-easing, ease); } .bfrn__digit .bfrn__scale { diff --git a/BlazorFastRollingNumbers/BlazorFastRollingNumbers.csproj b/BlazorFastRollingNumbers/BlazorFastRollingNumbers.csproj index 082081c..f14341e 100644 --- a/BlazorFastRollingNumbers/BlazorFastRollingNumbers.csproj +++ b/BlazorFastRollingNumbers/BlazorFastRollingNumbers.csproj @@ -1,9 +1,9 @@ - net9.0 + net10.0 enable enable - 9.0 + 10.0 true @@ -90,12 +90,12 @@ - + - + diff --git a/BlazorFastRollingNumbers/Easing.cs b/BlazorFastRollingNumbers/Easing.cs index a7078f0..aff2da1 100644 --- a/BlazorFastRollingNumbers/Easing.cs +++ b/BlazorFastRollingNumbers/Easing.cs @@ -25,7 +25,7 @@ public Easing(string value) /// /// Converts the Easing to its CSS string representation. /// - public override string ToString() => _value; + public override string ToString() => _value ?? "linear"; // Standard CSS easing functions public static readonly Easing Linear = new("linear"); From 22ceccf54ec27789dddf5bd2def25884063df6a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 14:13:19 +0000 Subject: [PATCH 2/2] Add accessibility parameters, upgrade to .NET 10, fix easing, and update Bunit tests Co-authored-by: erik --- .../BlazorFastRollingNumbers.Demo.csproj | 2 +- .../Pages/Home.razor | 7 ++++ .../RollingNumberTests.cs | 42 +++++++++---------- .../BlazorFastRollingNumbers.csproj | 3 +- CHANGELOG.md | 23 ++++++++++ README.md | 12 +++++- 6 files changed, 63 insertions(+), 26 deletions(-) create mode 100644 CHANGELOG.md diff --git a/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj b/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj index 83e72ab..c39b53e 100644 --- a/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj +++ b/BlazorFastRollingNumbers.Demo/BlazorFastRollingNumbers.Demo.csproj @@ -22,7 +22,7 @@ - $(PublishDir)wwwroot\index.html + $(PublishDir)wwwroot/index.html diff --git a/BlazorFastRollingNumbers.Demo/Pages/Home.razor b/BlazorFastRollingNumbers.Demo/Pages/Home.razor index ca42091..6b47dc6 100644 --- a/BlazorFastRollingNumbers.Demo/Pages/Home.razor +++ b/BlazorFastRollingNumbers.Demo/Pages/Home.razor @@ -144,6 +144,13 @@ } </style> +

Accessibility

+
+
<BlazorFastRollingNumber
+    Value="@@score"
+    AriaLabel="@@($"Score: {score}")"
+    AriaLive="polite" />
+