From b19ba79c12d61ddc69d3495dea955f39dc4399ff Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 14 Sep 2026 13:36:17 +0330 Subject: [PATCH 01/43] feat(brouter): reinforce tests #13230 (#13231) --- .github/workflows/bit.ci.Brouter.e2e.yml | 208 ++++++++ .github/workflows/bit.ci.Brouter.yml | 7 + src/Bit.slnx | 8 + src/Brouter/Bit.Brouter.slnx | 8 + src/Brouter/Bit.Brouter/Bit.Brouter.csproj | 16 +- src/Brouter/Bit.Brouter/Components/Brouter.cs | 9 +- .../Bit.Brouter/Routing/BrouteScanner.cs | 4 + .../Bit.Brouter/Scripts/bit-brouter.ts | 69 ++- .../Bit.Brouter.Tests.E2E/AssemblySetup.cs | 32 ++ .../Bit.Brouter.Tests.E2E.csproj | 31 ++ .../Bit.Brouter.Tests.E2E/HybridModeTests.cs | 55 ++ .../Infrastructure/ChildProcess.cs | 134 +++++ .../Infrastructure/E2EEnvironment.cs | 56 +++ .../Infrastructure/HarnessBuild.cs | 26 + .../Infrastructure/HarnessHosts.cs | 91 ++++ .../Infrastructure/HarnessTest.cs | 145 ++++++ .../Infrastructure/HybridHarnessHost.cs | 118 +++++ .../Infrastructure/HybridSession.cs | 29 ++ .../Infrastructure/PlaywrightSession.cs | 42 ++ .../Infrastructure/RepoLayout.cs | 32 ++ .../Infrastructure/WebSession.cs | 32 ++ .../InteractiveHarnessTests.cs | 474 ++++++++++++++++++ .../Tests/Bit.Brouter.Tests.E2E/README.md | 110 ++++ .../StaticSsrModeTests.cs | 158 ++++++ .../Bit.Brouter.Tests.E2E/WebModeTests.cs | 153 ++++++ .../Bit.Brouter.Tests.E2E/publish-gate.sh | 42 ++ .../Bit.Brouter.Tests.Harness.Hybrid.csproj | 34 ++ .../HarnessForm.cs | 46 ++ .../Program.cs | 49 ++ .../wwwroot/index.html | 18 + ...it.Brouter.Tests.Harness.Web.Client.csproj | 23 + .../Program.cs | 7 + .../Bit.Brouter.Tests.Harness.Web.csproj | 29 ++ .../Components/App.razor | 27 + .../Components/Pages/CatchAll.razor | 8 + .../Components/_Imports.razor | 4 + .../HarnessRenderModes.cs | 38 ++ .../Bit.Brouter.Tests.Harness.Web/Program.cs | 30 ++ .../Properties/launchSettings.json | 33 ++ .../Bit.Brouter.Tests.Harness.csproj | 24 + .../HarnessApp.razor | 48 ++ .../Bit.Brouter.Tests.Harness/HarnessData.cs | 20 + .../HarnessRouter.razor | 77 +++ .../HarnessRuntime.cs | 10 + .../Bit.Brouter.Tests.Harness/HarnessScope.cs | 47 ++ .../HarnessServiceCollectionExtensions.cs | 32 ++ .../HarnessStatus.razor | 23 + .../HarnessStatus.razor.cs | 11 + .../Pages/AboutPage.razor | 1 + .../Pages/ConfirmPage.razor | 16 + .../Pages/DataPage.razor | 22 + .../Pages/DeepPage.razor | 5 + .../Pages/HistoryPage.razor | 38 ++ .../Pages/HomePage.razor | 1 + .../Pages/ItemPage.razor | 5 + .../Pages/KeepAlivePage.razor | 8 + .../Pages/LeavePage.razor | 10 + .../Pages/LongPage.razor | 10 + .../Pages/MissingPage.razor | 1 + .../Pages/MissingPage.razor.cs | 19 + .../Pages/OtherPage.razor | 6 + .../Pages/PreloadPage.razor | 21 + .../Pages/PreloadTargetPage.razor | 9 + .../Bit.Brouter.Tests.Harness/_Imports.razor | 4 + .../wwwroot/harness.css | 20 + .../AssemblySetup.cs | 20 + .../Bit.Brouter.Tests.Hosting.csproj | 39 ++ .../HarnessHostingTests.cs | 219 ++++++++ .../Infrastructure/CapturingLoggerProvider.cs | 32 ++ .../Infrastructure/HarnessHostFactory.cs | 47 ++ .../Infrastructure/RenderedHtml.cs | 35 ++ .../SamplesHostingTests.cs | 90 ++++ .../StaticRenderingLogTests.cs | 42 ++ 73 files changed, 3440 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/bit.ci.Brouter.e2e.yml create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/AssemblySetup.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/HybridModeTests.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/ChildProcess.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/E2EEnvironment.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessBuild.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessHosts.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessTest.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridHarnessHost.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridSession.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/PlaywrightSession.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/RepoLayout.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/WebSession.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/InteractiveHarnessTests.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/README.md create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/StaticSsrModeTests.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/WebModeTests.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.E2E/publish-gate.sh create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Bit.Brouter.Tests.Harness.Hybrid.csproj create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/HarnessForm.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Program.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/wwwroot/index.html create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Bit.Brouter.Tests.Harness.Web.Client.csproj create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Program.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Bit.Brouter.Tests.Harness.Web.csproj create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/App.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/Pages/CatchAll.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/_Imports.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/HarnessRenderModes.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Program.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Properties/launchSettings.json create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Bit.Brouter.Tests.Harness.csproj create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessApp.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessData.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRouter.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRuntime.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessScope.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessServiceCollectionExtensions.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/AboutPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ConfirmPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DataPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DeepPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HistoryPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HomePage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ItemPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/KeepAlivePage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LeavePage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LongPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/OtherPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadTargetPage.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/_Imports.razor create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Harness/wwwroot/harness.css create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/AssemblySetup.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/Bit.Brouter.Tests.Hosting.csproj create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/HarnessHostingTests.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/Infrastructure/CapturingLoggerProvider.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/Infrastructure/HarnessHostFactory.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/Infrastructure/RenderedHtml.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/SamplesHostingTests.cs create mode 100644 src/Brouter/Tests/Bit.Brouter.Tests.Hosting/StaticRenderingLogTests.cs diff --git a/.github/workflows/bit.ci.Brouter.e2e.yml b/.github/workflows/bit.ci.Brouter.e2e.yml new file mode 100644 index 0000000000..22efa87bfa --- /dev/null +++ b/.github/workflows/bit.ci.Brouter.e2e.yml @@ -0,0 +1,208 @@ +name: bit platform CI - Brouter E2E + +# Browser, hybrid and publish-gate coverage for Bit.Brouter, next to the unit/hosting tests in +# bit.ci.Brouter.yml. See src/Brouter/Tests/Bit.Brouter.Tests.E2E/README.md for what each job proves +# and how to run the same thing locally. + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/Brouter/**' + - '.github/workflows/bit.ci.Brouter.e2e.yml' + +env: + DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: true + +permissions: + contents: read + +jobs: + + # Every web render mode (static SSR, Server, WebAssembly, Auto, with and without prerendering) in + # Chromium, on each runtime the library ships for. + e2e-web: + if: startsWith(github.event.pull_request.title, 'Prerelease') != true && startsWith(github.event.pull_request.title, 'Release') != true && startsWith(github.event.pull_request.title, 'Version') != true + name: Brouter E2E - web render modes (${{ matrix.framework }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + framework: [ net10.0, net9.0, net8.0 ] + + steps: + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - name: Setup .NET 9.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 9.0.x + + - name: Setup .NET 8.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 8.0.x + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Build the web harness host + run: dotnet build src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Bit.Brouter.Tests.Harness.Web.csproj -c Release -f ${{ matrix.framework }} + + - name: Build the E2E suite + run: dotnet build src/Brouter/Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj -c Release + + - name: Install Playwright Chromium + run: pwsh src/Brouter/Tests/Bit.Brouter.Tests.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Run E2E tests + env: + BROUTER_E2E_FRAMEWORK: ${{ matrix.framework }} + BROUTER_E2E_CONFIGURATION: Release + BROUTER_E2E_SKIP_BUILD: 1 + # Run from inside src so src/global.json's Microsoft.Testing.Platform runner setting applies. + run: > + cd src/Brouter && + dotnet test --project Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj -c Release --no-build + --filter "FullyQualifiedName!~HybridModeTests" + --report-trx --report-trx-filename brouter-e2e-web-${{ matrix.framework }}.trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: brouter-e2e-web-${{ matrix.framework }} + path: '**/brouter-e2e-web-*.trx' + if-no-files-found: ignore + + # Blazor Hybrid: the harness in a WinForms BlazorWebView, driven through WebView2's DevTools port. + e2e-hybrid: + if: startsWith(github.event.pull_request.title, 'Prerelease') != true && startsWith(github.event.pull_request.title, 'Release') != true && startsWith(github.event.pull_request.title, 'Version') != true + name: Brouter E2E - Blazor Hybrid (WebView2) + runs-on: windows-latest + + steps: + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + # windows-latest ships the Edge browser but not the WebView2 Runtime, which BlazorWebView needs. + - name: Install the WebView2 Runtime + shell: pwsh + run: | + $clientKey = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' + $version = (Get-ItemProperty $clientKey -ErrorAction SilentlyContinue).pv + if (-not $version -or $version -eq '0.0.0.0') { + $installer = Join-Path $env:RUNNER_TEMP 'MicrosoftEdgeWebview2Setup.exe' + Invoke-WebRequest 'https://go.microsoft.com/fwlink/p/?LinkId=2124703' -OutFile $installer + Start-Process $installer -ArgumentList '/silent', '/install' -Wait + $version = (Get-ItemProperty $clientKey -ErrorAction SilentlyContinue).pv + } + if (-not $version -or $version -eq '0.0.0.0') { throw 'The WebView2 Runtime did not install.' } + Write-Host "WebView2 Runtime $version" + + - name: Build the hybrid harness host + run: dotnet build src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Bit.Brouter.Tests.Harness.Hybrid.csproj -c Release + + - name: Build the E2E suite + run: dotnet build src/Brouter/Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj -c Release + + # No Playwright browser download: it attaches to the WebView2 Runtime installed above. + - name: Run hybrid E2E tests + env: + BROUTER_E2E_CONFIGURATION: Release + BROUTER_E2E_SKIP_BUILD: 1 + run: > + cd src/Brouter && + dotnet test --project Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj -c Release --no-build + --filter "FullyQualifiedName~HybridModeTests" + --report-trx --report-trx-filename brouter-e2e-hybrid.trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: brouter-e2e-hybrid + path: '**/brouter-e2e-hybrid.trx' + if-no-files-found: ignore + + # Publishes the harness the way a WebAssembly app ships - trimmed, and in a second leg AOT-compiled - + # failing on any trim analysis warning, then runs the browser suite against that published output. + # Trimming is where reflection-based parameter binding, route discovery and the prerender state + # bridge break, and none of it is visible in a Debug build. + publish-gate: + if: startsWith(github.event.pull_request.title, 'Prerelease') != true && startsWith(github.event.pull_request.title, 'Release') != true && startsWith(github.event.pull_request.title, 'Version') != true + name: Brouter publish gate (${{ matrix.name }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - name: trimmed + properties: '' + - name: aot + properties: '-p:RunAOTCompilation=true' + + steps: + - name: Checkout source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: src/global.json + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Install wasm-tools + if: matrix.name == 'aot' + run: dotnet workload install wasm-tools + + - name: Publish the web harness host (fails on trim/AOT warnings from Bit.Brouter) + run: bash src/Brouter/Tests/Bit.Brouter.Tests.E2E/publish-gate.sh ${{ github.workspace }}/artifacts/harness-${{ matrix.name }} ${{ matrix.properties }} + + - name: Build the E2E suite + run: dotnet build src/Brouter/Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj -c Release + + - name: Install Playwright Chromium + run: pwsh src/Brouter/Tests/Bit.Brouter.Tests.E2E/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + + - name: Run E2E tests against the published host + env: + BROUTER_E2E_PUBLISHED_HOST: ${{ github.workspace }}/artifacts/harness-${{ matrix.name }} + run: > + cd src/Brouter && + dotnet test --project Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj -c Release --no-build + --filter "FullyQualifiedName!~HybridModeTests" + --report-trx --report-trx-filename brouter-publish-${{ matrix.name }}.trx + + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: brouter-publish-${{ matrix.name }} + path: '**/brouter-publish-*.trx' + if-no-files-found: ignore diff --git a/.github/workflows/bit.ci.Brouter.yml b/.github/workflows/bit.ci.Brouter.yml index 29ceffb441..586055cbaa 100644 --- a/.github/workflows/bit.ci.Brouter.yml +++ b/.github/workflows/bit.ci.Brouter.yml @@ -61,3 +61,10 @@ jobs: # Run from inside src so src/global.json's "test" runner setting applies # (from the repo root the SDK falls back to the unsupported VSTest path). run: cd src/Brouter && dotnet test Tests/Bit.Brouter.Tests.Mcp/Bit.Brouter.Tests.Mcp.csproj --no-build + + - name: dotnet test (source generator) + run: cd src/Brouter && dotnet test Tests/Bit.Brouter.Tests.Generators/Bit.Brouter.Tests.Generators.csproj --no-build + + - name: dotnet test (hosting - every render mode over HTTP, net8.0/net9.0/net10.0) + # Browser, hybrid and trimmed/AOT publish coverage runs in bit.ci.Brouter.e2e.yml. + run: cd src/Brouter && dotnet test Tests/Bit.Brouter.Tests.Hosting/Bit.Brouter.Tests.Hosting.csproj --no-build diff --git a/src/Bit.slnx b/src/Bit.slnx index b9f8b0921b..340b6f0018 100644 --- a/src/Bit.slnx +++ b/src/Bit.slnx @@ -131,6 +131,14 @@ + + + + + + + + diff --git a/src/Brouter/Bit.Brouter.slnx b/src/Brouter/Bit.Brouter.slnx index 33c17a860d..ad43175775 100644 --- a/src/Brouter/Bit.Brouter.slnx +++ b/src/Brouter/Bit.Brouter.slnx @@ -28,8 +28,16 @@ + + + + + + + + diff --git a/src/Brouter/Bit.Brouter/Bit.Brouter.csproj b/src/Brouter/Bit.Brouter/Bit.Brouter.csproj index fe2bee7a38..7481d776e8 100644 --- a/src/Brouter/Bit.Brouter/Bit.Brouter.csproj +++ b/src/Brouter/Bit.Brouter/Bit.Brouter.csproj @@ -62,13 +62,27 @@ conflicting-target-path error) for builds where the glob already captured it. --> + DependsOnTargets="BuildBrouterJavaScriptOnce"> + + + + + + true + Exe + + + + + + + + + + + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/HybridModeTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/HybridModeTests.cs new file mode 100644 index 0000000000..6f433bbfe6 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/HybridModeTests.cs @@ -0,0 +1,55 @@ +using Bit.Brouter.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Brouter.Tests.E2E; + +/// +/// Blazor Hybrid: the harness inside a WinForms BlazorWebView (the same WebView core MAUI uses), driven +/// over CDP. What differs from the web: assets come from the WebView's virtual host rather than an HTTP +/// server, there is no prerendering and no response status, and the renderer is "WebView". +/// +/// +/// Windows only. The tests share the app's single window and run in order; each starts by navigating +/// the WebView, which reloads the page and gives the test a fresh DI scope. +/// +[TestClass] +public class HybridModeTests : InteractiveHarnessTests +{ + protected override string BaseUrl => HybridHarnessHost.AppOrigin; + + protected override string Framework => "net10.0"; + + protected override bool Prerenders => false; + + protected override IReadOnlyList ExpectedRenderers => ["WebView"]; + + protected override IReadOnlyList ExpectedPlatforms => ["dotnet"]; + + // BlazorWebView hands new-window requests to the operating system's browser. + protected override bool SupportsNewWindows => false; + + // Closing the page would close the app's only WebView. + protected override bool SupportsBeforeUnload => false; + + protected override async Task OpenPageAsync() + { + if (OperatingSystem.IsWindows() is false) + Assert.Inconclusive("BlazorWebView renders through WebView2, which needs Windows."); + + return (await HybridSession.GetAsync()).Page; + } + + // The page belongs to the shared app window; the session closes it at the end of the run. + protected override Task ClosePageAsync() => Task.CompletedTask; + + [TestMethod] + public async Task StartPath_opens_the_WebView_on_a_deep_route() + { + await using var host = await HybridHarnessHost.StartAsync("/items/9"); + + await Expect(host.Page.Locator("#status")).ToHaveAttributeAsync("data-interactive", "true", new() { Timeout = 90_000 }); + await Expect(host.Page.Locator("#page-item")).ToHaveAttributeAsync("data-item-id", "9"); + await Expect(host.Page).ToHaveURLAsync(BaseUrl + "/items/9"); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/ChildProcess.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/ChildProcess.cs new file mode 100644 index 0000000000..31de592e9d --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/ChildProcess.cs @@ -0,0 +1,134 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; + +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// A process the suite starts and owns: its output is drained (a full pipe would stall it) into a +/// bounded buffer that failure messages quote, and disposing kills the whole process tree. +/// +public sealed class ChildProcess : IAsyncDisposable +{ + private const int KeptOutputLines = 200; + + private readonly Process _process; + private readonly ConcurrentQueue _output = new(); + + private ChildProcess(Process process) => _process = process; + + public bool HasExited => _process.HasExited; + + /// The last lines the process wrote, for failure messages. + public string RecentOutput => string.Join(Environment.NewLine, _output); + + public static ChildProcess Start(string fileName, IEnumerable arguments, string workingDirectory, IDictionary? environment = null) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + foreach (var (name, value) in environment ?? new Dictionary()) startInfo.Environment[name] = value; + + var child = new ChildProcess(new Process { StartInfo = startInfo }); + child._process.OutputDataReceived += (_, e) => child.Keep(e.Data); + child._process.ErrorDataReceived += (_, e) => child.Keep(e.Data); + + if (child._process.Start() is false) + throw new InvalidOperationException($"Failed to start {fileName}."); + + child._process.BeginOutputReadLine(); + child._process.BeginErrorReadLine(); + return child; + } + + /// Runs a process to completion and throws, quoting its output, when it fails. + public static async Task RunToCompletionAsync(string fileName, IEnumerable arguments, string workingDirectory, TimeSpan timeout) + { + await using var child = Start(fileName, arguments, workingDirectory); + using var cts = new CancellationTokenSource(timeout); + + try + { + await child._process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + throw new TimeoutException($"{fileName} {string.Join(' ', arguments)} did not finish within {timeout}.{Environment.NewLine}{child.RecentOutput}"); + } + + if (child._process.ExitCode != 0) + throw new InvalidOperationException($"{fileName} {string.Join(' ', arguments)} exited with {child._process.ExitCode}.{Environment.NewLine}{child.RecentOutput}"); + } + + /// Polls until it answers with a success status. + public async Task WaitForHttpAsync(string url, TimeSpan timeout) + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + var deadline = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < deadline) + { + if (HasExited) + throw new InvalidOperationException($"The process exited before {url} answered.{Environment.NewLine}{RecentOutput}"); + + try + { + using var response = await client.GetAsync(url); + if (response.IsSuccessStatusCode) return; + } + catch (HttpRequestException) { /* not listening yet */ } + catch (TaskCanceledException) { /* not answering yet */ } + + await Task.Delay(250); + } + + throw new TimeoutException($"{url} did not answer within {timeout}.{Environment.NewLine}{RecentOutput}"); + } + + public static int FreePort() + { + // Bind to port 0 and release it: the OS hands back a free ephemeral port. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + public async ValueTask DisposeAsync() + { + try + { + if (_process.HasExited is false) + { + _process.Kill(entireProcessTree: true); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await _process.WaitForExitAsync(cts.Token); + } + } + catch (Exception ex) when (ex is InvalidOperationException or OperationCanceledException or System.ComponentModel.Win32Exception) + { + // Best-effort: the process may already be gone, or refuse to die before the run ends. + } + finally + { + _process.Dispose(); + } + } + + private void Keep(string? line) + { + if (line is null) return; + + _output.Enqueue(line); + while (_output.Count > KeptOutputLines) _output.TryDequeue(out _); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/E2EEnvironment.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/E2EEnvironment.cs new file mode 100644 index 0000000000..b1c943239a --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/E2EEnvironment.cs @@ -0,0 +1,56 @@ +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// Everything the suite reads from the environment, in one place. Environment variables rather than +/// runsettings, which the Microsoft.Testing.Platform runner does not thread through reliably. +/// +public static class E2EEnvironment +{ + /// + /// BROUTER_E2E_FRAMEWORK: the target framework the web harness host runs on + /// (net10.0, net9.0 or net8.0). Defaults to net10.0. The hybrid host is always net10.0-windows. + /// + public static string Framework { get; } = Read("BROUTER_E2E_FRAMEWORK") ?? "net10.0"; + + /// + /// BROUTER_E2E_CONFIGURATION: the build configuration of the harness hosts. Defaults to the + /// configuration this test assembly was built in. + /// + public static string Configuration { get; } = Read("BROUTER_E2E_CONFIGURATION") ?? +#if DEBUG + "Debug"; +#else + "Release"; +#endif + + /// + /// BROUTER_E2E_PUBLISHED_HOST: a folder holding a dotnet publish output of + /// Bit.Brouter.Tests.Harness.Web. When set the suite runs that (trimmed, AOT-compiled, ...) build + /// instead of the project's build output, and builds nothing. + /// + public static string? PublishedHost { get; } = Read("BROUTER_E2E_PUBLISHED_HOST"); + + /// BROUTER_E2E_SKIP_BUILD=1: the hosts were built beforehand (as CI does); do not rebuild them. + public static bool SkipBuild { get; } = Read("BROUTER_E2E_SKIP_BUILD") == "1"; + + /// BROUTER_E2E_CHANNEL: e.g. chrome / msedge, to use an installed browser. + public static string? Channel { get; } = Read("BROUTER_E2E_CHANNEL"); + + /// BROUTER_E2E_EXECUTABLE: full path to a chromium-family executable. + public static string? Executable { get; } = Read("BROUTER_E2E_EXECUTABLE"); + + /// BROUTER_E2E_HEADED=1: show the browser. + public static bool Headed { get; } = Read("BROUTER_E2E_HEADED") == "1"; + + /// RendererInfo, which reports the renderer name, only exists from .NET 9 on. + public static bool FrameworkReportsRendererName(string framework) => framework is not "net8.0"; + + /// NavigationManager.NotFound, which lets static rendering answer 404, only exists from .NET 10 on. + public static bool FrameworkHasNotFound(string framework) => framework is "net10.0"; + + private static string? Read(string name) + { + var value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessBuild.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessBuild.cs new file mode 100644 index 0000000000..1414d316ab --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessBuild.cs @@ -0,0 +1,26 @@ +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// Builds the harness hosts before the first test, so a run always drives the current sources. +/// Serial on purpose: both hosts build Bit.Brouter and the harness library into the same obj folders. +/// +public static class HarnessBuild +{ + public static async Task EnsureBuiltAsync() + { + if (E2EEnvironment.SkipBuild || E2EEnvironment.PublishedHost is not null) return; + + await BuildAsync(RepoLayout.WebHostProject(), "-f", E2EEnvironment.Framework); + + if (OperatingSystem.IsWindows()) + { + await BuildAsync(RepoLayout.HybridHostProject()); + } + } + + private static Task BuildAsync(string project, params string[] extraArguments) => + ChildProcess.RunToCompletionAsync("dotnet", + ["build", project, "-c", E2EEnvironment.Configuration, "-nologo", "-v:q", .. extraArguments], + Path.GetDirectoryName(project)!, + TimeSpan.FromMinutes(15)); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessHosts.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessHosts.cs new file mode 100644 index 0000000000..94ae62d60b --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessHosts.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; + +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// One running web harness host per render mode, started on first use and shared by every test that +/// drives that mode. The hosts only hold per-scope state, and every test opens its own browser +/// context (its own circuit / WebAssembly instance), so sharing a process never shares state. +/// +public static class HarnessHosts +{ + private static readonly ConcurrentDictionary>> _hosts = new(); + + public static Task GetAsync(string mode) => + _hosts.GetOrAdd(mode, m => new Lazy>(() => WebHarnessHost.StartAsync(m))).Value; + + public static async Task StopAllAsync() + { + foreach (var host in _hosts.Values.Where(h => h.IsValueCreated)) + { + try + { + await (await host.Value).DisposeAsync(); + } + catch (Exception) + { + // A host that failed to start already failed the tests that needed it. + } + } + + _hosts.Clear(); + } +} + +public sealed class WebHarnessHost : IAsyncDisposable +{ + private readonly ChildProcess _process; + + private WebHarnessHost(string mode, string baseUrl, ChildProcess process) + { + Mode = mode; + BaseUrl = baseUrl; + _process = process; + } + + public string Mode { get; } + + /// The URL the host answers at, without a trailing slash. + public string BaseUrl { get; } + + public string RecentOutput => _process.RecentOutput; + + public static async Task StartAsync(string mode) + { + var baseUrl = $"http://127.0.0.1:{ChildProcess.FreePort()}"; + string[] hostArguments = ["--urls", baseUrl, $"--BrouterHarness:Mode={mode}"]; + + ChildProcess process; + if (E2EEnvironment.PublishedHost is { } published) + { + // A publish output runs as it would in production: static web assets come from its wwwroot. + process = ChildProcess.Start("dotnet", [Path.Combine(published, $"{RepoLayout.WebHostName}.dll"), .. hostArguments], published, + new Dictionary { ["ASPNETCORE_ENVIRONMENT"] = "Production" }); + } + else + { + // A build output only finds the static web assets of its references (bit-brouter.js among + // them) through the development manifest, which Development switches on. + var assembly = RepoLayout.WebHostAssembly(E2EEnvironment.Framework, E2EEnvironment.Configuration); + if (File.Exists(assembly) is false) + throw new FileNotFoundException($"The web harness host has not been built for {E2EEnvironment.Framework}/{E2EEnvironment.Configuration}.", assembly); + + process = ChildProcess.Start("dotnet", [assembly, .. hostArguments], Path.GetDirectoryName(RepoLayout.WebHostProject())!, + new Dictionary { ["ASPNETCORE_ENVIRONMENT"] = "Development" }); + } + + try + { + await process.WaitForHttpAsync(baseUrl + "/", TimeSpan.FromMinutes(2)); + } + catch + { + await process.DisposeAsync(); + throw; + } + + return new WebHarnessHost(mode, baseUrl, process); + } + + public ValueTask DisposeAsync() => _process.DisposeAsync(); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessTest.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessTest.cs new file mode 100644 index 0000000000..95c200dab8 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HarnessTest.cs @@ -0,0 +1,145 @@ +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// Base of every suite: opens the page a test drives, fails the test on anything the page logged as an +/// error, and holds the helpers the suites share. +/// +/// +/// The console check is load-bearing, not hygiene. Brouter swallows every JS interop failure on purpose +/// (a missing module, a host without the API, a disconnected circuit all degrade to "the effect did not +/// happen"), so a broken integration rarely throws. What it does leave behind is an unhandled-exception +/// report from the Blazor runtime, or a failed module request, in the console. +/// +public abstract class HarnessTest +{ + private readonly object _consoleLock = new(); + private readonly List _consoleErrors = []; + private readonly List _allowedConsoleErrors = []; + private IPage? _page; + + public TestContext TestContext { get; set; } = default!; + + protected IPage Page => _page ?? throw new InvalidOperationException("The page is opened in TestInitialize."); + + /// The origin the harness is served from, without a trailing slash. + protected abstract string BaseUrl { get; } + + /// The target framework the harness runs on. + protected abstract string Framework { get; } + + protected abstract Task OpenPageAsync(); + + protected abstract Task ClosePageAsync(); + + [TestInitialize] + public async Task OpenHarnessPageAsync() + { + _page = await OpenPageAsync(); + _page.Console += OnConsole; + _page.PageError += OnPageError; + } + + [TestCleanup] + public async Task CloseHarnessPageAsync() + { + if (_page is not null) + { + _page.Console -= OnConsole; + _page.PageError -= OnPageError; + } + + await ClosePageAsync(); + + string[] unexpected; + lock (_consoleLock) + { + unexpected = [.. _consoleErrors.Where(error => _allowedConsoleErrors.Any(allowed => error.Contains(allowed, StringComparison.OrdinalIgnoreCase)) is false)]; + } + + if (unexpected.Length > 0) + Assert.Fail($"The page logged {unexpected.Length} error(s):{Environment.NewLine}{string.Join(Environment.NewLine, unexpected)}"); + } + + /// Lets a test that provokes an error on purpose keep the console check for everything else. + protected void AllowConsoleError(string fragment) + { + lock (_consoleLock) _allowedConsoleErrors.Add(fragment); + } + + protected Task GotoAsync(string path) => + Page.GotoAsync(BaseUrl + path, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 90_000 }); + + /// + /// Waits for the harness to have rendered interactively. Prerendered markup is on screen long before + /// its event handlers are, and a click in between is silently lost. + /// + protected Task WaitForInteractiveAsync() => + Expect(Page.Locator("#status")).ToHaveAttributeAsync("data-interactive", "true", new() { Timeout = 90_000 }); + + /// + /// Waits for Brouter to have applied the initial navigation's DOM effects, signalled by focus landing + /// on the page heading (the harness sets FocusOnNavigateSelector to "h1"). The initial load scrolls to + /// the top too, and without a prerendered page that can land well after the first interactive render - + /// undoing any scroll a test made in between. + /// + protected async Task WaitForInitialNavigationEffectsAsync(string headingSelector) + { + await WaitForInteractiveAsync(); + await Expect(Page.Locator(headingSelector)).ToBeFocusedAsync(); + } + + protected Task ExpectUrlAsync(string pathAndQuery) => Expect(Page).ToHaveURLAsync(BaseUrl + pathAndQuery); + + protected async Task ClickAndExpectAsync(string selector, string arrivedSelector) + { + await Page.Locator(selector).ClickAsync(); + await Expect(Page.Locator(arrivedSelector)).ToBeVisibleAsync(); + } + + protected Task ScrollYAsync() => Page.EvaluateAsync("() => window.scrollY"); + + protected async Task WaitForScrollYAsync(double expected, double tolerance = 5) + { + try + { + await Page.WaitForFunctionAsync("([y, t]) => Math.abs(window.scrollY - y) <= t", new object[] { expected, tolerance }, new() { Timeout = 10_000 }); + } + // Playwright reports a wait that ran out as System.TimeoutException, not as a PlaywrightException. + catch (Exception ex) when (ex is TimeoutException or PlaywrightException) + { + Assert.Fail($"window.scrollY is {await ScrollYAsync()}, expected {expected}."); + } + } + + /// The URLs of every Bit.Brouter JS module request the page made, with their response statuses. + protected async Task BrouterModuleRequestsAsync() => + await Page.EvaluateAsync(""" + () => performance.getEntriesByType('resource') + .filter(e => /bit-brouter(\.[a-z0-9]+)?\.js/i.test(e.name)) + .map(e => ({ url: e.name, status: e.responseStatus ?? 0 })) + """); + + // A settable class rather than a positional record: Playwright materializes evaluate results + // through a parameterless constructor. + public sealed class ModuleRequest + { + public string Url { get; set; } = string.Empty; + + public int Status { get; set; } + } + + private void OnConsole(object? sender, IConsoleMessage message) + { + if (message.Type is not "error") return; + + lock (_consoleLock) _consoleErrors.Add($"console: {message.Text} ({message.Location})"); + } + + private void OnPageError(object? sender, string error) + { + lock (_consoleLock) _consoleErrors.Add($"uncaught: {error}"); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridHarnessHost.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridHarnessHost.cs new file mode 100644 index 0000000000..79238f3f49 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridHarnessHost.cs @@ -0,0 +1,118 @@ +using Microsoft.Playwright; + +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// The WinForms BlazorWebView harness, driven over the Chrome DevTools Protocol. WebView2 opens a +/// debugging port when started with --remote-debugging-port in +/// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS, and Playwright attaches to it like to any Chromium - +/// the page it drives is the one inside the app window, running the real hybrid renderer. +/// +public sealed class HybridHarnessHost : IAsyncDisposable +{ + /// The origin BlazorWebView serves the app from. + public const string AppOrigin = "https://0.0.0.1"; + + private readonly ChildProcess _process; + private readonly string _userDataFolder; + private readonly IBrowser _browser; + + private HybridHarnessHost(ChildProcess process, string userDataFolder, IBrowser browser, IPage page) + { + _process = process; + _userDataFolder = userDataFolder; + _browser = browser; + Page = page; + } + + public IPage Page { get; } + + public static async Task StartAsync(string startPath = "/") + { + if (OperatingSystem.IsWindows() is false) + throw new PlatformNotSupportedException("The BlazorWebView harness runs on WebView2, which needs Windows."); + + var executable = RepoLayout.HybridHostExecutable(E2EEnvironment.Configuration); + if (File.Exists(executable) is false) + throw new FileNotFoundException($"The hybrid harness host has not been built for {E2EEnvironment.Configuration}.", executable); + + var port = ChildProcess.FreePort(); + // A user data folder of its own: WebView2 instances sharing one share a browser process, and + // the second app's debugging port would never open. + var userDataFolder = Path.Combine(Path.GetTempPath(), "bit-brouter-hybrid-" + Guid.NewGuid().ToString("N")); + + // Passed as arguments the host applies through the WebView2 API: an elevated host (CI runners run + // as admin) ignores --remote-debugging-port in WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS. + var process = ChildProcess.Start(executable, + ["--start-path", startPath, "--remote-debugging-port", port.ToString(), "--user-data-folder", userDataFolder], + Path.GetDirectoryName(executable)!); + + try + { + var debuggerUrl = $"http://127.0.0.1:{port}"; + await process.WaitForHttpAsync(debuggerUrl + "/json/version", TimeSpan.FromMinutes(2)); + + var browser = await (await PlaywrightSession.PlaywrightAsync()).Chromium.ConnectOverCDPAsync(debuggerUrl); + var page = await FindAppPageAsync(browser, TimeSpan.FromMinutes(1)); + + return new HybridHarnessHost(process, userDataFolder, browser, page); + } + catch + { + await process.DisposeAsync(); + await DeleteUserDataFolderAsync(userDataFolder); + throw; + } + } + + /// + /// Deletes a WebView2 user data folder once the app has exited. The browser processes WebView2 + /// spawned can hold files in it for a moment after the host goes, so a failed delete is retried + /// briefly; it is a temp folder, so giving up after that leaves nothing that matters. Never throws. + /// + private static async Task DeleteUserDataFolderAsync(string userDataFolder) + { + for (var attempt = 1; ; attempt++) + { + try + { + if (Directory.Exists(userDataFolder)) Directory.Delete(userDataFolder, recursive: true); + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + if (attempt == 10) return; + await Task.Delay(200); + } + } + } + + private static async Task FindAppPageAsync(IBrowser browser, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + + while (DateTime.UtcNow < deadline) + { + var page = browser.Contexts.SelectMany(c => c.Pages).FirstOrDefault(p => p.Url.StartsWith(AppOrigin, StringComparison.OrdinalIgnoreCase)); + if (page is not null) return page; + + await Task.Delay(250); + } + + throw new TimeoutException($"No page at {AppOrigin} appeared in the WebView within {timeout}."); + } + + public async ValueTask DisposeAsync() + { + try + { + // Detaches from the WebView; the app itself goes with the process below. + await _browser.CloseAsync(); + } + catch (PlaywrightException) { /* the connection died with the app */ } + + await _process.DisposeAsync(); + + await DeleteUserDataFolderAsync(_userDataFolder); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridSession.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridSession.cs new file mode 100644 index 0000000000..9dd9dfea6f --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/HybridSession.cs @@ -0,0 +1,29 @@ +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// The hybrid app every test drives: one window, started on first use. +/// A failed start is remembered, so the rest of the class fails fast instead of waiting out the +/// startup timeout once per test. +/// +public static class HybridSession +{ + private static Lazy> _host = new(() => HybridHarnessHost.StartAsync()); + + public static Task GetAsync() => _host.Value; + + public static async Task StopAsync() + { + if (_host.IsValueCreated is false) return; + + try + { + await (await _host.Value).DisposeAsync(); + } + catch (Exception) + { + // A host that failed to start already failed the tests that needed it. + } + + _host = new(() => HybridHarnessHost.StartAsync()); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/PlaywrightSession.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/PlaywrightSession.cs new file mode 100644 index 0000000000..27c87480b2 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/PlaywrightSession.cs @@ -0,0 +1,42 @@ +using Microsoft.Playwright; + +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// One Playwright driver and one launched Chromium for the whole run. Tests isolate from each other +/// with a browser context apiece (separate cookies, storage, cache, circuit and WebAssembly +/// instance), which is far cheaper than a browser apiece and isolates just as well. +/// +public static class PlaywrightSession +{ + private static readonly Lazy> _playwright = new(Playwright.CreateAsync); + private static readonly Lazy> _browser = new(LaunchAsync); + + public static Task PlaywrightAsync() => _playwright.Value; + + public static Task BrowserAsync() => _browser.Value; + + public static async Task StopAsync() + { + if (_browser.IsValueCreated) + { + try { await (await _browser.Value).CloseAsync(); } + catch (Exception) { /* a browser that failed to launch already failed the tests */ } + } + + if (_playwright.IsValueCreated) + { + try { (await _playwright.Value).Dispose(); } + catch (Exception) { } + } + } + + private static async Task LaunchAsync() + { + var options = new BrowserTypeLaunchOptions { Headless = E2EEnvironment.Headed is false }; + if (E2EEnvironment.Channel is { } channel) options.Channel = channel; + if (E2EEnvironment.Executable is { } executable) options.ExecutablePath = executable; + + return await (await PlaywrightAsync()).Chromium.LaunchAsync(options); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/RepoLayout.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/RepoLayout.cs new file mode 100644 index 0000000000..44da9a9508 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/RepoLayout.cs @@ -0,0 +1,32 @@ +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// +/// Finds the harness projects from the test binary's location by walking up, so a run does not depend +/// on the working directory the shell, the IDE or the test runner picked. +/// +public static class RepoLayout +{ + public const string WebHostName = "Bit.Brouter.Tests.Harness.Web"; + public const string HybridHostName = "Bit.Brouter.Tests.Harness.Hybrid"; + + public static string WebHostProject() => FindUpward(Path.Combine("Tests", WebHostName, $"{WebHostName}.csproj")); + + public static string HybridHostProject() => FindUpward(Path.Combine("Tests", HybridHostName, $"{HybridHostName}.csproj")); + + public static string WebHostAssembly(string framework, string configuration) => + Path.Combine(Path.GetDirectoryName(WebHostProject())!, "bin", configuration, framework, $"{WebHostName}.dll"); + + public static string HybridHostExecutable(string configuration) => + Path.Combine(Path.GetDirectoryName(HybridHostProject())!, "bin", configuration, "net10.0-windows", $"{HybridHostName}.exe"); + + private static string FindUpward(string relativePath) + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) return candidate; + } + + throw new FileNotFoundException($"Could not find {relativePath} walking up from {AppContext.BaseDirectory}."); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/WebSession.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/WebSession.cs new file mode 100644 index 0000000000..733ccc6eca --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/Infrastructure/WebSession.cs @@ -0,0 +1,32 @@ +using Microsoft.Playwright; + +namespace Bit.Brouter.Tests.E2E.Infrastructure; + +/// A browser context of its own against the shared web harness host of one render mode. +public sealed class WebSession +{ + private WebHarnessHost? _host; + private IBrowserContext? _context; + + public WebHarnessHost Host => _host ?? throw new InvalidOperationException("The session has not been opened."); + + public IBrowserContext Context => _context ?? throw new InvalidOperationException("The session has not been opened."); + + public async Task OpenAsync(string mode) + { + _host = await HarnessHosts.GetAsync(mode); + + var browser = await PlaywrightSession.BrowserAsync(); + _context = await browser.NewContextAsync(new() { ViewportSize = new() { Width = 1280, Height = 720 } }); + + return await _context.NewPageAsync(); + } + + public async Task CloseAsync() + { + if (_context is null) return; + + await _context.CloseAsync(); + _context = null; + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/InteractiveHarnessTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/InteractiveHarnessTests.cs new file mode 100644 index 0000000000..b2dc68f883 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/InteractiveHarnessTests.cs @@ -0,0 +1,474 @@ +using Bit.Brouter.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Brouter.Tests.E2E; + +/// +/// Everything Brouter promises once it runs interactively, asserted by its effect in a real browser. +/// Each interactive host - Server, WebAssembly and Auto, with and without prerendering, and +/// BlazorWebView - runs this same list, so a feature that works in one host and not another shows up +/// as exactly that. +/// +/// +/// The assertions are on effects (scroll offsets, focus, the history stack, attributes Brouter's module +/// writes) rather than on the absence of errors, because Brouter degrades silently by design: when its +/// JS module cannot load, navigation still works and scrolling, focus, view transitions and preloading +/// simply stop happening. +/// +public abstract class InteractiveHarnessTests : HarnessTest +{ + /// Whether the first response already carries the rendered route. + protected abstract bool Prerenders { get; } + + /// RendererInfo.Name values the host may render with (checked from .NET 9 on). + protected abstract IReadOnlyList ExpectedRenderers { get; } + + /// data-platform values the host may render on: "dotnet" or "browser". + protected abstract IReadOnlyList ExpectedPlatforms { get; } + + /// Whether a modified click may open a new page that the suite can observe and close. + protected virtual bool SupportsNewWindows => true; + + /// Whether the page can be closed to observe a beforeunload prompt. + protected virtual bool SupportsBeforeUnload => true; + + [TestMethod] + public async Task The_harness_runs_on_the_expected_runtime() + { + await GotoAsync("/"); + await WaitForInteractiveAsync(); + + var status = Page.Locator("#status"); + CollectionAssert.Contains(ExpectedPlatforms.ToList(), await status.GetAttributeAsync("data-platform")); + + if (E2EEnvironment.FrameworkReportsRendererName(Framework)) + { + CollectionAssert.Contains(ExpectedRenderers.ToList(), await status.GetAttributeAsync("data-renderer")); + } + } + + [TestMethod] + public async Task A_deep_link_renders_its_route_and_becomes_interactive() + { + await GotoAsync("/items/42"); + + await Expect(Page.Locator("#page-item")).ToHaveAttributeAsync("data-item-id", "42"); + await WaitForInteractiveAsync(); + await Expect(Page.Locator("#page-item")).ToHaveAttributeAsync("data-item-id", "42"); + } + + [TestMethod] + public async Task A_link_click_navigates_without_reloading_the_document() + { + await GotoAsync("/"); + await WaitForInteractiveAsync(); + await Page.EvaluateAsync("() => { window.__sameDocument = 'yes'; }"); + + await ClickAndExpectAsync("#nav-about", "#page-about"); + + await ExpectUrlAsync("/about"); + Assert.AreEqual("yes", await Page.EvaluateAsync("() => window.__sameDocument || 'reloaded'")); + } + + [TestMethod] + public async Task Browser_back_and_forward_move_between_routes() + { + await GotoAsync("/"); + await WaitForInteractiveAsync(); + await ClickAndExpectAsync("#nav-about", "#page-about"); + await ClickAndExpectAsync("#nav-item", "#page-item"); + + await Page.GoBackAsync(); + await Expect(Page.Locator("#page-about")).ToBeVisibleAsync(); + await ExpectUrlAsync("/about"); + + await Page.GoForwardAsync(); + await Expect(Page.Locator("#page-item")).ToHaveAttributeAsync("data-item-id", "7"); + await ExpectUrlAsync("/items/7"); + } + + [TestMethod] + public async Task A_guard_redirect_lands_on_its_target() + { + await GotoAsync("/"); + await WaitForInteractiveAsync(); + + await ClickAndExpectAsync("#nav-guarded", "#page-denied"); + + await ExpectUrlAsync("/denied"); + await Expect(Page.Locator("#page-guarded")).ToHaveCountAsync(0); + } + + [TestMethod] + public async Task A_RedirectTo_route_lands_on_its_target() + { + await GotoAsync("/"); + await WaitForInteractiveAsync(); + + await ClickAndExpectAsync("#nav-redirect", "#page-about"); + + await ExpectUrlAsync("/about"); + } + + [TestMethod] + public async Task An_unmatched_url_renders_the_not_found_content() + { + await GotoAsync("/"); + await WaitForInteractiveAsync(); + + await ClickAndExpectAsync("#nav-nope", "#not-found"); + + await Expect(Page.Locator("#not-found")).ToHaveAttributeAsync("data-path", "/nope"); + await ExpectUrlAsync("/nope"); + } + + [TestMethod] + public async Task A_failing_loader_renders_the_route_error_content() + { + // The failure is the point of the test; Brouter may report it before rendering the error content. + AllowConsoleError("harness loader failure"); + + await GotoAsync("/"); + await WaitForInteractiveAsync(); + + await ClickAndExpectAsync("#nav-broken", "#route-error"); + + await Expect(Page.Locator("#route-error")).ToHaveTextAsync("harness loader failure"); + } + + [TestMethod] + public async Task NavigationManager_NotFound_from_a_page_renders_the_not_found_content() + { + if (E2EEnvironment.FrameworkHasNotFound(Framework) is false) + Assert.Inconclusive("NavigationManager.NotFound exists from .NET 10 on."); + + await GotoAsync("/"); + await WaitForInteractiveAsync(); + + await ClickAndExpectAsync("#nav-missing", "#not-found"); + + await ExpectUrlAsync("/missing/0"); + } + + [TestMethod] + public async Task The_script_module_loads_from_the_app_base_when_the_app_starts_on_a_deep_url() + { + // Brouter imports "./_content/Bit.Brouter/bit-brouter.js". Resolved against the document instead + // of , that becomes /deep/a/b/c/_content/... - a 404 Brouter would swallow, leaving + // every JS-backed feature silently switched off. Brouter is a catch-all router, so most sessions + // do start on a deep URL. + await GotoAsync("/deep/a/b/c/leaf"); + await Expect(Page.Locator("#page-deep")).ToHaveAttributeAsync("data-leaf", "leaf"); + await WaitForInteractiveAsync(); + + await ClickAndExpectAsync("#nav-about", "#page-about"); + + // Stamped by the module's beginViewTransition: proof it loaded and ran. + await Expect(Page.Locator("html")).ToHaveAttributeAsync("data-brouter-nav", "push"); + + var requests = await BrouterModuleRequestsAsync(); + Assert.IsTrue(requests.Length > 0, "The page never requested the Bit.Brouter module."); + foreach (var request in requests) + { + StringAssert.StartsWith(new Uri(request.Url).AbsolutePath, "/_content/Bit.Brouter/", $"The module was requested from {request.Url}."); + // 0 is what a host that does not expose the status (an intercepted WebView request) reports. + Assert.IsTrue(request.Status is 0 or (>= 200 and < 300), $"{request.Url} answered {request.Status}."); + } + } + + [TestMethod] + public async Task The_initial_load_is_not_animated_as_a_navigation() + { + await GotoAsync("/items/42"); + await WaitForInteractiveAsync(); + + // Nothing to wait for: the assertion is that nothing happens. Hydration and the first + // navigation pipeline are over well within this. + await Page.WaitForTimeoutAsync(750); + + Assert.IsFalse(await Page.EvaluateAsync("() => document.documentElement.hasAttribute('data-brouter-nav')"), + "The initial load ran a view transition; with prerendering that re-animates over identical HTML."); + } + + [TestMethod] + public async Task View_transitions_are_stamped_with_the_direction_of_the_navigation() + { + await GotoAsync("/history/1"); + await WaitForInitialNavigationEffectsAsync("#page-history"); + var root = Page.Locator("html"); + + await ClickAndExpectAsync("#plain-link", "#page-history[data-step='4']"); + await Expect(root).ToHaveAttributeAsync("data-brouter-nav", "push"); + + await Page.GoBackAsync(); + await Expect(Page.Locator("#page-history[data-step='1']")).ToBeVisibleAsync(); + await Expect(root).ToHaveAttributeAsync("data-brouter-nav", "pop"); + + await ClickAndExpectAsync("#replace-link", "#page-history[data-step='3']"); + await Expect(root).ToHaveAttributeAsync("data-brouter-nav", "replace"); + + await Expect(Page.Locator("style#bit-brouter-view-transitions")).ToHaveCountAsync(1); + } + + [TestMethod] + public async Task A_new_navigation_scrolls_to_the_top_and_focuses_the_heading() + { + await GotoAsync("/long"); + await WaitForInitialNavigationEffectsAsync("#page-long"); + await Page.EvaluateAsync("() => window.scrollTo(0, 2000)"); + await WaitForScrollYAsync(2000); + + await ClickAndExpectAsync("#long-to-other", "#page-other"); + + await WaitForScrollYAsync(0); + await Expect(Page.Locator("#page-other")).ToBeFocusedAsync(); + } + + [TestMethod] + public async Task Back_restores_the_scroll_position_of_the_page_it_returns_to() + { + await GotoAsync("/long"); + await WaitForInitialNavigationEffectsAsync("#page-long"); + await Page.EvaluateAsync("() => window.scrollTo(0, 2000)"); + await WaitForScrollYAsync(2000); + await ClickAndExpectAsync("#long-to-other", "#page-other"); + await WaitForScrollYAsync(0); + + await Page.GoBackAsync(); + + await Expect(Page.Locator("#page-long")).ToBeVisibleAsync(); + await WaitForScrollYAsync(2000); + Assert.AreEqual("manual", await Page.EvaluateAsync("() => history.scrollRestoration")); + Assert.IsTrue(await Page.EvaluateAsync("() => sessionStorage.getItem('bit-brouter:scrollPositions') !== null"), + "ScrollPositionStorage.SessionStorage persisted nothing."); + } + + [TestMethod] + public async Task A_new_navigation_after_Back_scrolls_to_the_top_instead_of_restoring() + { + // Back used to leave Brouter's "this was a history traversal" flags set on WebAssembly: Blazor's + // own popstate listener runs the whole .NET commit synchronously, before Brouter's listener sees + // the same event. The next ordinary navigation was then restored like a Back. + await GotoAsync("/long"); + await WaitForInitialNavigationEffectsAsync("#page-long"); + await ClickAndExpectAsync("#long-to-other", "#page-other"); + await Page.EvaluateAsync("() => window.scrollTo(0, 1500)"); + await WaitForScrollYAsync(1500); + + await Page.GoBackAsync(); + await Expect(Page.Locator("#page-long")).ToBeVisibleAsync(); + + // /other has a remembered offset of 1500; only a Back/Forward may return to it. + await ClickAndExpectAsync("#long-to-other", "#page-other"); + await WaitForScrollYAsync(0); + } + + [TestMethod] + public async Task A_fragment_navigation_scrolls_to_and_focuses_its_target() + { + await GotoAsync("/other"); + await WaitForInitialNavigationEffectsAsync("#page-other"); + + await ClickAndExpectAsync("#other-to-anchor", "#page-long"); + + await ExpectUrlAsync("/long#bottom-anchor"); + await Expect(Page.Locator("#bottom-anchor")).ToBeFocusedAsync(); + Assert.IsTrue(await ScrollYAsync() > 2500, $"window.scrollY is {await ScrollYAsync()}; the anchor was not scrolled into view."); + } + + [TestMethod] + public async Task Intent_preloading_runs_the_loader_on_hover_and_the_click_reuses_its_result() + { + await GotoAsync("/preload"); + await WaitForInitialNavigationEffectsAsync("#page-preload"); + await Expect(Page.Locator("#intent-runs")).ToHaveTextAsync("0"); + + await Page.Locator("#intent-link").HoverAsync(); + await Expect(Page.Locator("#intent-runs")).ToHaveTextAsync("1"); + + await ClickAndExpectAsync("#intent-link", "#page-preload-target"); + await Expect(Page.Locator("#target-run")).ToHaveTextAsync("1"); + } + + [TestMethod] + public async Task Viewport_preloading_runs_the_loader_when_the_link_scrolls_into_view() + { + await GotoAsync("/preload"); + // The initial load scrolls to the top when its effects land; scrolling before that would be undone. + await WaitForInitialNavigationEffectsAsync("#page-preload"); + await Expect(Page.Locator("#viewport-runs")).ToHaveTextAsync("0"); + + await Page.Locator("#viewport-link").ScrollIntoViewIfNeededAsync(); + + await Expect(Page.Locator("#viewport-runs")).ToHaveTextAsync("1"); + } + + [TestMethod] + public async Task A_leave_guard_cancels_a_link_navigation() + { + await GotoAsync("/leave"); + await WaitForInteractiveAsync(); + await Page.Locator("#block-leave").CheckAsync(); + + await Page.Locator("#leave-to-about").ClickAsync(); + // A cancelled navigation leaves nothing to wait for; allow it the time a navigation takes here. + await Page.WaitForTimeoutAsync(1000); + + await ExpectUrlAsync("/leave"); + await Expect(Page.Locator("#page-leave")).ToBeVisibleAsync(); + + await Page.Locator("#block-leave").UncheckAsync(); + await ClickAndExpectAsync("#leave-to-about", "#page-about"); + } + + [TestMethod] + public async Task A_leave_guard_cancels_browser_back() + { + await GotoAsync("/about"); + await WaitForInteractiveAsync(); + await ClickAndExpectAsync("#nav-leave", "#page-leave"); + await Page.Locator("#block-leave").CheckAsync(); + + await Page.EvaluateAsync("() => history.back()"); + await Page.WaitForTimeoutAsync(1000); + + await ExpectUrlAsync("/leave"); + await Expect(Page.Locator("#page-leave")).ToBeVisibleAsync(); + } + + [TestMethod] + public async Task History_state_attached_by_a_link_survives_back_and_forward() + { + await GotoAsync("/history/1"); + await WaitForInitialNavigationEffectsAsync("#page-history"); + + await ClickAndExpectAsync("#state-link", "#page-history[data-step='2']"); + await Expect(Page.Locator("#history-state")).ToHaveTextAsync("from-link"); + + await ClickAndExpectAsync("#plain-link", "#page-history[data-step='4']"); + await Expect(Page.Locator("#history-state")).ToHaveTextAsync("(none)"); + + await Page.GoBackAsync(); + await Expect(Page.Locator("#page-history[data-step='2']")).ToBeVisibleAsync(); + await Expect(Page.Locator("#history-state")).ToHaveTextAsync("from-link"); + } + + [TestMethod] + public async Task A_Replace_link_does_not_add_a_history_entry() + { + await GotoAsync("/history/1"); + await WaitForInitialNavigationEffectsAsync("#page-history"); + var lengthBefore = await Page.EvaluateAsync("() => history.length"); + + await ClickAndExpectAsync("#replace-link", "#page-history[data-step='3']"); + + await ExpectUrlAsync("/history/3"); + Assert.AreEqual(lengthBefore, await Page.EvaluateAsync("() => history.length")); + } + + [TestMethod] + public async Task Programmatic_back_and_forward_walk_the_browser_history() + { + await GotoAsync("/history/1"); + await WaitForInitialNavigationEffectsAsync("#page-history"); + await ClickAndExpectAsync("#plain-link", "#page-history[data-step='4']"); + + await ClickAndExpectAsync("#go-back", "#page-history[data-step='1']"); + await ExpectUrlAsync("/history/1"); + + await ClickAndExpectAsync("#go-forward", "#page-history[data-step='4']"); + await ExpectUrlAsync("/history/4"); + } + + [TestMethod] + public async Task A_modified_click_on_an_intercepted_link_keeps_the_native_new_tab_behavior() + { + if (SupportsNewWindows is false) + Assert.Inconclusive("This host hands new-window requests to the operating system."); + + await GotoAsync("/history/1"); + await WaitForInitialNavigationEffectsAsync("#page-history"); + + var opened = Page.Context.WaitForPageAsync(); + await Page.Locator("#replace-link").ClickAsync(new() { Modifiers = [KeyboardModifier.ControlOrMeta] }); + await (await opened).CloseAsync(); + + // The Replace link intercepts plain clicks only; this one belonged to the browser. + await Page.WaitForTimeoutAsync(500); + await ExpectUrlAsync("/history/1"); + await Expect(Page.Locator("#page-history[data-step='1']")).ToBeVisibleAsync(); + } + + [TestMethod] + public async Task A_keep_alive_route_keeps_its_state_across_navigations() + { + await GotoAsync("/keepalive"); + await WaitForInteractiveAsync(); + for (var i = 0; i < 3; i++) await Page.Locator("#keepalive-increment").ClickAsync(); + await Expect(Page.Locator("#keepalive-count")).ToHaveTextAsync("3"); + + await ClickAndExpectAsync("#nav-about", "#page-about"); + await ClickAndExpectAsync("#nav-keepalive", "#page-keepalive"); + + await Expect(Page.Locator("#keepalive-count")).ToHaveTextAsync("3"); + } + + [TestMethod] + public async Task Confirming_external_navigation_prompts_before_the_page_unloads() + { + if (SupportsBeforeUnload is false) + Assert.Inconclusive("Closing the page here would close the host's only WebView."); + + await GotoAsync("/confirm"); + await WaitForInteractiveAsync(); + // A real click also gives the page the user activation browsers require before they prompt. + await Page.Locator("#arm-confirm").ClickAsync(); + await Expect(Page.Locator("#confirm-state")).ToHaveTextAsync("armed"); + + var dialogType = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Page.Dialog += (_, dialog) => + { + dialogType.TrySetResult(dialog.Type); + _ = dialog.AcceptAsync(); + }; + + await Page.CloseAsync(new() { RunBeforeUnload = true }); + + Assert.AreEqual("beforeunload", await dialogType.Task.WaitAsync(TimeSpan.FromSeconds(15))); + } + + [TestMethod] + public async Task The_initial_route_loader_result_reaches_the_interactive_page() + { + var response = await GotoAsync("/data"); + var prerenderedDataScope = Prerenders ? PrerenderedText(await response!.TextAsync(), "data-scope") : null; + + await WaitForInteractiveAsync(); + var interactiveScope = await Page.Locator("#status").GetAttributeAsync("data-scope"); + await Expect(Page.Locator("#render-scope")).ToHaveTextAsync(interactiveScope!); + var dataScope = await Page.Locator("#data-scope").TextContentAsync(); + + if (Prerenders) + { + // PersistLoaderState: the interactive pass restores what the prerender loaded instead of + // loading it again, so the data still names the prerender's scope. + Assert.IsFalse(string.IsNullOrEmpty(prerenderedDataScope), "The prerendered response carried no loader data."); + Assert.AreEqual(prerenderedDataScope, dataScope, "The interactive pass ran the loader again instead of restoring the prerendered result."); + Assert.AreNotEqual(interactiveScope, dataScope); + } + else + { + Assert.AreEqual(interactiveScope, dataScope); + } + + await Expect(Page.Locator("#data-run")).ToHaveTextAsync("1"); + } + + /// The text of the element with in raw prerendered HTML. + protected static string? PrerenderedText(string html, string id) + { + var match = System.Text.RegularExpressions.Regex.Match(html, $"id=\"{id}\"[^>]*>([^<]*)<"); + return match.Success ? match.Groups[1].Value.Trim() : null; + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/README.md b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/README.md new file mode 100644 index 0000000000..35af4f3891 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/README.md @@ -0,0 +1,110 @@ +# Bit.Brouter cross-host test suites + +The unit tests in `Bit.Brouter.Tests` run Brouter inside bUnit: one interactive renderer, a fake +`NavigationManager`, and JS interop that silently returns `default` for every call. That covers the +routing logic well and leaves out everything that depends on the host: + +- the 530 lines of `bit-brouter.ts` (scroll restoration, focus, view transitions, link preloading, + the external-navigation prompt) never execute; +- `RendererInfo` is never populated, so Brouter treats every test as interactive and its + static-rendering-only branches (the HTTP 404 propagation among them) never run; +- there is no HTTP response, so status codes, redirects and the persisted prerender state are invisible; +- there is no prerender-then-hydrate handover, no Auto runtime switch, no BlazorWebView, and no + trimmed or AOT-compiled build. + +Because Brouter swallows JS interop failures by design, most host-level breakage does not throw: a +module that fails to load simply switches scrolling, focus, transitions and preloading off. These +suites therefore assert **effects**, not the absence of errors. + +## Layout + +| Project | What it is | +|---|---| +| `Bit.Brouter.Tests.Harness` | Razor class library: one router and a page per feature, every element the tests touch carries an `id`. net8.0 / net9.0 / net10.0. | +| `Bit.Brouter.Tests.Harness.Web` (+ `.Client`) | One Blazor Web App serving the harness in any render mode, picked with `--BrouterHarness:Mode=`: `ssr`, `server`, `server-noprerender`, `wasm`, `wasm-noprerender`, `auto`, `auto-noprerender`. | +| `Bit.Brouter.Tests.Harness.Hybrid` | WinForms `BlazorWebView` host (same WebView core as MAUI). Windows only. | +| `Bit.Brouter.Tests.Hosting` | In-process HTTP tests (`WebApplicationFactory`) against the harness host in every mode and against the three hosting samples. Runs in the regular CI job on all three target frameworks. | +| `Bit.Brouter.Tests.E2E` | Playwright suites: every web render mode in Chromium, and the hybrid host through WebView2's DevTools port. | + +## What runs where + +| Area | Hosting (HTTP) | E2E web modes | E2E hybrid | Publish gate | +|---|---|---|---|---| +| Deep link rendered into the first response | yes | yes | - | trimmed + AOT | +| 404 status and not-found content for unmatched URLs / `NavigationManager.NotFound()` | yes | yes | content only | yes | +| Guard redirects and `RedirectTo` (302 during static rendering, client-side when interactive) | yes | yes | yes | yes | +| Loaders, route error content | yes | yes | yes | yes | +| `PersistLoaderState` written during prerender and restored instead of re-run | written | restored | - | yes | +| No JS interop attempted and no errors logged during static rendering | yes | module never requested | - | - | +| `bit-brouter.js` served, loads from the app base on a deep URL | served | loads | loads | yes | +| Link navigation without reload, back/forward, history state, `Replace` links | - | yes | yes | yes | +| Scroll to top, focus on navigate, fragment scrolling, Back restores scroll | - | yes | yes | yes | +| View transitions (push/pop/replace direction, never on initial load) | - | yes | yes | yes | +| Link preloading (intent, viewport) reusing the cached result | - | yes | yes | yes | +| Leave guards (link and browser Back), keep-alive | - | yes | yes | yes | +| Modified click keeps native new-tab behavior; `beforeunload` prompt | - | yes | not applicable | yes | +| Auto switching a later visit to WebAssembly | - | yes | - | yes | +| `BlazorWebView.StartPath` deep link | - | - | yes | - | +| The hosting samples boot and route | yes (net10.0) | - | - | - | + +## Running locally + +```bash +cd src/Brouter + +# HTTP-level suite, all target frameworks +dotnet test Tests/Bit.Brouter.Tests.Hosting/Bit.Brouter.Tests.Hosting.csproj + +# Browser suites (builds the harness hosts first; the hybrid host only on Windows) +dotnet build Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj +pwsh Tests/Bit.Brouter.Tests.E2E/bin/Debug/net10.0/playwright.ps1 install chromium +dotnet test Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj + +# One mode only +dotnet test Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj --filter "FullyQualifiedName~.WebAssemblyModeTests." +``` + +Run a harness host by hand with one of its launch profiles (`ssr`, `server`, `wasm`, `auto`), or +`dotnet run --project Tests/Bit.Brouter.Tests.Harness.Web -- --BrouterHarness:Mode=server-noprerender`. + +### Environment variables + +| Variable | Effect | +|---|---| +| `BROUTER_E2E_FRAMEWORK` | Target framework of the web harness host: `net10.0` (default), `net9.0`, `net8.0`. | +| `BROUTER_E2E_CONFIGURATION` | Build configuration of the hosts; defaults to the test assembly's. | +| `BROUTER_E2E_SKIP_BUILD=1` | Do not build the hosts before the run. | +| `BROUTER_E2E_PUBLISHED_HOST` | Run a `dotnet publish` output of the web host instead of the build output. | +| `BROUTER_E2E_CHANNEL` / `BROUTER_E2E_EXECUTABLE` | Use an installed Chrome/Edge or a specific Chromium binary. | +| `BROUTER_E2E_HEADED=1` | Show the browser. | + +### Publish gate (trimming / AOT) + +```bash +# Publishes trimmed and fails on any trim/AOT analysis warning raised inside Bit.Brouter. Ignored: +# the framework assemblies' own warnings, and the IL2110/IL2111 the trimmer reports at every call site +# that sets a component-typed [Parameter] (Blazor's LayoutView.Layout raises the same ones). +bash Tests/Bit.Brouter.Tests.E2E/publish-gate.sh ../../artifacts/harness-trimmed +# AOT leg (needs the wasm-tools workload): +bash Tests/Bit.Brouter.Tests.E2E/publish-gate.sh ../../artifacts/harness-aot -p:RunAOTCompilation=true + +BROUTER_E2E_PUBLISHED_HOST=$PWD/../../artifacts/harness-trimmed \ + dotnet test Tests/Bit.Brouter.Tests.E2E/Bit.Brouter.Tests.E2E.csproj --filter "FullyQualifiedName!~HybridModeTests" +``` + +CI runs all of the above in `.github/workflows/bit.ci.Brouter.e2e.yml`. + +## Hybrid notes + +The hybrid suite starts `Bit.Brouter.Tests.Harness.Hybrid.exe` with +`--remote-debugging-port --user-data-folder `, which the host applies through +`BlazorWebViewInitializing` (the WebView2 API), then attaches with `ConnectOverCDPAsync`. A window +opens while it runs. The `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` environment variable is not an +option: since WebView2 Runtime 150, an elevated process - such as a GitHub-hosted runner - ignores +`--remote-debugging-port` given that way. +Tests that would need a second window (modified clicks) or would close the WebView (`beforeunload`) +report Inconclusive there. + +It needs the WebView2 Runtime (Windows 11 has it; Windows Server images, including GitHub's +`windows-latest`, may only have the Edge browser - CI installs the runtime first). When the WebView +cannot start, the host exits with the reason on stderr, and the failing test quotes it. diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/StaticSsrModeTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/StaticSsrModeTests.cs new file mode 100644 index 0000000000..30208d29bd --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/StaticSsrModeTests.cs @@ -0,0 +1,158 @@ +using Bit.Brouter.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Brouter.Tests.E2E; + +/// +/// Static server-side rendering: no circuit, no WebAssembly, no JS interop. Every navigation is a +/// request (turned into a DOM patch by Blazor's enhanced navigation), so Brouter's whole pipeline - +/// matching, guards, redirects, loaders, not-found - runs once per request on the server, and the +/// response status is the only way to tell a crawler a page does not exist. +/// +[TestClass] +public class StaticSsrModeTests : HarnessTest +{ + private readonly WebSession _session = new(); + + protected override string BaseUrl => _session.Host.BaseUrl; + + protected override string Framework => E2EEnvironment.Framework; + + protected override Task OpenPageAsync() => _session.OpenAsync("ssr"); + + protected override Task ClosePageAsync() => _session.CloseAsync(); + + [TestMethod] + public async Task A_deep_link_is_served_as_static_html() + { + var response = await GotoAsync("/items/42"); + + Assert.AreEqual(200, response!.Status); + await Expect(Page.Locator("#page-item")).ToHaveAttributeAsync("data-item-id", "42"); + + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await Expect(Page.Locator("#status")).ToHaveAttributeAsync("data-interactive", "false"); + if (E2EEnvironment.FrameworkReportsRendererName(Framework)) + { + await Expect(Page.Locator("#status")).ToHaveAttributeAsync("data-renderer", "Static"); + } + } + + [TestMethod] + public async Task Enhanced_navigation_follows_a_link_without_reloading_the_document() + { + await GotoStartedAsync("/"); + await Page.EvaluateAsync("() => { window.__sameDocument = 'yes'; }"); + + await ClickAndExpectAsync("#nav-item", "#page-item"); + + await Expect(Page.Locator("#page-item")).ToHaveAttributeAsync("data-item-id", "7"); + await ExpectUrlAsync("/items/7"); + Assert.AreEqual("yes", await Page.EvaluateAsync("() => window.__sameDocument || 'reloaded'")); + } + + [TestMethod] + public async Task Enhanced_navigation_follows_a_guard_redirect() + { + await GotoStartedAsync("/"); + + await ClickAndExpectAsync("#nav-guarded", "#page-denied"); + + await ExpectUrlAsync("/denied"); + } + + [TestMethod] + public async Task A_deep_link_to_a_guarded_route_is_redirected() + { + await GotoAsync("/guarded"); + + await Expect(Page.Locator("#page-denied")).ToBeVisibleAsync(); + await ExpectUrlAsync("/denied"); + } + + [TestMethod] + public async Task A_deep_link_to_a_RedirectTo_route_is_redirected() + { + await GotoAsync("/old-about"); + + await Expect(Page.Locator("#page-about")).ToBeVisibleAsync(); + await ExpectUrlAsync("/about"); + } + + [TestMethod] + public async Task An_unmatched_url_is_answered_with_the_not_found_status_and_content() + { + AllowConsoleError("404"); + + var response = await GotoAsync("/nope/deeper"); + + Assert.AreEqual(E2EEnvironment.FrameworkHasNotFound(Framework) ? 404 : 200, response!.Status); + await Expect(Page.Locator("#not-found")).ToHaveAttributeAsync("data-path", "/nope/deeper"); + } + + [TestMethod] + public async Task A_page_calling_NavigationManager_NotFound_is_answered_with_404() + { + if (E2EEnvironment.FrameworkHasNotFound(Framework) is false) + Assert.Inconclusive("NavigationManager.NotFound exists from .NET 10 on."); + + AllowConsoleError("404"); + + var response = await GotoAsync("/missing/0"); + + Assert.AreEqual(404, response!.Status); + await Expect(Page.Locator("#not-found")).ToBeVisibleAsync(); + } + + [TestMethod] + public async Task The_loader_runs_in_the_request_that_renders_its_page() + { + await GotoAsync("/data"); + + var renderScope = await Page.Locator("#render-scope").TextContentAsync(); + await Expect(Page.Locator("#data-scope")).ToHaveTextAsync(renderScope!); + await Expect(Page.Locator("#data-run")).ToHaveTextAsync("1"); + } + + [TestMethod] + public async Task A_failing_loader_renders_the_route_error_content() + { + await GotoAsync("/broken"); + + await Expect(Page.Locator("#route-error")).ToHaveTextAsync("harness loader failure"); + } + + [TestMethod] + public async Task Static_rendering_never_loads_the_script_module() + { + await GotoStartedAsync("/"); + await ClickAndExpectAsync("#nav-long", "#page-long"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var requests = await BrouterModuleRequestsAsync(); + + Assert.AreEqual(0, requests.Length, $"Static rendering requested {string.Join(", ", requests.Select(r => r.Url))}."); + } + + [TestMethod] + public async Task Back_after_enhanced_navigation_returns_to_the_previous_route() + { + await GotoStartedAsync("/"); + await ClickAndExpectAsync("#nav-about", "#page-about"); + await ClickAndExpectAsync("#nav-item", "#page-item"); + + await Page.GoBackAsync(); + + await Expect(Page.Locator("#page-about")).ToBeVisibleAsync(); + await ExpectUrlAsync("/about"); + } + + /// Opens and waits until blazor.web.js intercepts links. + private async Task GotoStartedAsync(string path) + { + await GotoAsync(path); + await Page.WaitForFunctionAsync("() => !!window.Blazor"); + await Page.WaitForLoadStateAsync(LoadState.NetworkIdle); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/WebModeTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/WebModeTests.cs new file mode 100644 index 0000000000..4426cf2439 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/WebModeTests.cs @@ -0,0 +1,153 @@ +using Bit.Brouter.Tests.E2E.Infrastructure; +using Microsoft.Playwright; +using static Microsoft.Playwright.Assertions; + +namespace Bit.Brouter.Tests.E2E; + +/// An interactive render mode of the web harness host, each test in a browser context of its own. +public abstract class WebModeTests : InteractiveHarnessTests +{ + private readonly WebSession _session = new(); + + /// The BrouterHarness:Mode the host is started with. + protected abstract string Mode { get; } + + protected IBrowserContext Context => _session.Context; + + protected override string BaseUrl => _session.Host.BaseUrl; + + protected override string Framework => E2EEnvironment.Framework; + + protected override Task OpenPageAsync() => _session.OpenAsync(Mode); + + protected override Task ClosePageAsync() => _session.CloseAsync(); +} + +/// What only a prerendered interactive mode has to get right: the first response, and the handover. +public abstract class PrerenderedWebModeTests : WebModeTests +{ + protected override bool Prerenders => true; + + [TestMethod] + public async Task The_first_response_already_contains_the_matched_route() + { + var response = await GotoAsync("/items/42"); + + var html = await response!.TextAsync(); + StringAssert.Contains(html, "id=\"page-item\""); + StringAssert.Contains(html, "data-item-id=\"42\""); + } + + [TestMethod] + public async Task An_unmatched_deep_link_is_answered_with_the_not_found_status_and_content() + { + AllowConsoleError("404"); + + var response = await GotoAsync("/nope/deeper"); + + // Only .NET 10 gives a router a way to set the status (NavigationManager.NotFound). + Assert.AreEqual(E2EEnvironment.FrameworkHasNotFound(Framework) ? 404 : 200, response!.Status); + await Expect(Page.Locator("#not-found")).ToHaveAttributeAsync("data-path", "/nope/deeper"); + + await WaitForInteractiveAsync(); + await Expect(Page.Locator("#not-found")).ToHaveAttributeAsync("data-path", "/nope/deeper"); + } +} + +[TestClass] +public class ServerModeTests : PrerenderedWebModeTests +{ + protected override string Mode => "server"; + + protected override IReadOnlyList ExpectedRenderers => ["Server"]; + + protected override IReadOnlyList ExpectedPlatforms => ["dotnet"]; +} + +[TestClass] +public class WebAssemblyModeTests : PrerenderedWebModeTests +{ + protected override string Mode => "wasm"; + + protected override IReadOnlyList ExpectedRenderers => ["WebAssembly"]; + + protected override IReadOnlyList ExpectedPlatforms => ["browser"]; +} + +[TestClass] +public class AutoModeTests : PrerenderedWebModeTests +{ + protected override string Mode => "auto"; + + // Auto picks per visit: Server until the WebAssembly runtime is available, which on a fast + // connection can already be the first visit. + protected override IReadOnlyList ExpectedRenderers => ["Server", "WebAssembly"]; + + protected override IReadOnlyList ExpectedPlatforms => ["dotnet", "browser"]; + + [TestMethod] + public async Task A_later_visit_runs_on_WebAssembly_and_keeps_routing() + { + await GotoAsync("/"); + await WaitForInteractiveAsync(); + + // The first visit downloads the WebAssembly runtime in the background; a later visit in the + // same browser finds it cached and starts on WebAssembly - where Brouter has to pick up + // routing on a different runtime than the one that served the session so far. + IPage? later = null; + var deadline = DateTime.UtcNow.AddMinutes(2); + while (true) + { + later = await Context.NewPageAsync(); + await later.GotoAsync(BaseUrl + "/items/5", new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 90_000 }); + await Expect(later.Locator("#status")).ToHaveAttributeAsync("data-interactive", "true", new() { Timeout = 90_000 }); + + if (await later.Locator("#status").GetAttributeAsync("data-platform") == "browser") break; + + await later.CloseAsync(); + if (DateTime.UtcNow > deadline) Assert.Fail("Auto never switched a later visit to WebAssembly."); + await Task.Delay(2000); + } + + await Expect(later.Locator("#page-item")).ToHaveAttributeAsync("data-item-id", "5"); + await later.Locator("#nav-about").ClickAsync(); + await Expect(later.Locator("#page-about")).ToBeVisibleAsync(); + await Expect(later).ToHaveURLAsync(BaseUrl + "/about"); + } +} + +[TestClass] +public class ServerNoPrerenderModeTests : WebModeTests +{ + protected override string Mode => "server-noprerender"; + + protected override bool Prerenders => false; + + protected override IReadOnlyList ExpectedRenderers => ["Server"]; + + protected override IReadOnlyList ExpectedPlatforms => ["dotnet"]; +} + +[TestClass] +public class WebAssemblyNoPrerenderModeTests : WebModeTests +{ + protected override string Mode => "wasm-noprerender"; + + protected override bool Prerenders => false; + + protected override IReadOnlyList ExpectedRenderers => ["WebAssembly"]; + + protected override IReadOnlyList ExpectedPlatforms => ["browser"]; +} + +[TestClass] +public class AutoNoPrerenderModeTests : WebModeTests +{ + protected override string Mode => "auto-noprerender"; + + protected override bool Prerenders => false; + + protected override IReadOnlyList ExpectedRenderers => ["Server", "WebAssembly"]; + + protected override IReadOnlyList ExpectedPlatforms => ["dotnet", "browser"]; +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.E2E/publish-gate.sh b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/publish-gate.sh new file mode 100644 index 0000000000..fe20b7bd6f --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.E2E/publish-gate.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Publishes the web harness host the way a WebAssembly app ships (trimmed; pass +# -p:RunAOTCompilation=true for AOT) and fails when the trimmer or the AOT compiler reports an +# analysis warning raised inside Bit.Brouter. +# +# Two kinds of warning are deliberately not gated on: +# - the framework assemblies' own (Microsoft.AspNetCore.Components, Microsoft.JSInterop), which they +# report once TrimmerSingleWarn is off and which are not this library's to fix; +# - IL2110/IL2111 raised in application code (the harness) where a component-typed [Parameter] is set, +# e.g. . Blazor's own LayoutView.Layout and DynamicComponent.Type raise the +# identical warnings at their call sites: it is how the trimmer reports any +# [DynamicallyAccessedMembers] component parameter, not something Brouter's API does differently. +# +# Usage: publish-gate.sh [extra dotnet publish arguments...] +set -euo pipefail + +output="$1" +shift + +here="$(cd "$(dirname "$0")" && pwd)" +project="$here/../Bit.Brouter.Tests.Harness.Web/Bit.Brouter.Tests.Harness.Web.csproj" +log="$(mktemp)" + +# TargetFrameworks=net10.0 on top of -f: -f alone does not reach project references, which then still +# evaluate every framework they target - and with RunAOTCompilation each of those demands its own +# wasm-tools workload. +dotnet publish "$project" -c Release -f net10.0 -p:TargetFrameworks=net10.0 -o "$output" \ + -p:SuppressTrimAnalysisWarnings=false -p:TrimmerSingleWarn=false "$@" 2>&1 | tee "$log" + +# The origin member follows the warning code, so "IL2069: Bit.Brouter.X" is raised inside the library +# while "IL2111: Bit.Brouter.Tests.Harness.Y" is the harness. +brouter_warnings="$(grep -E "analysis (warning|error) IL[0-9]{4}: Bit\.Brouter\." "$log" | grep -vE "analysis (warning|error) IL[0-9]{4}: Bit\.Brouter\.Tests\." | sort -u || true)" + +if [ -n "$brouter_warnings" ]; then + echo + echo "Bit.Brouter produced trim/AOT analysis warnings:" + echo "$brouter_warnings" + exit 1 +fi + +echo +echo "No trim/AOT analysis warnings originate in Bit.Brouter." diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Bit.Brouter.Tests.Harness.Hybrid.csproj b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Bit.Brouter.Tests.Harness.Hybrid.csproj new file mode 100644 index 0000000000..d58d605aa3 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Bit.Brouter.Tests.Harness.Hybrid.csproj @@ -0,0 +1,34 @@ + + + + + + WinExe + net10.0-windows + true + + true + enable + enable + false + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/HarnessForm.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/HarnessForm.cs new file mode 100644 index 0000000000..558d60a986 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/HarnessForm.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Components.WebView.WindowsForms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Web.WebView2.Core; + +namespace Bit.Brouter.Tests.Harness.Hybrid; + +internal sealed class HarnessForm : Form +{ + public HarnessForm(string startPath, string? remoteDebuggingPort, string? userDataFolder) + { + Text = "Brouter hybrid harness"; + ClientSize = new Size(1280, 720); + + var services = new ServiceCollection(); + services.AddWindowsFormsBlazorWebView(); + services.AddBrouterHarness(); + + var webView = new BlazorWebView + { + Dock = DockStyle.Fill, + HostPage = "wwwroot/index.html", + StartPath = startPath, + Services = services.BuildServiceProvider(), + }; + webView.RootComponents.Add("#app"); + + // The debugging port goes through the WebView2 API, not WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: + // since Runtime 150 an elevated host (a CI runner's admin session) ignores the environment + // variable, and the port would silently never open. + webView.BlazorWebViewInitializing += (_, e) => + { + if (remoteDebuggingPort is not null) + e.EnvironmentOptions = new CoreWebView2EnvironmentOptions { AdditionalBrowserArguments = $"--remote-debugging-port={remoteDebuggingPort}" }; + if (userDataFolder is not null) + e.UserDataFolder = userDataFolder; + }; + webView.WebView.CoreWebView2InitializationCompleted += (_, e) => + { + if (e.IsSuccess is false) Program.Fail("WebView2 failed to initialize", e.InitializationException); + + Console.Error.WriteLine($"WebView2 initialized, browser process {webView.WebView.CoreWebView2.BrowserProcessId}"); + }; + + Controls.Add(webView); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Program.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Program.cs new file mode 100644 index 0000000000..d7ac2a0d3d --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/Program.cs @@ -0,0 +1,49 @@ +using Microsoft.Web.WebView2.Core; + +namespace Bit.Brouter.Tests.Harness.Hybrid; + +internal static class Program +{ + [STAThread] + private static int Main(string[] args) + { + // The E2E suite starts this app without a visible console and waits on WebView2's debugging port. + // Anything that stops the WebView from starting must end the process with the reason on stderr, + // which the suite quotes - never park it behind WinForms' unhandled exception dialog. + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.ThrowException); + AppDomain.CurrentDomain.UnhandledException += (_, e) => Fail("Unhandled exception", e.ExceptionObject); + + string runtimeVersion; + try + { + runtimeVersion = CoreWebView2Environment.GetAvailableBrowserVersionString(); + } + catch (WebView2RuntimeNotFoundException ex) + { + Fail("The WebView2 Runtime is not installed", ex); + return 1; + } + Console.Error.WriteLine($"WebView2 Runtime {runtimeVersion}, elevated: {Environment.IsPrivilegedProcess}"); + + ApplicationConfiguration.Initialize(); + Application.Run(new HarnessForm( + // --start-path /items/9 opens the WebView at a deep path, the hybrid counterpart of a deep link. + startPath: ArgumentValue(args, "--start-path") ?? "/", + remoteDebuggingPort: ArgumentValue(args, "--remote-debugging-port"), + userDataFolder: ArgumentValue(args, "--user-data-folder"))); + return 0; + } + + internal static void Fail(string reason, object? exception) + { + Console.Error.WriteLine($"{reason}: {exception}"); + Console.Error.Flush(); + Environment.Exit(1); + } + + private static string? ArgumentValue(string[] args, string name) + { + var index = Array.IndexOf(args, name); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/wwwroot/index.html b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/wwwroot/index.html new file mode 100644 index 0000000000..378f88afd3 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Hybrid/wwwroot/index.html @@ -0,0 +1,18 @@ + + + + + + + Brouter hybrid harness + + + + + + +
Loading...
+ + + + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Bit.Brouter.Tests.Harness.Web.Client.csproj b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Bit.Brouter.Tests.Harness.Web.Client.csproj new file mode 100644 index 0000000000..7e44f0f50d --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Bit.Brouter.Tests.Harness.Web.Client.csproj @@ -0,0 +1,23 @@ + + + + net10.0;net9.0;net8.0 + enable + enable + false + true + Default + true + + + + + + + + + + + + + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Program.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Program.cs new file mode 100644 index 0000000000..0bcab52e98 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web.Client/Program.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddBrouterHarness(); + +await builder.Build().RunAsync(); diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Bit.Brouter.Tests.Harness.Web.csproj b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Bit.Brouter.Tests.Harness.Web.csproj new file mode 100644 index 0000000000..f3b9866254 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Bit.Brouter.Tests.Harness.Web.csproj @@ -0,0 +1,29 @@ + + + + + + net10.0;net9.0;net8.0 + enable + enable + false + true + + + + + + + + + + + + + + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/App.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/App.razor new file mode 100644 index 0000000000..ae4d9e7603 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/App.razor @@ -0,0 +1,27 @@ +@inject IConfiguration Configuration + + + + + + + + + @* An empty icon: the default /favicon.ico request would 404 and trip the suites' console-error check. *@ + + + Brouter harness (@Mode) + + + + + + + + + +@code { + private string Mode => Configuration[HarnessRenderModes.ConfigurationKey] ?? HarnessRenderModes.Auto; + + private IComponentRenderMode? RenderMode => HarnessRenderModes.Resolve(Mode); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/Pages/CatchAll.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/Pages/CatchAll.razor new file mode 100644 index 0000000000..ff1545eda9 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/Pages/CatchAll.razor @@ -0,0 +1,8 @@ +@* Gives the endpoint router a page for every URL. App.razor never renders it: HarnessApp's Brouter + matches the real route, so this only exists to make MapRazorComponents answer the request. *@ +@page "/" +@page "/{*path}" + +@code { + [Parameter] public string? Path { get; set; } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/_Imports.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/_Imports.razor new file mode 100644 index 0000000000..ad122fabd6 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Components/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web +@using Bit.Brouter.Tests.Harness +@using Bit.Brouter.Tests.Harness.Web +@using Bit.Brouter.Tests.Harness.Web.Components diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/HarnessRenderModes.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/HarnessRenderModes.cs new file mode 100644 index 0000000000..d33ac32135 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/HarnessRenderModes.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace Bit.Brouter.Tests.Harness.Web; + +/// +/// The render modes the harness host can serve, by the names the suites pass as +/// --BrouterHarness:Mode=<name> (or UseSetting in-process). +/// +public static class HarnessRenderModes +{ + public const string ConfigurationKey = "BrouterHarness:Mode"; + + public const string Ssr = "ssr"; + public const string Server = "server"; + public const string ServerNoPrerender = "server-noprerender"; + public const string WebAssembly = "wasm"; + public const string WebAssemblyNoPrerender = "wasm-noprerender"; + public const string Auto = "auto"; + public const string AutoNoPrerender = "auto-noprerender"; + + public static IReadOnlyList All { get; } = + [Ssr, Server, ServerNoPrerender, WebAssembly, WebAssemblyNoPrerender, Auto, AutoNoPrerender]; + + /// Null means static server-side rendering: no interactive runtime at all. + public static IComponentRenderMode? Resolve(string? mode) => mode switch + { + Ssr => null, + Server => RenderMode.InteractiveServer, + ServerNoPrerender => new InteractiveServerRenderMode(prerender: false), + WebAssembly => RenderMode.InteractiveWebAssembly, + WebAssemblyNoPrerender => new InteractiveWebAssemblyRenderMode(prerender: false), + Auto => RenderMode.InteractiveAuto, + AutoNoPrerender => new InteractiveAutoRenderMode(prerender: false), + // A typo must not quietly fall back to some mode and let a whole suite pass against the wrong one. + _ => throw new InvalidOperationException($"Unknown {ConfigurationKey} '{mode}'. Expected one of: {string.Join(", ", All)}.") + }; +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Program.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Program.cs new file mode 100644 index 0000000000..acf4b1298f --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Program.cs @@ -0,0 +1,30 @@ +using Bit.Brouter.Tests.Harness.Web.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Both interactive runtimes are always registered: which one (if any) the harness uses is decided by +// App.razor from BrouterHarness:Mode. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddBrouterHarness(); + +var app = builder.Build(); + +#if NET9_0_OR_GREATER +app.UseAntiforgery(); +app.MapStaticAssets(); +#else +app.UseStaticFiles(); +// Explicitly after the static files: WebApplication otherwise matches endpoints first, the catch-all +// page claims /_content/... and UseStaticFiles steps aside for a request that already has an endpoint. +app.UseRouting(); +app.UseAntiforgery(); +#endif + +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddInteractiveWebAssemblyRenderMode(); + +app.Run(); diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Properties/launchSettings.json b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Properties/launchSettings.json new file mode 100644 index 0000000000..684ed5905e --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness.Web/Properties/launchSettings.json @@ -0,0 +1,33 @@ +{ + "profiles": { + "ssr": { + "commandName": "Project", + "commandLineArgs": "--BrouterHarness:Mode=ssr", + "launchBrowser": true, + "applicationUrl": "http://localhost:5290", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "server": { + "commandName": "Project", + "commandLineArgs": "--BrouterHarness:Mode=server", + "launchBrowser": true, + "applicationUrl": "http://localhost:5290", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "wasm": { + "commandName": "Project", + "commandLineArgs": "--BrouterHarness:Mode=wasm", + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5290", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "auto": { + "commandName": "Project", + "commandLineArgs": "--BrouterHarness:Mode=auto", + "launchBrowser": true, + "applicationUrl": "http://localhost:5290", + "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + } + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Bit.Brouter.Tests.Harness.csproj b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Bit.Brouter.Tests.Harness.csproj new file mode 100644 index 0000000000..86fa52004f --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Bit.Brouter.Tests.Harness.csproj @@ -0,0 +1,24 @@ + + + + + + net10.0;net9.0;net8.0 + enable + enable + false + false + + + + + + + + + + + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessApp.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessApp.razor new file mode 100644 index 0000000000..958edc07e3 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessApp.razor @@ -0,0 +1,48 @@ +@* Root component of every harness host. Each element a test drives or reads carries an id, and the + status line reports how and where the tree is being rendered. *@ + +@inject IBrouter Brouter +@inject HarnessScope Scope +@implements IDisposable + +
+ + + + +
+ +
+
+ +@code { + protected override void OnInitialized() + { + Brouter.OnNavigating += RecordNavigating; + } + + private ValueTask RecordNavigating(BrouterNavigationContext ctx) + { + Scope.RecordNavigation($"{ctx.NavigationType} {ctx.To.Path}"); + return ValueTask.CompletedTask; + } + + public void Dispose() => Brouter.OnNavigating -= RecordNavigating; +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessData.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessData.cs new file mode 100644 index 0000000000..35f97c388d --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessData.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Bit.Brouter.Tests.Harness; + +/// +/// What the harness loaders return: where they ran and in which scope. A page rendering data whose +/// is not its own scope's is looking at a result carried across the +/// prerender -> interactive boundary rather than one its own loader produced. +/// +public sealed record HarnessData(string ScopeId, string Platform, int Run); + +/// +/// Source-generated serialization for , plugged into +/// BrouterOptions.LoaderStateTypeInfoResolver - the trimming/AOT-safe configuration the +/// publish gate exercises. +/// +[JsonSerializable(typeof(HarnessData))] +internal partial class HarnessJsonContext : JsonSerializerContext +{ +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRouter.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRouter.razor new file mode 100644 index 0000000000..7bfc15f349 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRouter.razor @@ -0,0 +1,77 @@ +@inject HarnessScope Scope + + + + + + + + + + + +

guarded

+
+
+ + +

denied

+
+
+ + + + + +

unreachable

+
+ +

@err.Exception.Message

+
+
+ + + + + + + + + + + + + + +
+ + +

not found

+
+
+ +@code { + private ValueTask RedirectToDenied(BrouterNavigationContext ctx) + { + ctx.Redirect("/denied"); + return ValueTask.CompletedTask; + } + + private ValueTask GuardLeave(BrouterNavigationContext ctx) + { + if (Scope.BlockLeave) ctx.Cancel(); + return ValueTask.CompletedTask; + } + + private ValueTask LoadData(BrouterNavigationContext ctx) => + ValueTask.FromResult(new HarnessData(Scope.Id, HarnessRuntime.Platform, ++Scope.DataLoaderRuns)); + + private static ValueTask LoadBroken(BrouterNavigationContext ctx) => + ValueTask.FromException(new InvalidOperationException("harness loader failure")); + + private ValueTask LoadIntentTarget(BrouterNavigationContext ctx) => + ValueTask.FromResult(new HarnessData(Scope.Id, HarnessRuntime.Platform, Scope.CountIntentTargetRun())); + + private ValueTask LoadViewportTarget(BrouterNavigationContext ctx) => + ValueTask.FromResult(new HarnessData(Scope.Id, HarnessRuntime.Platform, Scope.CountViewportTargetRun())); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRuntime.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRuntime.cs new file mode 100644 index 0000000000..9f702587d0 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessRuntime.cs @@ -0,0 +1,10 @@ +namespace Bit.Brouter.Tests.Harness; + +public static class HarnessRuntime +{ + /// + /// "browser" inside the WebAssembly runtime, "dotnet" everywhere else (prerender, Server + /// circuits, BlazorWebView). Unlike RendererInfo this exists on every target framework. + /// + public static string Platform => OperatingSystem.IsBrowser() ? "browser" : "dotnet"; +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessScope.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessScope.cs new file mode 100644 index 0000000000..2c69a68810 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessScope.cs @@ -0,0 +1,47 @@ +namespace Bit.Brouter.Tests.Harness; + +/// +/// Per-DI-scope state the harness exposes to the browser suites. One instance lives per prerender +/// request, per Server circuit, per WebAssembly app and per BlazorWebView page load, so comparing +/// the id a loader stamped into its result with the id of the scope rendering it tells a test which +/// of those produced the data on screen. +/// +public sealed class HarnessScope +{ + public string Id { get; } = Guid.NewGuid().ToString("N")[..12]; + + public int DataLoaderRuns { get; set; } + + public int IntentTargetRuns { get; private set; } + + public int ViewportTargetRuns { get; private set; } + + /// Read by the /leave route's LeaveGuard; toggled from the page. + public bool BlockLeave { get; set; } + + /// Raised when a preload loader ran, so the page showing the counters can re-render. + public event Action? Changed; + + /// "Push /history/4", in the order Brouter reported its navigations to OnNavigating. + public List NavigationLog { get; } = []; + + public void RecordNavigation(string entry) + { + NavigationLog.Add(entry); + Changed?.Invoke(); + } + + public int CountIntentTargetRun() + { + IntentTargetRuns++; + Changed?.Invoke(); + return IntentTargetRuns; + } + + public int CountViewportTargetRun() + { + ViewportTargetRuns++; + Changed?.Invoke(); + return ViewportTargetRuns; + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessServiceCollectionExtensions.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessServiceCollectionExtensions.cs new file mode 100644 index 0000000000..a70093fd0b --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Bit.Brouter; +using Bit.Brouter.Tests.Harness; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class HarnessServiceCollectionExtensions +{ + /// + /// Registers Brouter with every browser-facing feature switched on, plus the per-scope + /// . Every host calls this, so the options under test are identical + /// wherever the harness runs. + /// + public static IServiceCollection AddBrouterHarness(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddBitBrouterServices(o => + { + o.ScrollBehavior = BrouterScrollMode.ToTop; + o.RestoreScrollPosition = true; + o.ScrollPositionStorage = BrouterScrollPositionStorage.SessionStorage; + o.FocusOnNavigateSelector = "h1"; + o.ViewTransitions = true; + o.PersistLoaderState = true; + o.LoaderStateTypeInfoResolver = HarnessJsonContext.Default; + }); + + services.AddScoped(); + + return services; + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor new file mode 100644 index 0000000000..36bc92fb4f --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor @@ -0,0 +1,23 @@ +@inject HarnessScope Scope + +@* data-interactive only flips after the first interactive render, so a test can wait for it before + driving anything that needs event handlers. *@ +

+ @RendererName | @(_interactive ? "interactive" : "static") | @HarnessRuntime.Platform | scope @Scope.Id +

+ +@code { + private bool _interactive; + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender is false) return; + + _interactive = true; + StateHasChanged(); + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor.cs new file mode 100644 index 0000000000..909762b598 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/HarnessStatus.razor.cs @@ -0,0 +1,11 @@ +namespace Bit.Brouter.Tests.Harness; + +public partial class HarnessStatus +{ +#if NET9_0_OR_GREATER + private string RendererName => RendererInfo.Name; +#else + // RendererInfo arrived in .NET 9; the suites fall back to data-platform on net8.0. + private string RendererName => "unknown"; +#endif +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/AboutPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/AboutPage.razor new file mode 100644 index 0000000000..c16e82f869 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/AboutPage.razor @@ -0,0 +1 @@ +

about

diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ConfirmPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ConfirmPage.razor new file mode 100644 index 0000000000..b0ec2e988c --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ConfirmPage.razor @@ -0,0 +1,16 @@ +@inject IBrouter Brouter + +

confirm

+ + +@(_armed ? "armed" : "disarmed") + +@code { + private bool _armed; + + private async Task Arm() + { + await Brouter.SetConfirmExternalNavigationAsync(true); + _armed = true; + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DataPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DataPage.razor new file mode 100644 index 0000000000..169ed9d34c --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DataPage.razor @@ -0,0 +1,22 @@ +@inject HarnessScope Scope + +

data

+ +
+
loaded in scope
+
@Info?.ScopeId
+
loaded on
+
@Info?.Platform
+
loader run
+
@Info?.Run
+
rendered in scope
+
@Scope.Id
+
rendered on
+
@HarnessRuntime.Platform
+
+ +@code { + [CascadingParameter] public BrouterRouteData? Data { get; set; } + + private HarnessData? Info => Data?.GetOrDefault(); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DeepPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DeepPage.razor new file mode 100644 index 0000000000..54a8f4153e --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/DeepPage.razor @@ -0,0 +1,5 @@ +

deep @Leaf

+ +@code { + [Parameter] public string? Leaf { get; set; } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HistoryPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HistoryPage.razor new file mode 100644 index 0000000000..8f8d86ce2b --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HistoryPage.razor @@ -0,0 +1,38 @@ +@inject IBrouter Brouter +@inject HarnessScope Scope +@implements IDisposable + +

history @Step

+ +@* What Brouter reported to OnNavigating, newest last: the C# side of the push/pop/replace classification. *@ + + +

state: @(Brouter.Location.HistoryState ?? "(none)")

+ +

+ step 2 with state + step 4 + replace with step 3 + + +

+ +@code { + [Parameter] public int Step { get; set; } + + // OnNavigated runs after the page rendered, so without this its entry would only appear on the next render. + protected override void OnInitialized() => Scope.Changed += OnScopeChanged; + + private void OnScopeChanged() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => Scope.Changed -= OnScopeChanged; + + private Task GoBack() => Brouter.BackAsync().AsTask(); + + private Task GoForward() => Brouter.ForwardAsync().AsTask(); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HomePage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HomePage.razor new file mode 100644 index 0000000000..06a80ab02a --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/HomePage.razor @@ -0,0 +1 @@ +

home

diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ItemPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ItemPage.razor new file mode 100644 index 0000000000..ddec3d8aac --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/ItemPage.razor @@ -0,0 +1,5 @@ +

item @Id

+ +@code { + [Parameter] public int Id { get; set; } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/KeepAlivePage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/KeepAlivePage.razor new file mode 100644 index 0000000000..83d98af372 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/KeepAlivePage.razor @@ -0,0 +1,8 @@ +

keep-alive

+ +

count @_count

+ + +@code { + private int _count; +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LeavePage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LeavePage.razor new file mode 100644 index 0000000000..a8e0653ac1 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LeavePage.razor @@ -0,0 +1,10 @@ +@inject HarnessScope Scope + +

leave

+ + + +

to about

diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LongPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LongPage.razor new file mode 100644 index 0000000000..c42d2d855a --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/LongPage.razor @@ -0,0 +1,10 @@ +@* Geometry the scroll tests rely on: the link sits inside the viewport once the window is scrolled to + 2000px (so clicking it does not scroll), and the anchor is well below that. *@ + +

long

+ +
+

to other

+
+

bottom anchor

+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor new file mode 100644 index 0000000000..106dd75a4e --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor @@ -0,0 +1 @@ +

entity @Id

diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor.cs new file mode 100644 index 0000000000..251f277e5b --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/MissingPage.razor.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Components; + +namespace Bit.Brouter.Tests.Harness.Pages; + +public partial class MissingPage +{ + [Inject] private NavigationManager Navigation { get; set; } = default!; + + [Parameter] public int Id { get; set; } + + protected override void OnParametersSet() + { +#if NET10_0_OR_GREATER + // Id 0 stands for an entity the page looked up and did not find: the .NET 10 not-found + // contract, which Brouter has to turn into its fallback (and static rendering into a 404). + if (Id == 0) Navigation.NotFound(); +#endif + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/OtherPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/OtherPage.razor new file mode 100644 index 0000000000..83f12d76e3 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/OtherPage.razor @@ -0,0 +1,6 @@ +@* Tall enough that arriving here scrolled would be visible: scroll-to-top has something to undo. *@ + +

other

+ +

to long#bottom-anchor

+
diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadPage.razor new file mode 100644 index 0000000000..3edd3a9521 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadPage.razor @@ -0,0 +1,21 @@ +@inject HarnessScope Scope +@implements IDisposable + +

preload

+ +

intent target loader runs: @Scope.IntentTargetRuns

+

viewport target loader runs: @Scope.ViewportTargetRuns

+ +

intent target

+ +@* Below the fold of a 720px viewport, so the viewport preload cannot fire until a test scrolls. *@ +
+

viewport target

+ +@code { + protected override void OnInitialized() => Scope.Changed += OnScopeChanged; + + private void OnScopeChanged() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() => Scope.Changed -= OnScopeChanged; +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadTargetPage.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadTargetPage.razor new file mode 100644 index 0000000000..d758a09f3a --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/Pages/PreloadTargetPage.razor @@ -0,0 +1,9 @@ +

preload target

+ +

served by loader run @Info?.Run

+ +@code { + [CascadingParameter] public BrouterRouteData? Data { get; set; } + + private HarnessData? Info => Data?.GetOrDefault(); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/_Imports.razor b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/_Imports.razor new file mode 100644 index 0000000000..47105e065c --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/_Imports.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web +@using Bit.Brouter +@using Bit.Brouter.Tests.Harness +@using Bit.Brouter.Tests.Harness.Pages diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Harness/wwwroot/harness.css b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/wwwroot/harness.css new file mode 100644 index 0000000000..cd461e88f3 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Harness/wwwroot/harness.css @@ -0,0 +1,20 @@ +/* Collapses every view transition to a single frame. Brouter's own default animations live in the + "bit-brouter" CSS layer, so these unlayered rules win; the transitions still start and complete + (which is what the suites assert), they just stop holding the page for ~300ms per navigation. */ +::view-transition-old(*), +::view-transition-new(*), +::view-transition-group(*) { + animation-duration: 1ms !important; + animation-delay: 0ms !important; +} + +body { + font-family: system-ui, sans-serif; + margin: 0 16px; +} + +.harness-nav { + display: flex; + flex-wrap: wrap; + gap: .5rem; +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/AssemblySetup.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/AssemblySetup.cs new file mode 100644 index 0000000000..335413f586 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/AssemblySetup.cs @@ -0,0 +1,20 @@ +using Bit.Brouter.Tests.Hosting.Infrastructure; + +// Classes in parallel, tests within a class in order: every test reads responses from a factory it +// either shares read-only or owns. +[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.ClassLevel)] + +namespace Bit.Brouter.Tests.Hosting; + +[TestClass] +public static class AssemblySetup +{ + [AssemblyCleanup] + public static async Task CleanupAsync() + { + await HarnessHostFactory.DisposeSharedAsync(); +#if NET10_0_OR_GREATER + await SampleHostFactories.DisposeAsync(); +#endif + } +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/Bit.Brouter.Tests.Hosting.csproj b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/Bit.Brouter.Tests.Hosting.csproj new file mode 100644 index 0000000000..c92c741398 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/Bit.Brouter.Tests.Hosting.csproj @@ -0,0 +1,39 @@ + + + + + + net10.0;net9.0;net8.0 + enable + enable + false + true + false + $(NoWarn);CS1591 + true + Exe + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/HarnessHostingTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/HarnessHostingTests.cs new file mode 100644 index 0000000000..719f673e94 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/HarnessHostingTests.cs @@ -0,0 +1,219 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bit.Brouter.Tests.Hosting.Infrastructure; +using Modes = Bit.Brouter.Tests.Harness.Web.HarnessRenderModes; + +namespace Bit.Brouter.Tests.Hosting; + +/// +/// What each render mode's server response has to contain. bUnit renders in an interactive renderer +/// with no HTTP response at all, and even the in-process HtmlRenderer tests leave RendererInfo +/// unpopulated - which makes Brouter treat them as interactive and skip its static-rendering-only +/// branches (the 404 propagation among them). Only a real host exercises those. +/// +[TestClass] +public class HarnessHostingTests +{ + [TestMethod] + [DataRow(Modes.Ssr)] + [DataRow(Modes.Server)] + [DataRow(Modes.WebAssembly)] + [DataRow(Modes.Auto)] + public async Task A_matched_deep_link_is_rendered_into_the_first_response(string mode) + { + using var response = await HarnessHostFactory.Shared(mode).CreateClient().GetAsync("/items/42"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + StringAssert.Contains(await response.Content.ReadAsStringAsync(), "id=\"page-item\" data-item-id=\"42\""); + } + + [TestMethod] + [DataRow(Modes.ServerNoPrerender)] + [DataRow(Modes.WebAssemblyNoPrerender)] + [DataRow(Modes.AutoNoPrerender)] + public async Task A_mode_without_prerendering_sends_only_the_interactive_component_marker(string mode) + { + using var response = await HarnessHostFactory.Shared(mode).CreateClient().GetAsync("/items/42"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + var html = await response.Content.ReadAsStringAsync(); + Assert.IsFalse(html.Contains("id=\"page-item\"", StringComparison.Ordinal), "A non-prerendered mode rendered the route on the server."); + StringAssert.Contains(html, "")] + private static partial Regex WebAssemblyStateComment(); +} diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/SamplesHostingTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/SamplesHostingTests.cs new file mode 100644 index 0000000000..d93a645d8b --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/SamplesHostingTests.cs @@ -0,0 +1,90 @@ +#if NET10_0_OR_GREATER +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Bit.Brouter.Tests.Hosting; + +internal static class SampleHostFactories +{ + public static readonly Lazy> Server = new(() => new()); + public static readonly Lazy> WebAssembly = new(() => new()); + public static readonly Lazy> Auto = new(() => new()); + + public static HttpClient CreateClient(string sample) => sample switch + { + "server" => Server.Value.CreateClient(), + "wasm" => WebAssembly.Value.CreateClient(), + "auto" => Auto.Value.CreateClient(), + _ => throw new ArgumentOutOfRangeException(nameof(sample), sample, null) + }; + + public static async Task DisposeAsync() + { + if (Server.IsValueCreated) await Server.Value.DisposeAsync(); + if (WebAssembly.IsValueCreated) await WebAssembly.Value.DisposeAsync(); + if (Auto.IsValueCreated) await Auto.Value.DisposeAsync(); + } +} + +/// +/// The hosting samples are what the README and the MCP setup guides hand to people as the way to wire +/// Brouter into each render mode, so they are booted as they ship - their own Program.cs and App.razor - +/// rather than trusted to keep matching the harness. +/// +[TestClass] +public class SamplesHostingTests +{ + [TestMethod] + [DataRow("server")] + [DataRow("wasm")] + [DataRow("auto")] + public async Task The_home_page_is_prerendered(string sample) + { + using var client = SampleHostFactories.CreateClient(sample); + using var response = await client.GetAsync("/"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + StringAssert.Contains(await response.Content.ReadAsStringAsync(), "

Brouter samples

"); + } + + [TestMethod] + [DataRow("server")] + [DataRow("wasm")] + [DataRow("auto")] + public async Task A_typed_route_parameter_is_bound_in_the_prerendered_page(string sample) + { + using var client = SampleHostFactories.CreateClient(sample); + using var response = await client.GetAsync("/counter/1234"); + + var html = await response.Content.ReadAsStringAsync(); + StringAssert.Contains(html, "

Counter

"); + StringAssert.Contains(html, "
1234
"); + } + + [TestMethod] + [DataRow("server")] + [DataRow("wasm")] + [DataRow("auto")] + public async Task An_unmatched_url_ends_on_the_samples_not_found_route(string sample) + { + using var client = SampleHostFactories.CreateClient(sample); + using var response = await client.GetAsync("/definitely/not/here"); + + Assert.AreEqual("/404", response.RequestMessage?.RequestUri?.AbsolutePath); + StringAssert.Contains(await response.Content.ReadAsStringAsync(), "Nothing matched this address."); + } + + [TestMethod] + [DataRow("server")] + [DataRow("wasm")] + [DataRow("auto")] + public async Task The_script_module_is_served(string sample) + { + using var client = SampleHostFactories.CreateClient(sample); + using var response = await client.GetAsync("/_content/Bit.Brouter/bit-brouter.js"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("text/javascript", response.Content.Headers.ContentType?.MediaType); + } +} +#endif diff --git a/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/StaticRenderingLogTests.cs b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/StaticRenderingLogTests.cs new file mode 100644 index 0000000000..0227389ac0 --- /dev/null +++ b/src/Brouter/Tests/Bit.Brouter.Tests.Hosting/StaticRenderingLogTests.cs @@ -0,0 +1,42 @@ +using Bit.Brouter.Tests.Hosting.Infrastructure; +using Microsoft.Extensions.Logging; +using Modes = Bit.Brouter.Tests.Harness.Web.HarnessRenderModes; + +namespace Bit.Brouter.Tests.Hosting; + +/// +/// What the server logs while rendering every harness route statically. Brouter logs the JS interop +/// failures it swallows at Debug, so a static render that tries interop - which never works there and +/// only costs an exception per call - shows up here even though the page renders fine. +/// +[TestClass] +public class StaticRenderingLogTests +{ + private static readonly string[] Routes = + [ + "/", "/about", "/items/42", "/deep/a/b/c/leaf", "/data", "/guarded", "/denied", "/old-about", "/nope", + "/broken", "/missing/0", "/long", "/other", "/preload", "/leave", "/history/1", "/keepalive", "/confirm", + ]; + + [TestMethod] + [DataRow(Modes.Ssr)] + [DataRow(Modes.Server)] + [DataRow(Modes.WebAssembly)] + [DataRow(Modes.Auto)] + public async Task Rendering_every_route_logs_no_errors_and_attempts_no_js_interop(string mode) + { + await using var factory = new HarnessHostFactory(mode); + using var client = factory.CreateNonRedirectingClient(); + + foreach (var route in Routes) + { + using var _ = await client.GetAsync(route); + } + + var errors = factory.Logs.Entries.Where(e => e.Level >= LogLevel.Error).ToArray(); + Assert.AreEqual(0, errors.Length, string.Join(Environment.NewLine, errors.Select(e => e.ToString()))); + + var interop = factory.Logs.Entries.Where(e => e.Category.StartsWith("Bit.Brouter", StringComparison.Ordinal) && e.Message.Contains("interop", StringComparison.OrdinalIgnoreCase)).ToArray(); + Assert.AreEqual(0, interop.Length, string.Join(Environment.NewLine, interop.Select(e => e.ToString()))); + } +} From 0d707655e3ab61ea8fee5485f920427f8a41915f Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 14 Sep 2026 13:44:07 +0330 Subject: [PATCH 02/43] feat(blazorui): apply BitAccordionList improvements #13151 (#13152) --- .../AccordionList/BitAccordionList.razor | 14 +- .../AccordionList/BitAccordionList.razor.cs | 1461 +++++++++++++++-- .../AccordionList/BitAccordionList.scss | 10 + .../BitAccordionListClassStyles.cs | 28 + .../AccordionList/BitAccordionListItem.cs | 61 + .../BitAccordionListNameSelectors.cs | 50 + .../AccordionList/BitAccordionListOption.cs | 95 +- .../BitAccordionListToggleArgs.cs | 57 + .../AccordionList/_BitAccordionListItem.razor | 69 +- .../_BitAccordionListItem.razor.cs | 97 +- .../JsInterop/ExtrasJsRuntimeExtensions.cs | 15 + .../Bit.BlazorUI.Extras/Scripts/Extras.ts | 66 +- .../ButtonGroup/BitButtonGroup.razor.cs | 27 + .../Components/Navs/Nav/BitNav.razor.cs | 55 +- .../Surfaces/Accordion/BitAccordion.razor | 4 +- .../Surfaces/Accordion/BitAccordion.razor.cs | 26 + .../Surfaces/Accordion/BitAccordion.scss | 17 +- .../Extensions/ObjectExtensions.cs | 9 +- .../AccordionList/BitAccordionListDemo.razor | 19 +- .../BitAccordionListDemo.razor.cs | 337 +++- .../BitAccordionListDemo.razor.scss | 13 + .../Extras/AccordionList/Section.cs | 8 + .../_BitAccordionListCustomDemo.razor | 453 ++++- .../_BitAccordionListCustomDemo.razor.cs | 123 +- ...itAccordionListCustomDemo.razor.samples.cs | 862 ++++------ .../_BitAccordionListItemDemo.razor | 446 ++++- .../_BitAccordionListItemDemo.razor.cs | 119 +- ..._BitAccordionListItemDemo.razor.samples.cs | 584 +++---- .../_BitAccordionListOptionDemo.razor | 746 ++++++++- .../_BitAccordionListOptionDemo.razor.cs | 23 +- ...itAccordionListOptionDemo.razor.samples.cs | 557 +++++-- .../Accordion/BitAccordionDemo.razor.cs | 7 + .../ButtonGroup/BitButtonGroupTests.cs | 83 + .../BitAccordionListBoundOptionsTest.razor | 24 + .../BitAccordionListFeaturesTests.cs | 1274 ++++++++++++++ .../BitAccordionListHtmlAttributesTest.razor | 9 + .../BitAccordionListOptionsOrderTest.razor | 12 +- .../BitAccordionListOptionsOrderTests.cs | 43 + ...itAccordionListPlainOptionsOrderTest.razor | 19 + .../BitAccordionListScrollOnExpandTest.razor | 19 + .../AccordionList/BitAccordionListTests.cs | 18 + .../Components/Navs/Nav/BitNavTests.cs | 49 + .../Surfaces/Accordion/BitAccordionTests.cs | 43 + 43 files changed, 6665 insertions(+), 1386 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListToggleArgs.cs create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListBoundOptionsTest.razor create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListFeaturesTests.cs create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListHtmlAttributesTest.razor create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListPlainOptionsOrderTest.razor create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListScrollOnExpandTest.razor diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor index b5c2a67bd4..c326daa869 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor @@ -2,14 +2,19 @@ @inherits BitComponentBase @typeparam TItem -
+ dir="@Dir?.ToString().ToLower()" + aria-label="@AriaLabel"> @if (ChildContent is not null || Options is not null) { + @* The options report the order they are rendered in from here on, which is their markup order. *@ + @{ BeginOptionsOrder(); } @(Options ?? ChildContent) } @@ -20,4 +25,9 @@ <_BitAccordionListItem @key="@GetItemKey(item)" AccordionList="this" Item="item" /> } } + + @if (_ShowEmptyContent) + { + @EmptyContent + }
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs index 76be31699a..7d9de72372 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.razor.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + namespace Bit.BlazorUI; /// @@ -7,17 +10,54 @@ namespace Bit.BlazorUI; /// public partial class BitAccordionList : BitComponentBase where TItem : class { + // The keys the header of an item answers on top of the Tab key. They are suppressed on a listener of + // the browser's own, since Blazor's preventDefault directive cannot be decided per key, and a header + // that moves the focus while the page scrolls under it moves the reader twice. + private static readonly string[] _navigationKeys = ["ArrowDown", "ArrowUp", "Home", "End"]; + private int _optionKeySeed; + private bool _isToggling; + private bool _oldMultiple; + private bool _hasRendered; + private bool _pendingBoundKeysPush; + private bool _preventKeysRegistered; + private bool _collectingOptionOrder; + // Set by an option that registers after the first render: it is the only way _items can end up in an + // order other than the markup one, and what says the rendered document is worth reading back. + private bool _optionOrderIsStale; + private string? _togglingKey; private List _items = []; - private IEnumerable _oldItems = default!; + private List? _oldItems; private string? _internalExpandedKey; private List _internalExpandedKeys = []; - private readonly HashSet _expandedKeys = []; + private BitAccordionListClassStyles? _oldClasses; + private BitAccordionListClassStyles? _oldStyles; + private readonly List _optionOrder = []; + // The keys of the panels waiting to be scrolled into view. Keys rather than items, since a panel can be + // opened by the very change that mounts the item it belongs to - and that item is not there to name yet. + private readonly List _pendingScrolls = []; + private readonly HashSet _expandedKeys = new(StringComparer.Ordinal); + // The order the keys were expanded in, which is what MaxExpanded closes the oldest panel by. It is kept + // beside the set rather than in it, since a HashSet has no order of its own. + private readonly List _expandOrder = []; + private readonly Dictionary _fallbackKeys = new(ReferenceComparer.Instance); + private readonly Dictionary> _itemRefs = new(ReferenceComparer.Instance); internal BitAccordionClassStyles? _itemClasses; internal BitAccordionClassStyles? _itemStyles; + [Inject] private IJSRuntime _js { get; set; } = default!; + + + + /// + /// The custom template to render beside the header of each item, outside of the toggle button and of the + /// heading it sits in, so that it can hold its own interactive elements (a menu, a delete button, a switch). + /// Used when an item does not provide its own actions. + /// + [Parameter] public RenderFragment? ActionsTemplate { get; set; } + /// /// The color kind of the background of all the accordion items. /// @@ -38,6 +78,23 @@ public partial class BitAccordionList : BitComponentBase where TItem : cl /// [Parameter] public BitAccordionListClassStyles? Classes { get; set; } + /// + /// Allows the expanded item to be collapsed again from its own header. + ///
+ /// The default value is true. + ///
+ /// + /// Setting it to false keeps one item open at all times: the header of the last expanded item reports + /// itself as aria-disabled, the way the WAI-ARIA authoring practices ask a header that cannot + /// collapse its panel to, and no longer answers the pointer or the keyboard. + ///
+ /// It is the header that is closed off, not the list itself: the , + /// and methods still drive the AccordionList, and + /// nothing is expanded on its behalf, so a list that starts with everything collapsed stays that way until + /// something is opened. + ///
+ [Parameter] public bool Collapsible { get; set; } = true; + /// /// The custom template to render the body (content) of each item. Used when an item does not provide its own body. /// @@ -63,6 +120,22 @@ public partial class BitAccordionList : BitComponentBase where TItem : cl /// [Parameter, TwoWayBound] public IEnumerable? ExpandedKeys { get; set; } + /// + /// Gets or sets the icon to show in place of the expander icon of all items while they are expanded, using + /// custom CSS classes for external icon libraries. + /// Takes precedence over when both are set. + /// Setting either of them also turns the rotation of the expander icon off, since a swapped icon already + /// reports the state on its own. Can be overridden per item. + /// + [Parameter] public BitIconInfo? ExpandedExpanderIcon { get; set; } + + /// + /// Gets or sets the name of the icon, from the built-in Fluent UI icons, to show in place of the expander + /// icon of all items while they are expanded. + /// Setting it also turns the rotation of the expander icon off. Can be overridden per item. + /// + [Parameter] public string? ExpandedExpanderIconName { get; set; } + /// /// Gets or sets the icon to display as the expander of all items using custom CSS classes for external icon libraries. /// Takes precedence over when both are set. @@ -76,6 +149,40 @@ public partial class BitAccordionList : BitComponentBase where TItem : cl /// [Parameter] public string? ExpanderIconName { get; set; } + /// + /// Gets or sets the side of the header the expander icon of all the items sits on. + ///
+ /// The default value is . + ///
+ [Parameter] public BitIconPosition? ExpanderIconPosition { get; set; } + + /// + /// The custom template to render in place of the expander icon of each item, leaving the rest of the header + /// as it is. Used when an item does not provide its own expander template. + /// + [Parameter] public RenderFragment? ExpanderTemplate { get; set; } + + /// + /// Opens the panel of every item while the page is being printed, so that a collapsed section is not left + /// out of the paper as a bare header. + /// + /// + /// Content that is not in the DOM at all cannot be printed by any of this: a + /// panel that has never been opened, and every collapsed panel of a list that uses + /// , are still printed as a bare header. + /// + [Parameter] public bool ExpandOnPrint { get; set; } + + /// + /// The custom content to render in place of the items when the list has none. + /// + /// + /// A list built from or only knows it is empty once its + /// options have had their turn to register, so the empty content of one takes the render after the first + /// rather than the first itself; a list built from shows it right away. + /// + [Parameter] public RenderFragment? EmptyContent { get; set; } + /// /// The space (gap) in pixels between the accordion items. /// @@ -86,21 +193,100 @@ public partial class BitAccordionList : BitComponentBase where TItem : cl /// [Parameter] public RenderFragment? HeaderTemplate { get; set; } + /// + /// Gets or sets the heading level (aria-level) reported for the header of every item, so that the list + /// takes its right place in the heading outline of the page. + ///
+ /// The default value is 3, and the value is clamped to the 1..6 range. + ///
+ [Parameter] public int? HeadingLevel { get; set; } + + /// + /// Removes the expander icon from the header of all the items. Can be overridden per item. + /// + [Parameter] public bool HideExpanderIcon { get; set; } + /// /// The collection of items to render in the AccordionList. /// [Parameter] public IEnumerable Items { get; set; } = []; + /// + /// Delays the first render of the content of each item until it is expanded for the first time. The content + /// stays in the DOM afterwards, so the state it holds survives a collapse. + /// + [Parameter] public bool LazyContent { get; set; } + + /// + /// Gets or sets the maximum height of the content of every item (any CSS length), beyond which the content + /// scrolls inside the item instead of growing it. + /// + [Parameter] public string? MaxHeight { get; set; } + + /// + /// Gets or sets the greatest number of items that can be expanded at the same time in multiple-expand + /// mode. Expanding one more closes the panel that has been open the longest. + /// + /// + /// Nothing is turned away: a click always opens the panel it was aimed at, which is what tells this apart + /// from a limit that leaves a header answering nothing. A value below 1 is no limit at all, and the cap + /// applies to and to the default and the bound keys as well - the ones beyond it + /// are the ones dropped. It means nothing outside of , where one panel is the limit + /// already. + /// + [Parameter] public int? MaxExpanded { get; set; } + /// /// Enables the multiple-expand mode in which more than one item can be expanded at the same time. + /// caps how many of them may be. /// [Parameter, ResetClassBuilder] public bool Multiple { get; set; } + /// + /// Moves the focus between the headers of the items with the ArrowUp, ArrowDown, Home and End keys, which + /// the WAI-ARIA authoring practices offer as an addition to the Tab key rather than in place of it. + ///
+ /// The default value is true. + ///
+ /// + /// Only the headers answer these keys: the same keys pressed inside the panel of an item belong to whatever + /// the panel holds and are left alone. The navigation wraps around at both ends of the list and skips the + /// items that are disabled. + /// + [Parameter] public bool Navigable { get; set; } = true; + /// /// Removes the default border of all the accordion items and gives a background color to their body. /// [Parameter] public bool NoBorder { get; set; } + /// + /// Removes the region role from the panel of every item, leaving it a plain container. + /// + /// + /// The role names the panel as a landmark, which helps a screen reader user find their way back to the + /// content of a panel that holds headings or another accordion. The WAI-ARIA authoring practices ask for it + /// to be dropped where it would flood the page with landmarks instead - more than about six panels that can + /// all be open at the same time - which is what this is for. + /// + [Parameter] public bool NoContentRegion { get; set; } + + /// + /// Keeps the expander icon of every item still instead of turning it over when the item is expanded. + /// + [Parameter] public bool NoExpanderRotation { get; set; } + + /// + /// Stops the keyboard navigation of at the two ends of the list instead of + /// wrapping it around from the last header to the first and back. + /// + /// + /// The two are the linear and the circular navigation other libraries offer: wrapping + /// keeps the arrow keys moving forever, while stopping tells the reader where the list ends. The Home + /// and End keys still reach both ends either way. + /// + [Parameter] public bool NoNavigationLoop { get; set; } + /// /// The callback that is called when an item is collapsed. /// @@ -121,6 +307,22 @@ public partial class BitAccordionList : BitComponentBase where TItem : cl /// [Parameter] public EventCallback OnToggle { get; set; } + /// + /// Callback invoked before an item expands or collapses, letting the change be cancelled. + /// + /// + /// Set Cancel on the provided to leave the item as it + /// is, and read its Item, Key, IsExpanding and Reason to tell an expansion from a + /// collapse and a click on a header from an , , + /// , or call. Since the callback + /// is awaited, it can also run asynchronous work first, and nothing else toggles the list while it is running. + ///
+ /// The implicit collapse of the previously expanded item in single-expand mode is part of the expansion that + /// caused it and is not offered here; it is still reported through and + /// . + ///
+ [Parameter] public EventCallback> OnToggling { get; set; } + /// /// Alias of the ChildContent. /// @@ -131,55 +333,194 @@ public partial class BitAccordionList : BitComponentBase where TItem : cl /// [Parameter] public BitAccordionListNameSelectors? NameSelectors { get; set; } + /// + /// Leaves every item where it is: the headers keep their colors and their place in the tab order, but they + /// no longer answer the pointer or the keyboard. Can be overridden per item. + /// + /// + /// This is the list whose panels have to stay as they are rather than the one that is turned off, so the + /// headers report themselves as aria-disabled without being greyed out the way + /// greys them. still reports the click, + /// and the public methods still drive the list. + /// + [Parameter] public bool ReadOnly { get; set; } + + /// + /// Brings the item that has just been expanded into view, so that a panel opened at the bottom of the + /// window is not left off the screen it was opened on. + /// + /// + /// The item is moved as little as the browser can move it, so nothing happens to one that is already in + /// view, and the scroll is instant rather than smooth for a reader who has asked for less motion. It + /// covers the ways a single panel opens - a click on its header, , + /// and the bound keys - and runs when the panel is put on screen rather than + /// when the transition that opens it ends. scrolls to nothing: there is no one + /// panel it opened. + /// + [Parameter] public bool ScrollIntoViewOnExpand { get; set; } + + /// + /// Gets or sets the size of all the accordion items, which drives the padding of the headers and of the + /// contents and the size of the titles. + ///
+ /// The default value is . + ///
+ [Parameter] public BitSize? Size { get; set; } + /// /// Custom CSS styles for different parts of the AccordionList. /// [Parameter] public BitAccordionListClassStyles? Styles { get; set; } + /// + /// The custom template to render in place of the title of each item, leaving the rest of the header as it is. + /// Used when an item does not provide its own title template. + /// + [Parameter] public RenderFragment? TitleTemplate { get; set; } + + /// + /// Gets or sets the duration of the expand/collapse transition of every item in milliseconds, overriding the + /// duration the theme provides. A reduced-motion preference still collapses it, unless the ForceAnimation + /// parameter opts out of that. + /// + [Parameter] public int? TransitionDuration { get; set; } + + /// + /// Removes the content of an item from the DOM while it is collapsed, so that nothing it holds keeps running + /// behind a closed header. + /// + [Parameter] public bool UnmountOnCollapse { get; set; } + /// - /// Expands all the items (only effective in multiple-expand mode). + /// Expands all the items (only effective in multiple-expand mode). Disabled items are left as they are, + /// since their headers could not close again what would be opened for them. /// public async Task ExpandAll() { if (Multiple is false) return; - foreach (var item in _items) + var changed = false; + + foreach (var item in _items.ToArray()) { + if (GetIsEnabled(item) is false) continue; + + // The cap is a cap on the whole list, so ExpandAll stops at it rather than opening every panel + // and letting each one close the one before it. + if (_MaxExpanded is int max && _expandedKeys.Count >= max) break; + var key = GetItemKey(item); if (key.HasNoValue() || _expandedKeys.Contains(key!)) continue; - _expandedKeys.Add(key!); - SetIsExpanded(item, true); - await OnExpand.InvokeAsync(item); - await OnToggle.InvokeAsync(item); + changed |= await ApplyToggle(item, key!, true, BitAccordionToggleReason.Method); } + if (changed is false) return; + await UpdateBoundKeys(); - RefreshOptions(); - StateHasChanged(); + await RefreshAndRender(); } /// - /// Collapses all the expanded items. + /// Collapses all the expanded items, the disabled ones included. /// + /// + /// Unlike , which cannot open what is turned off, this one closes every panel: a + /// disabled item whose panel was opened by a default value would otherwise be left open with no way of + /// closing it, since its own header answers nothing. + /// public async Task CollapseAll() { - foreach (var item in _items) + var changed = false; + + foreach (var item in _items.ToArray()) { var key = GetItemKey(item); if (key.HasNoValue() || _expandedKeys.Contains(key!) is false) continue; - _expandedKeys.Remove(key!); - SetIsExpanded(item, false); - await OnCollapse.InvokeAsync(item); - await OnToggle.InvokeAsync(item); + changed |= await ApplyToggle(item, key!, false, BitAccordionToggleReason.Method); + } + + // Keys that no longer map to an item of the list are dropped along with the rest, so a collapsed list + // does not keep reporting them through the two-way bound ExpandedKey(s). + var orphans = _expandedKeys.Where(k => FindItem(k) is null).ToArray(); + if (orphans.Length > 0) + { + foreach (var orphan in orphans) RemoveExpandedKey(orphan); + changed = true; } + if (changed is false) return; + await UpdateBoundKeys(); - RefreshOptions(); - StateHasChanged(); + await RefreshAndRender(); + } + + /// + /// Expands the item with the provided key. Does nothing if it is already expanded or if no item carries + /// that key. In single-expand mode the currently expanded item is collapsed along the way. + /// + /// + /// A call of its own is not turned away by , by + /// or by : what those close off is the way in from the + /// header, not the one the app itself uses. + /// + public Task Expand(string key) => SetExpandedByKey(key, true); + + /// + /// Collapses the item with the provided key. Does nothing if it is already collapsed or if no item carries + /// that key. + /// + /// + /// Not turned away by , or + /// ; see . + /// + public Task Collapse(string key) => SetExpandedByKey(key, false); + + /// + /// Expands the item with the provided key if it is collapsed and collapses it if it is expanded. + /// + /// + /// Not turned away by , or + /// ; see . + /// + public Task Toggle(string key) + { + return key.HasNoValue() ? Task.CompletedTask : SetExpandedByKey(key, _expandedKeys.Contains(key) is false); + } + + /// + /// Reports whether the item with the provided key is currently expanded. + /// + public bool IsExpanded(string? key) => key.HasValue() && _expandedKeys.Contains(key!); + + /// + /// Returns the keys of the currently expanded items, in the order of the items of the list. + /// + public IReadOnlyList GetExpandedKeys() => GetOrderedExpandedKeys(); + + /// + /// Gives the focus to the header of the item with the provided key. + /// + public async Task FocusItem(string key) + { + var item = FindItem(key); + if (item is null) return; + + await InvokeAsync(() => FocusItemCore(item)); + } + + /// + /// Gives the focus to the header of the first item of the list that can take it. + /// + public async Task FocusAsync() + { + var item = _items.FirstOrDefault(GetIsEnabled); + if (item is null) return; + + await InvokeAsync(() => FocusItemCore(item)); } @@ -190,10 +531,10 @@ internal void RegisterOption(BitAccordionListOption option) { // Use a monotonic seed so keys remain unique even after removals, and guard // against colliding with any existing explicit keys. - var key = (_optionKeySeed++).ToString(); + var key = _optionKeySeed++.ToString(CultureInfo.InvariantCulture); while (_items.Any(i => GetItemKey(i) == key)) { - key = (_optionKeySeed++).ToString(); + key = _optionKeySeed++.ToString(CultureInfo.InvariantCulture); } option.Key = key; } @@ -202,11 +543,22 @@ internal void RegisterOption(BitAccordionListOption option) _items.Add(item); - if (ShouldExpandOnRegister(option.Key!, option.IsExpanded)) + // An option that arrives after the first render is one the markup added conditionally, so it lands + // behind every option that was already there wherever in the markup it sits. + if (_hasRendered) _optionOrderIsStale = true; + + // An option opening itself on registration is held to MaxExpanded like a set of defaults is: the ones + // registering after the cap is reached stay closed rather than pushing the list beyond it. + if (ShouldExpandOnRegister(option.Key!, option.IsExpanded) && (_MaxExpanded is not int max || _expandedKeys.Count < max)) { - _expandedKeys.Add(option.Key!); + AddExpandedKey(option.Key!); _internalExpandedKeys = GetOrderedExpandedKeys(); _internalExpandedKey = _internalExpandedKeys.FirstOrDefault(); + + // The bound value is pushed as well, so a page that binds ExpandedKey(s) is told about the option + // that opened itself on registration rather than being left with a stale value. It is deferred to + // the end of the render, since the registration runs in the middle of one. + _pendingBoundKeysPush = true; } StateHasChanged(); @@ -247,12 +599,16 @@ private bool ShouldExpandOnRegister(string key, bool optionIsExpanded) internal async Task UnregisterOption(BitAccordionListOption option) { - _items.Remove((option as TItem)!); + var item = (option as TItem)!; + + _items.Remove(item); + _itemRefs.Remove(item); + _fallbackKeys.Remove(item); var wasExpanded = false; if (option.Key.HasValue()) { - wasExpanded = _expandedKeys.Remove(option.Key!); + wasExpanded = RemoveExpandedKey(option.Key!); } // When a removed option was expanded, refresh the internal representations and the @@ -265,6 +621,50 @@ internal async Task UnregisterOption(BitAccordionListOption option) StateHasChanged(); } + // An option registers itself when it is initialized, which is the markup order only for the options that + // were there on the first render: one added conditionally later registers behind every option that was + // already there, wherever in the markup it sits. So the order is read back from the render itself - the + // options render in markup order - and _items is put back into it once the render is over. It is what the + // keyboard navigation walks and what the expanded keys are reported in, so it has to be the order the + // reader sees rather than the order the options happened to arrive in. + internal void BeginOptionsOrder() + { + // A pass that is already open is left as it is: registering an option asks the list to render again, + // and that second render lands in the same batch as the first - so clearing here would throw away + // the very order the options had just reported. + if (_collectingOptionOrder) return; + + _optionOrder.Clear(); + _collectingOptionOrder = true; + } + + internal void ReportOptionOrder(BitAccordionListOption option) + { + if (_collectingOptionOrder is false) return; + + var item = (option as TItem)!; + + if (_optionOrder.Any(i => ReferenceEquals(i, item))) return; + + _optionOrder.Add(item); + } + + internal void RegisterItem(TItem item, _BitAccordionListItem itemRef) + { + _itemRefs[item] = itemRef; + + // The panel of an item that is only being mounted now can already be waiting to be scrolled into + // view, and nothing else would ask for the render that brings it there: registering an element is + // bookkeeping of the item's own and puts nothing new on screen. + var key = GetItemKey(item); + if (key.HasValue() && _pendingScrolls.Contains(key!, StringComparer.Ordinal)) StateHasChanged(); + } + + internal void UnregisterItem(TItem item) + { + _itemRefs.Remove(item); + } + protected override string RootElementClass => "bit-acl"; @@ -283,40 +683,68 @@ protected override void RegisterCssStyles() StyleBuilder.Register(() => Gap.HasValue ? $"gap:{Gap}px" : string.Empty); } - protected override async Task OnInitializedAsync() - { - _items = (ChildContent is null && Options is null && Items is not null) ? [.. Items] : []; - - if (ChildContent is null && Options is null) - { - AssignItemKeys(); - InitializeExpandedKeys(); - } - - await base.OnInitializedAsync(); - } - protected override async Task OnParametersSetAsync() { BuildItemClassStyles(); if (ChildContent is null && Options is null && Items is not null) { - if (_oldItems is null || (ReferenceEquals(Items, _oldItems) is false && Items.SequenceEqual(_oldItems) is false)) + // The snapshot is a copy rather than the collection itself, so a page that keeps mutating the very + // list it handed over - adding an item to it, removing one - is still noticed here. A lazy sequence + // is walked once, so what is compared is also exactly what is kept. + List snapshot = [.. Items]; + + if (_oldItems is null || snapshot.SequenceEqual(_oldItems) is false) { - _oldItems = Items; - _items = [.. Items]; + var isFirstPass = _oldItems is null; + + _oldItems = snapshot; + _items = [.. snapshot]; + AssignItemKeys(); - InitializeExpandedKeys(); + + // Only the very first pass falls back to the default values: a later change of the collection + // must not throw away the state the reader has built up by opening and closing the panels. + InitializeExpandedKeys(preserveCurrent: isFirstPass is false); } } + // Leaving the multiple-expand mode with more than one panel open would otherwise keep them all open + // until the next click, which is the one state a single-expand list is there to rule out. + if (_oldMultiple && Multiple is false && _expandedKeys.Count > 1) + { + var kept = GetOrderedExpandedKeys().FirstOrDefault(); + + ClearExpandedKeys(); + if (kept.HasValue()) AddExpandedKey(kept!); + + SyncItemsExpandedState(); + + _internalExpandedKeys = GetOrderedExpandedKeys(); + _internalExpandedKey = _internalExpandedKeys.FirstOrDefault(); + + // The push is deferred to the end of the render, since a parameter set is no place to call back + // into the page that is setting them. + _pendingBoundKeysPush = true; + } + + _oldMultiple = Multiple; + // React to external (controlled) changes of the bound keys. if (Multiple) { if (ExpandedKeysHasBeenSet && (ExpandedKeys ?? []).SequenceEqual(_internalExpandedKeys) is false) { SyncFromExpandedKeys(ExpandedKeys); + + // Not every set of keys survives the way in whole: the ones beyond MaxExpanded are dropped, + // and so are the empty and the repeated ones. The page is told what the list actually holds + // rather than being left with a value it does not show - and rather than being read again as + // a change on every render that follows. + if ((ExpandedKeys ?? []).SequenceEqual(_internalExpandedKeys) is false) + { + _pendingBoundKeysPush = true; + } } } else @@ -327,6 +755,26 @@ protected override async Task OnParametersSetAsync() } } + // A cap that arrives - or is lowered - while more panels are open than it allows closes the oldest of + // them, the same way opening one more would have. Everything that adds a key already stops at the cap, + // so this is only about the cap itself changing. + if (_MaxExpanded is int max && _expandedKeys.Count > max) + { + foreach (var key in _expandOrder.Take(_expandedKeys.Count - max).ToArray()) + { + RemoveExpandedKey(key); + } + + SyncItemsExpandedState(); + + _internalExpandedKeys = GetOrderedExpandedKeys(); + _internalExpandedKey = _internalExpandedKeys.FirstOrDefault(); + + // The push is deferred to the end of the render, since a parameter set is no place to call back + // into the page that is setting them. + _pendingBoundKeysPush = true; + } + // Options render their items themselves and Blazor skips re-rendering them when only the // accordion list's own parameters (Styles, ExpandedKey(s), ...) change, so push a re-render to each one. RefreshOptions(); @@ -334,18 +782,203 @@ protected override async Task OnParametersSetAsync() await base.OnParametersSetAsync(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // A list of options only knows it is empty once its options have had their turn to register, and the + // first render is where that is learned - so the empty content it was given needs a render of its own + // to appear in. Nothing else would ask for one: an empty list has no option to report anything. + var showEmptyContent = _ShowEmptyContent; + + _hasRendered = true; + + if (showEmptyContent is false && _ShowEmptyContent) + { + StateHasChanged(); + } + + await ReorderOptions(); + + if (_pendingBoundKeysPush) + { + _pendingBoundKeysPush = false; + + await PushBoundKeys(); + } + + await UpdatePreventedKeys(); + + await ScrollPendingItemsIntoView(); + + await base.OnAfterRenderAsync(firstRender); + } + + // Puts _items back into the order the options were rendered in, which is their markup order. + private async Task ReorderOptions() + { + if (_collectingOptionOrder is false) return; + + _collectingOptionOrder = false; + + // A pass in which not every option had its turn says nothing about the order of the ones that + // did, so it is thrown away rather than guessed at. Blazor hands a child its parameters again + // only when one of them has actually changed, so a list whose options carry nothing but + // constants - no content, no template, no handler - can render without any of them reporting. + var complete = _optionOrder.Count == _items.Count; + var order = complete ? _optionOrder.ToArray() : []; + + _optionOrder.Clear(); + + // What the options could not report is read back from the document they were rendered into, which + // holds the markup order whether they reported it or not. + if (complete is false) + { + order = await ReadOptionOrderFromDom(); + if (order.Length == 0) return; + } + else + { + _optionOrderIsStale = false; + } + + if (_items.SequenceEqual(order, ReferenceComparer.Instance)) return; + + _items = [.. order]; + + // Nothing on screen depends on the order - the options render their own items in place - but the + // keys the list reports do, so they are pushed again where the order changed them. + if (GetOrderedExpandedKeys().SequenceEqual(_internalExpandedKeys) is false) + { + await UpdateBoundKeys(); + } + + StateHasChanged(); + } + + // The order of this list's own items in the rendered document, which is the markup order of the options + // that rendered them. It is only asked for where an option was added after the first render - the one + // case _items can be out of order - and only where the options themselves could not report it. + private async Task ReadOptionOrderFromDom() + { + if (_optionOrderIsStale is false) return []; + + var items = _items.ToArray(); + var elements = new ElementReference[items.Length]; + + for (int i = 0; i < items.Length; i++) + { + // An item that has not registered its element yet is one this render has just mounted: its own + // OnAfterRender is still to come, so the reading is left to the render that follows this one. + if (_itemRefs.TryGetValue(items[i], out var itemRef) is false) return []; + + var element = itemRef.GetElement(); + if (element is null) return []; + + elements[i] = element.Value; + } + + int[]? indexes; + try + { + indexes = await _js.BitExtrasGetElementsOrder(elements); + } + catch (JSDisconnectedException) { return []; } // we can ignore this exception here + + // An element that is not in the document is left out of the answer, so a partial one is no order at + // all - the same way a partial pass of the options is none. Anything that is not a permutation of + // what was sent is not an order of these items either. + if (indexes is null || indexes.Length != items.Length) return []; + if (indexes.Distinct().Count() != items.Length) return []; + if (indexes.Any(i => i < 0 || i >= items.Length)) return []; + + _optionOrderIsStale = false; + + return [.. indexes.Select(i => items[i])]; + } + + // The navigation keys are suppressed on a listener of the browser's own, and only for a key pressed on + // one of this list's own item headers: the same keys pressed inside a panel belong to whatever the panel + // holds, and the ones pressed on the headers of a list nested in a panel belong to that list. The header + // is matched through the whole chain of an item's own accordion, since a plain BitAccordion placed in a + // panel or in the Actions carries the same header class but none of the navigation that would stand in + // for the default action the listener takes away. + private const string ItemHeaderSelector = ".bit-acl-itm > .bit-acd > .bit-acd-hwr > .bit-acd-hed > .bit-acd-hdr"; + + private async Task UpdatePreventedKeys() + { + var wanted = Navigable && IsEnabled; + + if (wanted == _preventKeysRegistered) return; + + try + { + if (wanted) + { + await _js.BitExtrasSetPreventKeys(RootElement, _navigationKeys, ItemHeaderSelector, ".bit-acl"); + } + else + { + await _js.BitExtrasDisposePreventKeys(RootElement); + } + + _preventKeysRegistered = wanted; + } + catch (JSDisconnectedException) { } // we can ignore this exception here + } + + private async Task ScrollPendingItemsIntoView() + { + if (_pendingScrolls.Count == 0) return; + + var keys = _pendingScrolls.ToArray(); + _pendingScrolls.Clear(); + + foreach (var key in keys) + { + var item = FindItem(key); + var element = (item is not null && _itemRefs.TryGetValue(item, out var itemRef)) ? itemRef.GetElement() : null; + + if (element is null) + { + // The item the panel belongs to can be one that is only being mounted by the change that + // opened it: it registers itself, and then its element, in renders that come after the one + // this is running at the end of. So the key waits here until it does - the registration asks + // for the render that scrolls it - and is dropped as soon as the panel it names is closed. + if (_expandedKeys.Contains(key)) _pendingScrolls.Add(key); + + continue; + } + + try + { + await _js.BitExtrasScrollIntoView(element.Value); + } + catch (JSDisconnectedException) { } // we can ignore this exception here + } + } + private void AssignItemKeys() { - // Collect the explicit keys first so the auto-generated keys never collide with them. - var usedKeys = new HashSet(); + // Collect the explicit keys first so the generated keys never collide with them. + var usedKeys = new HashSet(StringComparer.Ordinal); foreach (var item in _items) { - var key = GetItemKey(item); + var key = GetDeclaredKey(item); if (key.HasValue()) usedKeys.Add(key!); } + var present = new HashSet(_items, ReferenceComparer.Instance); + + foreach (var item in _fallbackKeys.Keys.ToArray()) + { + // A key handed out earlier is kept where the item is still in the list and the key is still free, + // so an item does not lose its expanded state only because the collection around it changed. + if (present.Contains(item) && usedKeys.Add(_fallbackKeys[item])) continue; + + _fallbackKeys.Remove(item); + } + for (int i = 0; i < _items.Count; i++) { var item = _items[i]; @@ -354,10 +987,10 @@ private void AssignItemKeys() // Start from the loop index and increment until a non-colliding key is found so the // result stays deterministic across renders while remaining unique. var suffix = i; - var candidate = suffix.ToString(); + var candidate = suffix.ToString(CultureInfo.InvariantCulture); while (usedKeys.Contains(candidate)) { - candidate = (++suffix).ToString(); + candidate = (++suffix).ToString(CultureInfo.InvariantCulture); } SetItemKey(item, candidate); @@ -365,48 +998,58 @@ private void AssignItemKeys() } } - private void InitializeExpandedKeys() + private void InitializeExpandedKeys(bool preserveCurrent = false) { - _expandedKeys.Clear(); + // Read before the set is cleared: the keys of the items that were expanded and are still in the list. + var surviving = preserveCurrent ? GetSurvivingExpandedKeys() : null; + var selfExpanded = GetSelfExpandedKeys(); + + ClearExpandedKeys(); - // Controlled values take precedence over default values. + // Controlled values take precedence over what was there before, which takes precedence over the + // default values, which take precedence over the items' own IsExpanded. if (Multiple) { if (ExpandedKeysHasBeenSet && ExpandedKeys is not null) { AddExpandedKeys(ExpandedKeys); } + else if (surviving is not null) + { + AddExpandedKeys(surviving); + AddExpandedKeys(selfExpanded); + } else if (DefaultExpandedKeys is not null) { AddExpandedKeys(DefaultExpandedKeys); } else { - foreach (var item in _items.Where(GetIsExpanded)) - { - var key = GetItemKey(item); - if (key.HasValue()) _expandedKeys.Add(key!); - } + AddExpandedKeys(selfExpanded); } } else { - string? key = null; + string? key; if (ExpandedKeyHasBeenSet) { key = ExpandedKey; } + else if (surviving is not null) + { + key = surviving.FirstOrDefault() ?? selfExpanded.FirstOrDefault(); + } else if (DefaultExpandedKey.HasValue()) { key = DefaultExpandedKey; } else { - key = _items.Where(GetIsExpanded).Select(GetItemKey).FirstOrDefault(k => k.HasValue()); + key = selfExpanded.FirstOrDefault(); } - if (key.HasValue()) _expandedKeys.Add(key!); + if (key.HasValue()) AddExpandedKey(key!); } SyncItemsExpandedState(); @@ -415,16 +1058,71 @@ private void InitializeExpandedKeys() _internalExpandedKey = _internalExpandedKeys.FirstOrDefault(); } + private List GetSurvivingExpandedKeys() + { + return [.. _items.Select(GetItemKey).Where(k => k.HasValue() && _expandedKeys.Contains(k!)).Select(k => k!)]; + } + + private List GetSelfExpandedKeys() + { + return [.. _items.Where(GetIsExpanded).Select(GetItemKey).Where(k => k.HasValue()).Select(k => k!)]; + } + private void AddExpandedKeys(IEnumerable keys) { foreach (var key in keys) { if (key.HasNoValue()) continue; - _expandedKeys.Add(key); + AddExpandedKey(key); if (Multiple is false) break; + + // The keys beyond the cap are the ones dropped, so a set of defaults or of bound keys longer + // than MaxExpanded opens the first of them rather than none of them. + if (_MaxExpanded is int max && _expandedKeys.Count >= max) break; } } + // The three of them are what keeps _expandOrder - the order the panels were opened in, which is what + // MaxExpanded closes the oldest one by - beside the set that answers whether a key is expanded at all. + private bool AddExpandedKey(string key) + { + if (_expandedKeys.Add(key) is false) return false; + + _expandOrder.Add(key); + + return true; + } + + private bool RemoveExpandedKey(string key) + { + if (_expandedKeys.Remove(key) is false) return false; + + _expandOrder.Remove(key); + + return true; + } + + private void ClearExpandedKeys() + { + _expandedKeys.Clear(); + _expandOrder.Clear(); + } + + // A cap of its own only means something where more than one panel can be open at a time, and a value + // below one is no cap at all rather than a list nothing can be opened in. + private int? _MaxExpanded => (Multiple && MaxExpanded is > 0) ? MaxExpanded : null; + + // The keys that have to close for the one being opened to fit under the cap, oldest first. + private string[] GetOverflowKeys(string key) + { + if (_MaxExpanded is not int max) return []; + + var overflow = _expandedKeys.Count + 1 - max; + if (overflow <= 0) return []; + + return [.. _expandOrder.Where(k => k != key).Take(overflow)]; + } + private void SyncItemsExpandedState() { foreach (var item in _items) @@ -439,7 +1137,7 @@ private void SyncItemsExpandedState() private List GetOrderedExpandedKeys() { var ordered = new List(_expandedKeys.Count); - var seen = new HashSet(); + var seen = new HashSet(StringComparer.Ordinal); foreach (var item in _items) { @@ -462,78 +1160,226 @@ private List GetOrderedExpandedKeys() private void SyncFromExpandedKey(string? key) { - _expandedKeys.Clear(); - if (key.HasValue()) _expandedKeys.Add(key!); + // Read before the set is replaced, so that a panel the page has just opened through the binding is + // brought into view the way a click on its header would have brought it. + var opened = OpenedKeysOf(key.HasValue() ? [key!] : []); + + ClearExpandedKeys(); + if (key.HasValue()) AddExpandedKey(key!); SyncItemsExpandedState(); _internalExpandedKey = key; + + QueueScrollIntoView(opened); } private void SyncFromExpandedKeys(IEnumerable? keys) { - _expandedKeys.Clear(); + var opened = OpenedKeysOf(keys ?? []); + + ClearExpandedKeys(); if (keys is not null) AddExpandedKeys(keys); SyncItemsExpandedState(); _internalExpandedKeys = GetOrderedExpandedKeys(); + + QueueScrollIntoView(opened); + } + + // The keys of the incoming set that are not expanded yet - the panels the change is about to open. + // Before the first render none is: the bound value is the state the list starts in, not a panel the + // reader has opened. A list of options has no item yet on its first parameter pass - they register + // during the render - so its bound keys would otherwise all read as just opened and scroll the page. + private List OpenedKeysOf(IEnumerable keys) + { + if (ScrollIntoViewOnExpand is false || _hasRendered is false) return []; + + return [.. keys.Where(k => k.HasValue() && _expandedKeys.Contains(k) is false)]; + } + + private void QueueScrollIntoView(IEnumerable keys) + { + foreach (var key in keys) + { + // Not every key of an incoming set opens a panel: the ones beyond MaxExpanded are dropped on the + // way in, and a panel that was never opened is nothing to scroll to. + if (_expandedKeys.Contains(key) is false) continue; + + QueueScrollIntoView(key); + } + } + + private void QueueScrollIntoView(TItem item) + { + var key = GetItemKey(item); + + if (key.HasValue()) QueueScrollIntoView(key!); + } + + // The scroll is left to the render that puts the panel on screen: the item it belongs to can be one + // that is only rendered - or only registered - by the change being applied here. + private void QueueScrollIntoView(string key) + { + if (ScrollIntoViewOnExpand is false) return; + + if (_pendingScrolls.Contains(key, StringComparer.Ordinal)) return; + + _pendingScrolls.Add(key); } internal async Task HandleOnItemClick(TItem item) { if (IsEnabled is false || GetIsEnabled(item) is false) return; - _ = OnItemClick.InvokeAsync(item); + await OnItemClick.InvokeAsync(item); - InvokeItemClick(item); + await InvokeItemClick(item); + + // A read-only item still reports the click - the page can want to say why the panel is staying where + // it is - it just does not act on it. + if (GetItemIsReadOnly(item)) return; var key = GetItemKey(item); if (key.HasNoValue()) return; - var isExpanded = _expandedKeys.Contains(key!); + var expand = _expandedKeys.Contains(key!) is false; - await ToggleItem(item, key!, isExpanded is false); + await ToggleItem(item, key!, expand, BitAccordionToggleReason.Click); } - private async Task ToggleItem(TItem item, string key, bool expand) + internal async Task HandleOnItemKeyDown(KeyboardEventArgs e, TItem item) { - if (expand) + if (Navigable is false || IsEnabled is false) return; + + if (e.Key is not ("ArrowDown" or "ArrowUp" or "Home" or "End")) return; + + // A disabled header is out of the tab order, so the navigation walks past it rather than parking the + // focus on something that cannot be reached by the Tab key either. + var focusables = _items.Where(GetIsEnabled).ToList(); + if (focusables.Count == 0) return; + + var index = focusables.FindIndex(i => ReferenceEquals(i, item)); + if (index < 0) return; + + var next = e.Key switch { - if (Multiple is false) + "ArrowDown" => index + 1, + "ArrowUp" => index - 1, + "Home" => 0, + _ => focusables.Count - 1 + }; + + // The navigation wraps around at both ends of the list, unless it was asked to stop there. + if (next < 0) next = NoNavigationLoop ? 0 : focusables.Count - 1; + else if (next >= focusables.Count) next = NoNavigationLoop ? focusables.Count - 1 : 0; + + await FocusItemCore(focusables[next]); + } + + private async Task SetExpandedByKey(string key, bool expand) + { + if (key.HasNoValue()) return; + + var item = FindItem(key); + if (item is null) return; + + if (_expandedKeys.Contains(key) == expand) return; + + await ToggleItem(item, key, expand, BitAccordionToggleReason.Method); + } + + private async Task ToggleItem(TItem item, string key, bool expand, BitAccordionToggleReason reason) + { + // Read before the expansion is applied, since applying it adds the new key to the set. A cancelled + // expansion therefore leaves the previously expanded item(s) exactly where they were. + // + // In single-expand mode that is every other panel; in multiple-expand mode under a MaxExpanded cap + // it is the oldest of them, as many as the one being opened needs to fit. + var others = expand + ? (Multiple ? GetOverflowKeys(key) : [.. _expandedKeys.Where(k => k != key)]) + : []; + + if (await ApplyToggle(item, key, expand, reason) is false) return; + + if (expand) QueueScrollIntoView(item); + + // Collapse the item(s) that were expanded before. + foreach (var otherKey in others) + { + if (RemoveExpandedKey(otherKey) is false) continue; + + var otherItem = FindItem(otherKey); + if (otherItem is null) continue; + + SetIsExpanded(otherItem, false); + await OnCollapse.InvokeAsync(otherItem); + await OnToggle.InvokeAsync(otherItem); + } + + await UpdateBoundKeys(); + + // A toggle can affect other items too (single-expand mode collapses the previously expanded + // item), and the click handler runs on the clicked item's renderer, so both the registered + // options and the accordion list itself need an explicit re-render. + await RefreshAndRender(); + } + + // Runs the cancellable OnToggling callback and, when it is not refused, moves the single item between the + // expanded and the collapsed state. The bound keys and the re-render are left to the caller, so that a + // batch of items - ExpandAll, CollapseAll - reports itself once rather than once per item. + private async Task ApplyToggle(TItem item, string key, bool expand, BitAccordionToggleReason reason) + { + if (_expandedKeys.Contains(key) == expand) return false; + + if (OnToggling.HasDelegate) + { + // The callback is awaited, so a second click - or a Toggle call while a confirmation prompt is + // still open - would otherwise start a change of its own alongside the first one. + if (_isToggling) return false; + + _isToggling = true; + _togglingKey = key; + + // Nothing toggles the list while the callback is running, so the header of the item it was + // asked about says as much - aria-busy for a screen reader, a busy cursor for a pointer - + // rather than going on looking like a toggle that answers at once. + await RefreshAndRender(); + + try { - // Collapse the currently expanded item(s) in single-expand mode. - foreach (var otherKey in _expandedKeys.ToArray()) - { - if (otherKey == key) continue; - - _expandedKeys.Remove(otherKey); - var otherItem = _items.FirstOrDefault(i => GetItemKey(i) == otherKey); - if (otherItem is not null) - { - SetIsExpanded(otherItem, false); - await OnCollapse.InvokeAsync(otherItem); - await OnToggle.InvokeAsync(otherItem); - } - } + var args = new BitAccordionListToggleArgs(item, key, expand, reason); + + await OnToggling.InvokeAsync(args); + + if (args.Cancel) return false; + + // The state can have moved on while the callback was awaited - the page can have driven the + // bound keys itself, or disposed the list altogether. + if (IsDisposed || _expandedKeys.Contains(key) == expand) return false; } + finally + { + _isToggling = false; + _togglingKey = null; - _expandedKeys.Add(key); + await RefreshAndRender(); + } + } + + if (expand) + { + AddExpandedKey(key); SetIsExpanded(item, true); await OnExpand.InvokeAsync(item); } else { - _expandedKeys.Remove(key); + RemoveExpandedKey(key); SetIsExpanded(item, false); await OnCollapse.InvokeAsync(item); } await OnToggle.InvokeAsync(item); - await UpdateBoundKeys(); - - // A toggle can affect other items too (single-expand mode collapses the previously expanded - // item), and the click handler runs on the clicked item's renderer, so both the registered - // options and the accordion list itself need an explicit re-render. - RefreshOptions(); - StateHasChanged(); + return true; } private void RefreshOptions() @@ -547,34 +1393,86 @@ private void RefreshOptions() } } + // The public methods are not called from an event handler, so nothing re-renders the component on their + // behalf the way Blazor does after a click, and the call can come from off the render loop altogether. + private Task RefreshAndRender() + { + if (IsDisposed) return Task.CompletedTask; + + return InvokeAsync(() => + { + RefreshOptions(); + StateHasChanged(); + }); + } + + private async Task FocusItemCore(TItem item) + { + if (_itemRefs.TryGetValue(item, out var itemRef) is false) return; + + try + { + await itemRef.FocusAsync(); + } + catch (JSDisconnectedException) { } // we can ignore this exception here + } + + private TItem? FindItem(string? key) + { + return key.HasNoValue() ? null : _items.FirstOrDefault(i => GetItemKey(i) == key); + } + private async Task UpdateBoundKeys() + { + _internalExpandedKeys = GetOrderedExpandedKeys(); + _internalExpandedKey = _internalExpandedKeys.FirstOrDefault(); + + await PushBoundKeys(); + } + + private async Task PushBoundKeys() { if (Multiple) { - _internalExpandedKeys = GetOrderedExpandedKeys(); + // The keys are handed over as a list of their own, which the page's value never is the same + // instance as - so a set it already holds would be reported back to it as a change of its own, + // and re-render the page for nothing. Where the list is a copy of that value, no push is due. + if (ExpandedKeys is not null && ExpandedKeys.SequenceEqual(_internalExpandedKeys)) return; + await AssignExpandedKeys([.. _internalExpandedKeys]); } else { - _internalExpandedKey = _expandedKeys.FirstOrDefault(); await AssignExpandedKey(_internalExpandedKey); } } private void BuildItemClassStyles() { + // The two objects are handed to every BitAccordion of the list as parameters, so a new pair of them on + // every render would re-render every item for nothing. + if (_itemClasses is not null && ReferenceEquals(_oldClasses, Classes) && ReferenceEquals(_oldStyles, Styles)) return; + + _oldClasses = Classes; + _oldStyles = Styles; + _itemClasses = new BitAccordionClassStyles { Root = Classes?.Item, Expanded = Classes?.ItemExpanded, + HeaderWrapper = Classes?.ItemHeaderWrapper, + Heading = Classes?.ItemHeading, Header = Classes?.ItemHeader, + Icon = Classes?.ItemIcon, HeaderContent = Classes?.ItemHeaderContent, Title = Classes?.ItemTitle, Description = Classes?.ItemDescription, ExpanderIconWrapper = Classes?.ItemExpanderIconWrapper, ExpanderIcon = Classes?.ItemExpanderIcon, ExpandedIcon = Classes?.ItemExpandedIcon, + Actions = Classes?.ItemActions, ContentContainer = Classes?.ItemContentContainer, + ContentWrapper = Classes?.ItemContentWrapper, Content = Classes?.ItemContent, }; @@ -582,14 +1480,19 @@ private void BuildItemClassStyles() { Root = Styles?.Item, Expanded = Styles?.ItemExpanded, + HeaderWrapper = Styles?.ItemHeaderWrapper, + Heading = Styles?.ItemHeading, Header = Styles?.ItemHeader, + Icon = Styles?.ItemIcon, HeaderContent = Styles?.ItemHeaderContent, Title = Styles?.ItemTitle, Description = Styles?.ItemDescription, ExpanderIconWrapper = Styles?.ItemExpanderIconWrapper, ExpanderIcon = Styles?.ItemExpanderIcon, ExpandedIcon = Styles?.ItemExpandedIcon, + Actions = Styles?.ItemActions, ContentContainer = Styles?.ItemContentContainer, + ContentWrapper = Styles?.ItemContentWrapper, Content = Styles?.ItemContent, }; } @@ -600,6 +1503,61 @@ internal bool IsItemExpanded(TItem item) return key.HasValue() && _expandedKeys.Contains(key!); } + // The header of the item an awaited OnToggling was asked about, and only that one: the rest of the list + // is not doing anything, it is only refusing to start something else while this one is being decided. + internal bool IsItemBusy(TItem item) + { + if (_togglingKey is null) return false; + + return GetItemKey(item) == _togglingKey; + } + + // A list built from options only knows it is empty once its options have had their turn to register, so + // the empty content of one waits for the render after the first rather than flashing on the first. + private bool _ShowEmptyContent => EmptyContent is not null + && _items.Count == 0 + && ((Options ?? ChildContent) is null || _hasRendered); + + // A label on a plain container is dropped by a screen reader, so the list that carries one says what it + // is. It is rendered before the splatted attributes, so a role the page sets itself still wins over it. + private string? _Role => AriaLabel.HasValue() ? "group" : null; + + // The header of the one panel that has to stay open reports itself as aria-disabled, the way the WAI-ARIA + // authoring practices ask a header whose panel cannot be collapsed to. + internal bool GetItemIsReadOnly(TItem item) + { + if (GetReadOnly(item) ?? ReadOnly) return true; + + return Collapsible is false && _expandedKeys.Count <= 1 && IsItemExpanded(item); + } + + internal bool GetItemHideExpanderIcon(TItem item) + { + return GetHideExpanderIcon(item) ?? HideExpanderIcon; + } + + internal string? GetItemHeaderAriaLabel(TItem item) + { + if (item is BitAccordionListItem listItem) + { + return listItem.HeaderAriaLabel; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.HeaderAriaLabel; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.HeaderAriaLabel.Selector is not null) + { + return NameSelectors.HeaderAriaLabel.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.HeaderAriaLabel.Name); + } + internal RenderFragment? GetItemHeaderTemplate(TItem item) { var itemTemplate = GetHeaderTemplate(item); @@ -613,6 +1571,35 @@ internal bool IsItemExpanded(TItem item) return null; } + internal RenderFragment? GetItemTitleTemplate(TItem item) + { + var itemTemplate = GetTitleTemplate(item); + if (itemTemplate is not null) return itemTemplate(item); + + return TitleTemplate is not null ? TitleTemplate(item) : null; + } + + internal RenderFragment? GetItemExpanderTemplate(TItem item) + { + var itemTemplate = GetExpanderTemplate(item); + if (itemTemplate is not null) return _ => itemTemplate(item); + + if (ExpanderTemplate is not null) + { + return _ => ExpanderTemplate(item); + } + + return null; + } + + internal RenderFragment? GetItemActions(TItem item) + { + var itemActions = GetActions(item); + if (itemActions is not null) return itemActions(item); + + return ActionsTemplate is not null ? ActionsTemplate(item) : null; + } + internal RenderFragment? GetItemBody(TItem item) { // The option's plain inline content (ChildContent) is rendered as-is. @@ -642,12 +1629,42 @@ internal bool IsItemExpanded(TItem item) return GetExpanderIconName(item) ?? ExpanderIconName; } + internal BitIconInfo? GetItemExpandedExpanderIcon(TItem item) + { + return GetExpandedExpanderIcon(item) ?? ExpandedExpanderIcon; + } + + internal string? GetItemExpandedExpanderIconName(TItem item) + { + return GetExpandedExpanderIconName(item) ?? ExpandedExpanderIconName; + } + + internal BitIconInfo? GetItemIcon(TItem item) + { + return GetIcon(item); + } + + internal string? GetItemIconName(TItem item) + { + return GetIconName(item); + } + internal string? GetItemKey(TItem? item) { if (item is null) return null; + var key = GetDeclaredKey(item); + if (key.HasValue()) return key; + + // An item that carries no key of its own - a custom type whose key property is computed, read-only or + // simply not there - is given a generated one that is kept beside it rather than written into it. + return _fallbackKeys.TryGetValue(item, out var fallback) ? fallback : null; + } + + private string? GetDeclaredKey(TItem item) + { if (item is BitAccordionListItem listItem) { return listItem.Key; @@ -682,9 +1699,7 @@ private void SetItemKey(TItem item, string value) return; } - if (NameSelectors is null) return; - - item.SetValueToProperty(NameSelectors.Key.Name, value); + _fallbackKeys[item] = value; } internal string? GetClass(TItem? item) @@ -851,6 +1866,102 @@ private void SetIsExpanded(TItem item, bool value) item.SetValueToProperty(NameSelectors.IsExpanded.Name, value); } + private bool? GetReadOnly(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.ReadOnly; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.ReadOnly; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.ReadOnly.Selector is not null) + { + return NameSelectors.ReadOnly.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.ReadOnly.Name); + } + + private bool? GetHideExpanderIcon(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.HideExpanderIcon; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.HideExpanderIcon; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.HideExpanderIcon.Selector is not null) + { + return NameSelectors.HideExpanderIcon.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.HideExpanderIcon.Name); + } + + private BitIconInfo? GetIcon(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.Icon; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.Icon; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.Icon.Selector is not null) + { + return NameSelectors.Icon.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.Icon.Name); + } + + private string? GetIconName(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.IconName; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.IconName; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.IconName.Selector is not null) + { + return NameSelectors.IconName.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.IconName.Name); + } + private BitIconInfo? GetExpanderIcon(TItem? item) { if (item is null) return null; @@ -899,6 +2010,78 @@ private void SetIsExpanded(TItem item, bool value) return item.GetValueFromProperty(NameSelectors.ExpanderIconName.Name); } + private BitIconInfo? GetExpandedExpanderIcon(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.ExpandedExpanderIcon; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.ExpandedExpanderIcon; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.ExpandedExpanderIcon.Selector is not null) + { + return NameSelectors.ExpandedExpanderIcon.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.ExpandedExpanderIcon.Name); + } + + private string? GetExpandedExpanderIconName(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.ExpandedExpanderIconName; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.ExpandedExpanderIconName; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.ExpandedExpanderIconName.Selector is not null) + { + return NameSelectors.ExpandedExpanderIconName.Selector!(item); + } + + return item.GetValueFromProperty(NameSelectors.ExpandedExpanderIconName.Name); + } + + private RenderFragment? GetActions(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.Actions as RenderFragment; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.Actions as RenderFragment; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.Actions.Selector is not null) + { + return NameSelectors.Actions.Selector!(item); + } + + return item.GetValueFromProperty?>(NameSelectors.Actions.Name); + } + private RenderFragment? GetBody(TItem? item) { if (item is null) return null; @@ -947,7 +2130,55 @@ private void SetIsExpanded(TItem item, bool value) return item.GetValueFromProperty?>(NameSelectors.HeaderTemplate.Name); } - private void InvokeItemClick(TItem item) + private RenderFragment? GetTitleTemplate(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.TitleTemplate as RenderFragment; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.TitleTemplate as RenderFragment; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.TitleTemplate.Selector is not null) + { + return NameSelectors.TitleTemplate.Selector!(item); + } + + return item.GetValueFromProperty?>(NameSelectors.TitleTemplate.Name); + } + + private RenderFragment? GetExpanderTemplate(TItem? item) + { + if (item is null) return null; + + if (item is BitAccordionListItem listItem) + { + return listItem.ExpanderTemplate as RenderFragment; + } + + if (item is BitAccordionListOption listOption) + { + return listOption.ExpanderTemplate as RenderFragment; + } + + if (NameSelectors is null) return null; + + if (NameSelectors.ExpanderTemplate.Selector is not null) + { + return NameSelectors.ExpanderTemplate.Selector!(item); + } + + return item.GetValueFromProperty?>(NameSelectors.ExpanderTemplate.Name); + } + + private async Task InvokeItemClick(TItem item) { if (item is BitAccordionListItem listItem) { @@ -957,7 +2188,7 @@ private void InvokeItemClick(TItem item) if (item is BitAccordionListOption listOption) { - _ = listOption.OnClick.InvokeAsync(listOption); + await listOption.OnClick.InvokeAsync(listOption); return; } @@ -972,4 +2203,36 @@ private void InvokeItemClick(TItem item) item.GetValueFromProperty?>(NameSelectors.OnClick.Name)?.Invoke(item); } } + + + + protected override async ValueTask DisposeAsync(bool disposing) + { + if (disposing && _preventKeysRegistered) + { + _preventKeysRegistered = false; + + try + { + await _js.BitExtrasDisposePreventKeys(RootElement); + } + catch (JSDisconnectedException) { } // we can ignore this exception here + catch (ObjectDisposedException) { } // we can ignore this exception here + } + + await base.DisposeAsync(disposing); + } + + + + // The items are held by identity: two items of a type that compares by value are still two panels of the + // list, each with its own key and its own expanded state. + private sealed class ReferenceComparer : IEqualityComparer + { + internal static readonly ReferenceComparer Instance = new(); + + public bool Equals(TItem? x, TItem? y) => ReferenceEquals(x, y); + + public int GetHashCode(TItem obj) => RuntimeHelpers.GetHashCode(obj); + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.scss index 11dbd451c4..f88f82b519 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionList.scss @@ -7,3 +7,13 @@ flex-flow: column nowrap; gap: spacing(1); } + +// The three wrappers the list puts around the parts of an item - one to know which item a key was pressed on, +// two to keep the keys pressed inside the panel and inside the actions from reaching it - are there for the +// events alone, so they generate no box at all and leave the layout of the item exactly as the accordion +// draws it. +.bit-acl-itm, +.bit-acl-bdy, +.bit-acl-act { + display: contents; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListClassStyles.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListClassStyles.cs index 046d1c3598..29943d0ac4 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListClassStyles.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListClassStyles.cs @@ -20,11 +20,28 @@ public class BitAccordionListClassStyles /// public string? ItemExpanded { get; set; } + /// + /// Custom CSS classes/styles for the header wrapper of each accordion item of the BitAccordionList, + /// which holds the heading and the actions. + /// + public string? ItemHeaderWrapper { get; set; } + + /// + /// Custom CSS classes/styles for the heading element of each accordion item of the BitAccordionList + /// that wraps the header button. + /// + public string? ItemHeading { get; set; } + /// /// Custom CSS classes/styles for the header of each accordion item of the BitAccordionList. /// public string? ItemHeader { get; set; } + /// + /// Custom CSS classes/styles for the icon at the start of the header of each accordion item of the BitAccordionList. + /// + public string? ItemIcon { get; set; } + /// /// Custom CSS classes/styles for the header content of each accordion item of the BitAccordionList. /// @@ -55,11 +72,22 @@ public class BitAccordionListClassStyles /// public string? ItemExpandedIcon { get; set; } + /// + /// Custom CSS classes/styles for the actions of each accordion item of the BitAccordionList, rendered beside the header. + /// + public string? ItemActions { get; set; } + /// /// Custom CSS classes/styles for the content container of each accordion item of the BitAccordionList. /// public string? ItemContentContainer { get; set; } + /// + /// Custom CSS classes/styles for the content wrapper of each accordion item of the BitAccordionList, + /// which clips the content while it collapses. + /// + public string? ItemContentWrapper { get; set; } + /// /// Custom CSS classes/styles for the content of each accordion item of the BitAccordionList. /// diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListItem.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListItem.cs index 347cd7a9af..56daac5aec 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListItem.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListItem.cs @@ -5,6 +5,13 @@ namespace Bit.BlazorUI; /// public class BitAccordionListItem { + /// + /// The content rendered beside the header of the item, outside of the toggle button and of the heading + /// it sits in, so that it can hold its own interactive elements (a menu, a delete button, a switch). + /// The context value provides the item itself. + /// + public RenderFragment? Actions { get; set; } + /// /// The custom CSS classes of the item. /// @@ -15,6 +22,19 @@ public class BitAccordionListItem /// public string? Description { get; set; } + /// + /// Gets or sets the icon to show in place of the expander icon while the item is expanded, using custom + /// CSS classes for external icon libraries. + /// Takes precedence over when both are set. + /// + public BitIconInfo? ExpandedExpanderIcon { get; set; } + + /// + /// Gets or sets the name of the icon, from the built-in Fluent UI icons, to show in place of the expander + /// icon while the item is expanded. + /// + public string? ExpandedExpanderIconName { get; set; } + /// /// Gets or sets the icon to display as the expander using custom CSS classes for external icon libraries. /// Takes precedence over when both are set. @@ -26,16 +46,45 @@ public class BitAccordionListItem /// public string? ExpanderIconName { get; set; } + /// + /// The custom content to render in place of the expander icon of the item, leaving the rest of the header + /// as it is. The context value provides the item itself. + /// + public RenderFragment? ExpanderTemplate { get; set; } + /// /// The content (body) of the item that is shown when the item is expanded. The context value provides the item itself. /// public RenderFragment? Body { get; set; } + /// + /// The accessible label of the toggle button in the header of the item, for a header whose own content does + /// not name it - an icon-only header template, most of all. + /// + public string? HeaderAriaLabel { get; set; } + /// /// The custom template for the header of the item. The context value provides the item itself. /// public RenderFragment? HeaderTemplate { get; set; } + /// + /// Removes the expander icon from the header of the item, overriding the value of the AccordionList. + /// + public bool? HideExpanderIcon { get; set; } + + /// + /// Gets or sets the icon to display at the start of the header of the item using custom CSS classes for + /// external icon libraries. Takes precedence over when both are set. + /// + public BitIconInfo? Icon { get; set; } + + /// + /// Gets or sets the name of the icon to display at the start of the header of the item from the built-in + /// Fluent UI icons. + /// + public string? IconName { get; set; } + /// /// Whether or not the item is enabled. /// @@ -56,6 +105,12 @@ public class BitAccordionListItem /// public Action? OnClick { get; set; } + /// + /// Leaves the item where it is: its header keeps its colors and its place in the tab order, but it no + /// longer answers the pointer or the keyboard. Overrides the value of the AccordionList. + /// + public bool? ReadOnly { get; set; } + /// /// The custom value for the style attribute of the item. /// @@ -65,4 +120,10 @@ public class BitAccordionListItem /// The title (header text) of the item. /// public string? Title { get; set; } + + /// + /// The custom content to render in place of the of the item, leaving the rest of the + /// header as it is. The context value provides the item itself. + /// + public RenderFragment? TitleTemplate { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListNameSelectors.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListNameSelectors.cs index 27fa7fa0a2..3431022d55 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListNameSelectors.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListNameSelectors.cs @@ -5,6 +5,11 @@ namespace Bit.BlazorUI; /// public class BitAccordionListNameSelectors { + /// + /// Actions field name and selector of the custom input class. + /// + public BitNameSelectorPair?> Actions { get; set; } = new(nameof(BitAccordionListItem.Actions)); + /// /// The CSS Class field name and selector of the custom input class. /// @@ -15,6 +20,16 @@ public class BitAccordionListNameSelectors /// public BitNameSelectorPair Description { get; set; } = new(nameof(BitAccordionListItem.Description)); + /// + /// ExpandedExpanderIcon field name and selector of the custom input class. + /// + public BitNameSelectorPair ExpandedExpanderIcon { get; set; } = new(nameof(BitAccordionListItem.ExpandedExpanderIcon)); + + /// + /// ExpandedExpanderIconName field name and selector of the custom input class. + /// + public BitNameSelectorPair ExpandedExpanderIconName { get; set; } = new(nameof(BitAccordionListItem.ExpandedExpanderIconName)); + /// /// ExpanderIcon field name and selector of the custom input class. /// @@ -25,16 +40,41 @@ public class BitAccordionListNameSelectors /// public BitNameSelectorPair ExpanderIconName { get; set; } = new(nameof(BitAccordionListItem.ExpanderIconName)); + /// + /// ExpanderTemplate field name and selector of the custom input class. + /// + public BitNameSelectorPair?> ExpanderTemplate { get; set; } = new(nameof(BitAccordionListItem.ExpanderTemplate)); + /// /// Body field name and selector of the custom input class. /// public BitNameSelectorPair?> Body { get; set; } = new(nameof(BitAccordionListItem.Body)); + /// + /// HeaderAriaLabel field name and selector of the custom input class. + /// + public BitNameSelectorPair HeaderAriaLabel { get; set; } = new(nameof(BitAccordionListItem.HeaderAriaLabel)); + /// /// HeaderTemplate field name and selector of the custom input class. /// public BitNameSelectorPair?> HeaderTemplate { get; set; } = new(nameof(BitAccordionListItem.HeaderTemplate)); + /// + /// HideExpanderIcon field name and selector of the custom input class. + /// + public BitNameSelectorPair HideExpanderIcon { get; set; } = new(nameof(BitAccordionListItem.HideExpanderIcon)); + + /// + /// Icon field name and selector of the custom input class. + /// + public BitNameSelectorPair Icon { get; set; } = new(nameof(BitAccordionListItem.Icon)); + + /// + /// IconName field name and selector of the custom input class. + /// + public BitNameSelectorPair IconName { get; set; } = new(nameof(BitAccordionListItem.IconName)); + /// /// IsEnabled field name and selector of the custom input class. /// @@ -55,6 +95,11 @@ public class BitAccordionListNameSelectors /// public BitNameSelectorPair?> OnClick { get; set; } = new(nameof(BitAccordionListItem.OnClick)); + /// + /// ReadOnly field name and selector of the custom input class. + /// + public BitNameSelectorPair ReadOnly { get; set; } = new(nameof(BitAccordionListItem.ReadOnly)); + /// /// The CSS Style field name and selector of the custom input class. /// @@ -64,4 +109,9 @@ public class BitAccordionListNameSelectors /// Title field name and selector of the custom input class. /// public BitNameSelectorPair Title { get; set; } = new(nameof(BitAccordionListItem.Title)); + + /// + /// TitleTemplate field name and selector of the custom input class. + /// + public BitNameSelectorPair?> TitleTemplate { get; set; } = new(nameof(BitAccordionListItem.TitleTemplate)); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs index ad8c7a4161..405b7640a1 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListOption.cs @@ -8,9 +8,16 @@ public partial class BitAccordionListOption : ComponentBase, IAsyncDisposable private bool _disposed; - [CascadingParameter] protected BitAccordionList Parent { get; set; } = default!; + [CascadingParameter] protected BitAccordionList? Parent { get; set; } + /// + /// The content rendered beside the header of the option, outside of the toggle button and of the heading + /// it sits in, so that it can hold its own interactive elements (a menu, a delete button, a switch). + /// The context value provides the option itself. + /// + [Parameter] public RenderFragment? Actions { get; set; } + /// /// The custom CSS classes of the option. /// @@ -21,6 +28,19 @@ public partial class BitAccordionListOption : ComponentBase, IAsyncDisposable /// [Parameter] public string? Description { get; set; } + /// + /// Gets or sets the icon to show in place of the expander icon while the option is expanded, using custom + /// CSS classes for external icon libraries. + /// Takes precedence over when both are set. + /// + [Parameter] public BitIconInfo? ExpandedExpanderIcon { get; set; } + + /// + /// Gets or sets the name of the icon, from the built-in Fluent UI icons, to show in place of the expander + /// icon while the option is expanded. + /// + [Parameter] public string? ExpandedExpanderIconName { get; set; } + /// /// Gets or sets the icon to display as the expander using custom CSS classes for external icon libraries. /// Takes precedence over when both are set. @@ -32,6 +52,12 @@ public partial class BitAccordionListOption : ComponentBase, IAsyncDisposable /// [Parameter] public string? ExpanderIconName { get; set; } + /// + /// The custom content to render in place of the expander icon of the option, leaving the rest of the header + /// as it is. The context value provides the option itself. + /// + [Parameter] public RenderFragment? ExpanderTemplate { get; set; } + /// /// The content (body) of the option that is shown when the option is expanded. The context value provides the option itself. /// @@ -43,11 +69,34 @@ public partial class BitAccordionListOption : ComponentBase, IAsyncDisposable /// [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// The accessible label of the toggle button in the header of the option, for a header whose own content does + /// not name it - an icon-only header template, most of all. + /// + [Parameter] public string? HeaderAriaLabel { get; set; } + /// /// The custom template for the header of the option. The context value provides the option itself. /// [Parameter] public RenderFragment? HeaderTemplate { get; set; } + /// + /// Removes the expander icon from the header of the option, overriding the value of the AccordionList. + /// + [Parameter] public bool? HideExpanderIcon { get; set; } + + /// + /// Gets or sets the icon to display at the start of the header of the option using custom CSS classes for + /// external icon libraries. Takes precedence over when both are set. + /// + [Parameter] public BitIconInfo? Icon { get; set; } + + /// + /// Gets or sets the name of the icon to display at the start of the header of the option from the built-in + /// Fluent UI icons. + /// + [Parameter] public string? IconName { get; set; } + /// /// Whether or not the option is enabled. /// @@ -68,6 +117,12 @@ public partial class BitAccordionListOption : ComponentBase, IAsyncDisposable /// [Parameter] public EventCallback OnClick { get; set; } + /// + /// Leaves the option where it is: its header keeps its colors and its place in the tab order, but it no + /// longer answers the pointer or the keyboard. Overrides the value of the AccordionList. + /// + [Parameter] public bool? ReadOnly { get; set; } + /// /// The custom value for the style attribute of the option. /// @@ -78,6 +133,12 @@ public partial class BitAccordionListOption : ComponentBase, IAsyncDisposable /// [Parameter] public string? Title { get; set; } + /// + /// The custom content to render in place of the of the option, leaving the rest of the + /// header as it is. The context value provides the option itself. + /// + [Parameter] public RenderFragment? TitleTemplate { get; set; } + internal void InternalStateHasChanged() { @@ -88,16 +149,32 @@ internal void InternalStateHasChanged() protected override async Task OnInitializedAsync() { - if (Parent is not null) - { - Parent.RegisterOption(this); - } + // An option outside of an accordion list, or inside one closed over another item type, receives no + // cascading parent and would otherwise render nothing at all without saying why. + if (Parent is null) + { + throw new InvalidOperationException( + $"{nameof(BitAccordionListOption)} must be placed inside a BitAccordionList whose TItem is {nameof(BitAccordionListOption)}."); + } + + Parent.RegisterOption(this); await base.OnInitializedAsync(); } // Renders the option's item in place, so the rendered order of the items always follows the // markup order of the options, even when an option is added or removed conditionally later on. + protected override void OnParametersSet() + { + // The list hands its options their parameters in markup order, every one of them, every time it + // renders - which is the order the list wants its items in, and not the order they registered + // themselves in once one of them has been added conditionally. A render of the option's own is no + // use here: only the option that was just added has one when it is added. + Parent?.ReportOptionOrder(this); + + base.OnParametersSet(); + } + protected override void BuildRenderTree(RenderTreeBuilder builder) { if (Parent is null) return; @@ -118,12 +195,12 @@ protected virtual async ValueTask DisposeAsync(bool disposing) { if (disposing is false || _disposed) return; - if (Parent is not null) - { + if (Parent is not null) + { // Await the unregistration so that any UpdateBoundKeys or ExpandedKey(s) callbacks it // triggers are awaited and observed, rather than running as fire-and-forget. - await Parent.UnregisterOption(this); - } + await Parent.UnregisterOption(this); + } _disposed = true; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListToggleArgs.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListToggleArgs.cs new file mode 100644 index 0000000000..cb9c172f16 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/BitAccordionListToggleArgs.cs @@ -0,0 +1,57 @@ +namespace Bit.BlazorUI; + +/// +/// Arguments for the OnToggling callback of . +/// Set to true to leave the item as it is. +/// +public class BitAccordionListToggleArgs where TItem : class +{ + /// + /// Creates a new instance of . + /// + /// + /// The item that is about to expand or collapse. + /// + /// + /// The key of the item that is about to expand or collapse. + /// + /// + /// Whether the item is about to expand. + /// + /// + /// What made the item expand or collapse. + /// + public BitAccordionListToggleArgs(TItem item, string? key, bool isExpanding, BitAccordionToggleReason reason) + { + Item = item; + Key = key; + IsExpanding = isExpanding; + Reason = reason; + } + + /// + /// The item that is about to expand or collapse. + /// + public TItem Item { get; } + + /// + /// The key of the item that is about to expand or collapse. + /// + public string? Key { get; } + + /// + /// The state the item is about to move to: true while it is expanding, false while it is collapsing. + /// + public bool IsExpanding { get; } + + /// + /// What made the item expand or collapse: a click on its header, or a call to one of the + /// Expand, Collapse, Toggle, ExpandAll and CollapseAll methods of the AccordionList. + /// + public BitAccordionToggleReason Reason { get; } + + /// + /// Set to true to cancel the expansion or the collapse and leave the item as it is. + /// + public bool Cancel { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor index 0140f10a7a..3494e4930d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor @@ -4,23 +4,56 @@ @{ var isEnabled = AccordionList.IsEnabled && AccordionList.GetIsEnabled(Item); var headerTemplate = AccordionList.GetItemHeaderTemplate(Item); + var titleTemplate = AccordionList.GetItemTitleTemplate(Item); + var expanderTemplate = AccordionList.GetItemExpanderTemplate(Item); } - - @AccordionList.GetItemBody(Item) - +@* The wrapper generates no box of its own (display: contents), so it leaves the layout of the list exactly + as it was while giving the keyboard navigation of the AccordionList something to listen on that knows + which item the key was pressed on - which the list root, shared by every item, could not tell. *@ +
+ + @* A key pressed inside the panel belongs to whatever the panel holds - a text field, a nested list - + so it is kept from reaching the navigation handler on the wrapper above. *@ +
+ @AccordionList.GetItemBody(Item) +
+
+
diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor.cs index 925952b803..9e7a390c5b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AccordionList/_BitAccordionListItem.razor.cs @@ -1,8 +1,103 @@ namespace Bit.BlazorUI; -public partial class _BitAccordionListItem : ComponentBase where TItem : class +public partial class _BitAccordionListItem : ComponentBase, IDisposable where TItem : class { + private bool _skipRender; + private TItem? _registeredItem; + private BitAccordion? _accordion; + [Parameter] public TItem Item { get; set; } = default!; [Parameter] public BitAccordionList AccordionList { get; set; } = default!; + + + + internal ValueTask FocusAsync() => _accordion?.FocusAsync() ?? ValueTask.CompletedTask; + + // The root element of the accordion the item renders, which is the box the list scrolls into view: the + // wrapper around it generates none of its own (display: contents) and could not be scrolled to. + internal ElementReference? GetElement() => _accordion?.RootElement; + + + + protected override void OnParametersSet() + { + // A render the list asks for is never the one the keyboard bookkeeping below is trying to skip. + _skipRender = false; + + base.OnParametersSet(); + } + + // The keydown handler on the wrapper is bookkeeping for the list rather than a state change of this item, + // so the render Blazor runs after every one of its event handlers is skipped: without this, every key + // pressed on a header - Tab included - would re-render the whole item and its panel for nothing. + protected override bool ShouldRender() + { + if (_skipRender is false) return true; + + _skipRender = false; + + return false; + } + + protected override void OnAfterRender(bool firstRender) + { + // The list keeps a reference of every item it renders so that it can move the focus to it. The item + // itself can be swapped for another one on a later render, so the old registration is dropped first. + if (ReferenceEquals(_registeredItem, Item) is false) + { + if (_registeredItem is not null) + { + AccordionList.UnregisterItem(_registeredItem); + } + + _registeredItem = Item; + + AccordionList.RegisterItem(Item, this); + } + + base.OnAfterRender(firstRender); + } + + private RenderFragment? BuildActions() + { + var actions = AccordionList.GetItemActions(Item); + + if (actions is null) return null; + + // The actions sit outside of the panel, so they need a stop of their own to keep the keys pressed on + // whatever they hold - a menu, a switch - from reaching the navigation handler on the wrapper. + return builder => + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "bit-acl-act"); + builder.AddEventStopPropagationAttribute(2, "onkeydown", true); + builder.AddContent(3, actions); + builder.CloseElement(); + }; + } + + private async Task HandleOnKeyDown(KeyboardEventArgs e) + { + _skipRender = true; + + // A panel that scrolls (MaxHeight) is a tab stop of its own, and the arrow keys pressed on it are its + // own scroll. The stop inside it never sees them - they are fired on the box that holds the content + // rather than inside it - so the navigation steps aside for as long as the panel holds the focus, + // rather than moving the reader twice: once down the list and once down the panel. + if (_accordion?.IsContentFocused is true) return; + + await AccordionList.HandleOnItemKeyDown(e, Item); + } + + public void Dispose() + { + if (_registeredItem is not null) + { + AccordionList?.UnregisterItem(_registeredItem); + _registeredItem = null; + } + + GC.SuppressFinalize(this); + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs index 68a2da41d3..2517351187 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs @@ -32,6 +32,21 @@ internal static ValueTask BitExtrasSetPreventKeys(this IJSRuntime jsRuntime, Ele return jsRuntime.InvokeVoid("BitBlazorUI.Extras.setPreventKeys", element, keys); } + internal static ValueTask BitExtrasSetPreventKeys(this IJSRuntime jsRuntime, ElementReference element, string[] keys, string targetSelector, string scopeSelector) + { + return jsRuntime.InvokeVoid("BitBlazorUI.Extras.setPreventKeys", element, keys, targetSelector, scopeSelector); + } + + internal static ValueTask BitExtrasGetElementsOrder(this IJSRuntime jsRuntime, ElementReference[] elements) + { + return jsRuntime.Invoke("BitBlazorUI.Extras.getElementsOrder", elements); + } + + internal static ValueTask BitExtrasScrollIntoView(this IJSRuntime jsRuntime, ElementReference element) + { + return jsRuntime.InvokeVoid("BitBlazorUI.Extras.scrollIntoView", element); + } + internal static ValueTask BitExtrasDisposePreventKeys(this IJSRuntime jsRuntime, ElementReference element) { return jsRuntime.InvokeVoid("BitBlazorUI.Extras.disposePreventKeys", element); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts index f5f215e628..dbd5a5630b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts @@ -24,18 +24,35 @@ namespace BitBlazorUI { // value is evaluated at render time and therefore only applies to the *next* key event // -- this evaluates the actual key of the *current* event, so stale state can never // block typing, Space, or Tab. - public static setPreventKeys(element: HTMLElement, keys: string[]) { + // + // The two optional selectors narrow the listener to the elements the keys really belong to, + // for a container that also holds content of its own: targetSelector is what the key must + // have been pressed on, and scopeSelector the container that element must belong to - so a + // list of panels can suppress the page scroll of its own headers without touching the same + // keys pressed inside a panel, or on the headers of another list nested in one. + public static setPreventKeys(element: HTMLElement, keys: string[], targetSelector?: string, scopeSelector?: string) { if (!element) return; const el = element as any; el.bitPreventKeys = keys ?? []; + el.bitPreventKeysTarget = targetSelector; + el.bitPreventKeysScope = scopeSelector; if (!el.bitPreventKeysHandler) { el.bitPreventKeysHandler = (e: KeyboardEvent) => { const ks: string[] = el.bitPreventKeys ?? []; - if (ks.indexOf(e.key) !== -1) { - e.preventDefault(); + if (ks.indexOf(e.key) === -1) return; + + const target: string | undefined = el.bitPreventKeysTarget; + if (target) { + const node = e.target as Element; + if (!node || typeof node.matches !== 'function' || !node.matches(target)) return; + + const scope: string | undefined = el.bitPreventKeysScope; + if (scope && node.closest(scope) !== element) return; } + + e.preventDefault(); }; element.addEventListener('keydown', el.bitPreventKeysHandler); } @@ -50,6 +67,49 @@ namespace BitBlazorUI { delete el.bitPreventKeysHandler; } delete el.bitPreventKeys; + delete el.bitPreventKeysTarget; + delete el.bitPreventKeysScope; + } + + // Brings the element into view with the least movement that puts it there ('nearest'), so a panel + // that opens below the fold is shown without the page jumping under a reader who could already see + // it. The smooth scroll is a courtesy rather than a requirement, so it is dropped for a reader who + // has asked for less motion. + public static scrollIntoView(element: HTMLElement) { + if (!element) return; + + try { + const reduced = typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + element.scrollIntoView({ + behavior: reduced ? 'auto' : 'smooth', + block: 'nearest', + inline: 'nearest' + }); + } catch (e) { console.error('BitBlazorUI.Extras.scrollIntoView:', e); } + } + + // Answers with the indexes of the provided elements in the order they appear in the document, so a + // component that cannot tell the order of the children it was given in markup - Blazor hands a child + // its parameters again only when one of them has actually changed, so a child of nothing but + // constants can sit a render out without reporting anything - can read it back from what was + // rendered. An element that is not in the document is left out, which the caller reads as a miss. + public static getElementsOrder(elements: HTMLElement[]): number[] { + if (!elements) return []; + + const indexes = elements + .map((el, i) => ({ el, i })) + .filter(e => e.el && e.el.isConnected); + + indexes.sort((a, b) => { + if (a.el === b.el) return 0; + + // DOCUMENT_POSITION_FOLLOWING (4) is set when b comes after a in the document. + return (a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING) ? -1 : 1; + }); + + return indexes.map(e => e.i); } // Scrolls the option element into the visible area of its scroll container using diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs index 6235ac62b6..df039ee10a 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Buttons/ButtonGroup/BitButtonGroup.razor.cs @@ -435,6 +435,7 @@ protected override async Task OnParametersSetAsync() _items = [.. Items]; AssignItemKeys(); + RemapToggledItems(); } } @@ -479,6 +480,32 @@ private void RefreshOptions() } } + // The toggled items are held by reference, so a collection rebuilt out of new instances - a page that + // hands over a fresh list on every render - would leave them pointing at items no longer in the list, and + // nothing would render as toggled. Each is followed to the item that now carries its key. + private void RemapToggledItems() + { + if (_toggleItem is not null && _items.Contains(_toggleItem) is false) + { + // An item without a key is one there is nothing to follow by: matched as it is, it would land on + // the first item that has no key either, which is not the one that was toggled. + var key = GetItemKey(_toggleItem); + _toggleItem = key.HasValue() ? _items.FirstOrDefault(i => GetItemKey(i) == key) : null; + + if (_toggleItem is not null) SetIsToggled(_toggleItem, true); + } + + if (_toggledItems.Count == 0 || _toggledItems.All(_items.Contains)) return; + + var keys = _toggledItems.Select(GetItemKey).Where(k => k.HasValue()).Select(k => k!).ToHashSet(); + _toggledItems = [.. _items.Where(i => keys.Contains(GetItemKey(i) ?? string.Empty))]; + + foreach (var item in _toggledItems) + { + SetIsToggled(item, true); + } + } + private void AssignItemKeys() { // Collect the explicit keys first so the auto-generated keys never collide with them. diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.razor.cs index f0573a1f4e..5a2269966b 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Navs/Nav/BitNav.razor.cs @@ -27,6 +27,9 @@ public partial class BitNav : BitComponentBase where TItem : class private DateTime _lastTypeAheadAt = DateTime.MinValue; internal Dictionary _itemExpandStates = []; private readonly HashSet _initializedItems = []; + // What each item answers to across a rebuild of the tree: its key, or failing one its place in the tree, + // the same way the rendered items are keyed. + private readonly Dictionary _itemIdentities = []; private readonly Dictionary _itemElements = []; @@ -462,6 +465,22 @@ private void SyncItems() // the nav has read before and there is nothing to do. if (rootChanged is false && live.SetEquals(_initializedItems)) return; + // The items are held by reference, so a tree rebuilt out of new instances - a page that hands over a + // fresh list on every render - is all new items to the set above, and each would be given its initial + // state rather than the one the reader left it in. So what the outgoing items hold is kept by identity + // and handed to the incoming item that answers to the same one. + var outgoing = new Dictionary(StringComparer.Ordinal); + string? focusedIdentity = null; + + foreach (var item in _initializedItems.Where(item => live.Contains(item) is false)) + { + if (_itemIdentities.TryGetValue(item, out var identity) is false) continue; + + outgoing[identity] = GetItemExpanded(item); + + if (AreEqual(item, _focusedItem)) focusedIdentity = identity; + } + // The expansion state of the items that are gone is dropped, so a nav whose items are swapped // repeatedly (a filtered list, a reloaded menu, ...) does not keep growing. _initializedItems.RemoveWhere(item => live.Contains(item) is false); @@ -470,7 +489,15 @@ private void SyncItems() _itemExpandStates.Remove(item); } - InitializeExpandStates(); + _itemIdentities.Clear(); + CollectItemIdentities(_items, null); + + InitializeExpandStates(outgoing); + + if (focusedIdentity is not null) + { + _focusedItem = _itemIdentities.FirstOrDefault(pair => pair.Value == focusedIdentity).Key; + } // The match is deferred to the end of the render instead of running here, because the parameters of // a single SetParametersAsync are assigned one by one: matching now would read a Mode (or a Match) @@ -481,16 +508,40 @@ private void SyncItems() // Applies the initial expansion state to the items the nav has not seen yet, so items that arrive // after the first render (loaded from a service, for instance) still honor AllExpanded and their own // IsExpanded, while the items already on screen keep whatever the user has expanded or collapsed. - private void InitializeExpandStates() + private void InitializeExpandStates(Dictionary? carried = null) { foreach (var item in Flatten(_items)) { if (_initializedItems.Add(item) is false) continue; + if (carried is not null && _itemIdentities.TryGetValue(item, out var identity) && carried.TryGetValue(identity, out var expanded)) + { + SetItemExpanded(item, expanded); + continue; + } + SetItemExpanded(item, AllExpanded || (GetIsExpanded(item) ?? false)); } } + private void CollectItemIdentities(IList items, string? parentIdentity) + { + for (var idx = 0; idx < items.Count; idx++) + { + var item = items[idx]; + // An explicit key and a position live in namespaces of their own, the key length-prefixed, so no key + // (a "0", or a "parent/0") can be read as the position of another item. + var key = GetKey(item); + var identity = key is not null + ? $"k{key.Length}:{key}" + : (parentIdentity is null ? $"i{idx}" : $"{parentIdentity}/{idx}"); + + _itemIdentities[item] = identity; + + CollectItemIdentities(GetChildItems(item), identity); + } + } + private void ToggleItemAndChildren(TItem item, bool isExpanded = false) { SetItemExpanded(item, isExpanded); diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor index fc20ae6218..0aa72a9563 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor @@ -21,12 +21,12 @@ id="@_HeaderId" type="button" style="@Styles?.Header" - class="bit-acd-hdr @(_isToggling ? "bit-acd-bsy" : "") @Classes?.Header" + class="bit-acd-hdr @(_IsBusy ? "bit-acd-bsy" : "") @Classes?.Header" aria-label="@HeaderAriaLabel" aria-expanded="@(IsExpanded ? "true" : "false")" aria-controls="@_ContentId" aria-disabled="@((IsEnabled is false || ReadOnly) ? "true" : null)" - aria-busy="@(_isToggling ? "true" : null)" + aria-busy="@(_IsBusy ? "true" : null)" tabindex="@(IsEnabled ? (TabIndex ?? "0") : "-1")"> @if (HeaderTemplate is not null) { diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor.cs index c297713090..914b586f22 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.razor.cs @@ -50,6 +50,17 @@ public partial class BitAccordion : BitComponentBase /// [Parameter] public RenderFragment? Body { get; set; } + /// + /// Reports the header as busy while something the page is doing on the accordion's behalf is still + /// running - the awaited work of a list that owns the expansion, most of all. + /// + /// + /// The header says as much - aria-busy for a screen reader, a busy cursor for a pointer - rather + /// than going on looking like a toggle that answers at once. An accordion whose own + /// is being awaited reports itself as busy without being told to. + /// + [Parameter] public bool Busy { get; set; } + /// /// Custom CSS classes for different parts of the accordion. /// @@ -361,6 +372,15 @@ public partial class BitAccordion : BitComponentBase // keyboard could not otherwise reach: the scroll of a content that is taller than its MaxHeight. private bool _IsContentFocusable => IsExpanded && MaxHeight.HasValue(); + // Whether the focus is inside the panel - on the scrollable region itself or on anything it holds. What + // reads it is a list of accordions (BitAccordionList): the keys it navigates its headers with are the + // scroll keys of the panel, so it leaves them alone while the panel is the one holding the focus. + internal bool IsContentFocused => _contentHasFocus; + + // The header answers nothing while an awaited OnToggling of its own is running, and nothing while the + // page - a BitAccordionList that owns the expansion - says so through the Busy parameter either. + private bool _IsBusy => _isToggling || Busy; + // A one-way bound IsExpanded is owned by the page that hands it over: the accordion cannot move it, so // nothing it would report about a move of its own would be true. private bool _OwnsExpansion => IsExpandedHasBeenSet is false || IsExpandedChanged.HasDelegate; @@ -501,6 +521,12 @@ private async Task HandleOnClick(MouseEventArgs e) // where it is - it just does not act on it. if (ReadOnly) return; + // A header that reports itself as busy - an awaited OnToggling of its own, or the Busy parameter of a + // list that owns the expansion - is not a toggle that answers, so the click stops here rather than + // starting a change behind the busy cursor. Expand, Collapse and Toggle are the way the app itself + // drives the accordion, and they are not turned away by this. + if (_IsBusy) return; + await AssignExpanded(IsExpanded is false, BitAccordionToggleReason.Click); } diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss index c15742ed53..0f202eefae 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/Accordion/BitAccordion.scss @@ -192,6 +192,7 @@ .bit-acd-hwr { display: flex; align-items: center; + transition: background-color $mot-duration-short $mot-easing 0ms; } .bit-acd-hed { @@ -226,17 +227,21 @@ } } -// The whole header answers the pointer, so it says so. The rule is chained down from the root rather than -// reaching for a descendant, so that the disabled state of an outer accordion does not take the hover away -// from the headers of the accordions nested in its panel. -.bit-acd:not(.bit-dis, .bit-acd-rdo) > .bit-acd-hwr > .bit-acd-hed > .bit-acd-hdr { +// The whole header answers the pointer, so it says so. What is painted is the header line rather than the +// button inside it: the Actions slot has to stay outside the button and the heading, so painting the button +// would leave that strip in the accordion's own color beside a header that has gone darker, reading as a box +// of its own rather than as the end of the same line. The state is still read off the button, so the paint +// keeps meaning "a click here toggles" and stays away while the pointer is over the actions. +// The rule is chained down from the root rather than reaching for a descendant, so that the disabled state of +// an outer accordion does not take the hover away from the headers of the accordions nested in its panel. +.bit-acd:not(.bit-dis, .bit-acd-rdo) > .bit-acd-hwr { @media (hover: hover) { - &:hover { + &:has(> .bit-acd-hed > .bit-acd-hdr:hover) { background-color: var(--bit-acd-hov); } } - &:active { + &:has(> .bit-acd-hed > .bit-acd-hdr:active) { background-color: var(--bit-acd-prs); } } diff --git a/src/BlazorUI/Bit.BlazorUI/Extensions/ObjectExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Extensions/ObjectExtensions.cs index 8805b983ab..42203e3bc9 100644 --- a/src/BlazorUI/Bit.BlazorUI/Extensions/ObjectExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Extensions/ObjectExtensions.cs @@ -60,6 +60,13 @@ internal static void SetValueToProperty(this object? obj, string propertyName, o { if (obj is null) return; - GetPropertyInfo(obj.GetType(), propertyName)?.SetValue(obj, value); + var property = GetPropertyInfo(obj.GetType(), propertyName); + + // A custom item type is free to expose a property the component only ever reads - a computed key, + // an expanded flag driven from elsewhere - so a property that cannot be written to is left alone + // rather than throwing from the middle of a render. + if (property?.CanWrite is not true) return; + + property.SetValue(obj, value); } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor index 6377b69b84..1b5c96527a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor @@ -5,14 +5,15 @@ + Description="BitAccordionList renders a list of expandable BitAccordion items from a single collection and owns their expand/collapse state, in single- or multiple-expand mode, with bound keys, cancellable toggles and keyboard navigation." />
@@ -39,7 +40,17 @@ - BitAccordionList is a sugar component over the single-item BitAccordion component. Instead of wiring multiple accordions manually, you provide a list of items and it handles rendering and expand/collapse state for you. + BitAccordionList is a sugar component over the single-item BitAccordion: + instead of wiring several accordions together by hand, you hand it one collection and it renders an item for each + entry and owns the expand/collapse state of all of them - one panel at a time by default, or several at once with + Multiple. That state can be left to the component, seeded with DefaultExpandedKey(s), + two-way bound through ExpandedKey(s), or driven from code with Expand, + Collapse, Toggle, ExpandAll and CollapseAll, while + MaxExpanded caps how many panels stay open at once and OnToggling can refuse a change + before it happens. Everything a single accordion can do is set once for the whole list and overridden per item + where it matters - the icons, the size, the colors, the templates, the actions beside a header, how the panels + are rendered - and the headers answer the arrow, Home and End keys on top of Tab, as the WAI-ARIA accordion + pattern offers. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.cs index 1b39d38715..381c8958fd 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.cs @@ -1,4 +1,4 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; public partial class BitAccordionListDemo { @@ -6,12 +6,21 @@ public partial class BitAccordionListDemo private readonly List componentParameters = [ + new() + { + Name = "ActionsTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The custom template to render beside the header of each item, outside of the toggle button and of the heading it sits in, so that it can hold its own interactive elements. Used when an item does not provide its own actions.", + }, new() { Name = "Background", Type = "BitColorKind?", DefaultValue = "null", Description = "The color kind of the background of all the accordion items.", + LinkType = LinkType.Link, + Href = "#color-kind-enum", }, new() { @@ -19,13 +28,15 @@ public partial class BitAccordionListDemo Type = "BitColorKind?", DefaultValue = "null", Description = "The color kind of the border of all the accordion items.", + LinkType = LinkType.Link, + Href = "#color-kind-enum", }, new() { Name = "BodyTemplate", Type = "RenderFragment?", DefaultValue = "null", - Description = "The custom template to render the body (content) of each item.", + Description = "The custom template to render the body (content) of each item. Used when an item does not provide its own body.", }, new() { @@ -44,6 +55,13 @@ public partial class BitAccordionListDemo Href = "#class-styles", }, new() + { + Name = "Collapsible", + Type = "bool", + DefaultValue = "true", + Description = "Allows the expanded item to be collapsed again from its own header. Setting it to false keeps one item open at all times: the header of the last expanded item reports itself as aria-disabled and no longer answers the pointer or the keyboard, while the public methods still drive the list.", + }, + new() { Name = "DefaultExpandedKey", Type = "string?", @@ -58,6 +76,20 @@ public partial class BitAccordionListDemo Description = "The default expanded keys in multiple-expand mode (used when ExpandedKeys is not set).", }, new() + { + Name = "ExpandedExpanderIcon", + Type = "BitIconInfo?", + DefaultValue = "null", + Description = "The icon to show in place of the expander icon of all items while they are expanded, using custom CSS classes for external icon libraries. Setting it also turns the rotation of the expander icon off. Can be overridden per item.", + }, + new() + { + Name = "ExpandedExpanderIconName", + Type = "string?", + DefaultValue = "null", + Description = "The name of the icon, from the built-in Fluent UI icons, to show in place of the expander icon of all items while they are expanded. Can be overridden per item.", + }, + new() { Name = "ExpandedKey", Type = "string?", @@ -86,6 +118,36 @@ public partial class BitAccordionListDemo Description = "The name of the icon to display as the expander of all items from the built-in Fluent UI icons. Can be overridden per item.", }, new() + { + Name = "ExpanderIconPosition", + Type = "BitIconPosition?", + DefaultValue = "null", + Description = "The side of the header the expander icon of all the items sits on. The default value is End.", + LinkType = LinkType.Link, + Href = "#icon-position-enum", + }, + new() + { + Name = "ExpanderTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The custom template to render in place of the expander icon of each item, leaving the rest of the header as it is. Used when an item does not provide its own expander template.", + }, + new() + { + Name = "ExpandOnPrint", + Type = "bool", + DefaultValue = "false", + Description = "Opens the panel of every item while the page is being printed, so that a collapsed section is not left out of the paper as a bare header.", + }, + new() + { + Name = "EmptyContent", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The custom content to render in place of the items when the list has none. A list built from Options or ChildContent only knows it is empty once its options have had their turn to register, so its empty content takes the render after the first; a list built from Items shows it right away.", + }, + new() { Name = "Gap", Type = "int?", @@ -100,6 +162,20 @@ public partial class BitAccordionListDemo Description = "The custom template to render the header of each item. Replaces the default Title/Description header.", }, new() + { + Name = "HeadingLevel", + Type = "int?", + DefaultValue = "null", + Description = "The heading level (aria-level) reported for the header of every item. The default value is 3, and the value is clamped to the 1..6 range.", + }, + new() + { + Name = "HideExpanderIcon", + Type = "bool", + DefaultValue = "false", + Description = "Removes the expander icon from the header of all the items. Can be overridden per item.", + }, + new() { Name = "Items", Type = "IEnumerable", @@ -109,6 +185,27 @@ public partial class BitAccordionListDemo Href = "#accordion-list-item", }, new() + { + Name = "LazyContent", + Type = "bool", + DefaultValue = "false", + Description = "Delays the first render of the content of each item until it is expanded for the first time. The content stays in the DOM afterwards, so the state it holds survives a collapse.", + }, + new() + { + Name = "MaxHeight", + Type = "string?", + DefaultValue = "null", + Description = "The maximum height of the content of every item (any CSS length), beyond which the content scrolls inside the item instead of growing it.", + }, + new() + { + Name = "MaxExpanded", + Type = "int?", + DefaultValue = "null", + Description = "The greatest number of items that can be expanded at the same time in multiple-expand mode. Expanding one more closes the panel that has been open the longest, so nothing is ever turned away. A value below 1 is no limit at all, the cap applies to ExpandAll and to the default and bound keys as well, and it means nothing outside of Multiple.", + }, + new() { Name = "Multiple", Type = "bool", @@ -125,6 +222,13 @@ public partial class BitAccordionListDemo Href = "#name-selectors", }, new() + { + Name = "Navigable", + Type = "bool", + DefaultValue = "true", + Description = "Moves the focus between the headers of the items with the ArrowUp, ArrowDown, Home and End keys, in addition to the Tab key. The navigation wraps around at both ends of the list, skips the disabled items, and leaves the same keys pressed inside a panel to whatever the panel holds.", + }, + new() { Name = "NoBorder", Type = "bool", @@ -132,6 +236,27 @@ public partial class BitAccordionListDemo Description = "Removes the default border of all the accordion items and gives a background color to their body.", }, new() + { + Name = "NoContentRegion", + Type = "bool", + DefaultValue = "false", + Description = "Removes the region role from the panel of every item, leaving it a plain container. The WAI-ARIA authoring practices ask for it beyond about six panels that can all be open at the same time.", + }, + new() + { + Name = "NoExpanderRotation", + Type = "bool", + DefaultValue = "false", + Description = "Keeps the expander icon of every item still instead of turning it over when the item is expanded.", + }, + new() + { + Name = "NoNavigationLoop", + Type = "bool", + DefaultValue = "false", + Description = "Stops the keyboard navigation of Navigable at the two ends of the list instead of wrapping it around from the last header to the first and back. The Home and End keys still reach both ends either way.", + }, + new() { Name = "OnCollapse", Type = "EventCallback", @@ -156,6 +281,14 @@ public partial class BitAccordionListDemo Description = "The callback that is called when an item is toggled (expanded or collapsed).", }, new() + { + Name = "OnToggling", + Type = "EventCallback>", + Description = "The callback invoked before an item expands or collapses, letting the change be cancelled. It is awaited, and nothing else toggles the list while it runs.", + LinkType = LinkType.Link, + Href = "#toggle-args", + }, + new() { Name = "Options", Type = "RenderFragment?", @@ -163,6 +296,29 @@ public partial class BitAccordionListDemo Description = "Alias of the ChildContent.", }, new() + { + Name = "ReadOnly", + Type = "bool", + DefaultValue = "false", + Description = "Leaves every item where it is: the headers keep their colors and their place in the tab order and report themselves as aria-disabled, but they no longer answer the pointer or the keyboard. Can be overridden per item.", + }, + new() + { + Name = "ScrollIntoViewOnExpand", + Type = "bool", + DefaultValue = "false", + Description = "Brings the item that has just been expanded into view, moving it as little as the browser can - so nothing happens to one that is already in view - and instantly rather than smoothly for a reader who has asked for less motion. It covers every way a panel opens: a click, one of the public methods, and the bound keys.", + }, + new() + { + Name = "Size", + Type = "BitSize?", + DefaultValue = "null", + Description = "The size of all the accordion items, which drives the padding of the headers and of the contents and the size of the titles. The default value is Medium.", + LinkType = LinkType.Link, + Href = "#size-enum", + }, + new() { Name = "Styles", Type = "BitAccordionListClassStyles?", @@ -171,6 +327,27 @@ public partial class BitAccordionListDemo LinkType = LinkType.Link, Href = "#class-styles", }, + new() + { + Name = "TitleTemplate", + Type = "RenderFragment?", + DefaultValue = "null", + Description = "The custom template to render in place of the title of each item, leaving the rest of the header as it is. Used when an item does not provide its own title template.", + }, + new() + { + Name = "TransitionDuration", + Type = "int?", + DefaultValue = "null", + Description = "The duration of the expand/collapse transition of every item in milliseconds, overriding the duration the theme provides.", + }, + new() + { + Name = "UnmountOnCollapse", + Type = "bool", + DefaultValue = "false", + Description = "Removes the content of an item from the DOM while it is collapsed, so that nothing it holds keeps running behind a closed header.", + }, ]; private readonly List componentPublicMembers = @@ -179,13 +356,55 @@ public partial class BitAccordionListDemo { Name = "ExpandAll", Type = "Task", - Description = "Expands all the items (only effective in multiple-expand mode).", + Description = "Expands all the items (only effective in multiple-expand mode). Disabled items are left as they are, since their headers could not close again what would be opened for them.", }, new() { Name = "CollapseAll", Type = "Task", - Description = "Collapses all the expanded items.", + Description = "Collapses all the expanded items, the disabled ones included, so that nothing is left open with no way of closing it.", + }, + new() + { + Name = "Expand", + Type = "Task", + Description = "Expands the item with the provided key. In single-expand mode the currently expanded item is collapsed along the way. Not turned away by IsEnabled, ReadOnly or Collapsible.", + }, + new() + { + Name = "Collapse", + Type = "Task", + Description = "Collapses the item with the provided key. Not turned away by IsEnabled, ReadOnly or Collapsible.", + }, + new() + { + Name = "Toggle", + Type = "Task", + Description = "Expands the item with the provided key if it is collapsed and collapses it if it is expanded.", + }, + new() + { + Name = "IsExpanded", + Type = "bool", + Description = "Reports whether the item with the provided key is currently expanded.", + }, + new() + { + Name = "GetExpandedKeys", + Type = "IReadOnlyList", + Description = "Returns the keys of the currently expanded items, in the order of the items of the list.", + }, + new() + { + Name = "FocusItem", + Type = "Task", + Description = "Gives the focus to the header of the item with the provided key.", + }, + new() + { + Name = "FocusAsync", + Type = "Task", + Description = "Gives the focus to the header of the first item of the list that can take it.", }, ]; @@ -198,18 +417,28 @@ public partial class BitAccordionListDemo Description = "The class for the items of the BitAccordionList when using the Items parameter.", Parameters = [ + new() { Name = "Actions", Type = "RenderFragment?", DefaultValue = "null", Description = "The content rendered beside the header of the item, outside of the toggle button and of the heading it sits in. The context value provides the item itself." }, new() { Name = "Class", Type = "string?", DefaultValue = "null", Description = "The custom CSS classes of the item." }, new() { Name = "Description", Type = "string?", DefaultValue = "null", Description = "A short description rendered in the header of the item." }, + new() { Name = "ExpandedExpanderIcon", Type = "BitIconInfo?", DefaultValue = "null", Description = "The icon to show in place of the expander icon while the item is expanded, using custom CSS classes for external icon libraries." }, + new() { Name = "ExpandedExpanderIconName", Type = "string?", DefaultValue = "null", Description = "The name of the icon, from the built-in Fluent UI icons, to show in place of the expander icon while the item is expanded." }, new() { Name = "ExpanderIcon", Type = "BitIconInfo?", DefaultValue = "null", Description = "The icon to display as the expander using custom CSS classes for external icon libraries. Takes precedence over ExpanderIconName." }, new() { Name = "ExpanderIconName", Type = "string?", DefaultValue = "null", Description = "The name of the icon to display as the expander from the built-in Fluent UI icons." }, + new() { Name = "ExpanderTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "The custom content to render in place of the expander icon of the item. The context value provides the item itself." }, new() { Name = "Body", Type = "RenderFragment?", DefaultValue = "null", Description = "The content (body) of the item that is shown when the item is expanded. The context value provides the item itself." }, + new() { Name = "HeaderAriaLabel", Type = "string?", DefaultValue = "null", Description = "The accessible label of the toggle button in the header of the item, for a header whose own content does not name it." }, new() { Name = "HeaderTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "The custom template for the header of the item. The context value provides the item itself." }, + new() { Name = "HideExpanderIcon", Type = "bool?", DefaultValue = "null", Description = "Removes the expander icon from the header of the item, overriding the value of the AccordionList." }, + new() { Name = "Icon", Type = "BitIconInfo?", DefaultValue = "null", Description = "The icon to display at the start of the header of the item using custom CSS classes for external icon libraries. Takes precedence over IconName." }, + new() { Name = "IconName", Type = "string?", DefaultValue = "null", Description = "The name of the icon to display at the start of the header of the item from the built-in Fluent UI icons." }, new() { Name = "IsEnabled", Type = "bool", DefaultValue = "true", Description = "Whether or not the item is enabled." }, new() { Name = "IsExpanded", Type = "bool", DefaultValue = "false", Description = "Determines whether the item is expanded. This value is also assigned by the component during interactions." }, - new() { Name = "Key", Type = "string?", DefaultValue = "null", Description = "A unique value to use as the key of the item." }, + new() { Name = "Key", Type = "string?", DefaultValue = "null", Description = "A unique value to use as the key of the item. A key that is not given is generated from the position of the item." }, new() { Name = "OnClick", Type = "Action?", DefaultValue = "null", Description = "The click event handler of the header of the item." }, + new() { Name = "ReadOnly", Type = "bool?", DefaultValue = "null", Description = "Leaves the item where it is: its header keeps its colors and its place in the tab order, but it no longer answers the pointer or the keyboard. Overrides the value of the AccordionList." }, new() { Name = "Style", Type = "string?", DefaultValue = "null", Description = "The custom value for the style attribute of the item." }, new() { Name = "Title", Type = "string?", DefaultValue = "null", Description = "The title (header text) of the item." }, + new() { Name = "TitleTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "The custom content to render in place of the Title of the item. The context value provides the item itself." }, ] }, new() @@ -219,19 +448,29 @@ public partial class BitAccordionListDemo Description = "The component for the items of the BitAccordionList when using the BitAccordionListOption components.", Parameters = [ + new() { Name = "Actions", Type = "RenderFragment?", DefaultValue = "null", Description = "The content rendered beside the header of the option, outside of the toggle button and of the heading it sits in. The context value provides the option itself." }, new() { Name = "Class", Type = "string?", DefaultValue = "null", Description = "The custom CSS classes of the option." }, new() { Name = "Description", Type = "string?", DefaultValue = "null", Description = "A short description rendered in the header of the option." }, + new() { Name = "ExpandedExpanderIcon", Type = "BitIconInfo?", DefaultValue = "null", Description = "The icon to show in place of the expander icon while the option is expanded, using custom CSS classes for external icon libraries." }, + new() { Name = "ExpandedExpanderIconName", Type = "string?", DefaultValue = "null", Description = "The name of the icon, from the built-in Fluent UI icons, to show in place of the expander icon while the option is expanded." }, new() { Name = "ExpanderIcon", Type = "BitIconInfo?", DefaultValue = "null", Description = "The icon to display as the expander using custom CSS classes for external icon libraries. Takes precedence over ExpanderIconName." }, new() { Name = "ExpanderIconName", Type = "string?", DefaultValue = "null", Description = "The name of the icon to display as the expander from the built-in Fluent UI icons." }, + new() { Name = "ExpanderTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "The custom content to render in place of the expander icon of the option. The context value provides the option itself." }, new() { Name = "Body", Type = "RenderFragment?", DefaultValue = "null", Description = "The content (body) of the option that is shown when the option is expanded. The context value provides the option itself." }, - new() { Name = "ChildContent", Type = "RenderFragment?", DefaultValue = "null", Description = "Alias for the Body parameter (the default child content). Used for simple inline content." }, + new() { Name = "ChildContent", Type = "RenderFragment?", DefaultValue = "null", Description = "The default child content of the option, for simple inline content without context. It takes precedence over Body when both are set." }, + new() { Name = "HeaderAriaLabel", Type = "string?", DefaultValue = "null", Description = "The accessible label of the toggle button in the header of the option, for a header whose own content does not name it." }, new() { Name = "HeaderTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "The custom template for the header of the option. The context value provides the option itself." }, + new() { Name = "HideExpanderIcon", Type = "bool?", DefaultValue = "null", Description = "Removes the expander icon from the header of the option, overriding the value of the AccordionList." }, + new() { Name = "Icon", Type = "BitIconInfo?", DefaultValue = "null", Description = "The icon to display at the start of the header of the option using custom CSS classes for external icon libraries. Takes precedence over IconName." }, + new() { Name = "IconName", Type = "string?", DefaultValue = "null", Description = "The name of the icon to display at the start of the header of the option from the built-in Fluent UI icons." }, new() { Name = "IsEnabled", Type = "bool", DefaultValue = "true", Description = "Whether or not the option is enabled." }, new() { Name = "IsExpanded", Type = "bool", DefaultValue = "false", Description = "Determines whether the option is initially expanded." }, - new() { Name = "Key", Type = "string?", DefaultValue = "null", Description = "A unique value to use as the key of the option." }, + new() { Name = "Key", Type = "string?", DefaultValue = "null", Description = "A unique value to use as the key of the option. A key that is not given is generated by the list, unique among the options." }, new() { Name = "OnClick", Type = "EventCallback", DefaultValue = "", Description = "The click event handler of the header of the option." }, + new() { Name = "ReadOnly", Type = "bool?", DefaultValue = "null", Description = "Leaves the option where it is: its header keeps its colors and its place in the tab order, but it no longer answers the pointer or the keyboard. Overrides the value of the AccordionList." }, new() { Name = "Style", Type = "string?", DefaultValue = "null", Description = "The custom value for the style attribute of the option." }, new() { Name = "Title", Type = "string?", DefaultValue = "null", Description = "The title (header text) of the option." }, + new() { Name = "TitleTemplate", Type = "RenderFragment?", DefaultValue = "null", Description = "The custom content to render in place of the Title of the option. The context value provides the option itself." }, ] }, new() @@ -241,18 +480,28 @@ public partial class BitAccordionListDemo Description = "The names and selectors of the custom input type properties.", Parameters = [ + new() { Name = "Actions", Type = "BitNameSelectorPair?>", DefaultValue = "new(nameof(BitAccordionListItem.Actions))", Description = "Actions field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "Class", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Class))", Description = "The CSS Class field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "Description", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Description))", Description = "Description field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "ExpandedExpanderIcon", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.ExpandedExpanderIcon))", Description = "ExpandedExpanderIcon field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "ExpandedExpanderIconName", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.ExpandedExpanderIconName))", Description = "ExpandedExpanderIconName field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "ExpanderIcon", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.ExpanderIcon))", Description = "ExpanderIcon field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "ExpanderIconName", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.ExpanderIconName))", Description = "ExpanderIconName field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "ExpanderTemplate", Type = "BitNameSelectorPair?>", DefaultValue = "new(nameof(BitAccordionListItem.ExpanderTemplate))", Description = "ExpanderTemplate field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "Body", Type = "BitNameSelectorPair?>", DefaultValue = "new(nameof(BitAccordionListItem.Body))", Description = "Body field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "HeaderAriaLabel", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.HeaderAriaLabel))", Description = "HeaderAriaLabel field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "HeaderTemplate", Type = "BitNameSelectorPair?>", DefaultValue = "new(nameof(BitAccordionListItem.HeaderTemplate))", Description = "HeaderTemplate field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "HideExpanderIcon", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.HideExpanderIcon))", Description = "HideExpanderIcon field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "Icon", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Icon))", Description = "Icon field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "IconName", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.IconName))", Description = "IconName field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "IsEnabled", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.IsEnabled))", Description = "IsEnabled field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "IsExpanded", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.IsExpanded))", Description = "IsExpanded field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, - new() { Name = "Key", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Key))", Description = "Key field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "Key", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Key))", Description = "Key field name and selector of the custom input class. An item that carries no key of its own is given a generated one, kept beside it rather than written into it.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "OnClick", Type = "BitNameSelectorPair?>", DefaultValue = "new(nameof(BitAccordionListItem.OnClick))", Description = "OnClick field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, - new() { Name = "Style", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Style))", Description = "Style field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "ReadOnly", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.ReadOnly))", Description = "ReadOnly field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "Style", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Style))", Description = "The CSS Style field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, new() { Name = "Title", Type = "BitNameSelectorPair", DefaultValue = "new(nameof(BitAccordionListItem.Title))", Description = "Title field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, + new() { Name = "TitleTemplate", Type = "BitNameSelectorPair?>", DefaultValue = "new(nameof(BitAccordionListItem.TitleTemplate))", Description = "TitleTemplate field name and selector of the custom input class.", LinkType = LinkType.Link, Href = "#name-selector-pair" }, ] }, new() @@ -266,6 +515,20 @@ public partial class BitAccordionListDemo ] }, new() + { + Id = "toggle-args", + Title = "BitAccordionListToggleArgs", + Description = "The arguments of the OnToggling callback of the BitAccordionList.", + Parameters = + [ + new() { Name = "Item", Type = "TItem", Description = "The item that is about to expand or collapse." }, + new() { Name = "Key", Type = "string?", Description = "The key of the item that is about to expand or collapse." }, + new() { Name = "IsExpanding", Type = "bool", Description = "The state the item is about to move to: true while it is expanding, false while it is collapsing." }, + new() { Name = "Reason", Type = "BitAccordionToggleReason", Description = "What made the item expand or collapse: a click on its header, or a call to one of the public methods.", LinkType = LinkType.Link, Href = "#accordion-toggle-reason-enum" }, + new() { Name = "Cancel", Type = "bool", DefaultValue = "false", Description = "Set to true to cancel the expansion or the collapse and leave the item as it is." }, + ] + }, + new() { Id = "class-styles", Title = "BitAccordionListClassStyles", @@ -274,16 +537,72 @@ public partial class BitAccordionListDemo new() { Name = "Root", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the root element of the BitAccordionList." }, new() { Name = "Item", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for each accordion item of the BitAccordionList." }, new() { Name = "ItemExpanded", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the expanded state of each accordion item of the BitAccordionList." }, + new() { Name = "ItemHeaderWrapper", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the header wrapper of each accordion item, which holds the heading and the actions." }, + new() { Name = "ItemHeading", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the heading element of each accordion item that wraps the header button." }, new() { Name = "ItemHeader", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the header of each accordion item of the BitAccordionList." }, + new() { Name = "ItemIcon", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the icon at the start of the header of each accordion item." }, new() { Name = "ItemHeaderContent", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the header content of each accordion item of the BitAccordionList." }, new() { Name = "ItemTitle", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the title of each accordion item of the BitAccordionList." }, new() { Name = "ItemDescription", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the description of each accordion item of the BitAccordionList." }, new() { Name = "ItemExpanderIconWrapper", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the expander icon wrapper of each accordion item of the BitAccordionList." }, new() { Name = "ItemExpanderIcon", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the expander icon of each accordion item of the BitAccordionList." }, new() { Name = "ItemExpandedIcon", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the expander icon of each accordion item of the BitAccordionList in the expanded state." }, + new() { Name = "ItemActions", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the actions of each accordion item, rendered beside the header." }, new() { Name = "ItemContentContainer", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the content container of each accordion item of the BitAccordionList." }, + new() { Name = "ItemContentWrapper", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the content wrapper of each accordion item, which clips the content while it collapses." }, new() { Name = "ItemContent", Type = "string?", DefaultValue = "null", Description = "Custom CSS classes/styles for the content of each accordion item of the BitAccordionList." }, ] } ]; + + private readonly List componentSubEnums = + [ + new() + { + Id = "color-kind-enum", + Name = "BitColorKind", + Description = "Defines the color kinds available in the bit BlazorUI.", + Items = + [ + new() { Name = "Primary", Description = "The primary color kind.", Value = "0" }, + new() { Name = "Secondary", Description = "The secondary color kind.", Value = "1" }, + new() { Name = "Tertiary", Description = "The tertiary color kind.", Value = "2" }, + new() { Name = "Transparent", Description = "The transparent color kind.", Value = "3" }, + ] + }, + new() + { + Id = "icon-position-enum", + Name = "BitIconPosition", + Description = "Describes the placement of an icon relative to other content.", + Items = + [ + new() { Name = "Start", Description = "Icon renders before the content.", Value = "0" }, + new() { Name = "End", Description = "Icon renders after the content (default).", Value = "1" }, + ] + }, + new() + { + Id = "accordion-toggle-reason-enum", + Name = "BitAccordionToggleReason", + Description = "What made an item of the list expand or collapse.", + Items = + [ + new() { Name = "Click", Description = "The header of the item was clicked, or activated by the Enter or the Space key.", Value = "0" }, + new() { Name = "Method", Description = "One of the Expand, Collapse, Toggle, ExpandAll and CollapseAll methods was called.", Value = "1" }, + ] + }, + new() + { + Id = "size-enum", + Name = "BitSize", + Description = "Defines the sizes available in the bit BlazorUI.", + Items = + [ + new() { Name = "Small", Description = "The small size.", Value = "0" }, + new() { Name = "Medium", Description = "The medium size.", Value = "1" }, + new() { Name = "Large", Description = "The large size.", Value = "2" }, + ] + }, + ]; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.scss index 1422bdc3fb..42e7fd3349 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/BitAccordionListDemo.razor.scss @@ -5,6 +5,15 @@ flex-flow: column; } + // A scroll of its own, so the ScrollIntoViewOnExpand example can be seen without the page moving. + .scroll-box { + padding: 0.5rem; + max-block-size: 12rem; + overflow-y: auto; + border-radius: 0.25rem; + border: 1px solid var(--bit-clr-brd-sec); + } + .custom-item { color: peachpuff; background-color: tomato; @@ -14,4 +23,8 @@ color: tomato; font-style: italic; } + + .custom-expanded { + border-color: tomato; + } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/Section.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/Section.cs index a943dbd82b..8fbe2833a3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/Section.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/Section.cs @@ -10,13 +10,21 @@ public class Section public bool IsEnabled { get; set; } = true; + public bool? Locked { get; set; } + public string? Class { get; set; } public string? Style { get; set; } public string? Image { get; set; } + public string? Glyph { get; set; } + + public BitIconInfo? CustomGlyph { get; set; } + public RenderFragment
? Content { get; set; } + public RenderFragment
? Extra { get; set; } + public Action
? Clicked { get; set; } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor index a6c6e37afe..01a4428576 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor @@ -1,17 +1,42 @@ - -
The items are provided as a custom generic type mapped with NameSelectors. By default it works in single-expand mode.
+ +
+ The items are provided as a custom generic type, and NameSelectors says which of its members + stands for the key, the title, the description, the body and everything else the list reads. The list + renders one BitAccordion per item and owns their + expand/collapse state, so nothing has to be wired up by hand. By default it works in single-expand mode: + opening one panel closes the one that was open. +

-
Enable the Multiple parameter to allow more than one item to be expanded at the same time.
+
+ Multiple lets more than one item stay open at the same time; each header then toggles only its + own panel. MaxExpanded puts a ceiling on how many of them may be open at once: opening one + more closes the panel that has been open the longest, so a click always opens the panel it was aimed at + rather than being turned away by a header that answers nothing. +

- +
+
Multiple:
+ +
+

+
+
At most two panels open at once:
+ +
-
Use DefaultExpandedKey (single) or DefaultExpandedKeys (multiple) to set the initially expanded items.
+
+ DefaultExpandedKey (single-expand) and DefaultExpandedKeys (multiple-expand) say + which items start open, leaving the list in charge of everything that happens afterwards. An item can also + open itself through the member mapped to IsExpanded, which the defaults take precedence over. + A key that is not given is generated from the position of the item, so a key member of your own keeps it + readable. +


Single (DefaultExpandedKey):
@@ -24,8 +49,98 @@
- -
Handle the OnExpand, OnCollapse and OnToggle events of the component.
+ +
+ Collapsible="false" keeps one panel open at all times: the header of the last expanded item + stops answering the pointer and the keyboard and reports itself as aria-disabled, which is + the state the WAI-ARIA authoring practices ask for a header whose panel cannot be closed. Another item can + still take its place, and it is only the header that is closed off - Collapse, + Toggle and CollapseAll still drive the list. +
+
+ +
+ + +
+ ExpanderIconName replaces the chevron of every item, and each item can override it through the + member mapped to it. ExpandedExpanderIconName swaps the icon while the panel is open - which + reports the state on its own, so the rotation is dropped along with it - while + NoExpanderRotation keeps the icon still without swapping it and HideExpanderIcon + removes it altogether. ExpanderIconPosition moves the expander to the start of the header, and + the member mapped to IconName draws an icon of the item ahead of its title. +
+

+
+
Per-item expander icon and leading icon:
+ +
+

+
+
Swapped while expanded (ExpandedExpanderIconName):
+ +
+

+
+
At the start of the header (ExpanderIconPosition):
+ +
+

+
+
Without an expander icon (HideExpanderIcon):
+ +
+
+ + +
+ ActionsTemplate renders content beside the header of every item, outside of the toggle button + and of the heading it sits in, so it can hold interactive elements of its own - a menu, a delete button, a + switch - without nesting a control inside another one. An item can also carry its own actions through the + member mapped to Actions, which take precedence over the template. +
+
+ + + + + +
+
Last action: @actionedTitle
+
+ + +
+ An item mapped to IsEnabled = false is greyed out and its header leaves the tab order, while + the member mapped to ReadOnly is for the panel that has to stay as it is rather than the one + that is turned off: it keeps the colors of a live item and its place in the tab order, reports itself as + aria-disabled, and still raises OnItemClick so the page can say why nothing + moved. Both can be set for the whole list or per item, and the item value wins. +
+
+ +
+
Clicks on the read-only header: @readOnlyClickCount
+
+ + +
+ OnExpand, OnCollapse and OnToggle report the item that moved - + including the one single-expand mode closes on its own to make room. +

Item click count: @clickCounter
- -
In multiple-expand mode, the ExpandAll and CollapseAll public methods can be used.
+ +
+ OnToggling runs before an item moves and can leave it where it is by setting + Cancel on its arguments. They carry the item, its key, whether it is about to expand and what + asked for the change - a click on the header, or one of the public methods. The callback is awaited, so it + can also load the content of the panel or ask for a confirmation first, and nothing else toggles the list + while it runs - the header it was asked about says as much while it waits, reporting itself as + aria-busy and taking a busy cursor, rather than going on looking like a toggle that answers + at once. +

- Expand all - Collapse all -

- + + +
+ +
+
Last request: @togglingReport
- -
Two-way bind the expanded key in single-expand mode using @@bind-ExpandedKey.
+ +
+ @@bind-ExpandedKey (single-expand) and @@bind-ExpandedKeys (multiple-expand) hand + the open state to the page: the list reports every change through them, and a value written from outside + moves the panels the same way a click does. +


Bound expanded key: @boundExpandedKey

+

+
Bound expanded keys: @string.Join(", ", boundExpandedKeys)
+
+ +
+ + +
+ Expand, Collapse and Toggle drive a single item by key, + ExpandAll (multiple-expand mode only) and CollapseAll drive all of them, and + FocusItem puts the keyboard on the header of one. IsExpanded and + GetExpandedKeys read the state back. None of them is turned away by ReadOnly or + Collapsible: what those close off is the way in from the header, not the one the app uses. +
+
+
+ + Expand all + Collapse all + Toggle Users + Focus Advanced + +
+
+ +
+
Expanded keys: @string.Join(", ", programmaticKeys)
- -
Customize the expander icon for the whole list or per item.
+ +
+ By default every panel is rendered up front and stays in the DOM. LazyContent holds the first + render of a panel back until it is opened, so a heavy panel costs nothing until it is asked for, and keeps + it afterwards - whatever state it holds survives a collapse. UnmountOnCollapse goes the other + way and drops the content again on every close, so nothing it holds keeps running behind a closed header. + MaxHeight caps a panel and lets it scroll inside the item rather than growing it; the + scrolling region takes a tab stop of its own so the keyboard can reach it. +


-
Component-level:
- +
LazyContent & UnmountOnCollapse (watch the render timestamps):
+ +


-
Per-item:
- +
MaxHeight:
+
- -
Customize the background and border color kinds of all the items.
+ +
+ TransitionDuration sets the length of the expand/collapse animation of every item in + milliseconds, overriding the duration of the theme; 0 turns it off. A reduced-motion + preference still collapses it to nothing, unless ForceAnimation opts out of that. +


-
NoBorder:
- +
No animation (0):
+


-
Background & Border:
- +
Slowed down (1500):
+
- -
Customize the header and body of the items using HeaderTemplate and BodyTemplate.
+ +
+ HeaderTemplate replaces the whole header of every item and BodyTemplate its panel, + while TitleTemplate and ExpanderTemplate take the place of only the title and only + the expander, leaving the rest of the header as it is. Each of them receives the item as its context, and an + item that carries a template of its own through NameSelectors takes precedence over the one of + the list. +
+

+
+
HeaderTemplate & BodyTemplate:
+ + + + @item.Name + + + @item.Info + + +
+

+
+
TitleTemplate & ExpanderTemplate:
+ + + + + + + + +
+
+ + +
+ Every header is a button inside a heading, tied to its panel with aria-controls and + aria-expanded, and every panel is a region named by its own header - the structure the + WAI-ARIA accordion pattern asks for. HeadingLevel puts the headers at the right depth of the + heading outline of the page (3 by default, clamped to 1..6). Navigable, which is on by + default, adds the ArrowUp, ArrowDown, Home and End keys on top of Tab: they move the focus between the + headers without scrolling the page under it, wrap around at both ends - NoNavigationLoop + stops them there instead - and skip the disabled items, while the same keys pressed inside a panel are + left to whatever the panel holds. NoContentRegion drops the landmark role from the panels, + which the authoring practices ask for beyond about six panels that can all be open at once, and + AriaLabel names the list itself. A header that does not name itself - an icon-only + HeaderTemplate - takes a HeaderAriaLabel of its own, which names both the toggle + and the panel it opens. +

- - - - @item.Name - - - @item.Info - + +
+ + +
+ A collapsed panel is hidden outright, so a list of them prints as a column of bare headers. + ExpandOnPrint opens every panel for the print stylesheet alone - what is on screen stays + exactly where the reader left it - and lifts the cap of MaxHeight with it, since paper does + not scroll. What is not in the DOM at all cannot be printed by any of this: the panels of a + LazyContent list that were never opened, and every closed panel of a list using + UnmountOnCollapse, still print as bare headers. +
+
+
Leave these closed and open the print preview of the browser (Ctrl+P): only the first list carries its text onto the page.
+
+
+
Printed with their content:
+ +
+

+
+
Printed as bare headers:
+ +
+
+ + +
+ Gap sets the space in pixels between the items - 0 stacks them into one block - + and NoBorder drops the outline of every item and fills it with the secondary background + instead, for a flat list that leans on the surface it sits on. +
+

+
+
Gap:
+ +
+

+
+
NoBorder:
+ +
+
+ + +
+ EmptyContent is what the list draws in place of the items while it has none, so a collection + that is still loading, or one a filter has emptied, says so instead of leaving a blank where a list is + meant to be. +
+
+ +
+ + + There is nothing to show here yet. +
- -
Customize the appearance using the Style, Class, Styles, and Classes parameters.
+ +
+ ScrollIntoViewOnExpand brings the item that has just been expanded into view, so a panel + opened at the bottom of the window is not left off the screen it was opened on. The item is moved as + little as the browser can move it, so nothing happens to one that is already in view, and the scroll is + instant rather than smooth for a reader who has asked for less motion. It covers the ways a single panel opens: + a click on its header, Expand, Toggle and the bound keys - ExpandAll scrolls to nothing, since there is no one panel it opened. +
+
+
Open the last panels of this box: the list follows them without the box being scrolled by hand.
+
+
+ +
+
+ + +
+ Background and Border take a BitColorKind and repaint every item of + the list, down to the shades its header takes under the pointer. +
+
+ +
+ +
+ + +
+ ExpanderIcon, ExpandedExpanderIcon and the member mapped to Icon + take a BitIconInfo rather than the name of a built-in icon, so the list can be dressed in the + icons of any library that draws them from CSS classes. BitIconInfo.Css takes the classes as + they are, while BitIconInfo.Fa and BitIconInfo.Bi spell out the prefixes of + FontAwesome and Bootstrap Icons. +
+
+ + +
+
FontAwesome:
+ +
+

+
+
Bootstrap:
+ +
+
+ + +
+ Size drives the padding of the headers and of the panels and the type scale of every item - + the title, the description, the icons and the text of the panel - so a list can be tuned to how much room + it is given. +
+

+
+
Small:
+ +
+

+
+
Medium:
+ +
+

+
+
Large:
+ +
+
+ + +
+ Style and Class dress the root element of the list, while Styles and + Classes reach each part of every item on its own - the header, the title, the expander icon, + the panel and everything between them. Two of their slots are states rather than parts: + ItemExpanded is added to an item only while its panel is open and ItemExpandedIcon + only to its expander icon while it is. An item can also carry a style and a class of its own through + NameSelectors. +


Component's style & class:
@@ -127,12 +485,15 @@ + Classes="@(new() { ItemTitle = "custom-title", ItemExpanded = "custom-expanded" })" />
- -
Use BitAccordionList in right-to-left (RTL).
+ +
+ Dir="BitDir.Rtl" mirrors the whole list: the expander icon moves to the other end of the + header and the padding follows the writing direction with it. +

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.cs index 3b3cbce587..3a5ddfac50 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.cs @@ -1,13 +1,25 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; public partial class _BitAccordionListCustomDemo { private int clickCounter; + private int readOnlyClickCount; + private bool lockToggling; + private bool slowToggling; + private bool showEmptyItems; private string? expandedTitle; private string? collapsedTitle; private string? toggledTitle; + private string? actionedTitle; + private string? togglingReport; private string? boundExpandedKey = "users"; - private BitAccordionList
accordionListRef = default!; + private IEnumerable boundExpandedKeys = ["general"]; + private IEnumerable programmaticKeys = []; + private BitAccordionList
? accordionListRef; + + private const string Story1 = "Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."; + private const string Story2 = "Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."; + private const string Story3 = "In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."; private readonly BitAccordionListNameSelectors
nameSelectors = new() { @@ -15,32 +27,79 @@ public partial class _BitAccordionListCustomDemo Title = { Selector = i => i.Name }, Description = { Selector = i => i.Info }, IsEnabled = { Selector = i => i.IsEnabled }, + ReadOnly = { Selector = i => i.Locked }, ExpanderIconName = { Selector = i => i.Image }, + IconName = { Selector = i => i.Glyph }, + Icon = { Selector = i => i.CustomGlyph }, Style = { Selector = i => i.Style }, Class = { Selector = i => i.Class }, OnClick = { Selector = i => i.Clicked }, + Actions = { Selector = i => i.Extra }, Body = { Selector = i => i.Content }, }; private readonly List
basicItems = [ - new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Content = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Content = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Content = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, + new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Content = BodyFor(Story1) }, + new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Content = BodyFor(Story2) }, + new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Content = BodyFor(Story3) }, ]; private readonly List
keyedItems = [ - new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Content = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Content = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Content = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, + new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Content = BodyFor(Story1) }, + new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Content = BodyFor(Story2) }, + new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Content = BodyFor(Story3) }, ]; private readonly List
iconItems = [ - new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Image = BitIconName.Settings, Content = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Image = BitIconName.Contact, Content = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Image = BitIconName.Ringer, Content = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, + new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Glyph = BitIconName.Settings, Image = BitIconName.ChevronDownSmall, Content = BodyFor(Story1) }, + new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Glyph = BitIconName.Contact, Image = BitIconName.ChevronDownSmall, Content = BodyFor(Story2) }, + new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Glyph = BitIconName.Ringer, Content = BodyFor(Story3) }, + ]; + + // No Content: an item that carries a body of its own takes precedence over the list's BodyTemplate. + private readonly List
templateItems = + [ + new() { Id = "general", Name = "General settings", Info = "The general settings of the application" }, + new() { Id = "users", Name = "Users", Info = "You are currently not an owner" }, + new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled" }, + ]; + + private readonly List
stateItems = + [ + new() { Id = "normal", Name = "General settings", Info = "A live item", Content = BodyFor(Story1) }, + new() { Id = "disabled", Name = "Users", Info = "Turned off altogether", IsEnabled = false, Content = BodyFor(Story2) }, + new() { Id = "locked", Name = "Advanced settings", Info = "Open on purpose and staying that way", Locked = true, Content = BodyFor(Story3) }, + ]; + + private readonly List
lazyItems = + [ + new() { Id = "lazy-1", Name = "Lazy panel", Info = "Rendered the first time it is opened, and kept afterwards", Content = TimestampBody() }, + ]; + + private readonly List
unmountItems = + [ + new() { Id = "unmount-1", Name = "Unmounted panel", Info = "Rendered again on every open", Content = TimestampBody() }, + ]; + + private readonly List
longItems = + [ + new() { Id = "long-1", Name = "A long panel", Info = "Scrolls inside the item", Content = BodyFor($"{Story1} {Story2} {Story3} {Story1} {Story2} {Story3}") }, + new() { Id = "long-2", Name = "Another long panel", Info = "Scrolls inside the item", Content = BodyFor($"{Story3} {Story2} {Story1} {Story3} {Story2} {Story1}") }, + ]; + + private readonly List
faItems = + [ + new() { Id = "general", Name = "General settings", Info = "The general settings of the application", CustomGlyph = BitIconInfo.Fa("solid gear"), Content = BodyFor(Story1) }, + new() { Id = "users", Name = "Users", Info = "You are currently not an owner", CustomGlyph = BitIconInfo.Fa("solid user"), Content = BodyFor(Story2) }, + ]; + + private readonly List
biItems = + [ + new() { Id = "general", Name = "General settings", Info = "The general settings of the application", CustomGlyph = BitIconInfo.Bi("gear"), Content = BodyFor(Story1) }, + new() { Id = "users", Name = "Users", Info = "You are currently not an owner", CustomGlyph = BitIconInfo.Bi("person"), Content = BodyFor(Story2) }, ]; private readonly List
rtlItems = @@ -49,6 +108,23 @@ public partial class _BitAccordionListCustomDemo new() { Id = "users", Name = "کاربران", Info = "شما در حال حاضر مالک نیستید", Content = BodyFor("لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است.") }, ]; + private readonly List
noItems = []; + + private readonly List
scrollItems = + [ + new() { Id = "scroll-1", Name = "First section", Info = "Opens without moving anything", Content = BodyFor(Story1) }, + new() { Id = "scroll-2", Name = "Second section", Info = "Sits just below the fold", Content = BodyFor($"{Story2} {Story3}") }, + new() { Id = "scroll-3", Name = "Third section", Info = "Is scrolled to when it opens", Content = BodyFor($"{Story3} {Story1}") }, + new() { Id = "scroll-4", Name = "Fourth section", Info = "Is scrolled to when it opens", Content = BodyFor($"{Story1} {Story2}") }, + ]; + + private readonly List
eventsItems = + [ + new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Content = BodyFor(Story1) }, + new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Content = BodyFor(Story2) }, + new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Content = BodyFor(Story3) }, + ]; + private List bindingButtons => [ new() { Key = "general", Text = "General" }, @@ -56,13 +132,6 @@ public partial class _BitAccordionListCustomDemo new() { Key = "advanced", Text = "Advanced" }, ]; - private List
eventsItems = - [ - new() { Id = "general", Name = "General settings", Info = "The general settings of the application", Content = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Id = "users", Name = "Users", Info = "You are currently not an owner", Content = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Id = "advanced", Name = "Advanced settings", Info = "Filtering has been entirely disabled", Content = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, - ]; - protected override void OnInitialized() { foreach (var item in eventsItems) @@ -71,8 +140,26 @@ protected override void OnInitialized() } } + private async Task HandleOnToggling(BitAccordionListToggleArgs
args) + { + togglingReport = $"{args.Item.Name} is {(args.IsExpanding ? "expanding" : "collapsing")} ({args.Reason})"; + + // The header of this item reports itself as aria-busy for as long as the callback is awaited. + if (slowToggling) + { + await Task.Delay(1000); + } + + args.Cancel = lockToggling; + } + private static RenderFragment
BodyFor(string? text) => section => builder => { builder.AddContent(0, text); }; + + private static RenderFragment
TimestampBody() => section => builder => + { + builder.AddContent(0, $"This panel was rendered at {DateTime.Now:HH:mm:ss.fff}"); + }; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.samples.cs index 44700e0afb..1fbb9dd663 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListCustomDemo.razor.samples.cs @@ -1,20 +1,22 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; public partial class _BitAccordionListCustomDemo { - private readonly string example1RazorCode = @" -"; - private readonly string example1CsharpCode = @" + private const string sectionCsharpCode = @" public class Section { public string? Id { get; set; } public string? Name { get; set; } public string? Info { get; set; } public bool IsEnabled { get; set; } = true; + public bool? Locked { get; set; } public string? Class { get; set; } public string? Style { get; set; } public string? Image { get; set; } + public string? Glyph { get; set; } + public BitIconInfo? CustomGlyph { get; set; } public RenderFragment
? Content { get; set; } + public RenderFragment
? Extra { get; set; } public Action
? Clicked { get; set; } } @@ -24,156 +26,134 @@ public class Section Title = { Selector = i => i.Name }, Description = { Selector = i => i.Info }, IsEnabled = { Selector = i => i.IsEnabled }, + ReadOnly = { Selector = i => i.Locked }, ExpanderIconName = { Selector = i => i.Image }, + IconName = { Selector = i => i.Glyph }, + Icon = { Selector = i => i.CustomGlyph }, Style = { Selector = i => i.Style }, Class = { Selector = i => i.Class }, OnClick = { Selector = i => i.Clicked }, + Actions = { Selector = i => i.Extra }, Body = { Selector = i => i.Content }, }; +private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; + + private const string basicItemsCsharpCode = @" private readonly List
basicItems = [ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Id = ""general"", Name = ""General settings"", Info = ""The general settings of the application"", Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""users"", Name = ""Users"", Info = ""You are currently not an owner"", Content = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Id = ""advanced"", Name = ""Advanced settings"", Info = ""Filtering has been entirely disabled"", Content = BodyFor(""In the beginning, there is silence, ..."") }, ]; +" + sectionCsharpCode; -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; - - private readonly string example2RazorCode = @" -"; - private readonly string example2CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} - -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; + // No Content: an item that carries a body of its own takes precedence over the list's BodyTemplate. + private const string templateItemsCsharpCode = @" +private readonly List
templateItems = +[ + new() { Id = ""general"", Name = ""General settings"", Info = ""The general settings of the application"" }, + new() { Id = ""users"", Name = ""Users"", Info = ""You are currently not an owner"" }, + new() { Id = ""advanced"", Name = ""Advanced settings"", Info = ""Filtering has been entirely disabled"" }, +]; +"; -private readonly List
basicItems = + private const string keyedItemsCsharpCode = @" +private readonly List
keyedItems = [ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Id = ""general"", Name = ""General settings"", Info = ""The general settings of the application"", Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""users"", Name = ""Users"", Info = ""You are currently not an owner"", Content = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Id = ""advanced"", Name = ""Advanced settings"", Info = ""Filtering has been entirely disabled"", Content = BodyFor(""In the beginning, there is silence, ..."") }, ]; +" + sectionCsharpCode; -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; + + private readonly string example1RazorCode = @" +"; + private readonly string example1CsharpCode = basicItemsCsharpCode; + + private readonly string example2RazorCode = @" + + +"; + private readonly string example2CsharpCode = basicItemsCsharpCode; private readonly string example3RazorCode = @" "; - private readonly string example3CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} + private readonly string example3CsharpCode = keyedItemsCsharpCode; -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; + private readonly string example4RazorCode = @" +"; + private readonly string example4CsharpCode = keyedItemsCsharpCode; -private readonly List
keyedItems = + private readonly string example5RazorCode = @" + + + + + + +"; + private readonly string example5CsharpCode = @" +private readonly List
iconItems = [ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Id = ""general"", Name = ""General settings"", Info = ""The general settings of the application"", Glyph = BitIconName.Settings, Image = BitIconName.ChevronDownSmall, Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""users"", Name = ""Users"", Info = ""You are currently not an owner"", Glyph = BitIconName.Contact, Image = BitIconName.ChevronDownSmall, Content = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Id = ""advanced"", Name = ""Advanced settings"", Info = ""Filtering has been entirely disabled"", Glyph = BitIconName.Ringer, Content = BodyFor(""In the beginning, there is silence, ..."") }, ]; +" + basicItemsCsharpCode; -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; + private readonly string example6RazorCode = @" + + + actionedTitle = item.Name"" /> + + + +
Last action: @actionedTitle
"; + private readonly string example6CsharpCode = @" +private string? actionedTitle; - private readonly string example4RazorCode = @" - { if (item.Locked is true) readOnlyClickCount++; }"" /> + +
Clicks on the read-only header: @readOnlyClickCount
"; + private readonly string example7CsharpCode = @" +private int readOnlyClickCount; + +private readonly List
stateItems = +[ + new() { Id = ""normal"", Name = ""General settings"", Info = ""A live item"", Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""disabled"", Name = ""Users"", Info = ""Turned off altogether"", IsEnabled = false, Content = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Id = ""locked"", Name = ""Advanced settings"", Info = ""Open on purpose and staying that way"", Locked = true, Content = BodyFor(""In the beginning, there is silence, ..."") }, +]; +" + sectionCsharpCode; + + private readonly string example8RazorCode = @" + expandedTitle = item.Name"" @@ -186,61 +166,17 @@ public class Section
Item click count: @clickCounter
"; - private readonly string example4CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} - -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; - + private readonly string example8CsharpCode = @" private int clickCounter; private string? expandedTitle; private string? collapsedTitle; private string? toggledTitle; -private List
eventsItems = +private readonly List
eventsItems = [ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Id = ""general"", Name = ""General settings"", Info = ""The general settings of the application"", Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""users"", Name = ""Users"", Info = ""You are currently not an owner"", Content = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Id = ""advanced"", Name = ""Advanced settings"", Info = ""Filtering has been entirely disabled"", Content = BodyFor(""In the beginning, there is silence, ..."") }, ]; protected override void OnInitialized() @@ -250,104 +186,47 @@ protected override void OnInitialized() item.Clicked = _ => { clickCounter++; StateHasChanged(); }; } } +" + basicItemsCsharpCode; -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; + private readonly string example9RazorCode = @" + + - private readonly string example5RazorCode = @" - accordionListRef.ExpandAll()"">Expand all - accordionListRef.CollapseAll()"">Collapse all + -"; - private readonly string example5CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} +
Last request: @togglingReport
"; + private readonly string example9CsharpCode = @" +private bool lockToggling; +private bool slowToggling; +private string? togglingReport; -private readonly BitAccordionListNameSelectors
nameSelectors = new() +private async Task HandleOnToggling(BitAccordionListToggleArgs
args) { - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; + togglingReport = $""{args.Item.Name} is {(args.IsExpanding ? ""expanding"" : ""collapsing"")} ({args.Reason})""; -private BitAccordionList
accordionListRef = default!; - -private readonly List
basicItems = -[ - new() + // The header of this item reports itself as aria-busy for as long as the callback is awaited. + if (slowToggling) { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, -]; + await Task.Delay(1000); + } -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; + args.Cancel = lockToggling; +} +" + basicItemsCsharpCode; - private readonly string example6RazorCode = @" + private readonly string example10RazorCode = @"
Bound expanded key: @boundExpandedKey
-"; - private readonly string example6CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} + -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; +
Bound expanded keys: @string.Join("", "", boundExpandedKeys)
+"; + private readonly string example10CsharpCode = @" private string? boundExpandedKey = ""users""; +private IEnumerable boundExpandedKeys = [""general""]; private List bindingButtons => [ @@ -355,184 +234,62 @@ public class Section new() { Key = ""users"", Text = ""Users"" }, new() { Key = ""advanced"", Text = ""Advanced"" }, ]; +" + keyedItemsCsharpCode; -private readonly List
keyedItems = -[ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, -]; + private readonly string example11RazorCode = @" + accordionListRef!.ExpandAll())"">Expand all + accordionListRef!.CollapseAll())"">Collapse all + accordionListRef!.Toggle(""users""))"">Toggle Users + accordionListRef!.FocusItem(""advanced""))"">Focus Advanced -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; + - private readonly string example7RazorCode = @" - +
Expanded keys: @string.Join("", "", programmaticKeys)
"; + private readonly string example11CsharpCode = @" +private IEnumerable programmaticKeys = []; +private BitAccordionList
? accordionListRef; -"; - private readonly string example7CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} +// The same state can also be read back without a binding: +// accordionListRef.IsExpanded(""users""); accordionListRef.GetExpandedKeys(); +" + keyedItemsCsharpCode; -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; + private readonly string example12RazorCode = @" + -private readonly List
basicItems = + + +"; + private readonly string example12CsharpCode = @" +private readonly List
lazyItems = [ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Id = ""lazy-1"", Name = ""Lazy panel"", Info = ""Rendered the first time it is opened, and kept afterwards"", Content = TimestampBody() }, ]; -private readonly List
iconItems = +private readonly List
unmountItems = [ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Image = BitIconName.Settings, - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Image = BitIconName.Contact, - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Image = BitIconName.Ringer, - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Id = ""unmount-1"", Name = ""Unmounted panel"", Info = ""Rendered again on every open"", Content = TimestampBody() }, ]; -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; - - private readonly string example8RazorCode = @" - - -"; - private readonly string example8CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} +private readonly List
longItems = +[ + new() { Id = ""long-1"", Name = ""A long panel"", Info = ""Scrolls inside the item"", Content = BodyFor(""a very long text ..."") }, + new() { Id = ""long-2"", Name = ""Another long panel"", Info = ""Scrolls inside the item"", Content = BodyFor(""a very long text ..."") }, +]; -private readonly BitAccordionListNameSelectors
nameSelectors = new() +private static RenderFragment
TimestampBody() => section => builder => { - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, + builder.AddContent(0, $""This panel was rendered at {DateTime.Now:HH:mm:ss.fff}""); }; +" + sectionCsharpCode; -private readonly List
basicItems = -[ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, -]; + private readonly string example13RazorCode = @" + -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; +"; + private readonly string example13CsharpCode = basicItemsCsharpCode; - private readonly string example9RazorCode = @" - + private readonly string example14RazorCode = @" + @item.Name @@ -540,174 +297,149 @@ public class Section @item.Info + + + + + + + + + "; - private readonly string example9CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} + private readonly string example14CsharpCode = templateItemsCsharpCode + basicItemsCsharpCode; -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; + private readonly string example15RazorCode = @" +"; + private readonly string example15CsharpCode = basicItemsCsharpCode; -private readonly List
basicItems = -[ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, -]; + private readonly string example16RazorCode = @" + -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; +"; + private readonly string example16CsharpCode = basicItemsCsharpCode; - private readonly string example10RazorCode = @" - - + private readonly string example17RazorCode = @" + +"; + private readonly string example17CsharpCode = basicItemsCsharpCode; + + private readonly string example18RazorCode = @" + + + + + There is nothing to show here yet. + +"; + private readonly string example18CsharpCode = @" +private bool showEmptyItems; + +private readonly List
noItems = []; +" + basicItemsCsharpCode; + + private readonly string example19RazorCode = @" +
+ +
"; + private readonly string example19CsharpCode = @" +private readonly List
scrollItems = +[ + new() { Id = ""scroll-1"", Name = ""First section"", Info = ""Opens without moving anything"", Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""scroll-2"", Name = ""Second section"", Info = ""Sits just below the fold"", Content = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Id = ""scroll-3"", Name = ""Third section"", Info = ""Is scrolled to when it opens"", Content = BodyFor(""In the beginning, there is silence, ..."") }, + new() { Id = ""scroll-4"", Name = ""Fourth section"", Info = ""Is scrolled to when it opens"", Content = BodyFor(""Once upon a time, ..."") }, +]; +" + sectionCsharpCode; + + private readonly string example20RazorCode = @" + Background=""BitColorKind.Secondary"" + Border=""BitColorKind.Tertiary"" /> "; - private readonly string example10CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} + Background=""BitColorKind.Tertiary"" + Border=""BitColorKind.Transparent"" />"; + private readonly string example20CsharpCode = basicItemsCsharpCode; -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; + private readonly string example21RazorCode = @" + + -private readonly List
basicItems = + + +"; + private readonly string example21CsharpCode = @" +private readonly List
faItems = [ - new() - { - Id = ""general"", - Name = ""General settings"", - Info = ""The general settings of the application"", - Content = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Id = ""users"", - Name = ""Users"", - Info = ""You are currently not an owner"", - Content = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Id = ""advanced"", - Name = ""Advanced settings"", - Info = ""Filtering has been entirely disabled"", - Content = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Id = ""general"", Name = ""General settings"", Info = ""The general settings of the application"", CustomGlyph = BitIconInfo.Fa(""solid gear""), Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""users"", Name = ""Users"", Info = ""You are currently not an owner"", CustomGlyph = BitIconInfo.Fa(""solid user""), Content = BodyFor(""Every story starts with a blank canvas, ..."") }, ]; -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; +private readonly List
biItems = +[ + new() { Id = ""general"", Name = ""General settings"", Info = ""The general settings of the application"", CustomGlyph = BitIconInfo.Bi(""gear""), Content = BodyFor(""Once upon a time, ..."") }, + new() { Id = ""users"", Name = ""Users"", Info = ""You are currently not an owner"", CustomGlyph = BitIconInfo.Bi(""person""), Content = BodyFor(""Every story starts with a blank canvas, ..."") }, +]; +" + sectionCsharpCode; - private readonly string example11RazorCode = @" -"; - private readonly string example11CsharpCode = @" -public class Section -{ - public string? Id { get; set; } - public string? Name { get; set; } - public string? Info { get; set; } - public bool IsEnabled { get; set; } = true; - public string? Class { get; set; } - public string? Style { get; set; } - public string? Image { get; set; } - public RenderFragment
? Content { get; set; } - public Action
? Clicked { get; set; } -} + private readonly string example22RazorCode = @" + -private readonly BitAccordionListNameSelectors
nameSelectors = new() -{ - Key = { Selector = i => i.Id }, - Title = { Selector = i => i.Name }, - Description = { Selector = i => i.Info }, - IsEnabled = { Selector = i => i.IsEnabled }, - ExpanderIconName = { Selector = i => i.Image }, - Style = { Selector = i => i.Style }, - Class = { Selector = i => i.Class }, - OnClick = { Selector = i => i.Clicked }, - Body = { Selector = i => i.Content }, -}; + + +"; + private readonly string example22CsharpCode = basicItemsCsharpCode; + + private readonly string example23RazorCode = @" + + + + + +"; + private readonly string example23CsharpCode = basicItemsCsharpCode; + + private readonly string example24RazorCode = @" +"; + private readonly string example24CsharpCode = @" private readonly List
rtlItems = [ - new() - { - Id = ""general"", - Name = ""تنظیمات عمومی"", - Info = ""تنظیمات کلی برنامه"", - Content = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") - }, - new() - { - Id = ""users"", - Name = ""کاربران"", - Info = ""شما در حال حاضر مالک نیستید"", - Content = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") - }, + new() { Id = ""general"", Name = ""تنظیمات عمومی"", Info = ""تنظیمات کلی برنامه"", Content = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") }, + new() { Id = ""users"", Name = ""کاربران"", Info = ""شما در حال حاضر مالک نیستید"", Content = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") }, ]; - -private static RenderFragment
BodyFor(string? text) => section => builder => builder.AddContent(0, text);"; +" + sectionCsharpCode; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor index 2c4b9c143d..b59655847a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor @@ -1,17 +1,42 @@ - -
The items are provided as a list of BitAccordionListItem objects. By default it works in single-expand mode.
+ +
+ The items are provided as a list of BitAccordionListItem objects, each carrying the + Title and Description of its header and the Body of its panel. The + list renders one BitAccordion per item and owns their + expand/collapse state, so nothing has to be wired up by hand. By default it works in single-expand mode: + opening one panel closes the one that was open. +

-
Enable the Multiple parameter to allow more than one item to be expanded at the same time.
+
+ Multiple lets more than one item stay open at the same time; each header then toggles only its + own panel. MaxExpanded puts a ceiling on how many of them may be open at once: opening one + more closes the panel that has been open the longest, so a click always opens the panel it was aimed at + rather than being turned away by a header that answers nothing. +

- +
+
Multiple:
+ +
+

+
+
At most two panels open at once:
+ +
-
Use DefaultExpandedKey (single) or DefaultExpandedKeys (multiple) to set the initially expanded items.
+
+ DefaultExpandedKey (single-expand) and DefaultExpandedKeys (multiple-expand) say + which items start open, leaving the list in charge of everything that happens afterwards. An item can also + open itself through its own IsExpanded, which the defaults take precedence over. A + Key that is not given is generated from the position of the item, so the keys below are + spelled out to keep them readable. +


Single (DefaultExpandedKey):
@@ -24,8 +49,97 @@
- -
Handle the OnExpand, OnCollapse and OnToggle events of the component.
+ +
+ Collapsible="false" keeps one panel open at all times: the header of the last expanded item + stops answering the pointer and the keyboard and reports itself as aria-disabled, which is + the state the WAI-ARIA authoring practices ask for a header whose panel cannot be closed. Another item can + still take its place, and it is only the header that is closed off - Collapse, + Toggle and CollapseAll still drive the list. +
+
+ +
+ + +
+ ExpanderIconName replaces the chevron of every item, and each item can override it with one of + its own. ExpandedExpanderIconName swaps the icon while the panel is open - which reports the + state on its own, so the rotation is dropped along with it - while NoExpanderRotation keeps + the icon still without swapping it and HideExpanderIcon removes it altogether (an item can opt + back in with HideExpanderIcon="false"). ExpanderIconPosition moves the expander to + the start of the header, and each item can carry an IconName of its own, drawn ahead of the + title. +
+

+
+
Per-item expander icon and leading icon:
+ +
+

+
+
Swapped while expanded (ExpandedExpanderIconName):
+ +
+

+
+
At the start of the header (ExpanderIconPosition):
+ +
+

+
+
Without an expander icon (HideExpanderIcon):
+ +
+
+ + +
+ ActionsTemplate renders content beside the header of every item, outside of the toggle button + and of the heading it sits in, so it can hold interactive elements of its own - a menu, a delete button, a + switch - without nesting a control inside another one. An item can also carry its own Actions, + which takes precedence over the template. +
+
+ + + + + +
+
Last action: @actionedTitle
+
+ + +
+ An item with IsEnabled="false" is greyed out and its header leaves the tab order, while + ReadOnly is for the panel that has to stay as it is rather than the one that is turned off: + it keeps the colors of a live item and its place in the tab order, reports itself as + aria-disabled, and still raises OnItemClick so the page can say why nothing + moved. Both can be set for the whole list or per item, and the item value wins. +
+
+ +
+
Clicks on the read-only header: @readOnlyClickCount
+
+ + +
+ OnExpand, OnCollapse and OnToggle report the item that moved - + including the one single-expand mode closes on its own to make room. +

Item click count: @clickCounter
- -
In multiple-expand mode, the ExpandAll and CollapseAll public methods can be used.
+ +
+ OnToggling runs before an item moves and can leave it where it is by setting + Cancel on its arguments. They carry the item, its key, whether it is about to expand and what + asked for the change - a click on the header, or one of the public methods. The callback is awaited, so it + can also load the content of the panel or ask for a confirmation first, and nothing else toggles the list + while it runs - the header it was asked about says as much while it waits, reporting itself as + aria-busy and taking a busy cursor, rather than going on looking like a toggle that answers + at once. +

- Expand all - Collapse all -

- + + +
+ +
+
Last request: @togglingReport
- -
Two-way bind the expanded key in single-expand mode using @@bind-ExpandedKey.
+ +
+ @@bind-ExpandedKey (single-expand) and @@bind-ExpandedKeys (multiple-expand) hand + the open state to the page: the list reports every change through them, and a value written from outside + moves the panels the same way a click does. +


Bound expanded key: @boundExpandedKey

+

+
Bound expanded keys: @string.Join(", ", boundExpandedKeys)
+
+ +
+ + +
+ Expand, Collapse and Toggle drive a single item by key, + ExpandAll (multiple-expand mode only) and CollapseAll drive all of them, and + FocusItem puts the keyboard on the header of one. IsExpanded and + GetExpandedKeys read the state back. None of them is turned away by ReadOnly or + Collapsible: what those close off is the way in from the header, not the one the app uses. +
+
+
+ + Expand all + Collapse all + Toggle Users + Focus Advanced + +
+
+ +
+
Expanded keys: @string.Join(", ", programmaticKeys)
- -
Customize the expander icon for the whole list or per item.
+ +
+ By default every panel is rendered up front and stays in the DOM. LazyContent holds the first + render of a panel back until it is opened, so a heavy panel costs nothing until it is asked for, and keeps + it afterwards - whatever state it holds survives a collapse. UnmountOnCollapse goes the other + way and drops the content again on every close, so nothing it holds keeps running behind a closed header. + MaxHeight caps a panel and lets it scroll inside the item rather than growing it; the + scrolling region takes a tab stop of its own so the keyboard can reach it. +


-
Component-level:
- +
LazyContent & UnmountOnCollapse (watch the render timestamps):
+ +


-
Per-item:
- +
MaxHeight:
+
- -
Customize the background and border color kinds of all the items.
+ +
+ TransitionDuration sets the length of the expand/collapse animation of every item in + milliseconds, overriding the duration of the theme; 0 turns it off. A reduced-motion + preference still collapses it to nothing, unless ForceAnimation opts out of that. +


-
NoBorder:
- +
No animation (0):
+


-
Background & Border:
- +
Slowed down (1500):
+ +
+
+ + +
+ HeaderTemplate replaces the whole header of every item and BodyTemplate its panel, + while TitleTemplate and ExpanderTemplate take the place of only the title and only + the expander, leaving the rest of the header as it is. Each of them receives the item as its context, and an + item that carries a template of its own takes precedence over the one of the list. +
+

+
+
HeaderTemplate & BodyTemplate:
+ + + + @item.Title + + + @item.Description + + +
+

+
+
TitleTemplate & ExpanderTemplate:
+ + + + + + + +
- -
Customize the header and body of the items using HeaderTemplate and BodyTemplate.
+ +
+ Every header is a button inside a heading, tied to its panel with aria-controls and + aria-expanded, and every panel is a region named by its own header - the structure the + WAI-ARIA accordion pattern asks for. HeadingLevel puts the headers at the right depth of the + heading outline of the page (3 by default, clamped to 1..6). Navigable, which is on by + default, adds the ArrowUp, ArrowDown, Home and End keys on top of Tab: they move the focus between the + headers without scrolling the page under it, wrap around at both ends - NoNavigationLoop + stops them there instead - and skip the disabled items, while the same keys pressed inside a panel are + left to whatever the panel holds. NoContentRegion drops the landmark role from the panels, + which the authoring practices ask for beyond about six panels that can all be open at once, and + AriaLabel names the list itself. A header that does not name itself - an icon-only + HeaderTemplate - takes a HeaderAriaLabel of its own, which names both the toggle + and the panel it opens. +

- - - - @item.Title - - - @item.Description - + +
+ + +
+ A collapsed panel is hidden outright, so a list of them prints as a column of bare headers. + ExpandOnPrint opens every panel for the print stylesheet alone - what is on screen stays + exactly where the reader left it - and lifts the cap of MaxHeight with it, since paper does + not scroll. What is not in the DOM at all cannot be printed by any of this: the panels of a + LazyContent list that were never opened, and every closed panel of a list using + UnmountOnCollapse, still print as bare headers. +
+
+
Leave these closed and open the print preview of the browser (Ctrl+P): only the first list carries its text onto the page.
+
+
+
Printed with their content:
+ +
+

+
+
Printed as bare headers:
+ +
+
+ + +
+ Gap sets the space in pixels between the items - 0 stacks them into one block - + and NoBorder drops the outline of every item and fills it with the secondary background + instead, for a flat list that leans on the surface it sits on. +
+

+
+
Gap:
+ +
+

+
+
NoBorder:
+ +
+
+ + +
+ EmptyContent is what the list draws in place of the items while it has none, so a collection + that is still loading, or one a filter has emptied, says so instead of leaving a blank where a list is + meant to be. +
+
+ +
+ + + There is nothing to show here yet. +
- -
Customize the appearance using the Style, Class, Styles, and Classes parameters.
+ +
+ ScrollIntoViewOnExpand brings the item that has just been expanded into view, so a panel + opened at the bottom of the window is not left off the screen it was opened on. The item is moved as + little as the browser can move it, so nothing happens to one that is already in view, and the scroll is + instant rather than smooth for a reader who has asked for less motion. It covers the ways a single panel opens: + a click on its header, Expand, Toggle and the bound keys - ExpandAll scrolls to nothing, since there is no one panel it opened. +
+
+
Open the last panels of this box: the list follows them without the box being scrolled by hand.
+
+
+ +
+
+ + +
+ Background and Border take a BitColorKind and repaint every item of + the list, down to the shades its header takes under the pointer. +
+
+ +
+ +
+ + +
+ ExpanderIcon, ExpandedExpanderIcon and the Icon of an item take a + BitIconInfo rather than the name of a built-in icon, so the list can be dressed in the icons + of any library that draws them from CSS classes. BitIconInfo.Css takes the classes as they + are, while BitIconInfo.Fa and BitIconInfo.Bi spell out the prefixes of + FontAwesome and Bootstrap Icons. +
+
+ + +
+
FontAwesome:
+ +
+

+
+
Bootstrap:
+ +
+
+ + +
+ Size drives the padding of the headers and of the panels and the type scale of every item - + the title, the description, the icons and the text of the panel - so a list can be tuned to how much room + it is given. +
+

+
+
Small:
+ +
+

+
+
Medium:
+ +
+

+
+
Large:
+ +
+
+ + +
+ Style and Class dress the root element of the list, while Styles and + Classes reach each part of every item on its own - the header, the title, the expander icon, + the panel and everything between them. Two of their slots are states rather than parts: + ItemExpanded is added to an item only while its panel is open and ItemExpandedIcon + only to its expander icon while it is. An item can also carry a Style and a Class + of its own. +


Component's style & class:
@@ -123,12 +476,15 @@ Styles="@(new() { ItemTitle = "color: tomato;", ItemHeader = "background-color: var(--bit-clr-bg-sec);" })" /> + Classes="@(new() { ItemTitle = "custom-title", ItemExpanded = "custom-expanded" })" />
- -
Use BitAccordionList in right-to-left (RTL).
+ +
+ Dir="BitDir.Rtl" mirrors the whole list: the expander icon moves to the other end of the + header and the padding follows the writing direction with it. +

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.cs index 01e58d0463..071f94e9d8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.cs @@ -1,33 +1,88 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; public partial class _BitAccordionListItemDemo { private int clickCounter; + private int readOnlyClickCount; + private bool lockToggling; + private bool slowToggling; + private bool showEmptyItems; private string? expandedTitle; private string? collapsedTitle; private string? toggledTitle; + private string? actionedTitle; + private string? togglingReport; private string? boundExpandedKey = "users"; - private BitAccordionList accordionListRef = default!; + private IEnumerable boundExpandedKeys = ["general"]; + private IEnumerable programmaticKeys = []; + private BitAccordionList? accordionListRef; + + private const string Story1 = "Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."; + private const string Story2 = "Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."; + private const string Story3 = "In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."; private readonly List basicItems = [ - new() { Title = "General settings", Description = "The general settings of the application", Body = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Title = "Users", Description = "You are currently not an owner", Body = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Title = "Advanced settings", Description = "Filtering has been entirely disabled", Body = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, + new() { Title = "General settings", Description = "The general settings of the application", Body = BodyFor(Story1) }, + new() { Title = "Users", Description = "You are currently not an owner", Body = BodyFor(Story2) }, + new() { Title = "Advanced settings", Description = "Filtering has been entirely disabled", Body = BodyFor(Story3) }, ]; private readonly List keyedItems = [ - new() { Key = "general", Title = "General settings", Description = "The general settings of the application", Body = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Key = "users", Title = "Users", Description = "You are currently not an owner", Body = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Key = "advanced", Title = "Advanced settings", Description = "Filtering has been entirely disabled", Body = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, + new() { Key = "general", Title = "General settings", Description = "The general settings of the application", Body = BodyFor(Story1) }, + new() { Key = "users", Title = "Users", Description = "You are currently not an owner", Body = BodyFor(Story2) }, + new() { Key = "advanced", Title = "Advanced settings", Description = "Filtering has been entirely disabled", Body = BodyFor(Story3) }, ]; private readonly List iconItems = [ - new() { Title = "General settings", Description = "The general settings of the application", ExpanderIconName = BitIconName.Settings, Body = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Title = "Users", Description = "You are currently not an owner", ExpanderIconName = BitIconName.Contact, Body = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Title = "Advanced settings", Description = "Filtering has been entirely disabled", ExpanderIconName = BitIconName.Ringer, Body = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, + new() { Title = "General settings", Description = "The general settings of the application", IconName = BitIconName.Settings, ExpanderIconName = BitIconName.ChevronDownSmall, Body = BodyFor(Story1) }, + new() { Title = "Users", Description = "You are currently not an owner", IconName = BitIconName.Contact, ExpanderIconName = BitIconName.ChevronDownSmall, Body = BodyFor(Story2) }, + new() { Title = "Advanced settings", Description = "Filtering has been entirely disabled", IconName = BitIconName.Ringer, Body = BodyFor(Story3) }, + ]; + + // No Body: an item that carries one of its own takes precedence over the list's BodyTemplate. + private readonly List templateItems = + [ + new() { Title = "General settings", Description = "The general settings of the application" }, + new() { Title = "Users", Description = "You are currently not an owner" }, + new() { Title = "Advanced settings", Description = "Filtering has been entirely disabled" }, + ]; + + private readonly List stateItems = + [ + new() { Key = "normal", Title = "General settings", Description = "A live item", Body = BodyFor(Story1) }, + new() { Key = "disabled", Title = "Users", Description = "Turned off altogether", IsEnabled = false, Body = BodyFor(Story2) }, + new() { Key = "locked", Title = "Advanced settings", Description = "Open on purpose and staying that way", ReadOnly = true, Body = BodyFor(Story3) }, + ]; + + private readonly List lazyItems = + [ + new() { Key = "lazy-1", Title = "Lazy panel", Description = "Rendered the first time it is opened, and kept afterwards", Body = TimestampBody() }, + ]; + + private readonly List unmountItems = + [ + new() { Key = "unmount-1", Title = "Unmounted panel", Description = "Rendered again on every open", Body = TimestampBody() }, + ]; + + private readonly List longItems = + [ + new() { Key = "long-1", Title = "A long panel", Description = "Scrolls inside the item", Body = BodyFor($"{Story1} {Story2} {Story3} {Story1} {Story2} {Story3}") }, + new() { Key = "long-2", Title = "Another long panel", Description = "Scrolls inside the item", Body = BodyFor($"{Story3} {Story2} {Story1} {Story3} {Story2} {Story1}") }, + ]; + + private readonly List faItems = + [ + new() { Title = "General settings", Description = "The general settings of the application", Icon = BitIconInfo.Fa("solid gear"), Body = BodyFor(Story1) }, + new() { Title = "Users", Description = "You are currently not an owner", Icon = BitIconInfo.Fa("solid user"), Body = BodyFor(Story2) }, + ]; + + private readonly List biItems = + [ + new() { Title = "General settings", Description = "The general settings of the application", Icon = BitIconInfo.Bi("gear"), Body = BodyFor(Story1) }, + new() { Title = "Users", Description = "You are currently not an owner", Icon = BitIconInfo.Bi("person"), Body = BodyFor(Story2) }, ]; private readonly List rtlItems = @@ -36,6 +91,23 @@ public partial class _BitAccordionListItemDemo new() { Title = "کاربران", Description = "شما در حال حاضر مالک نیستید", Body = BodyFor("لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است.") }, ]; + private readonly List noItems = []; + + private readonly List scrollItems = + [ + new() { Key = "scroll-1", Title = "First section", Description = "Opens without moving anything", Body = BodyFor(Story1) }, + new() { Key = "scroll-2", Title = "Second section", Description = "Sits just below the fold", Body = BodyFor($"{Story2} {Story3}") }, + new() { Key = "scroll-3", Title = "Third section", Description = "Is scrolled to when it opens", Body = BodyFor($"{Story3} {Story1}") }, + new() { Key = "scroll-4", Title = "Fourth section", Description = "Is scrolled to when it opens", Body = BodyFor($"{Story1} {Story2}") }, + ]; + + private readonly List eventsItems = + [ + new() { Title = "General settings", Description = "The general settings of the application", Body = BodyFor(Story1) }, + new() { Title = "Users", Description = "You are currently not an owner", Body = BodyFor(Story2) }, + new() { Title = "Advanced settings", Description = "Filtering has been entirely disabled", Body = BodyFor(Story3) }, + ]; + private List bindingButtons => [ new() { Key = "general", Text = "General" }, @@ -43,13 +115,6 @@ public partial class _BitAccordionListItemDemo new() { Key = "advanced", Text = "Advanced" }, ]; - private List eventsItems = - [ - new() { Title = "General settings", Description = "The general settings of the application", Body = BodyFor("Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams.") }, - new() { Title = "Users", Description = "You are currently not an owner", Body = BodyFor("Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams.") }, - new() { Title = "Advanced settings", Description = "Filtering has been entirely disabled", Body = BodyFor("In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.") }, - ]; - protected override void OnInitialized() { foreach (var item in eventsItems) @@ -58,8 +123,26 @@ protected override void OnInitialized() } } + private async Task HandleOnToggling(BitAccordionListToggleArgs args) + { + togglingReport = $"{args.Item.Title} is {(args.IsExpanding ? "expanding" : "collapsing")} ({args.Reason})"; + + // The header of this item reports itself as aria-busy for as long as the callback is awaited. + if (slowToggling) + { + await Task.Delay(1000); + } + + args.Cancel = lockToggling; + } + private static RenderFragment BodyFor(string? text) => item => builder => { builder.AddContent(0, text); }; + + private static RenderFragment TimestampBody() => item => builder => + { + builder.AddContent(0, $"This panel was rendered at {DateTime.Now:HH:mm:ss.fff}"); + }; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.samples.cs index 5a77efa3d5..dc7361438d 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListItemDemo.razor.samples.cs @@ -1,94 +1,141 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; public partial class _BitAccordionListItemDemo { - private readonly string example1RazorCode = @" -"; - private readonly string example1CsharpCode = @" + private const string basicItemsCsharpCode = @" private readonly List basicItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Title = ""General settings"", Description = ""The general settings of the application"", Body = BodyFor(""Once upon a time, ..."") }, + new() { Title = ""Users"", Description = ""You are currently not an owner"", Body = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Title = ""Advanced settings"", Description = ""Filtering has been entirely disabled"", Body = BodyFor(""In the beginning, there is silence, ..."") }, ]; private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; - private readonly string example2RazorCode = @" -"; - private readonly string example2CsharpCode = @" -private readonly List basicItems = + // No Body: an item that carries one of its own takes precedence over the list's BodyTemplate. + private const string templateItemsCsharpCode = @" +private readonly List templateItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Title = ""General settings"", Description = ""The general settings of the application"" }, + new() { Title = ""Users"", Description = ""You are currently not an owner"" }, + new() { Title = ""Advanced settings"", Description = ""Filtering has been entirely disabled"" }, +]; +"; + + private const string keyedItemsCsharpCode = @" +private readonly List keyedItems = +[ + new() { Key = ""general"", Title = ""General settings"", Description = ""The general settings of the application"", Body = BodyFor(""Once upon a time, ..."") }, + new() { Key = ""users"", Title = ""Users"", Description = ""You are currently not an owner"", Body = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Key = ""advanced"", Title = ""Advanced settings"", Description = ""Filtering has been entirely disabled"", Body = BodyFor(""In the beginning, there is silence, ..."") }, ]; private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; + + private readonly string example1RazorCode = @" +"; + private readonly string example1CsharpCode = basicItemsCsharpCode; + + private readonly string example2RazorCode = @" + + +"; + private readonly string example2CsharpCode = basicItemsCsharpCode; + private readonly string example3RazorCode = @" "; - private readonly string example3CsharpCode = @" -private readonly List keyedItems = + private readonly string example3CsharpCode = keyedItemsCsharpCode; + + private readonly string example4RazorCode = @" +"; + private readonly string example4CsharpCode = keyedItemsCsharpCode; + + private readonly string example5RazorCode = @" + + + + + + +"; + private readonly string example5CsharpCode = @" +private readonly List iconItems = [ new() { - Key = ""general"", Title = ""General settings"", Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") + IconName = BitIconName.Settings, + ExpanderIconName = BitIconName.ChevronDownSmall, + Body = BodyFor(""Once upon a time, ..."") }, new() { - Key = ""users"", Title = ""Users"", Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") + IconName = BitIconName.Contact, + ExpanderIconName = BitIconName.ChevronDownSmall, + Body = BodyFor(""Every story starts with a blank canvas, ..."") }, new() { - Key = ""advanced"", Title = ""Advanced settings"", Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") + IconName = BitIconName.Ringer, + Body = BodyFor(""In the beginning, there is silence, ..."") }, ]; +" + basicItemsCsharpCode; + + private readonly string example6RazorCode = @" + + + actionedTitle = item.Title"" /> + + + +
Last action: @actionedTitle
"; + private readonly string example6CsharpCode = @" +private string? actionedTitle; + +// An item can also carry its own actions, which take precedence over the ActionsTemplate: +// new BitAccordionListItem { Title = ""Users"", Actions = item => @ } +" + basicItemsCsharpCode; + + private readonly string example7RazorCode = @" + { if (item.ReadOnly is true) readOnlyClickCount++; }"" /> + +
Clicks on the read-only header: @readOnlyClickCount
"; + private readonly string example7CsharpCode = @" +private int readOnlyClickCount; + +private readonly List stateItems = +[ + new() { Key = ""normal"", Title = ""General settings"", Description = ""A live item"", Body = BodyFor(""Once upon a time, ..."") }, + new() { Key = ""disabled"", Title = ""Users"", Description = ""Turned off altogether"", IsEnabled = false, Body = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Key = ""locked"", Title = ""Advanced settings"", Description = ""Open on purpose and staying that way"", ReadOnly = true, Body = BodyFor(""In the beginning, there is silence, ..."") }, +]; private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; - private readonly string example4RazorCode = @" + private readonly string example8RazorCode = @" expandedTitle = item.Title"" @@ -101,54 +148,17 @@ public partial class _BitAccordionListItemDemo
Item click count: @clickCounter
"; - private readonly string example4CsharpCode = @" + private readonly string example8CsharpCode = @" private int clickCounter; private string? expandedTitle; private string? collapsedTitle; private string? toggledTitle; -private readonly List basicItems = +private readonly List eventsItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, -]; - -private List eventsItems = -[ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Title = ""General settings"", Description = ""The general settings of the application"", Body = BodyFor(""Once upon a time, ..."") }, + new() { Title = ""Users"", Description = ""You are currently not an owner"", Body = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Title = ""Advanced settings"", Description = ""Filtering has been entirely disabled"", Body = BodyFor(""In the beginning, there is silence, ..."") }, ]; protected override void OnInitialized() @@ -161,46 +171,45 @@ protected override void OnInitialized() private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; - private readonly string example5RazorCode = @" - accordionListRef.ExpandAll()"">Expand all - accordionListRef.CollapseAll()"">Collapse all + private readonly string example9RazorCode = @" + + -"; - private readonly string example5CsharpCode = @" -private BitAccordionList accordionListRef = default!; + -private readonly List basicItems = -[ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() +
Last request: @togglingReport
"; + private readonly string example9CsharpCode = @" +private bool lockToggling; +private bool slowToggling; +private string? togglingReport; + +private async Task HandleOnToggling(BitAccordionListToggleArgs args) +{ + togglingReport = $""{args.Item.Title} is {(args.IsExpanding ? ""expanding"" : ""collapsing"")} ({args.Reason})""; + + // The header of this item reports itself as aria-busy for as long as the callback is awaited. + if (slowToggling) { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, -]; + await Task.Delay(1000); + } -private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; + args.Cancel = lockToggling; +} +" + basicItemsCsharpCode; - private readonly string example6RazorCode = @" + private readonly string example10RazorCode = @"
Bound expanded key: @boundExpandedKey
-"; - private readonly string example6CsharpCode = @" + + +
Bound expanded keys: @string.Join("", "", boundExpandedKeys)
+ +"; + private readonly string example10CsharpCode = @" private string? boundExpandedKey = ""users""; +private IEnumerable boundExpandedKeys = [""general""]; private List bindingButtons => [ @@ -208,122 +217,63 @@ protected override void OnInitialized() new() { Key = ""users"", Text = ""Users"" }, new() { Key = ""advanced"", Text = ""Advanced"" }, ]; +" + keyedItemsCsharpCode; -private readonly List keyedItems = -[ - new() - { - Key = ""general"", - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Key = ""users"", - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Key = ""advanced"", - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, -]; + private readonly string example11RazorCode = @" + accordionListRef!.ExpandAll())"">Expand all + accordionListRef!.CollapseAll())"">Collapse all + accordionListRef!.Toggle(""users""))"">Toggle Users + accordionListRef!.FocusItem(""advanced""))"">Focus Advanced -private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; + - private readonly string example7RazorCode = @" - +
Expanded keys: @string.Join("", "", programmaticKeys)
"; + private readonly string example11CsharpCode = @" +private IEnumerable programmaticKeys = []; +private BitAccordionList? accordionListRef; -"; - private readonly string example7CsharpCode = @" -private readonly List basicItems = +// The same state can also be read back without a binding: +// accordionListRef.IsExpanded(""users""); accordionListRef.GetExpandedKeys(); +" + keyedItemsCsharpCode; + + private readonly string example12RazorCode = @" + + + + +"; + private readonly string example12CsharpCode = @" +private readonly List lazyItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Key = ""lazy-1"", Title = ""Lazy panel"", Description = ""Rendered the first time it is opened, and kept afterwards"", Body = TimestampBody() }, ]; -private readonly List iconItems = +private readonly List unmountItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - ExpanderIconName = BitIconName.Settings, - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - ExpanderIconName = BitIconName.Contact, - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - ExpanderIconName = BitIconName.Ringer, - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Key = ""unmount-1"", Title = ""Unmounted panel"", Description = ""Rendered again on every open"", Body = TimestampBody() }, ]; -private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; - - private readonly string example8RazorCode = @" - - -"; - private readonly string example8CsharpCode = @" -private readonly List basicItems = +private readonly List longItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Key = ""long-1"", Title = ""A long panel"", Description = ""Scrolls inside the item"", Body = BodyFor(""a very long text ..."") }, + new() { Key = ""long-2"", Title = ""Another long panel"", Description = ""Scrolls inside the item"", Body = BodyFor(""a very long text ..."") }, ]; +private static RenderFragment TimestampBody() => item => builder => +{ + builder.AddContent(0, $""This panel was rendered at {DateTime.Now:HH:mm:ss.fff}""); +}; + private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; - private readonly string example9RazorCode = @" - + private readonly string example13RazorCode = @" + + +"; + private readonly string example13CsharpCode = basicItemsCsharpCode; + + private readonly string example14RazorCode = @" + @item.Title @@ -331,84 +281,146 @@ protected override void OnInitialized() @item.Description + + + + + + + + + "; - private readonly string example9CsharpCode = @" -private readonly List basicItems = + private readonly string example14CsharpCode = templateItemsCsharpCode + basicItemsCsharpCode; + + private readonly string example15RazorCode = @" +"; + private readonly string example15CsharpCode = basicItemsCsharpCode; + + private readonly string example16RazorCode = @" + + +"; + private readonly string example16CsharpCode = basicItemsCsharpCode; + + private readonly string example17RazorCode = @" + + +"; + private readonly string example17CsharpCode = basicItemsCsharpCode; + + private readonly string example18RazorCode = @" + + + + + There is nothing to show here yet. + +"; + private readonly string example18CsharpCode = @" +private bool showEmptyItems; + +private readonly List noItems = []; +" + basicItemsCsharpCode; + + private readonly string example19RazorCode = @" +
+ +
"; + private readonly string example19CsharpCode = @" +private readonly List scrollItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Key = ""scroll-1"", Title = ""First section"", Description = ""Opens without moving anything"", Body = BodyFor(""Once upon a time, ..."") }, + new() { Key = ""scroll-2"", Title = ""Second section"", Description = ""Sits just below the fold"", Body = BodyFor(""Every story starts with a blank canvas, ..."") }, + new() { Key = ""scroll-3"", Title = ""Third section"", Description = ""Is scrolled to when it opens"", Body = BodyFor(""In the beginning, there is silence, ..."") }, + new() { Key = ""scroll-4"", Title = ""Fourth section"", Description = ""Is scrolled to when it opens"", Body = BodyFor(""Once upon a time, ..."") }, ]; private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; - private readonly string example10RazorCode = @" - - - + private readonly string example20RazorCode = @" + Background=""BitColorKind.Secondary"" + Border=""BitColorKind.Tertiary"" /> + "; - private readonly string example10CsharpCode = @" -private readonly List basicItems = + Background=""BitColorKind.Tertiary"" + Border=""BitColorKind.Transparent"" />"; + private readonly string example20CsharpCode = basicItemsCsharpCode; + + private readonly string example21RazorCode = @" + + + + + +"; + private readonly string example21CsharpCode = @" +private readonly List faItems = [ - new() - { - Title = ""General settings"", - Description = ""The general settings of the application"", - Body = BodyFor(""Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams."") - }, - new() - { - Title = ""Users"", - Description = ""You are currently not an owner"", - Body = BodyFor(""Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams."") - }, - new() - { - Title = ""Advanced settings"", - Description = ""Filtering has been entirely disabled"", - Body = BodyFor(""In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken."") - }, + new() { Title = ""General settings"", Description = ""The general settings of the application"", Icon = BitIconInfo.Fa(""solid gear""), Body = BodyFor(""Once upon a time, ..."") }, + new() { Title = ""Users"", Description = ""You are currently not an owner"", Icon = BitIconInfo.Fa(""solid user""), Body = BodyFor(""Every story starts with a blank canvas, ..."") }, +]; + +private readonly List biItems = +[ + new() { Title = ""General settings"", Description = ""The general settings of the application"", Icon = BitIconInfo.Bi(""gear""), Body = BodyFor(""Once upon a time, ..."") }, + new() { Title = ""Users"", Description = ""You are currently not an owner"", Icon = BitIconInfo.Bi(""person""), Body = BodyFor(""Every story starts with a blank canvas, ..."") }, ]; private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; - private readonly string example11RazorCode = @" + private readonly string example22RazorCode = @" + + + + +"; + private readonly string example22CsharpCode = basicItemsCsharpCode; + + private readonly string example23RazorCode = @" + + + + + + +"; + private readonly string example23CsharpCode = basicItemsCsharpCode; + + private readonly string example24RazorCode = @" "; - private readonly string example11CsharpCode = @" + private readonly string example24CsharpCode = @" private readonly List rtlItems = [ - new() - { - Title = ""تنظیمات عمومی"", - Description = ""تنظیمات کلی برنامه"", - Body = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") - }, - new() - { - Title = ""کاربران"", - Description = ""شما در حال حاضر مالک نیستید"", - Body = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") - }, + new() { Title = ""تنظیمات عمومی"", Description = ""تنظیمات کلی برنامه"", Body = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") }, + new() { Title = ""کاربران"", Description = ""شما در حال حاضر مالک نیستید"", Body = BodyFor(""لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است."") }, ]; private static RenderFragment BodyFor(string? text) => item => builder => builder.AddContent(0, text);"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor index a4e6af84f0..21f2dd9385 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor @@ -1,5 +1,11 @@ - -
The items are declared using the BitAccordionListOption child components. By default it works in single-expand mode.
+ +
+ The items are declared as BitAccordionListOption child components, each carrying the + Title and Description of its header and its panel as plain child content. The + list renders one BitAccordion per option and owns their + expand/collapse state, so nothing has to be wired up by hand. By default it works in single-expand mode: + opening one panel closes the one that was open. +

@@ -15,23 +21,52 @@
-
Enable the Multiple parameter to allow more than one item to be expanded at the same time.
+
+ Multiple lets more than one option stay open at the same time; each header then toggles only + its own panel. MaxExpanded puts a ceiling on how many of them may be open at once: opening + one more closes the panel that has been open the longest, so a click always opens the panel it was aimed + at rather than being turned away by a header that answers nothing. +

- - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - - +
+
Multiple:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+

+
+
At most two panels open at once:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
-
Use DefaultExpandedKey (single) or DefaultExpandedKeys (multiple) to set the initially expanded items.
+
+ DefaultExpandedKey (single-expand) and DefaultExpandedKeys (multiple-expand) say + which options start open, leaving the list in charge of everything that happens afterwards. An option can + also open itself with its own IsExpanded, which the defaults take precedence over. A + Key that is not given is generated by the list, unique among the options, so the keys below + are spelled out to keep them readable. +


Single (DefaultExpandedKey):
@@ -64,13 +99,169 @@
- -
Handle the OnExpand, OnCollapse and OnToggle events of the component.
+ +
+ Collapsible="false" keeps one panel open at all times: the header of the last expanded option + stops answering the pointer and the keyboard and reports itself as aria-disabled, which is + the state the WAI-ARIA authoring practices ask for a header whose panel cannot be closed. Another option + can still take its place, and it is only the header that is closed off - Collapse, + Toggle and CollapseAll still drive the list. +
+
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+ + +
+ ExpanderIconName replaces the chevron of every option, and each option can override it with + one of its own. ExpandedExpanderIconName swaps the icon while the panel is open - which + reports the state on its own, so the rotation is dropped along with it - while + NoExpanderRotation keeps the icon still without swapping it and HideExpanderIcon + removes it altogether (an option can opt back in with HideExpanderIcon="false"). + ExpanderIconPosition moves the expander to the start of the header, and each option can carry + an IconName of its own, drawn ahead of the title. +
+

+
+
Per-option expander icon and leading icon:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+

+
+
Swapped while expanded (ExpandedExpanderIconName):
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+

+
+
At the start of the header (ExpanderIconPosition):
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+

+
+
Without an expander icon (HideExpanderIcon):
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+
+ + +
+ ActionsTemplate renders content beside the header of every option, outside of the toggle + button and of the heading it sits in, so it can hold interactive elements of its own - a menu, a delete + button, a switch - without nesting a control inside another one. An option can also carry its own + Actions, which takes precedence over the template. +
+
+ + + + + + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + + +
+
Last action: @actionedTitle
+
+ + +
+ An option with IsEnabled="false" is greyed out and its header leaves the tab order, while + ReadOnly is for the panel that has to stay as it is rather than the one that is turned off: + it keeps the colors of a live option and its place in the tab order, reports itself as + aria-disabled, and still raises OnItemClick so the page can say why nothing + moved. Both can be set for the whole list or per option, and the option value wins. +
+
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+
Clicks on the read-only header: @readOnlyClickCount
+
+ + +
+ OnExpand, OnCollapse and OnToggle report the option that moved - + including the one single-expand mode closes on its own to make room. +

+ OnExpand="(BitAccordionListOption option) => expandedTitle = option.Title" + OnCollapse="(BitAccordionListOption option) => collapsedTitle = option.Title" + OnToggle="(BitAccordionListOption option) => toggledTitle = option.Title"> Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. @@ -86,7 +277,7 @@
Last collapsed: @collapsedTitle
Last toggled: @toggledTitle


-
Each item can also have its own click handler.
+
Each option can also have its own click handler.

@@ -102,27 +293,41 @@
Item click count: @clickCounter
- -
In multiple-expand mode, the ExpandAll and CollapseAll public methods can be used.
+ +
+ OnToggling runs before an option moves and can leave it where it is by setting + Cancel on its arguments. They carry the option, its key, whether it is about to expand and + what asked for the change - a click on the header, or one of the public methods. The callback is awaited, + so it can also load the content of the panel or ask for a confirmation first, and nothing else toggles the + list while it runs - the header it was asked about says as much while it waits, reporting itself as + aria-busy and taking a busy cursor, rather than going on looking like a toggle that answers + at once. +

- Expand all - Collapse all -

- - + + +
+ + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. +
+
Last request: @togglingReport
- -
Two-way bind the expanded key in single-expand mode using @@bind-ExpandedKey.
+ +
+ @@bind-ExpandedKey (single-expand) and @@bind-ExpandedKeys (multiple-expand) hand + the open state to the page: the list reports every change through them, and a value written from outside + moves the panels the same way a click does. +


@@ -139,14 +344,112 @@ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.
+

+
Bound expanded keys: @string.Join(", ", boundExpandedKeys)
+
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+ + +
+ Expand, Collapse and Toggle drive a single option by key, + ExpandAll (multiple-expand mode only) and CollapseAll drive all of them, and + FocusItem puts the keyboard on the header of one. IsExpanded and + GetExpandedKeys read the state back. None of them is turned away by ReadOnly or + Collapsible: what those close off is the way in from the header, not the one the app uses. +
+
+
+ + Expand all + Collapse all + Toggle Users + Focus Advanced + +
+
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+
Expanded keys: @string.Join(", ", programmaticKeys)
+
+ + +
+ By default every panel is rendered up front and stays in the DOM. LazyContent holds the first + render of a panel back until it is opened, so a heavy panel costs nothing until it is asked for, and keeps + it afterwards - whatever state it holds survives a collapse. UnmountOnCollapse goes the other + way and drops the content again on every close, so nothing it holds keeps running behind a closed header. + MaxHeight caps a panel and lets it scroll inside the option rather than growing it; the + scrolling region takes a tab stop of its own so the keyboard can reach it. +
+

+
+
LazyContent & UnmountOnCollapse (watch the render timestamps):
+ + + This panel was rendered at @DateTime.Now.ToString("HH:mm:ss.fff") + + + + + This panel was rendered at @DateTime.Now.ToString("HH:mm:ss.fff") + + +
+

+
+
MaxHeight:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + +
- -
Customize the expander icon for the whole list or per item.
+ +
+ TransitionDuration sets the length of the expand/collapse animation of every option in + milliseconds, overriding the duration of the theme; 0 turns it off. A reduced-motion + preference still collapses it to nothing, unless ForceAnimation opts out of that. +


-
Component-level:
- +
No animation (0):
+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. @@ -160,27 +463,159 @@


-
Per-item:
+
Slowed down (1500):
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+
+ + +
+ HeaderTemplate replaces the whole header of every option and BodyTemplate its + panel, while TitleTemplate and ExpanderTemplate take the place of only the title + and only the expander, leaving the rest of the header as it is. Each of them receives the option as its + context, and an option that carries a template of its own - or, for the panel, plain child content - takes + precedence over the one of the list. +
+

+
+
HeaderTemplate & BodyTemplate:
+ + + + @option.Title + + + @option.Description + + + + + + + +
+

+
+
TitleTemplate & ExpanderTemplate:
- + + + + + + + + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + + +
+
+ + +
+ Every header is a button inside a heading, tied to its panel with aria-controls and + aria-expanded, and every panel is a region named by its own header - the structure the + WAI-ARIA accordion pattern asks for. HeadingLevel puts the headers at the right depth of the + heading outline of the page (3 by default, clamped to 1..6). Navigable, which is on by + default, adds the ArrowUp, ArrowDown, Home and End keys on top of Tab: they move the focus between the + headers without scrolling the page under it, wrap around at both ends - NoNavigationLoop + stops them there instead - and skip the disabled options, while the same keys pressed inside a + panel are left to whatever the panel holds. NoContentRegion drops the landmark role from the + panels, which the authoring practices ask for beyond about six panels that can all be open at once, and + AriaLabel names the list itself. A header that does not name itself - an icon-only + HeaderTemplate - takes a HeaderAriaLabel of its own, which names both the toggle + and the panel it opens. +
+
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+ + +
+ A collapsed panel is hidden outright, so a list of them prints as a column of bare headers. + ExpandOnPrint opens every panel for the print stylesheet alone - what is on screen stays + exactly where the reader left it - and lifts the cap of MaxHeight with it, since paper does + not scroll. What is not in the DOM at all cannot be printed by any of this: the panels of a + LazyContent list that were never opened, and every closed panel of a list using + UnmountOnCollapse, still print as bare headers. +
+
+
Leave these closed and open the print preview of the browser (Ctrl+P): only the first list carries its text onto the page.
+
+
+
Printed with their content:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+

+
+
Printed as bare headers:
+ + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.
- -
Customize the background and border color kinds of all the items.
+ +
+ Gap sets the space in pixels between the options - 0 stacks them into one block - + and NoBorder drops the outline of every option and fills it with the secondary background + instead, for a flat list that leans on the surface it sits on. +


-
NoBorder:
- +
Gap:
+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. @@ -194,8 +629,8 @@


-
Background & Border:
- +
NoBorder:
+ Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. @@ -209,42 +644,194 @@
- -
Customize the header and body of the items using HeaderTemplate and Body.
+ +
+ EmptyContent is what the list draws in place of the items while it has none, so a set of + options that is still loading, or one a filter has emptied, says so instead of leaving a blank where a + list is meant to be. +

- - - - - @option.Title - - - The general settings of the application - + +
+ + + There is nothing to show here yet. + + + @if (showEmptyItems) + { + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + } + + +
+ + +
+ ScrollIntoViewOnExpand brings the option that has just been expanded into view, so a panel + opened at the bottom of the window is not left off the screen it was opened on. The item is moved as + little as the browser can move it, so nothing happens to one that is already in view, and the scroll is + instant rather than smooth for a reader who has asked for less motion. It covers the ways a single panel opens: + a click on its header, Expand, Toggle and the bound keys - ExpandAll scrolls to nothing, since there is no one panel it opened. +
+
+
Open the last panels of this box: the list follows them without the box being scrolled by hand.
+
+
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + +
+
+ + +
+ Background and Border take a BitColorKind and repaint every option + of the list, down to the shades its header takes under the pointer. +
+
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - - @option.Title - - - You are currently not an owner - + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - - @option.Title - - - Filtering has been entirely disabled - + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken.
- -
Customize the appearance using the Style, Class, Styles, and Classes parameters.
+ +
+ ExpanderIcon, ExpandedExpanderIcon and the Icon of an option take a + BitIconInfo rather than the name of a built-in icon, so the list can be dressed in the icons + of any library that draws them from CSS classes. BitIconInfo.Css takes the classes as they + are, while BitIconInfo.Fa and BitIconInfo.Bi spell out the prefixes of + FontAwesome and Bootstrap Icons. +
+
+ + +
+
FontAwesome:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + +
+

+
+
Bootstrap:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + +
+
+ + +
+ Size drives the padding of the headers and of the panels and the type scale of every option - + the title, the description, the icons and the text of the panel - so a list can be tuned to how much room + it is given. +
+

+
+
Small:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+

+
+
Medium:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+

+
+
Large:
+ + + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + + In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + +
+
+ + +
+ Style and Class dress the root element of the list, while Styles and + Classes reach each part of every option on its own - the header, the title, the expander icon, + the panel and everything between them. Two of their slots are states rather than parts: + ItemExpanded is added to an option only while its panel is open and + ItemExpandedIcon only to its expander icon while it is. An option can also carry a + Style and a Class of its own. +


Component's style & class:
@@ -285,7 +872,7 @@ In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. @@ -299,8 +886,11 @@
- -
Use BitAccordionList in right-to-left (RTL).
+ +
+ Dir="BitDir.Rtl" mirrors the whole list: the expander icon moves to the other end of the + header and the padding follows the writing direction with it. +

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.cs index 3900423af5..7a17f3bed2 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.cs @@ -3,11 +3,19 @@ namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; public partial class _BitAccordionListOptionDemo { private int clickCounter; + private int readOnlyClickCount; + private bool lockToggling; + private bool slowToggling; + private bool showEmptyItems; private string? expandedTitle; private string? collapsedTitle; private string? toggledTitle; + private string? actionedTitle; + private string? togglingReport; private string? boundExpandedKey = "users"; - private BitAccordionList accordionListRef = default!; + private IEnumerable boundExpandedKeys = ["general"]; + private IEnumerable programmaticKeys = []; + private BitAccordionList? accordionListRef; private List bindingButtons => [ @@ -15,4 +23,17 @@ public partial class _BitAccordionListOptionDemo new() { Key = "users", Text = "Users" }, new() { Key = "advanced", Text = "Advanced" }, ]; + + private async Task HandleOnToggling(BitAccordionListToggleArgs args) + { + togglingReport = $"{args.Item.Title} is {(args.IsExpanding ? "expanding" : "collapsing")} ({args.Reason})"; + + // The header of this option reports itself as aria-busy for as long as the callback is awaited. + if (slowToggling) + { + await Task.Delay(1000); + } + + args.Cancel = lockToggling; + } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.samples.cs index ef9c87ce89..b97098d861 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AccordionList/_BitAccordionListOptionDemo.razor.samples.cs @@ -1,132 +1,246 @@ -namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AccordionList; public partial class _BitAccordionListOptionDemo { private readonly string example1RazorCode = @" - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Once upon a time, ... - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + Every story starts with a blank canvas, ... - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + In the beginning, there is silence, ... "; private readonly string example2RazorCode = @" - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Once upon a time, ... - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + Every story starts with a blank canvas, ... - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + In the beginning, there is silence, ... + + + + + + Once upon a time, ... + + + Every story starts with a blank canvas, ... + + + In the beginning, there is silence, ... "; private readonly string example3RazorCode = @" - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... +"; + + private readonly string example4RazorCode = @" + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... +"; + + private readonly string example5RazorCode = @" + + + Once upon a time, ... - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + Every story starts with a blank canvas, ... - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + In the beginning, there is silence, ... - - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + + + Once upon a time, ... + + + Every story starts with a blank canvas, ... - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + + In the beginning, there is silence, ... - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + + + + Once upon a time, ... -"; + + Every story starts with a blank canvas, ... + + + In the beginning, there is silence, ... + + - private readonly string example4RazorCode = @" - expandedTitle = item.Title"" - OnCollapse=""(BitAccordionListOption item) => collapsedTitle = item.Title"" - OnToggle=""(BitAccordionListOption item) => toggledTitle = item.Title""> + - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + Once upon a time, ... - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + Every story starts with a blank canvas, ... - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + In the beginning, there is silence, ... + +"; + + private readonly string example6RazorCode = @" + + + actionedTitle = option.Title"" /> + + + + Once upon a time, ... + + + Every story starts with a blank canvas, ... + + + In the beginning, there is silence, ... + + + + +
Last action: @actionedTitle
"; + private readonly string example6CsharpCode = @" +private string? actionedTitle; + +// An option can also carry its own Actions, which take precedence over the ActionsTemplate: +// "; + + private readonly string example7RazorCode = @" + { if (option.ReadOnly is true) readOnlyClickCount++; }""> + + Once upon a time, ... + + + Every story starts with a blank canvas, ... + + + In the beginning, there is silence, ... +
Clicks on the read-only header: @readOnlyClickCount
"; + private readonly string example7CsharpCode = @" +private int readOnlyClickCount;"; + + private readonly string example8RazorCode = @" + expandedTitle = option.Title"" + OnCollapse=""(BitAccordionListOption option) => collapsedTitle = option.Title"" + OnToggle=""(BitAccordionListOption option) => toggledTitle = option.Title""> + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + +
Last expanded: @expandedTitle
Last collapsed: @collapsedTitle
Last toggled: @toggledTitle
- { clickCounter++; StateHasChanged(); }""> - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. + { clickCounter++; StateHasChanged(); }""> + Once upon a time, ... - { clickCounter++; StateHasChanged(); }""> - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + { clickCounter++; StateHasChanged(); }""> + Every story starts with a blank canvas, ... - { clickCounter++; StateHasChanged(); }""> - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + { clickCounter++; StateHasChanged(); }""> + In the beginning, there is silence, ... +
Item click count: @clickCounter
"; - private readonly string example4CsharpCode = @" + private readonly string example8CsharpCode = @" private int clickCounter; private string? expandedTitle; private string? collapsedTitle; private string? toggledTitle;"; - private readonly string example5RazorCode = @" - accordionListRef.ExpandAll()"">Expand all - accordionListRef.CollapseAll()"">Collapse all + private readonly string example9RazorCode = @" + + - - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - -"; - private readonly string example5CsharpCode = @" -private BitAccordionList accordionListRef = default!;"; + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + - private readonly string example6RazorCode = @" +
Last request: @togglingReport
"; + private readonly string example9CsharpCode = @" +private bool lockToggling; +private bool slowToggling; +private string? togglingReport; + +private async Task HandleOnToggling(BitAccordionListToggleArgs args) +{ + togglingReport = $""{args.Item.Title} is {(args.IsExpanding ? ""expanding"" : ""collapsing"")} ({args.Reason})""; + + // The header of this option reports itself as aria-busy for as long as the callback is awaited. + if (slowToggling) + { + await Task.Delay(1000); + } + + args.Cancel = lockToggling; +}"; + + private readonly string example10RazorCode = @"
Bound expanded key: @boundExpandedKey
- - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + +
Bound expanded keys: @string.Join("", "", boundExpandedKeys)
+ + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... "; - private readonly string example6CsharpCode = @" + private readonly string example10CsharpCode = @" private string? boundExpandedKey = ""users""; +private IEnumerable boundExpandedKeys = [""general""]; private List bindingButtons => [ @@ -135,137 +249,242 @@ public partial class _BitAccordionListOptionDemo new() { Key = ""advanced"", Text = ""Advanced"" }, ];"; - private readonly string example7RazorCode = @" - - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. + private readonly string example11RazorCode = @" + accordionListRef!.ExpandAll())"">Expand all + accordionListRef!.CollapseAll())"">Collapse all + accordionListRef!.Toggle(""users""))"">Toggle Users + accordionListRef!.FocusItem(""advanced""))"">Focus Advanced + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + +
Expanded keys: @string.Join("", "", programmaticKeys)
"; + private readonly string example11CsharpCode = @" +private IEnumerable programmaticKeys = []; +private BitAccordionList? accordionListRef; + +// The same state can also be read back without a binding: +// accordionListRef.IsExpanded(""users""); accordionListRef.GetExpandedKeys();"; + + private readonly string example12RazorCode = @" + + + This panel was rendered at @DateTime.Now.ToString(""HH:mm:ss.fff"") - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. + + + + + This panel was rendered at @DateTime.Now.ToString(""HH:mm:ss.fff"") + + a very long text ... + a very long text ... +"; + + private readonly string example13RazorCode = @" + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... +"; + + private readonly string example14RazorCode = @" - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + + + @option.Title + + + @option.Description + + + + + + + + + + + + + + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + "; - private readonly string example8RazorCode = @" - - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + private readonly string example15RazorCode = @" + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... +"; + + private readonly string example16RazorCode = @" + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... - - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... "; - private readonly string example9RazorCode = @" + private readonly string example17RazorCode = @" + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... +"; + + private readonly string example18RazorCode = @" + + - - - - @option.Title - - - The general settings of the application - - - - - - @option.Title - - - You are currently not an owner - - - - - - @option.Title - - - Filtering has been entirely disabled - - + + There is nothing to show here yet. + + + @if (showEmptyItems) + { + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + } + "; + private readonly string example18CsharpCode = @" +private bool showEmptyItems;"; + + private readonly string example19RazorCode = @" +
+ + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + Once upon a time, ... + +
"; + + private readonly string example20RazorCode = @" + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... +"; + + private readonly string example21RazorCode = @" + + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + + + + Once upon a time, ... + Every story starts with a blank canvas, ... +"; + + private readonly string example22RazorCode = @" + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... + + + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... +"; + + private readonly string example23RazorCode = @" + - private readonly string example10RazorCode = @" - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... - - - Once upon a time, stories wove connections between people, a symphony of voices crafting shared dreams. - - - Every story starts with a blank canvas, a quiet space waiting to be filled with ideas, emotions, and dreams. - - - In the beginning, there is silence a blank canvas yearning to be filled, a quiet space where creativity waits to awaken. - + + Once upon a time, ... + Every story starts with a blank canvas, ... + In the beginning, there is silence, ... "; - private readonly string example11RazorCode = @" + private readonly string example24RazorCode = @" لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ است. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Accordion/BitAccordionDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Accordion/BitAccordionDemo.razor.cs index 3512f92b7f..c05ac19c2c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Accordion/BitAccordionDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Surfaces/Accordion/BitAccordionDemo.razor.cs @@ -37,6 +37,13 @@ public partial class BitAccordionDemo Description = "Alias for the ChildContent parameter." }, new() + { + Name = "Busy", + Type = "bool", + DefaultValue = "false", + Description = "Reports the header as busy - aria-busy for a screen reader, a busy cursor for a pointer - while something the page is doing on the accordion's behalf is still running. An accordion whose own OnToggling is being awaited reports itself as busy without being told to." + }, + new() { Name = "Classes", Type = "BitAccordionClassStyles?", diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupTests.cs index cc0a4aca21..8b7e771b90 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Buttons/ButtonGroup/BitButtonGroupTests.cs @@ -511,4 +511,87 @@ public void BitButtonGroupVerticalShouldSetTheAriaOrientation() Assert.AreEqual("vertical", root.GetAttribute("aria-orientation")); Assert.AreEqual("Operations", root.GetAttribute("aria-label")); } + + [TestMethod] + public void BitButtonGroupShouldKeepTheToggledItemWhenItemsAreRebuiltWithNewInstances() + { + // A page that builds its items in a property hands the group a fresh list of fresh instances on + // every render, so the toggled item has to be followed by its key rather than by reference. + static List NewItems() => + [ + new() { Text = "A", Key = "a" }, + new() { Text = "B", Key = "b" } + ]; + + var comp = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, NewItems()); + parameters.Add(p => p.SelectionMode, BitButtonGroupSelectionMode.Single); + parameters.Add(p => p.ToggleKey, "b"); + parameters.Add(p => p.ToggleKeyChanged, (string? _) => { }); + }); + + Assert.AreEqual("true", comp.FindAll("button")[1].GetAttribute("aria-checked")); + + comp.Render(parameters => parameters.Add(p => p.Items, NewItems())); + + Assert.AreEqual("true", comp.FindAll("button")[1].GetAttribute("aria-checked")); + Assert.AreEqual(1, comp.FindAll(".bit-btg-chk").Count); + } + + [TestMethod] + public void BitButtonGroupShouldKeepTheToggledItemsWhenItemsAreRebuiltWithNewInstancesInMultipleMode() + { + static List NewItems() => + [ + new() { Text = "A", Key = "a" }, + new() { Text = "B", Key = "b" }, + new() { Text = "C", Key = "c" } + ]; + + var comp = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, NewItems()); + parameters.Add(p => p.SelectionMode, BitButtonGroupSelectionMode.Multiple); + parameters.Add(p => p.ToggleKeys, new[] { "a", "c" }); + parameters.Add(p => p.ToggleKeysChanged, (IEnumerable? _) => { }); + }); + + Assert.AreEqual(2, comp.FindAll(".bit-btg-chk").Count); + + comp.Render(parameters => parameters.Add(p => p.Items, NewItems())); + + var buttons = comp.FindAll("button"); + + Assert.AreEqual("true", buttons[0].GetAttribute("aria-pressed")); + Assert.AreEqual("false", buttons[1].GetAttribute("aria-pressed")); + Assert.AreEqual("true", buttons[2].GetAttribute("aria-pressed")); + } + + [TestMethod] + public void BitButtonGroupShouldNotMoveTheToggleToAnotherItemWhenKeylessItemsAreRebuilt() + { + // An item type without a key gives the toggled item nothing to be followed by, so a rebuilt list must + // not hand the toggle to whichever of the new items happens to lack a key first. + static List NewItems() => [new(), new()]; + + var comp = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, NewItems()); + parameters.Add(p => p.SelectionMode, BitButtonGroupSelectionMode.Single); + }); + + comp.FindAll("button")[1].Click(); + + Assert.AreEqual(1, comp.FindAll(".bit-btg-chk").Count); + + comp.Render(parameters => parameters.Add(p => p.Items, NewItems())); + + Assert.AreEqual(0, comp.FindAll(".bit-btg-chk").Count); + } + + public class KeylessButtonGroupItem + { + public string? Text { get; set; } + } } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListBoundOptionsTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListBoundOptionsTest.razor new file mode 100644 index 0000000000..b22a355d92 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListBoundOptionsTest.razor @@ -0,0 +1,24 @@ +@using Bit.BlazorUI + +@* The bound value already holds the key of an option, so the option opening itself on registration must not + be reported back to the page as a change - the page would otherwise be re-rendered for a value it holds. *@ + + Body of the first + Body of the second + + +@code { + public IEnumerable ExpandedKeys + { + get => _expandedKeys; + set + { + _expandedKeys = value; + ChangeCount++; + } + } + + public int ChangeCount { get; private set; } + + private IEnumerable _expandedKeys = ["second"]; +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListFeaturesTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListFeaturesTests.cs new file mode 100644 index 0000000000..750c255535 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListFeaturesTests.cs @@ -0,0 +1,1274 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Extras.AccordionList; + +[TestClass] +public class BitAccordionListFeaturesTests : BunitTestContext +{ + private static List GetItems() => + [ + new() { Key = "a", Title = "Item A", Body = Content("Body A") }, + new() { Key = "b", Title = "Item B", Body = Content("Body B") }, + new() { Key = "c", Title = "Item C", Body = Content("Body C") }, + ]; + + private static RenderFragment Content(string text) => item => builder => builder.AddContent(0, text); + + + [TestMethod] + public void BitAccordionListShouldNotCollapseTheLastItemWhenNotCollapsible() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.Collapsible, false); + parameters.Add(p => p.DefaultExpandedKey, "a"); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + Assert.IsTrue(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + Assert.AreEqual("true", component.FindAll(".bit-acd-hdr")[0].GetAttribute("aria-disabled")); + + // Another item can still take its place, and the one that opens becomes the one that is locked. + component.FindAll(".bit-acd-hdr")[1].Click(); + + Assert.IsFalse(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + Assert.IsTrue(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex")); + Assert.IsNull(component.FindAll(".bit-acd-hdr")[0].GetAttribute("aria-disabled")); + Assert.AreEqual("true", component.FindAll(".bit-acd-hdr")[1].GetAttribute("aria-disabled")); + } + + [TestMethod] + public void BitAccordionListNotCollapsibleShouldOnlyLockTheLastExpandedItemInMultiple() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.Collapsible, false); + parameters.Add(p => p.DefaultExpandedKeys, ["a", "b"]); + }); + + // Two are open, so either of them can still be closed. + component.FindAll(".bit-acd-hdr")[0].Click(); + Assert.IsFalse(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + + // The one that is left cannot. + component.FindAll(".bit-acd-hdr")[1].Click(); + Assert.IsTrue(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex")); + } + + [TestMethod] + public async Task BitAccordionListNotCollapsibleShouldStillCollapseFromTheCollapseAllMethod() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.Collapsible, false); + parameters.Add(p => p.DefaultExpandedKey, "a"); + }); + + await component.InvokeAsync(() => component.Instance.CollapseAll()); + + component.WaitForAssertion(() => Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count)); + } + + [TestMethod] + public void BitAccordionListReadOnlyShouldReportTheClickWithoutToggling() + { + var clicked = 0; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.ReadOnly, true); + parameters.Add(p => p.OnItemClick, (BitAccordionListItem i) => clicked++); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + Assert.AreEqual(1, clicked); + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + Assert.AreEqual("true", component.FindAll(".bit-acd-hdr")[0].GetAttribute("aria-disabled")); + } + + [TestMethod] + public void BitAccordionListItemReadOnlyShouldOverrideTheListValue() + { + var items = GetItems(); + items[0].ReadOnly = true; + items[1].ReadOnly = false; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, items); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + component.FindAll(".bit-acd-hdr")[1].Click(); + + Assert.IsFalse(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + Assert.IsTrue(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex")); + } + + [TestMethod] + public void BitAccordionListShouldNotToggleADisabledItem() + { + var items = GetItems(); + items[0].IsEnabled = false; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + Assert.IsTrue(component.FindAll(".bit-acd")[0].ClassList.Contains("bit-dis")); + } + + [TestMethod] + public async Task BitAccordionListExpandAllShouldSkipTheDisabledItems() + { + var items = GetItems(); + items[1].IsEnabled = false; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, items); + }); + + await component.InvokeAsync(() => component.Instance.ExpandAll()); + + component.WaitForAssertion(() => Assert.AreEqual(2, component.FindAll(".bit-acd-con.bit-acd-cex").Count)); + } + + [TestMethod] + public void BitAccordionListShouldApplyTheSizeToEveryItem() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.Size, BitSize.Large); + }); + + Assert.AreEqual(3, component.FindAll(".bit-acd.bit-acd-lg").Count); + } + + [TestMethod] + public void BitAccordionListShouldApplyTheExpanderIconPositionToEveryItem() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.ExpanderIconPosition, BitIconPosition.Start); + }); + + Assert.AreEqual(3, component.FindAll(".bit-acd.bit-acd-sei").Count); + } + + [TestMethod] + public void BitAccordionListShouldHideTheExpanderIconAndLetAnItemOptOut() + { + var items = GetItems(); + items[0].HideExpanderIcon = false; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + parameters.Add(p => p.HideExpanderIcon, true); + }); + + Assert.AreEqual(1, component.FindAll(".bit-acd-eiw").Count); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheItemIconAndTheExpandedExpanderIcon() + { + var items = GetItems(); + items[0].IconName = "Settings"; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + parameters.Add(p => p.DefaultExpandedKey, "a"); + parameters.Add(p => p.ExpandedExpanderIconName, "Remove"); + }); + + Assert.AreEqual(1, component.FindAll(".bit-acd-ico").Count); + Assert.IsTrue(component.FindAll(".bit-acd-eic")[0].ClassList.Contains("bit-icon--Remove")); + Assert.IsFalse(component.FindAll(".bit-acd-eic")[1].ClassList.Contains("bit-icon--Remove")); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheActionsBesideTheHeader() + { + var items = GetItems(); + items[0].Actions = item => builder => builder.AddContent(0, "action"); + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + }); + + Assert.AreEqual(1, component.FindAll(".bit-acd-act").Count); + Assert.AreEqual("action", component.Find(".bit-acd-act").TextContent.Trim()); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheActionsTemplateForEveryItem() + { + RenderFragment actionsTemplate = item => builder => builder.AddContent(0, item.Key); + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.ActionsTemplate, actionsTemplate); + }); + + Assert.AreEqual(3, component.FindAll(".bit-acd-act").Count); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheTitleAndExpanderTemplates() + { + RenderFragment titleTemplate = item => builder => builder.AddContent(0, $"T-{item.Key}"); + RenderFragment expanderTemplate = item => builder => builder.AddContent(0, $"E-{item.Key}"); + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.TitleTemplate, titleTemplate); + parameters.Add(p => p.ExpanderTemplate, expanderTemplate); + }); + + Assert.AreEqual("T-a", component.FindAll(".bit-acd-ttl")[0].TextContent.Trim()); + Assert.AreEqual("E-a", component.FindAll(".bit-acd-eiw")[0].TextContent.Trim()); + } + + [TestMethod] + public void BitAccordionListShouldApplyTheHeadingLevelToEveryItem() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.HeadingLevel, 2); + }); + + Assert.AreEqual("2", component.FindAll(".bit-acd-hed")[0].GetAttribute("aria-level")); + } + + [TestMethod] + public void BitAccordionListShouldDropTheContentRegionRole() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.NoContentRegion, true); + }); + + Assert.IsNull(component.FindAll(".bit-acd-con")[0].GetAttribute("role")); + } + + [TestMethod] + public void BitAccordionListShouldApplyTheLayoutParametersToEveryItem() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxHeight, "100px"); + parameters.Add(p => p.TransitionDuration, 250); + parameters.Add(p => p.ExpandOnPrint, true); + parameters.Add(p => p.Gap, 8); + }); + + var first = component.FindAll(".bit-acd")[0]; + Assert.IsTrue(first.ClassList.Contains("bit-acd-mxh")); + Assert.IsTrue(first.ClassList.Contains("bit-acd-eop")); + Assert.IsTrue(first.GetAttribute("style")!.Contains("--bit-acd-max-h:100px")); + Assert.IsTrue(first.GetAttribute("style")!.Contains("--bit-acd-dur-full:250ms")); + Assert.IsTrue(component.Find(".bit-acl").GetAttribute("style")!.Contains("gap:8px")); + } + + [TestMethod] + public void BitAccordionListLazyContentShouldDelayTheFirstRenderOfTheBody() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.LazyContent, true); + }); + + Assert.AreEqual(string.Empty, component.FindAll(".bit-acd-con")[0].TextContent.Trim()); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + Assert.AreEqual("Body A", component.FindAll(".bit-acd-con")[0].TextContent.Trim()); + } + + [TestMethod] + public void BitAccordionListUnmountOnCollapseShouldRemoveTheBodyAgain() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.UnmountOnCollapse, true); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + Assert.AreEqual("Body A", component.FindAll(".bit-acd-con")[0].TextContent.Trim()); + + component.FindAll(".bit-acd-hdr")[0].Click(); + Assert.AreEqual(string.Empty, component.FindAll(".bit-acd-con")[0].TextContent.Trim()); + } + + [TestMethod] + public void BitAccordionListShouldCancelTheToggleFromOnToggling() + { + BitAccordionListToggleArgs? received = null; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.OnToggling, (BitAccordionListToggleArgs args) => + { + received = args; + args.Cancel = true; + }); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + Assert.IsNotNull(received); + Assert.AreEqual("a", received!.Key); + Assert.IsTrue(received.IsExpanding); + Assert.AreEqual(BitAccordionToggleReason.Click, received.Reason); + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public void BitAccordionListOnTogglingShouldLeaveThePreviouslyExpandedItemAloneWhenCancelled() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.DefaultExpandedKey, "a"); + parameters.Add(p => p.OnToggling, (BitAccordionListToggleArgs args) => args.Cancel = true); + }); + + component.FindAll(".bit-acd-hdr")[1].Click(); + + Assert.IsTrue(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + Assert.IsFalse(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex")); + } + + [TestMethod] + public async Task BitAccordionListOnTogglingShouldReportTheMethodReason() + { + BitAccordionToggleReason? reason = null; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.OnToggling, (BitAccordionListToggleArgs args) => reason = args.Reason); + }); + + await component.InvokeAsync(() => component.Instance.Expand("b")); + + Assert.AreEqual(BitAccordionToggleReason.Method, reason); + } + + [TestMethod] + public async Task BitAccordionListShouldExpandCollapseAndToggleByKey() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + await component.InvokeAsync(() => component.Instance.Expand("b")); + Assert.IsTrue(component.Instance.IsExpanded("b")); + Assert.IsTrue(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex")); + + // Single-expand mode collapses the previously expanded item along the way. + await component.InvokeAsync(() => component.Instance.Expand("c")); + Assert.IsFalse(component.Instance.IsExpanded("b")); + CollectionAssert.AreEqual(new[] { "c" }, component.Instance.GetExpandedKeys().ToArray()); + + await component.InvokeAsync(() => component.Instance.Toggle("c")); + Assert.IsFalse(component.Instance.IsExpanded("c")); + + await component.InvokeAsync(() => component.Instance.Toggle("c")); + Assert.IsTrue(component.Instance.IsExpanded("c")); + + await component.InvokeAsync(() => component.Instance.Collapse("c")); + Assert.AreEqual(0, component.Instance.GetExpandedKeys().Count); + } + + [TestMethod] + public async Task BitAccordionListShouldIgnoreAnUnknownKeyInTheMethods() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + await component.InvokeAsync(() => component.Instance.Expand("nope")); + + Assert.AreEqual(0, component.Instance.GetExpandedKeys().Count); + } + + [TestMethod] + public async Task BitAccordionListCollapseAllShouldDropTheKeysThatMapToNoItem() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.DefaultExpandedKeys, ["a", "orphan"]); + }); + + CollectionAssert.AreEqual(new[] { "a", "orphan" }, component.Instance.GetExpandedKeys().ToArray()); + + await component.InvokeAsync(() => component.Instance.CollapseAll()); + + Assert.AreEqual(0, component.Instance.GetExpandedKeys().Count); + } + + [TestMethod] + public void BitAccordionListShouldNoticeAMutationOfTheVeryCollectionItWasGiven() + { + var items = GetItems(); + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + }); + + items.Add(new BitAccordionListItem { Key = "d", Title = "Item D" }); + + component.Render(); + + Assert.AreEqual(4, component.FindAll(".bit-acd").Count); + } + + [TestMethod] + public void BitAccordionListShouldKeepTheExpandedStateWhenTheItemsChange() + { + var items = GetItems(); + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + }); + + component.FindAll(".bit-acd-hdr")[1].Click(); + Assert.IsTrue(component.Instance.IsExpanded("b")); + + items.Add(new BitAccordionListItem { Key = "d", Title = "Item D" }); + component.Render(); + + Assert.IsTrue(component.Instance.IsExpanded("b")); + Assert.IsTrue(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex")); + } + + [TestMethod] + public void BitAccordionListShouldWorkWithACustomTypeThatCarriesNoKey() + { + var items = new List { new() { Name = "X" }, new() { Name = "Y" } }; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + parameters.Add(p => p.NameSelectors, new BitAccordionListNameSelectors + { + Title = { Selector = i => i.Name }, + }); + }); + + component.FindAll(".bit-acd-hdr")[1].Click(); + + Assert.IsTrue(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex")); + Assert.IsFalse(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + } + + [TestMethod] + public void BitAccordionListShouldWorkWithACustomTypeWhoseStateIsReadOnly() + { + var items = new List { new() { Name = "X" }, new() { Name = "Y" } }; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + parameters.Add(p => p.NameSelectors, new BitAccordionListNameSelectors + { + Title = { Selector = i => i.Name }, + }); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + Assert.IsTrue(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + Assert.IsTrue(component.Instance.IsExpanded("X")); + } + + [TestMethod] + public void BitAccordionListShouldMoveTheFocusBetweenTheHeadersWithTheArrowKeys() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + var wrappers = component.FindAll(".bit-acl-itm"); + Assert.AreEqual(3, wrappers.Count); + + // The focus itself is a JS call the loose interop swallows, so what is asserted here is that the + // navigation runs over the headers without throwing and leaves the expanded state alone. + wrappers[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + wrappers[0].KeyDown(new KeyboardEventArgs { Key = "ArrowUp" }); + wrappers[0].KeyDown(new KeyboardEventArgs { Key = "Home" }); + wrappers[0].KeyDown(new KeyboardEventArgs { Key = "End" }); + + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public void BitAccordionListShouldIgnoreTheNavigationKeysWhenNotNavigable() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.Navigable, false); + }); + + component.FindAll(".bit-acl-itm")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public async Task BitAccordionListShouldFocusAnItemByKey() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + await component.InvokeAsync(() => component.Instance.FocusItem("b")); + await component.InvokeAsync(() => component.Instance.FocusAsync()); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheAriaLabelOnTheRoot() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.AriaLabel, "settings"); + }); + + Assert.AreEqual("settings", component.Find(".bit-acl").GetAttribute("aria-label")); + } + + [TestMethod] + public void BitAccordionListShouldPassTheClassesAndStylesToEveryPartOfTheItems() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.Classes, new BitAccordionListClassStyles + { + ItemHeaderWrapper = "custom-hwr", + ItemHeading = "custom-hed", + ItemContentWrapper = "custom-cwr", + }); + parameters.Add(p => p.Styles, new BitAccordionListClassStyles + { + ItemTitle = "color: red;", + }); + }); + + Assert.AreEqual(3, component.FindAll(".bit-acd-hwr.custom-hwr").Count); + Assert.AreEqual(3, component.FindAll(".bit-acd-hed.custom-hed").Count); + Assert.AreEqual(3, component.FindAll(".bit-acd-cwr.custom-cwr").Count); + Assert.AreEqual("color: red;", component.FindAll(".bit-acd-ttl")[0].GetAttribute("style")); + } + + [TestMethod] + public void BitAccordionListOptionShouldExpandFromItsOwnHeaderClick() + { + var component = RenderComponent>(parameters => + { + parameters.AddChildContent(p => p.Add(o => o.Title, "Option A")); + parameters.AddChildContent(p => p.Add(o => o.Title, "Option B")); + }); + + component.WaitForAssertion(() => Assert.AreEqual(2, component.FindAll(".bit-acd-hdr").Count)); + + component.FindAll(".bit-acd-hdr")[1].Click(); + + component.WaitForAssertion(() => Assert.IsTrue(component.FindAll(".bit-acd-con")[1].ClassList.Contains("bit-acd-cex"))); + } + + [TestMethod] + public void BitAccordionListOptionShouldRenderItsOwnIconAndActions() + { + RenderFragment actions = option => builder => builder.AddContent(0, "act"); + + var component = RenderComponent>(parameters => + { + parameters.AddChildContent(p => + { + p.Add(o => o.Title, "Option A"); + p.Add(o => o.IconName, "Settings"); + p.Add(o => o.Actions, actions); + }); + }); + + component.WaitForAssertion(() => + { + Assert.AreEqual(1, component.FindAll(".bit-acd-ico").Count); + Assert.AreEqual("act", component.Find(".bit-acd-act").TextContent.Trim()); + }); + } + + [TestMethod] + public void BitAccordionListOptionReadOnlyShouldKeepThePanelWhereItIs() + { + var component = RenderComponent>(parameters => + { + parameters.AddChildContent(p => + { + p.Add(o => o.Title, "Option A"); + p.Add(o => o.ReadOnly, true); + }); + }); + + component.WaitForAssertion(() => Assert.AreEqual(1, component.FindAll(".bit-acd-hdr").Count)); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + + [TestMethod] + public void BitAccordionListShouldNameAHeaderThatDoesNotNameItself() + { + var items = GetItems(); + items[0].HeaderAriaLabel = "Notifications"; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + }); + + Assert.AreEqual("Notifications", component.FindAll(".bit-acd-hdr")[0].GetAttribute("aria-label")); + Assert.IsNull(component.FindAll(".bit-acd-hdr")[1].GetAttribute("aria-label")); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheHeaderAndBodyTemplatesOfTheList() + { + RenderFragment headerTemplate = item => builder => builder.AddContent(0, $"H-{item.Key}"); + RenderFragment bodyTemplate = item => builder => builder.AddContent(0, $"B-{item.Key}"); + + // The templates of the list stand in for the items that bring none of their own. + List items = [new() { Key = "a", Title = "Item A" }, new() { Key = "b", Title = "Item B" }]; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + parameters.Add(p => p.HeaderTemplate, headerTemplate); + parameters.Add(p => p.BodyTemplate, bodyTemplate); + }); + + Assert.AreEqual("H-a", component.FindAll(".bit-acd-hdr")[0].TextContent.Trim()); + Assert.AreEqual("B-a", component.FindAll(".bit-acd-con")[0].TextContent.Trim()); + } + + [TestMethod] + public void BitAccordionListItemTemplatesShouldWinOverTheTemplatesOfTheList() + { + RenderFragment listTemplate = item => builder => builder.AddContent(0, "from-the-list"); + + var items = GetItems(); + items[0].HeaderTemplate = item => builder => builder.AddContent(0, "from-the-item"); + items[0].Actions = item => builder => builder.AddContent(0, "item-actions"); + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + parameters.Add(p => p.HeaderTemplate, listTemplate); + parameters.Add(p => p.ActionsTemplate, listTemplate); + }); + + Assert.AreEqual("from-the-item", component.FindAll(".bit-acd-hdr")[0].TextContent.Trim()); + Assert.AreEqual("from-the-list", component.FindAll(".bit-acd-hdr")[1].TextContent.Trim()); + Assert.AreEqual("item-actions", component.FindAll(".bit-acd-act")[0].TextContent.Trim()); + Assert.AreEqual("from-the-list", component.FindAll(".bit-acd-act")[1].TextContent.Trim()); + } + + [TestMethod] + public void BitAccordionListShouldKeepTheExpanderIconStill() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.NoExpanderRotation, true); + parameters.Add(p => p.DefaultExpandedKey, "a"); + }); + + Assert.IsFalse(component.FindAll(".bit-acd-eiw")[0].ClassList.Contains("bit-ico--r180")); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheOptionsAlias() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Options, builder => + { + builder.OpenComponent(0); + builder.AddComponentParameter(1, nameof(BitAccordionListOption.Title), "Option A"); + builder.CloseComponent(); + }); + }); + + component.WaitForAssertion(() => Assert.AreEqual(1, component.FindAll(".bit-acd").Count)); + } + + [TestMethod] + public void BitAccordionListShouldReadTheNewMembersOfACustomTypeThroughNameSelectors() + { + var items = new List + { + new() { Id = "x", Name = "X", Glyph = "Settings", Locked = true, NoChevron = true }, + new() { Id = "y", Name = "Y" }, + }; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, items); + parameters.Add(p => p.NameSelectors, new BitAccordionListNameSelectors + { + Key = { Selector = i => i.Id }, + Title = { Selector = i => i.Name }, + IconName = { Selector = i => i.Glyph }, + ReadOnly = { Selector = i => i.Locked }, + HideExpanderIcon = { Selector = i => i.NoChevron }, + }); + }); + + Assert.AreEqual(1, component.FindAll(".bit-acd-ico").Count); + Assert.AreEqual(1, component.FindAll(".bit-acd-eiw").Count); + + // The read-only item does not answer the click, the other one does. + component.FindAll(".bit-acd-hdr")[0].Click(); + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + + component.FindAll(".bit-acd-hdr")[1].Click(); + Assert.AreEqual(1, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public async Task BitAccordionListShouldIgnoreAnEmptyKeyInTheMethods() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + await component.InvokeAsync(() => component.Instance.Expand(string.Empty)); + await component.InvokeAsync(() => component.Instance.Toggle(string.Empty)); + + Assert.AreEqual(0, component.Instance.GetExpandedKeys().Count); + Assert.IsFalse(component.Instance.IsExpanded(null)); + } + + + public class RichItem + { + public string? Id { get; set; } + + public string? Name { get; set; } + + public string? Glyph { get; set; } + + public bool? Locked { get; set; } + + public bool? NoChevron { get; set; } + } + + [TestMethod] + public void BitAccordionListShouldKeepOnlyOnePanelOpenWhenLeavingTheMultipleExpandMode() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + component.FindAll(".bit-acd-hdr")[2].Click(); + Assert.AreEqual(2, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + + component.Render(parameters => parameters.Add(p => p.Multiple, false)); + + Assert.AreEqual(1, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + CollectionAssert.AreEqual(new[] { "a" }, component.Instance.GetExpandedKeys().ToArray()); + } + + [TestMethod] + public async Task BitAccordionListCollapseAllShouldCloseTheDisabledItemsAsWell() + { + var items = GetItems(); + items[1].IsEnabled = false; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, items); + parameters.Add(p => p.DefaultExpandedKeys, ["a", "b"]); + }); + + Assert.AreEqual(2, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + + await component.InvokeAsync(() => component.Instance.CollapseAll()); + + component.WaitForAssertion(() => Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count)); + } + + [TestMethod] + public void BitAccordionListMaxExpandedShouldCloseTheOldestPanel() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxExpanded, 2); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + component.FindAll(".bit-acd-hdr")[1].Click(); + + CollectionAssert.AreEqual(new[] { "a", "b" }, component.Instance.GetExpandedKeys().ToArray()); + + // The third one is opened all the same - nothing is turned away - and the one that was opened first + // is the one that closes for it. + component.FindAll(".bit-acd-hdr")[2].Click(); + + CollectionAssert.AreEqual(new[] { "b", "c" }, component.Instance.GetExpandedKeys().ToArray()); + Assert.IsFalse(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + Assert.IsTrue(component.FindAll(".bit-acd-con")[2].ClassList.Contains("bit-acd-cex")); + } + + [TestMethod] + public void BitAccordionListMaxExpandedShouldCapTheDefaultExpandedKeys() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxExpanded, 2); + parameters.Add(p => p.DefaultExpandedKeys, ["a", "b", "c"]); + }); + + CollectionAssert.AreEqual(new[] { "a", "b" }, component.Instance.GetExpandedKeys().ToArray()); + } + + [TestMethod] + public void BitAccordionListMaxExpandedShouldCapTheOptionsThatExpandThemselves() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.MaxExpanded, 1); + parameters.AddChildContent(p => + { + p.Add(o => o.Key, "a"); + p.Add(o => o.Title, "Option A"); + p.Add(o => o.IsExpanded, true); + }); + parameters.AddChildContent(p => + { + p.Add(o => o.Key, "b"); + p.Add(o => o.Title, "Option B"); + p.Add(o => o.IsExpanded, true); + }); + }); + + component.WaitForAssertion(() => CollectionAssert.AreEqual(new[] { "a" }, component.Instance.GetExpandedKeys().ToArray())); + Assert.AreEqual(1, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public void BitAccordionListMaxExpandedShouldCapTheDefaultExpandedKeysOfOptions() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.MaxExpanded, 1); + parameters.Add(p => p.DefaultExpandedKeys, ["a", "b"]); + parameters.AddChildContent(p => + { + p.Add(o => o.Key, "a"); + p.Add(o => o.Title, "Option A"); + }); + parameters.AddChildContent(p => + { + p.Add(o => o.Key, "b"); + p.Add(o => o.Title, "Option B"); + }); + }); + + component.WaitForAssertion(() => CollectionAssert.AreEqual(new[] { "a" }, component.Instance.GetExpandedKeys().ToArray())); + Assert.AreEqual(1, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public async Task BitAccordionListMaxExpandedShouldCapExpandAll() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxExpanded, 2); + }); + + await component.InvokeAsync(() => component.Instance.ExpandAll()); + + component.WaitForAssertion(() => Assert.AreEqual(2, component.FindAll(".bit-acd-con.bit-acd-cex").Count)); + } + + [TestMethod] + public void BitAccordionListMaxExpandedShouldMeanNothingOutsideOfTheMultipleExpandMode() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxExpanded, 2); + }); + + component.FindAll(".bit-acd-hdr")[0].Click(); + component.FindAll(".bit-acd-hdr")[1].Click(); + + CollectionAssert.AreEqual(new[] { "b" }, component.Instance.GetExpandedKeys().ToArray()); + } + + [TestMethod] + public void BitAccordionListMaxExpandedBelowOneShouldBeNoCapAtAll() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxExpanded, 0); + parameters.Add(p => p.DefaultExpandedKeys, ["a", "b", "c"]); + }); + + Assert.AreEqual(3, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public void BitAccordionListMaxExpandedShouldCloseTheOldestPanelsWhenItIsLowered() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.DefaultExpandedKeys, ["a", "b", "c"]); + }); + + Assert.AreEqual(3, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + + component.Render(parameters => parameters.Add(p => p.MaxExpanded, 1)); + + CollectionAssert.AreEqual(new[] { "c" }, component.Instance.GetExpandedKeys().ToArray()); + } + + [TestMethod] + public void BitAccordionListShouldPushBackABoundSetThatTheCapTrimmed() + { + var bound = new List { "a", "b", "c" }; + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxExpanded, 2); + parameters.Add(p => p.ExpandedKeys, bound); + parameters.Add(p => p.ExpandedKeysChanged, keys => bound = [.. keys ?? []]); + }); + + component.WaitForAssertion(() => CollectionAssert.AreEqual(new[] { "a", "b" }, bound.ToArray())); + Assert.AreEqual(2, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public void BitAccordionListNoNavigationLoopShouldStopAtTheEndsOfTheList() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.NoNavigationLoop, true); + }); + + // The focus itself is a JS call the loose interop swallows, so what is asserted here is that the + // navigation past either end runs without throwing and leaves the expanded state alone. + var wrappers = component.FindAll(".bit-acl-itm"); + wrappers[0].KeyDown(new KeyboardEventArgs { Key = "ArrowUp" }); + wrappers[2].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + + Assert.AreEqual(0, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheEmptyContentOnlyWhileTheListIsEmpty() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, []); + parameters.Add(p => p.EmptyContent, (RenderFragment)(builder => builder.AddMarkupContent(0, "Nothing here"))); + }); + + Assert.AreEqual(1, component.FindAll(".no-items").Count); + Assert.AreEqual(0, component.FindAll(".bit-acd").Count); + + component.Render(parameters => parameters.Add(p => p.Items, GetItems())); + + Assert.AreEqual(0, component.FindAll(".no-items").Count); + Assert.AreEqual(3, component.FindAll(".bit-acd").Count); + } + + [TestMethod] + public void BitAccordionListShouldRenderTheEmptyContentOfAListOfOptions() + { + // A list of options only knows it is empty once its options have had their turn to register, so the + // empty content needs a render of its own - and an empty list has no option to ask for one. + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.ChildContent, (RenderFragment)(builder => { })); + parameters.Add(p => p.EmptyContent, (RenderFragment)(builder => builder.AddMarkupContent(0, "Nothing here"))); + }); + + Assert.AreEqual(1, component.FindAll(".no-items").Count); + } + + [TestMethod] + public void BitAccordionListShouldNameItselfAsAGroupOnlyWhenItCarriesALabel() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + // A label on a plain container is dropped by a screen reader, so the role comes with the label + // rather than being there for a list that has nothing to be named by. + Assert.IsNull(component.Find(".bit-acl").GetAttribute("role")); + + component.Render(parameters => parameters.Add(p => p.AriaLabel, "settings")); + + Assert.AreEqual("group", component.Find(".bit-acl").GetAttribute("role")); + } + + [TestMethod] + public void BitAccordionListShouldLeaveARoleOfThePagesOwnAlone() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + Assert.AreEqual("region", component.Find(".bit-acl").GetAttribute("role")); + } + + [TestMethod] + public async Task BitAccordionListShouldReportTheItemAsBusyWhileAnAwaitedOnTogglingRuns() + { + var gate = new TaskCompletionSource(); + + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.OnToggling, EventCallback.Factory.Create>(this, async _ => await gate.Task)); + }); + + var click = component.InvokeAsync(() => component.Find(".bit-acd-hdr").Click()); + + // The header of the item the callback was asked about says it is busy; the rest of the list is only + // refusing to start something else while this one is being decided. + component.WaitForAssertion(() => Assert.AreEqual("true", component.FindAll(".bit-acd-hdr")[0].GetAttribute("aria-busy"))); + Assert.IsTrue(component.FindAll(".bit-acd-hdr")[0].ClassList.Contains("bit-acd-bsy")); + Assert.IsNull(component.FindAll(".bit-acd-hdr")[1].GetAttribute("aria-busy")); + + gate.SetResult(); + await click; + + component.WaitForAssertion(() => Assert.IsNull(component.FindAll(".bit-acd-hdr")[0].GetAttribute("aria-busy"))); + Assert.IsTrue(component.FindAll(".bit-acd-con")[0].ClassList.Contains("bit-acd-cex")); + } + + [TestMethod] + public void BitAccordionListShouldSuppressTheDefaultActionOfItsNavigationKeys() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + // The keys are suppressed on a listener of the browser's own - Blazor's preventDefault directive + // cannot be decided per key - and only for a key pressed on one of this list's own headers. + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.Extras.setPreventKeys"); + + CollectionAssert.AreEqual(new[] { "ArrowDown", "ArrowUp", "Home", "End" }, (string[])invocation.Arguments[1]!); + Assert.AreEqual(".bit-acl-itm > .bit-acd > .bit-acd-hwr > .bit-acd-hed > .bit-acd-hdr", invocation.Arguments[2]); + Assert.AreEqual(".bit-acl", invocation.Arguments[3]); + } + + [TestMethod] + public void BitAccordionListShouldNotSuppressAnyKeyWhenItIsNotNavigable() + { + RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.Navigable, false); + }); + + Assert.IsFalse(Context.JSInterop.Invocations.Any(i => i.Identifier == "BitBlazorUI.Extras.setPreventKeys")); + } + + [TestMethod] + public void BitAccordionListShouldScrollAnExpandedItemIntoView() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.ScrollIntoViewOnExpand, true); + }); + + Assert.IsFalse(Context.JSInterop.Invocations.Any(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView")); + + component.FindAll(".bit-acd-hdr")[1].Click(); + + component.WaitForAssertion(() => Assert.AreEqual(1, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView"))); + + // A collapse takes nothing off the screen that was not already there. + component.FindAll(".bit-acd-hdr")[1].Click(); + + Assert.AreEqual(1, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView")); + } + + [TestMethod] + public void BitAccordionListShouldNotScrollAnythingIntoViewWithoutBeingAskedTo() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + }); + + component.FindAll(".bit-acd-hdr")[1].Click(); + + Assert.IsFalse(Context.JSInterop.Invocations.Any(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView")); + } + + [TestMethod] + public void BitAccordionListShouldNotScrollToThePanelsTheMaxExpandedCapKeptClosed() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxExpanded, 1); + parameters.Add(p => p.ScrollIntoViewOnExpand, true); + parameters.Add(p => p.ExpandedKeys, (IEnumerable)[]); + }); + + // Only the first key of the set fits under the cap, and the panels the cap kept closed are nothing + // to scroll to. + component.Render(parameters => parameters.Add(p => p.ExpandedKeys, (IEnumerable)["a", "b"])); + + component.WaitForAssertion(() => Assert.AreEqual(1, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView"))); + } + + [TestMethod] + public void BitAccordionListOfOptionsShouldNotScrollToThePanelItIsBoundToOpenWith() + { + // The options register only during the first render, so the bound key arrives before its item does - + // it is still the state the list starts in, not a panel the reader has just opened. + RenderComponent(parameters => parameters.Add(p => p.ExpandedKey, "first")); + + Assert.IsFalse(Context.JSInterop.Invocations.Any(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView")); + } + + [TestMethod] + public void BitAccordionListShouldScrollToAPanelTheSameChangeAddedToTheList() + { + var component = RenderComponent(parameters => parameters.Add(p => p.ShowSecond, false)); + + Assert.IsFalse(Context.JSInterop.Invocations.Any(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView")); + + // The panel this change opens belongs to the option the same change adds, so it registers itself - + // and then its element - only in the renders that follow the one that asked for the scroll. + component.Render(parameters => + { + parameters.Add(p => p.ShowSecond, true); + parameters.Add(p => p.ExpandedKey, "second"); + }); + + component.WaitForAssertion(() => Assert.AreEqual(1, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.Extras.scrollIntoView"))); + } + + [TestMethod] + public void BitAccordionListShouldLeaveTheNavigationKeysToAPanelThatScrollsAndHoldsTheFocus() + { + var component = RenderComponent>(parameters => + { + parameters.Add(p => p.Items, GetItems()); + parameters.Add(p => p.MaxHeight, "100px"); + parameters.Add(p => p.DefaultExpandedKey, "a"); + }); + + // A panel with a MaxHeight is a tab stop of its own, so the arrow keys pressed on it are its own + // scroll: the stop inside it never sees them, and the navigation would otherwise move the reader + // twice - once down the list and once down the panel. + var panel = component.FindAll(".bit-acd-con")[0]; + panel.FocusIn(new FocusEventArgs()); + panel.KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + + Assert.AreEqual(0, FocusCalls()); + + // The same key pressed on the header of the item is the navigation it has always been. + component.FindAll(".bit-acd-con")[0].FocusOut(new FocusEventArgs()); + component.FindAll(".bit-acl-itm")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + + Assert.AreEqual(1, FocusCalls()); + + int FocusCalls() => Context.JSInterop.Invocations.Count(i => i.Identifier == "Blazor._internal.domWrapper.focus"); + } + + public class KeylessItem + { + public string? Name { get; set; } + } + + public class ReadOnlyStateItem + { + public string? Name { get; set; } + + public string? Key => Name; + + public bool IsExpanded => false; + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListHtmlAttributesTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListHtmlAttributesTest.razor new file mode 100644 index 0000000000..6b8ee9b8b7 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListHtmlAttributesTest.razor @@ -0,0 +1,9 @@ +@using Bit.BlazorUI + +@* A role the page sets itself is splatted through HtmlAttributes, which BitComponentBase only accepts from + markup - hence a component of its own rather than an unmatched parameter. *@ + + +@code { + [Parameter] public List Items { get; set; } = []; +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTest.razor index 7039e3c627..bcaa6c75f7 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTest.razor +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTest.razor @@ -1,14 +1,18 @@ @using Bit.BlazorUI - - + + Body of the first @if (ShowMiddle) { - + Body of the middle } - + Body of the last @code { [Parameter] public bool ShowMiddle { get; set; } + + [Parameter] public bool Multiple { get; set; } + + public BitAccordionList? List { get; set; } } diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTests.cs index 80f2947ed1..06e14a98be 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListOptionsOrderTests.cs @@ -23,6 +23,49 @@ public void BitAccordionListShouldPreserveOptionsOrderWhenAnOptionIsAddedConditi CollectionAssert.AreEqual(new[] { "First", "Last" }, GetItemTitles(component)); } + [TestMethod] + public void BitAccordionListShouldOrderTheItemsOfConditionalOptionsByTheirMarkupOrder() + { + // An option added conditionally registers itself behind every option that was already there, so the + // order the list keeps its items in has to be read back from the render - which is the markup order, + // and the order the expanded keys are reported in and the keyboard navigation walks. + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Multiple, true); + parameters.Add(p => p.ShowMiddle, false); + }); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + var headers = component.FindAll(".bit-acd-hdr"); + headers[2].Click(); + component.FindAll(".bit-acd-hdr")[1].Click(); + + Assert.AreEqual("middle,last", string.Join(",", component.Instance.List!.GetExpandedKeys())); + } + + [TestMethod] + public void BitAccordionListShouldReadTheOrderOfPlainOptionsBackFromTheRenderedDocument() + { + // An option of nothing but constants cannot report the order it was rendered in: Blazor hands a child + // its parameters again only when one of them has actually changed, so the two options that were + // already there sit out the render that adds the third one between them. The document holds the + // markup order either way - here the middle option is the last to register and the second to render. + Context.JSInterop + .Setup("BitBlazorUI.Extras.getElementsOrder", inv => inv.Identifier == "BitBlazorUI.Extras.getElementsOrder") + .SetResult([0, 2, 1]); + + var component = RenderComponent(parameters => parameters.Add(p => p.ShowMiddle, false)); + + component.Render(parameters => parameters.Add(p => p.ShowMiddle, true)); + + var headers = component.FindAll(".bit-acd-hdr"); + headers[2].Click(); + component.FindAll(".bit-acd-hdr")[1].Click(); + + component.WaitForAssertion(() => Assert.AreEqual("middle,last", string.Join(",", component.Instance.List!.GetExpandedKeys()))); + } + private static string[] GetItemTitles(IRenderedComponent component) { return component.FindAll(".bit-acd-ttl").Select(e => e.TextContent).ToArray(); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListPlainOptionsOrderTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListPlainOptionsOrderTest.razor new file mode 100644 index 0000000000..24fb9f2237 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListPlainOptionsOrderTest.razor @@ -0,0 +1,19 @@ +@using Bit.BlazorUI + +@* Every option carries nothing but constants - no content, no template, no handler - so Blazor hands none of + them their parameters again when another one is added beside them, and none of them can report the order + they are rendered in. *@ + + + @if (ShowMiddle) + { + + } + + + +@code { + [Parameter] public bool ShowMiddle { get; set; } + + public BitAccordionList? List { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListScrollOnExpandTest.razor b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListScrollOnExpandTest.razor new file mode 100644 index 0000000000..104ff250bc --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListScrollOnExpandTest.razor @@ -0,0 +1,19 @@ +@using Bit.BlazorUI + +@* The option the key names is added by the same change that names it, so the list is asked to scroll to a + panel whose item has not registered itself - let alone its element - yet. *@ + + Body of the first + @if (ShowSecond) + { + Body of the second + } + + +@code { + [Parameter] public bool ShowSecond { get; set; } + + [Parameter] public string? ExpandedKey { get; set; } + + public BitAccordionList? List { get; set; } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListTests.cs index b8df1382ef..f20a07cf25 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AccordionList/BitAccordionListTests.cs @@ -307,6 +307,24 @@ public void BitAccordionListShouldSupportCustomTypeWithNameSelectors() Assert.IsTrue(contents[1].ClassList.Contains("bit-acd-cex")); } + [TestMethod] + public void BitAccordionListOptionsShouldNotReportBoundExpandedKeysThePageAlreadyHolds() + { + var component = RenderComponent(); + + Assert.AreEqual(1, component.FindAll(".bit-acd-con.bit-acd-cex").Count); + CollectionAssert.AreEqual(new[] { "second" }, component.Instance.ExpandedKeys.ToArray()); + Assert.AreEqual(0, component.Instance.ChangeCount); + + component.FindAll(".bit-acd-hdr")[0].Click(); + + component.WaitForAssertion(() => + { + CollectionAssert.AreEqual(new[] { "first", "second" }, component.Instance.ExpandedKeys.ToArray()); + Assert.AreEqual(1, component.Instance.ChangeCount); + }); + } + [TestMethod] public void BitAccordionListMultipleShouldSetRootClass() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavTests.cs index 31f7fc4f5e..6d98eb5a38 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Navs/Nav/BitNavTests.cs @@ -723,6 +723,55 @@ public void BitNavShouldRenderTheChildrenOfAnExpandedItemOnly() Assert.AreEqual(4, component.FindAll(".bit-nav-ict").Count); } + [TestMethod] + public void BitNavShouldKeepTheExpansionStateWhenItemsAreRebuiltWithNewInstances() + { + // A page that builds its items in a property hands the nav a fresh tree of fresh instances on every + // render, so an item the reader expanded has to be followed by its place in the tree, not by reference. + var component = RenderNav(TreeItems()); + + component.FindAll(".bit-nav-cbt")[0].Click(); + + Assert.AreEqual(4, component.FindAll(".bit-nav-ict").Count); + + component.Render(parameters => parameters.Add(p => p.Items, TreeItems())); + + Assert.AreEqual(4, component.FindAll(".bit-nav-ict").Count); + Assert.AreEqual("true", component.FindAll(".bit-nav-ict")[0].GetAttribute("aria-expanded")); + } + + [TestMethod] + public void BitNavShouldFollowAKeyedItemAcrossARebuildThatReordersIt() + { + static List Items(bool reversed) + { + List items = + [ + new() { Key = "fruits", Text = "Fruits", ChildItems = [new() { Text = "Apple" }] }, + new() { Key = "drinks", Text = "Drinks", ChildItems = [new() { Text = "Tea" }, new() { Text = "Coffee" }] }, + ]; + + if (reversed) items.Reverse(); + + return items; + } + + var component = RenderNav(Items(reversed: false)); + + // Expand Fruits, the first item. + component.FindAll(".bit-nav-cbt")[0].Click(); + + Assert.AreEqual(3, component.FindAll(".bit-nav-ict").Count); + + component.Render(parameters => parameters.Add(p => p.Items, Items(reversed: true))); + + // Fruits is now the second item and is still the one expanded, by its key rather than its place. + var headers = component.FindAll(".bit-nav-ict"); + Assert.AreEqual(3, headers.Count); + Assert.AreEqual("false", headers[0].GetAttribute("aria-expanded")); + Assert.AreEqual("true", headers[1].GetAttribute("aria-expanded")); + } + [TestMethod] public void BitNavShouldRespectAllExpanded() { diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Accordion/BitAccordionTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Accordion/BitAccordionTests.cs index 6117d20047..08b99000f6 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Accordion/BitAccordionTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Surfaces/Accordion/BitAccordionTests.cs @@ -2086,6 +2086,49 @@ public async Task BitAccordionShouldDropTheBusyStateOfACancelledToggleToo() Assert.IsFalse(com.Find(".bit-acd").ClassList.Contains("bit-acd-exp")); } + [TestMethod] + public void BitAccordionBusyShouldNotToggleOnClick() + { + var changes = 0; + + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Busy, true); + parameters.Add(p => p.DefaultIsExpanded, true); + parameters.Add(p => p.OnChange, (bool _) => changes++); + }); + + com.Find(".bit-acd-hdr").Click(); + + Assert.AreEqual(0, changes); + Assert.IsTrue(com.Find(".bit-acd").ClassList.Contains("bit-acd-exp")); + Assert.AreEqual("true", com.Find(".bit-acd-hdr").GetAttribute("aria-expanded")); + + com.Render(parameters => parameters.Add(p => p.Busy, false)); + + com.Find(".bit-acd-hdr").Click(); + + Assert.AreEqual(1, changes); + Assert.IsFalse(com.Find(".bit-acd").ClassList.Contains("bit-acd-exp")); + } + + [TestMethod] + public async Task BitAccordionBusyShouldStillAnswerTheMethods() + { + var com = RenderComponent(parameters => + { + parameters.Add(p => p.Busy, true); + }); + + await com.InvokeAsync(() => com.Instance.Expand()); + + Assert.IsTrue(com.Find(".bit-acd").ClassList.Contains("bit-acd-exp")); + + await com.InvokeAsync(() => com.Instance.Collapse()); + + Assert.IsFalse(com.Find(".bit-acd").ClassList.Contains("bit-acd-exp")); + } + [TestMethod] public void BitAccordionShouldNotReportTheHeaderAsBusyWithoutAnOnToggling() { From 5b2843aa6a79632a1ea63bb1e2eee22a2939da1f Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 14 Sep 2026 15:48:04 +0330 Subject: [PATCH 03/43] feat(blazorui): apply BitAppShell improvements #13153 (#13157) --- .../Components/AppShell/BitAppShell.razor | 10 +- .../Components/AppShell/BitAppShell.razor.cs | 930 +++++++++++- .../Components/AppShell/BitAppShell.scss | 114 +- .../Components/AppShell/BitAppShell.ts | 416 +++++- .../AppShell/BitAppShellClassStyles.cs | 9 +- .../BitAppShellJsRuntimeExtensions.cs | 26 +- .../JsInterop/ExtrasJsRuntimeExtensions.cs | 14 +- .../Bit.BlazorUI.Extras/Scripts/Extras.ts | 51 +- .../Styles/extra-general.scss | 14 + .../Styles/extra-variables.scss | 5 + .../BitScrollablePaneJsRuntimeExtensions.cs | 15 +- .../AppShell/AppShellDemoConsumer.razor | 16 + .../Extras/AppShell/AppShellDemoUser.cs | 6 + .../Extras/AppShell/BitAppShellDemo.razor | 613 +++++++- .../Extras/AppShell/BitAppShellDemo.razor.cs | 678 ++++++++- .../AppShell/BitAppShellDemo.razor.samples.cs | 369 +++++ .../AppShell/BitAppShellDemo.razor.scss | 106 ++ .../Extras/AppShell/BitAppShellTests.cs | 1316 ++++++++++++++++- 18 files changed, 4593 insertions(+), 115 deletions(-) create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoConsumer.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoUser.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.samples.cs diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor index 102db220cd..f616fc13b3 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor @@ -9,12 +9,18 @@ style="@StyleBuilder.Value" class="@ClassBuilder.Value" dir="@Dir?.ToString().ToLower()"> -
+
+ @* The direction is repeated here on purpose, and the pair of it and the LOGICAL inset variables the + two side bars are sized from is what keeps each safe area on the physical edge it belongs to. This + row lays its three children out in the reading direction, so the first of them is the physically + right one in a right-to-left shell - and the inline-start inset it reads is, under the same + direction, the inset of the physical right. Take either half away and the two stop cancelling out: + the left of the screen would be inset by whatever the right of it needed. *@
-
+
@ChildContent diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor.cs index e278276e34..46b5943b05 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.razor.cs @@ -6,15 +6,53 @@ namespace Bit.BlazorUI; /// /// BitAppShell is an advanced container to handle the nuances of a cross-platform layout. /// +/// +/// It is the outermost element of an application: it insets the four edges of the screen by the safe +/// areas the device reports, owns the one region the application scrolls in - which is what lets a Modal, +/// a Panel or an Overlay inside it hold the right scroller - and can keep the reader's place in the pages +/// they navigate between. +///
+/// The safe area insets are read from env(safe-area-inset-*), which the browser only reports as +/// anything other than zero on a page whose viewport meta tag carries viewport-fit=cover. +///
+/// What it measures is published on its root as CSS variables, so a page can lay its own chrome out +/// against the same numbers: --bit-ash-inset-top, --bit-ash-inset-bottom, +/// --bit-ash-inset-start and --bit-ash-inset-end for the four safe areas, and +/// --bit-ash-keyboard-inset for how much of the shell the on-screen keyboard is covering. +///
[SuppressMessage("Trimming", "IL2110:Field with 'DynamicallyAccessedMembersAttribute' is accessed via reflection. Trimmer can't guarantee availability of the requirements of the field.", Justification = "")] public partial class BitAppShell : BitComponentBase { + /// + /// The name the app shell cascades the element of its main (scrolling) container under, so that the + /// components which have to hold a scroller - Modal, Panel, Dialog, Overlay - can find the one the + /// application actually scrolls in without being handed it. + /// public const string Container = "BitAppShell.Container"; + /// + /// The id the main container of the app shell carries. + /// + /// + /// It is well known rather than unique because it is read before Blazor has started, by the script + /// that keeps the scrolling the reader did on the server-rendered shell. Only one element of a page + /// can carry an id, so the rare page that renders more than one app shell gives the extra ones an + /// Id of their own, off which their container ids are derived. + /// + public const string ContainerId = "BitAppShell-container"; + + private bool _subscribed; + private bool _scrollInit; + private bool _paneSetup; + private bool _autoScrolled; + private bool _keyboardSetup; private bool _locationChanged; + private string? _lastLocation; private ElementReference? _containerRef; + private BitScrollablePaneOptions? _paneOptions; + private DotNetObjectReference? _dotnetObj; @@ -26,8 +64,63 @@ public partial class BitAppShell : BitComponentBase /// /// Enables auto-scroll to the top of the main container on navigation. /// + /// + /// A navigation that only changes the fragment of the url - an in-page anchor - is left alone, since + /// scrolling to the top is the opposite of what following an anchor asks for. + /// takes precedence over this. + /// [Parameter] public bool AutoGoToTop { get; set; } + /// + /// Keeps the main container of the app shell pinned to the end of its content as the content grows. + /// + /// + /// This is what the chat, the log or the console of a shell wants: the newest content is at the end and + /// the shell stays there on its own. It pins the container as soon as it is turned on, and after that + /// only while the reader left it standing at the end - a reader who scrolled up to read something is + /// not dragged back down by the next arrival, and scrolling back to the end takes the pinning up again. + /// decides how near the end still counts as being at it, and + /// how the moves it makes are animated. + /// + [Parameter] public bool AutoScroll { get; set; } + + /// + /// How near the end of the content (in pixels) the main container has to have been left for + /// to keep pinning it there. + /// + /// + /// The default of 0 asks the reader to be at the very end, which is the strictest reading and the one a + /// chat usually wants. A larger value keeps the pinning going while they are within that many pixels of + /// it, so a line or two of slack does not count as having scrolled away. + /// + [Parameter] public int AutoScrollThreshold { get; set; } + + /// + /// Takes the height of the on-screen keyboard off the scrolling area of the app shell while it is + /// open, so the content is laid out in the room that is actually left rather than behind it. + /// + /// + /// Opening the keyboard leaves the layout viewport - and therefore every percentage height on the + /// page - exactly as it was on the platforms that need this, so a shell that is not told about it + /// goes on believing it owns a screen whose bottom is now covered. With this on, the shell publishes + /// the covered height on its root as the --bit-ash-keyboard-inset CSS variable and takes it off + /// the height of the middle, which is what brings a bottom bar declared inside the shell back above + /// the keyboard. The bottom safe area inset is given up for as long as the keyboard is open, since what + /// that inset keeps clear is covered by the keyboard anyway and a band of background above it would be + /// room taken from the content for nothing. + ///
+ /// The root is also marked with the data-bit-ash-keyboard attribute while the keyboard is up - + /// which is what the chrome that hides itself while the reader is typing can be styled against - and + /// the same measurement is reported to for whatever cannot be + /// placed in CSS alone. + ///
+ /// It reports 0 - and so does nothing - wherever the browser shrinks the layout viewport itself, which + /// is every desktop browser and any page asking for interactive-widget=resizes-content, and it + /// leaves the measurement alone while the page is pinch-zoomed, since a zoomed page shrinks its visual + /// viewport in exactly the way an open keyboard does. + ///
+ [Parameter] public bool AvoidKeyboard { get; set; } + /// /// The content of the app shell. /// @@ -38,10 +131,267 @@ public partial class BitAppShell : BitComponentBase /// [Parameter] public BitAppShellClassStyles? Classes { get; set; } + /// + /// Pins the app shell to the four edges of the screen, so that it fills the window whatever height the + /// page around it has. + /// + /// + /// The app shell otherwise fills the room it is given, which means the html and the body + /// of the host page have to be given a height of their own for it to have any - the commonest reason a + /// shell is reported as having no height at all. This takes the page out of the question: the shell is + /// positioned against the viewport instead, which is what the shell of an application wants anyway, + /// since nothing is meant to be laid out around it or scrolled past it. + /// + [Parameter, ResetClassBuilder] public bool FullScreen { get; set; } + + /// + /// Reserves the room the scrollbar of the main container takes, whether or not there is anything left + /// to scroll. + /// + /// + /// This is the CSS scrollbar-gutter property, and the one scroller of an application is where it + /// earns its keep: without it, every navigation between a page long enough to scroll and a page that is + /// not moves the whole layout sideways by the width of a scrollbar. It costs nothing at all where the + /// platform draws its scrollbars over the content, which is every mobile browser. + /// + [Parameter] public BitScrollbarGutter? Gutter { get; set; } + + /// + /// Removes the bottom safe area inset of the app shell, leaving the other three where they are. + /// + /// + /// For the application that insets that one edge itself - a bottom bar padding itself by the inset so + /// that its background paints behind the home indicator, in a shell that still keeps the status bar and + /// the rounded sides clear. is the same thing for all four edges at once. + /// + [Parameter, ResetClassBuilder] public bool NoBottomInset { get; set; } + + /// + /// Removes the trailing side safe area inset of the app shell - the one on the right of a left-to-right + /// shell and on the left of a right-to-left one - leaving the other three where they are. + /// See . + /// + [Parameter, ResetClassBuilder] public bool NoEndInset { get; set; } + + /// + /// Removes the safe area insets, so the four edges of the app shell are not inset at all and the + /// content fills the whole screen. + /// + /// + /// For an application that insets its own edges - a header that pads itself by the top inset so that + /// its background paints behind the status bar - which is what an edge-to-edge layout asks for. + /// + [Parameter, ResetClassBuilder] public bool NoInsets { get; set; } + + /// + /// Prevents the reader from scrolling the main container of the app shell at all. + /// + /// + /// The content that overflows is clipped rather than reachable. The scrolling API of this component + /// still moves the container, since overflow: hidden only stops the reader's own gestures. + /// + [Parameter] public bool NoScroll { get; set; } + + /// + /// Removes the leading side safe area inset of the app shell - the one on the left of a left-to-right + /// shell and on the right of a right-to-left one - leaving the other three where they are. + /// See . + /// + [Parameter, ResetClassBuilder] public bool NoStartInset { get; set; } + + /// + /// Removes the top safe area inset of the app shell, leaving the other three where they are. + /// + /// + /// For the application that insets that one edge itself - a header padding itself by the inset so that + /// its background paints behind the status bar, in a shell that still keeps the home indicator clear, + /// which is the commonest half of an edge-to-edge layout. See . + /// + [Parameter, ResetClassBuilder] public bool NoTopInset { get; set; } + + /// + /// Callback for how much of the app shell the on-screen keyboard covers, in pixels, raised as that + /// changes and with 0 as the keyboard closes. + /// + /// + /// It is what publishes as the --bit-ash-keyboard-inset CSS + /// variable, handed to the page as a number as well so the chrome that cannot be placed in CSS alone - + /// a map to re-center, a list to keep the selected row of in view - can be moved with it. Nothing is + /// measured, and so nothing is reported, on a shell that has not asked to avoid the keyboard. + /// + [Parameter] public EventCallback OnKeyboardInsetChanged { get; set; } + + /// + /// Callback for when the main container of the app shell reaches the bottom of its content. + /// + /// + /// It is raised once per arrival rather than on every frame that stays there, and how near the bottom + /// counts as having reached it is . + /// + [Parameter] public EventCallback OnReachedBottom { get; set; } + /// + /// Callback for when the main container of the app shell reaches the visual left edge of its content. + /// See . + /// + /// + /// The edge is the one on the screen rather than the one in reading order, so it is the same edge in a + /// right-to-left shell. + /// + [Parameter] public EventCallback OnReachedLeft { get; set; } + + /// + /// Callback for when the main container of the app shell reaches the visual right edge of its content. + /// See . + /// + [Parameter] public EventCallback OnReachedRight { get; set; } + + + /// + /// Callback for when the main container of the app shell reaches the top of its content. + /// See . + /// + [Parameter] public EventCallback OnReachedTop { get; set; } + + /// + /// Callback for the scroll position of the main container of the app shell, raised as it is scrolled. + /// + /// + /// Reporting costs a listener and a measurement per frame of every scroll, so nothing is reported at + /// all until one of the scroll callbacks is given a handler. Use to + /// report less often than once per frame. + /// + [Parameter] public EventCallback OnScroll { get; set; } + + /// + /// Callback for when a scroll of the main container of the app shell comes to a stop. + /// + [Parameter] public EventCallback OnScrollEnd { get; set; } + + /// + /// Callback for when a scroll of the main container of the app shell begins. + /// + [Parameter] public EventCallback OnScrollStart { get; set; } + + /// + /// What the main container of the app shell does with content that overflows it sideways. + /// + /// + /// The main container scrolls along both axes, which is what an application laying a page out wider + /// than the screen needs. Set this to to clip that overflow instead, + /// so the one element a few pixels too wide cannot leave the whole application scrollable sideways - + /// the commonest layout bug of a mobile web app. takes both axes away at once + /// and wins over this. + /// + [Parameter] public BitOverflow? OverflowX { get; set; } + + /// + /// What the main container of the app shell does with content that overflows it downwards. + /// See . + /// + [Parameter] public BitOverflow? OverflowY { get; set; } + + /// + /// Determines what happens when the main container of the app shell is scrolled past its edge. + /// + /// + /// Left unset, the app shell behaves as , which is what an + /// application-like layout wants: no scroll chaining out of the shell, and no pull-to-refresh or + /// rubber-banding of the page behind it. Set it to to give the + /// platform's own overscroll affordances back to a shell that is really a web page. + /// + [Parameter] public BitOverscroll? Overscroll { get; set; } + /// /// Persists scroll position of the main container and restores it on navigation. /// + /// + /// The positions are kept per url in session storage, so they survive a reload and are gone when the + /// tab is - forgets them sooner. A navigation that only changes the + /// fragment of the url is left alone, so an in-page anchor still works. It takes precedence over + /// . + ///
+ /// The store belongs to the page rather than to this component, so it is the one app shell of an + /// application that is meant to ask for it: a second shell doing so on the same page would take the + /// store over from the first. + ///
[Parameter] public bool PersistScroll { get; set; } + /// + /// Keeps the reader's place when content is added above what they are looking at. + /// + /// + /// This is the other half of an endless list: a page of older messages arriving at the top of a + /// conversation is as tall as the messages in it, and without this it pushes what the reader was + /// reading that far down the screen. With it the container is moved down by exactly what arrived, so + /// what they were looking at does not move at all. + ///
+ /// Every engine but WebKit already does this on its own (it is the CSS overflow-anchor + /// behavior), so this changes nothing where the browser is anchoring the container and brings the rest + /// - Safari, most of all - up to the same behavior. It is worth pairing with + /// and a of about a screenful, so the fetch starts before the reader is at + /// the top rather than once they are. + ///
+ [Parameter] public bool PreserveScroll { get; set; } + + + /// + /// How near an edge (in pixels) counts as having reached it, for and + /// . + /// + [Parameter] public int ReachOffset { get; set; } + + /// + /// The scroll behavior of the main container of the app shell. + /// + /// + /// It decides how every move the container is not dragged to by the reader is made: the scrolling API + /// of this component, a fragment navigation into it, and the browser bringing a focused element into + /// view. The default is , which is taken back off on its own + /// under the reduced motion preference unless ForceAnimation is set. + /// + [Parameter] public BitScrollBehavior? ScrollBehavior { get; set; } + /// + /// The room the main container of the app shell keeps between its edges and anything scrolled into + /// view inside it, as any CSS length. + /// + /// + /// This is the CSS scroll-padding property, and it is what keeps a header stuck to the top of + /// the shell from covering whatever was just scrolled to - by a fragment navigation, by the browser + /// bringing a focused field into view, or by , which reads it as well so + /// that the moves this component makes leave the same room the browser's own do. The other scrolling + /// methods take an absolute position or a distance and are left alone by it. + ///
+ /// A shell whose header is the height of the top safe area plus a bar of its own can say so: + /// ScrollPadding="calc(var(--bit-ash-inset-top) + 3rem) 0 0 0". + ///
+ [Parameter] public string? ScrollPadding { get; set; } + + + /// + /// The shortest interval (in milliseconds) between two reports. + /// The default of 0 reports once per animation frame. + /// + [Parameter] public int ScrollThrottle { get; set; } + + /// + /// Sizes the four inset bars from the largest safe areas the device can ask for rather than from the + /// ones it is asking for right now, so the layout is not relaid out as the browser's own chrome slides + /// in and out. + /// + /// + /// The insets a browser reports are not constants: an edge-to-edge Chrome on Android retracts its + /// bottom bar as the reader scrolls down and brings it back on the way up, and the bottom inset follows + /// it the whole way - so the bar sized from it, and everything laid out against it, is moved on every + /// frame of that slide. With this on the shell is sized from the static maximums instead + /// (env(safe-area-max-inset-*)): the layout is laid out once, for the room left when nothing is + /// retracted, and the browser slides its own chrome over the background of an inset bar rather than + /// over the content. It costs that much room on the screen for as long as the chrome is retracted, + /// which is the trade being made. + ///
+ /// A browser that does not report the maximums - which is every one but Chromium 135 and later, and + /// every platform whose insets do not move in the first place - is left reading the insets it does + /// report, so this changes nothing there. + ///
+ [Parameter, ResetClassBuilder] public bool StableInsets { get; set; } /// /// Custom CSS styles for different parts of the app shell. @@ -60,20 +410,173 @@ public partial class BitAppShell : BitComponentBase + /// + /// The element reference to the main container of the app shell. + /// + public ElementReference? ContainerRef => _containerRef; + + /// + /// The id of the main container element of this app shell. + /// + /// + /// It is unless an Id was given to the app shell, in which case it + /// is that id with -container after it - which is how a page that renders more than one app + /// shell keeps each container id to itself. + /// + public string MainContainerId => Id.HasValue() ? $"{Id}-container" : ContainerId; + + + /// /// Scrolls the main container to top. /// + /// + /// How the move is made. When it is not given, the of the app shell + /// decides, which honors the reduced motion preference. + /// public async Task GoToTop(BitScrollBehavior? behavior = null) { if (_containerRef.HasValue is false) return; - await _js.BitExtrasGoToTop(_containerRef.Value, behavior); + await InvokeJs(() => _js.BitExtrasGoToTop(_containerRef!.Value, behavior)); } /// - /// The element reference to the main container of the app shell. + /// Scrolls the main container to the bottom of its content. /// - public ElementReference? ContainerRef => _containerRef; + /// See . + public async Task GoToBottom(BitScrollBehavior? behavior = null) + { + if (_containerRef.HasValue is false) return; + + await InvokeJs(() => _js.BitExtrasGoToBottom(_containerRef!.Value, behavior)); + } + + /// + /// Scrolls the main container to a position. + /// + /// The horizontal position, or null to leave that axis where it stands. + /// The vertical position, or null to leave that axis where it stands. + /// See . + public async Task ScrollTo(double? left, double? top, BitScrollBehavior? behavior = null) + { + if (_containerRef.HasValue is false) return; + + await InvokeJs(() => _js.BitExtrasScrollTo(_containerRef!.Value, left, top, behavior)); + } + + /// + /// Scrolls the main container by an amount, from wherever it currently stands. + /// + /// How far to move sideways, in pixels. + /// How far to move up or down, in pixels. + /// See . + public async Task ScrollBy(double x, double y, BitScrollBehavior? behavior = null) + { + if (_containerRef.HasValue is false) return; + + // A NaN or an infinity is not a distance, and the serializer of the interop call refuses both, so a + // call made with one is dropped here rather than thrown out of the caller's own event handler. + if (double.IsFinite(x) is false || double.IsFinite(y) is false) return; + + await InvokeJs(() => _js.BitExtrasScrollBy(_containerRef!.Value, x, y, behavior)); + } + + /// + /// Brings an element inside the main container into view by scrolling the container itself. + /// + /// The id of the element to scroll to. + /// How much room (in pixels) to leave above it, for a sticky header of the page. + /// Whether the move is animated. Honors the reduced motion preference. + /// Where in the container the element comes to rest. + /// + /// How the move is made, which wins over both and + /// where it is given. Left out, the move is animated only where the two + /// of them agree that it should be. + /// + public async Task ScrollToElement(string elementId, + double offset = 0, + bool smooth = true, + BitScrollAlignment alignment = BitScrollAlignment.Start, + BitScrollBehavior? behavior = null) + { + if (_containerRef.HasValue is false || elementId.HasNoValue()) return; + + // The behavior is the same argument every other move of this component takes, and it wins over + // both the flag beside it and the property of the shell where it is given. Left out, the two of + // them have to agree: the flag is what this call asks for and the property is what the shell + // moves like, so a shell told to move instantly is not animated by a flag that was never set. + var animated = behavior switch + { + BitScrollBehavior.Smooth => true, + BitScrollBehavior.Instant => false, + BitScrollBehavior.Auto => ScrollBehavior is null or BitScrollBehavior.Smooth, + _ => smooth && ScrollBehavior is null or BitScrollBehavior.Smooth + }; + + await InvokeJs(() => _js.BitScrollablePaneScrollToElement(_containerRef!.Value, + elementId, + offset, + animated, + alignment.ToString().ToLowerInvariant())); + } + + /// + /// Re-measures the main container of the app shell and reports whatever has changed since it was last + /// measured. + /// + /// + /// The container watches both its own size and its content on its own, so this is only for the changes + /// neither of those can see - a web font that finished loading, an image that settled at a size the + /// markup never named - after which the edge callbacks and the pinning of are + /// brought back up to date. It does nothing on a shell that asked for none of them, since such a shell + /// has no browser side to bring up to date. + /// + public async Task Refresh() + { + if (_paneSetup is false) return; + + await InvokeJs(() => _js.BitScrollablePaneRefresh(UniqueId)); + } + + /// + /// Reads where the main container currently stands, measured in the browser. + /// + /// + /// It is a read of the element at the moment it is asked for, so it needs none of the scroll + /// callbacks to have been given a handler. + /// + public async Task GetScrollOffset() + { + if (_containerRef.HasValue is false) return null; + + try + { + return await _js.BitScrollablePaneGetOffset(_containerRef.Value); + } + catch (JSDisconnectedException) { return null; } + catch (ObjectDisposedException) { return null; } + } + + /// + /// Forgets every scroll position has kept, for the pages of this app + /// shell and of any other. + /// + /// + /// The positions are kept for the whole session, so an application that signs a user out calls this + /// to keep the next one from being put back where the previous one was. Given a url, only that one page + /// is forgotten, which is what a page whose content has been replaced under the reader - a list that was + /// filtered, a search that was run again - wants, since the position it was left at no longer points at + /// anything. + /// + /// + /// The url to forget, exactly as NavigationManager.Uri reports it. When it is not given, every + /// position is forgotten. + /// + public async Task ClearPersistedScroll(string? url = null) + { + await InvokeJs(() => _js.BitAppShellClearScrolls(url)); + } @@ -82,6 +585,28 @@ public async Task GoToTop(BitScrollBehavior? behavior = null) protected override void RegisterCssClasses() { ClassBuilder.Register(() => Classes?.Root); + + // The insets are the four bars around the main container, so the flag that removes them belongs + // on the root all four of them are sized from. + ClassBuilder.Register(() => NoInsets ? "bit-ash-nin" : string.Empty); + + // The shell is taken out of the flow of the host page and positioned against the viewport itself, + // so it has a height of its own without the page having given html and body one. + ClassBuilder.Register(() => FullScreen ? "bit-ash-fsc" : string.Empty); + + // Written before the flags that take an inset away, and the stylesheet keeps them in that order, + // so a shell asking for both is left with the edge removed rather than with the largest inset the + // device can ask for. + ClassBuilder.Register(() => StableInsets ? "bit-ash-sin" : string.Empty); + + // And each of the four can be taken back to zero on its own, for the application that insets that + // one edge itself: a header painting behind the status bar over a shell that still keeps the home + // indicator clear is an edge-to-edge layout of the top edge alone. The two side ones are the + // LOGICAL edges, so each of them stays with the reading direction the bars are laid out in. + ClassBuilder.Register(() => NoTopInset ? "bit-ash-nit" : string.Empty); + ClassBuilder.Register(() => NoBottomInset ? "bit-ash-nib" : string.Empty); + ClassBuilder.Register(() => NoStartInset ? "bit-ash-nis" : string.Empty); + ClassBuilder.Register(() => NoEndInset ? "bit-ash-nie" : string.Empty); } protected override void RegisterCssStyles() @@ -89,14 +614,91 @@ protected override void RegisterCssStyles() StyleBuilder.Register(() => Styles?.Root); } - protected override void OnInitialized() + // The class and the style of the main container, which is the element that actually scrolls - so + // everything about the scrolling of the app shell lands here rather than on the root. + private string _MainClass => string.Join(' ', new[] + { + "bit-ash-main", + // Smooth is the default of the app shell, so the class is on unless another behavior was asked + // for. The stylesheet takes it back off under the reduced motion preference; a behavior handed to + // one of the scrolling methods overrides the property either way, which is why the methods that + // are called without one pass nothing at all and let this property decide. + ScrollBehavior is null or BitScrollBehavior.Smooth ? "bit-ash-smt" : null, + NoScroll ? "bit-ash-nsc" : null, + Classes?.Main + }.Where(c => c.HasValue())); + + private string? _MainStyle { - if (AutoGoToTop || PersistScroll) + get { - _navManager.LocationChanged += LocationChanged; + // Everything the scrolling container is styled with lands in this one attribute, in the order + // the declarations override one another in: whatever the page wrote first, then the ones this + // component derives from its parameters, and NoScroll over all of it - so a shell the reader + // is not to be able to move is never left movable by an overflow the page happened to write. + List declarations = + [ + Styles?.Main?.TrimEnd().TrimEnd(';'), + + Overscroll switch + { + BitOverscroll.Auto => "overscroll-behavior:auto", + BitOverscroll.Contain => "overscroll-behavior:contain", + BitOverscroll.None => "overscroll-behavior:none", + _ => null + }, + + // Each axis is spelled out as its own longhand rather than folded into the shorthand, + // which would also reset the axis it was not asked about. + OverflowX switch + { + BitOverflow.Auto => "overflow-x:auto", + BitOverflow.Hidden => "overflow-x:hidden", + BitOverflow.Scroll => "overflow-x:scroll", + BitOverflow.Visible => "overflow-x:visible", + _ => null + }, + + OverflowY switch + { + BitOverflow.Auto => "overflow-y:auto", + BitOverflow.Hidden => "overflow-y:hidden", + BitOverflow.Scroll => "overflow-y:scroll", + BitOverflow.Visible => "overflow-y:visible", + _ => null + }, + + // Auto is the initial value, which the container already has. + Gutter switch + { + BitScrollbarGutter.Stable => "scrollbar-gutter:stable", + BitScrollbarGutter.BothEdges => "scrollbar-gutter:stable both-edges", + _ => null + }, + + ScrollPadding.HasValue() ? $"scroll-padding:{ScrollPadding}" : null, + + // The class of the container says this as well, but a style attribute wins over a class: + // an overflow written into Styles.Main, or an axis asked for above, would otherwise leave + // the container scrollable after all. + NoScroll ? "overflow:hidden" : null, + ]; + + // Two declarations landing in the same style attribute are only two declarations while a + // semicolon stands between them. + var style = string.Join(';', declarations.Where(d => d.HasValue())); + + return style.HasValue() ? style : null; } + } + + protected override void OnParametersSet() + { + // The two navigation features are parameters like any other, so turning either of them on after + // the shell has been rendered has to subscribe it - and turning both of them off, unsubscribe it. + UpdateSubscription(); - base.OnInitialized(); + base.OnParametersSet(); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -111,36 +713,306 @@ protected override async Task OnAfterRenderAsync(bool firstRender) StateHasChanged(); } - if (firstRender && PersistScroll) - { - if (_containerRef.HasValue is false) return; + await SetupPersistScroll(); - await _js.BitAppShellInitScroll(_containerRef.Value, _navManager.Uri); - } + await SetupScrollReporting(); + + await SetupKeyboard(); if (_locationChanged && firstRender is false) { _locationChanged = false; - await _js.BitAppShellAfterRenderScroll(_navManager.Uri); + await InvokeJs(() => _js.BitAppShellAfterRenderScroll(_navManager.Uri)); } + + await base.OnAfterRenderAsync(firstRender); } - private void LocationChanged(object? sender, LocationChangedEventArgs args) + // The scroll persistence of the browser side, followed as the parameter flips rather than settled on + // the first render: turning it on later starts it, and turning it off again stops it rather than + // leaving a listener behind that goes on writing positions nothing will ever restore. + private async Task SetupPersistScroll() { + if (PersistScroll == _scrollInit) return; + if (PersistScroll) { - if (IsRendered) + if (_containerRef.HasValue is false) return; + + _scrollInit = true; + + await InvokeJs(() => _js.BitAppShellInitScroll(_containerRef.Value, _navManager.Uri)); + return; + } + + _scrollInit = false; + _locationChanged = false; + + await InvokeJs(() => _js.BitAppShellDisposeScroll()); + } + + // The keyboard tracking, followed as the parameter flips rather than settled on the first render, so + // that a page which only avoids the keyboard on the screens that have a field on them can turn it on + // and off. It costs two listeners on the visual viewport, which is why it is not simply always on. + private async Task SetupKeyboard() + { + if (AvoidKeyboard == _keyboardSetup) return; + + _keyboardSetup = AvoidKeyboard; + + if (AvoidKeyboard is false) + { + await InvokeJs(() => _js.BitAppShellDisposeKeyboard(UniqueId)); + return; + } + + // The reference is handed over whether or not anything is listening for the measurement, since a + // page that starts listening later would otherwise have to make the shell set its tracking up + // again to be heard; the browser side only calls back when the measurement CHANGES, which is once + // or twice per keyboard rather than per frame. + _dotnetObj ??= DotNetObjectReference.Create(this); + + await InvokeJs(() => _js.BitAppShellSetupKeyboard(UniqueId, RootElement, _dotnetObj)); + } + + // The scroll reporting is driven by the very engine BitScrollablePane uses, so there is not a second + // implementation of measuring, throttling and edge detection in the library. It is only ever set up + // for a shell that asked for one of the reports, since it costs a listener and a measurement per + // frame of every scroll. + private async Task SetupScrollReporting() + { + if (_containerRef.HasValue is false) return; + + var options = BuildPaneOptions(); + + if (options is null) + { + _autoScrolled = false; + + if (_paneSetup is false) return; + + _paneSetup = false; + await InvokeJs(() => _js.BitScrollablePaneDispose(UniqueId)); + return; + } + + if (_paneSetup is false) + { + _paneSetup = true; + _paneOptions = options; + _dotnetObj ??= DotNetObjectReference.Create(this); + + await InvokeJs(() => _js.BitScrollablePaneSetup(UniqueId, _containerRef!.Value, _dotnetObj, options)); + } + else if (options != _paneOptions) + { + // Nothing is sent for a set of options the browser side already has, so a shell that + // re-renders on every navigation does not re-configure its scroller on every navigation. + _paneOptions = options; + await InvokeJs(() => _js.BitScrollablePaneUpdate(UniqueId, options)); + } + + // The first pinning is the one this side has to ask for: it is unconditional - a shell that starts + // out with content already in it belongs at the end of it - and the browser side has nothing to + // compare against on its very first measurement, so it would leave a shell that has not scrolled + // yet standing at the top. Every pinning after that is its own answer to the content it watches, + // without a round trip per render. + if (AutoScroll) + { + if (_autoScrolled is false) { - _locationChanged = true; - _ = _js.BitAppShellLocationChangedScroll(_navManager.Uri); + _autoScrolled = true; + + await InvokeJs(() => _js.BitScrollablePaneAutoScroll(UniqueId, true)); } } + else + { + _autoScrolled = false; + } + } + + // What the browser side is driven with, or null when nothing has been asked of it at all. NoScroll is + // passed along because the engine has to know not to move a container whose page alone decides where + // it stands, but it is not on its own a reason to start one: the reader's own scrolling is already + // stopped by `overflow: hidden` in the stylesheet, and the gestures that flag holds the engine back + // from - drag, wheel, momentum - are not ones the shell ever turns on. + private BitScrollablePaneOptions? BuildPaneOptions() + { + var scroll = OnScroll.HasDelegate; + var scrollStart = OnScrollStart.HasDelegate; + var scrollEnd = OnScrollEnd.HasDelegate; + var top = OnReachedTop.HasDelegate; + var bottom = OnReachedBottom.HasDelegate; + var left = OnReachedLeft.HasDelegate; + var right = OnReachedRight.HasDelegate; + + if (scroll is false && scrollStart is false && scrollEnd is false && + top is false && bottom is false && left is false && right is false && + AutoScroll is false && PreserveScroll is false) return null; + + return new() + { + Scroll = scroll, + ScrollStart = scrollStart, + ScrollEnd = scrollEnd, + Top = top, + Bottom = bottom, + Left = left, + Right = right, + Offset = ReachOffset, + Throttle = ScrollThrottle, + NoScroll = NoScroll, + AutoScroll = AutoScroll, + AutoScrollThreshold = AutoScrollThreshold, + Preserve = PreserveScroll, + Smooth = ScrollBehavior is null or BitScrollBehavior.Smooth, + }; + } + + private void UpdateSubscription() + { + var needed = AutoGoToTop || PersistScroll; + + if (needed == _subscribed) return; + + _subscribed = needed; + + if (needed) + { + // Where the application stands as the subscription is taken, so that the FIRST navigation + // after it can be told apart from an in-page anchor as well as every one after that. + _lastLocation = _navManager.Uri; + + _navManager.LocationChanged += LocationChanged; + } + else + { + _navManager.LocationChanged -= LocationChanged; + } + } + + private void LocationChanged(object? sender, LocationChangedEventArgs args) + { + // An in-page anchor is a navigation like any other as far as the NavigationManager is concerned, + // and both features below would fight what following an anchor is asking for: one sends the + // reader to the top of the page they just jumped into, the other to whatever position was stored + // for a url key that has never been scrolled. + if (TakeLocation(args.Location) is false) return; + + if (PersistScroll) + { + if (IsRendered is false) return; + + _locationChanged = true; + + _ = InvokeJs(() => _js.BitAppShellLocationChangedScroll()); + + // The restore happens on the next render of this component. In its intended place - the + // layout - the new page arrives as this component's own ChildContent, so that render is + // guaranteed; asking for it here is what makes the restore work for a shell rendered anywhere + // else too, instead of leaving its browser side with the scroll listener detached. + // Through the dispatcher, since a navigation is not always raised on the renderer's thread - + // a NavigateTo from a background task is not - and a render queued from another one throws. + _ = InvokeAsync(StateHasChanged); + } else if (AutoGoToTop) { - _ = GoToTop(BitScrollBehavior.Instant); + _ = GoToTop(ScrollBehavior ?? BitScrollBehavior.Instant); + } + } + + // Remembers where the application now stands and answers whether that was a navigation to another + // PAGE, which is the only kind either feature above acts on. A move that left everything but the + // fragment of the url the same is an in-page anchor, and both of them would fight what following one + // is asking for. The url the NavigationManager reports is absolute, so the two are compared with the + // fragment cut off each of them. + private bool TakeLocation(string location) + { + var previous = _lastLocation; + + _lastLocation = location; + + if (previous is null) return true; + + if (string.Equals(previous, location, StringComparison.Ordinal)) return true; + + return string.Equals(WithoutFragment(previous), WithoutFragment(location), StringComparison.Ordinal) is false; + } + + private static string WithoutFragment(string url) + { + var index = url.IndexOf('#', StringComparison.Ordinal); + + return index < 0 ? url : url[..index]; + } + + // Every call out to the browser goes through here, so that the two failures none of them can do + // anything about - a circuit that dropped, and a runtime already torn down - are never surfaced as an + // unhandled exception of an application that did nothing wrong. + private async Task InvokeJs(Func action) + { + try + { + await action(); } + catch (JSDisconnectedException) { } + catch (ObjectDisposedException) { } + } + + + + [JSInvokable("OnScroll")] + public async Task _OnScroll(BitScrollOffset offset) + { + if (IsDisposed || offset is null) return; + + await OnScroll.InvokeAsync(offset); + } + + [JSInvokable("OnScrollStart")] + public async Task _OnScrollStart(BitScrollOffset offset) + { + if (IsDisposed || offset is null) return; + + await OnScrollStart.InvokeAsync(offset); + } + + [JSInvokable("OnScrollEnd")] + public async Task _OnScrollEnd(BitScrollOffset offset) + { + if (IsDisposed || offset is null) return; + + await OnScrollEnd.InvokeAsync(offset); + } + + [JSInvokable("OnReached")] + public async Task _OnReached(string edge) + { + if (IsDisposed) return; + + var callback = edge switch + { + "top" => OnReachedTop, + "bottom" => OnReachedBottom, + "left" => OnReachedLeft, + "right" => OnReachedRight, + _ => default + }; + + if (callback.HasDelegate is false) return; + + await callback.InvokeAsync(); + } + + [JSInvokable("OnKeyboardInset")] + public async Task _OnKeyboardInset(double inset) + { + if (IsDisposed || OnKeyboardInsetChanged.HasDelegate is false) return; + + await OnKeyboardInsetChanged.InvokeAsync(inset); } @@ -150,16 +1022,26 @@ protected override async ValueTask DisposeAsync(bool disposing) if (IsDisposed || disposing is false) return; _navManager.LocationChanged -= LocationChanged; + _subscribed = false; - if (PersistScroll) + if (_scrollInit) { - try - { - await _js.BitAppShellDisposeScroll(); - } - catch (JSDisconnectedException) { } // we can ignore this exception here + await InvokeJs(() => _js.BitAppShellDisposeScroll()); + } + + if (_paneSetup) + { + await InvokeJs(() => _js.BitScrollablePaneDispose(UniqueId)); } + if (_keyboardSetup) + { + await InvokeJs(() => _js.BitAppShellDisposeKeyboard(UniqueId)); + } + + _dotnetObj?.Dispose(); + _dotnetObj = null; + await base.DisposeAsync(disposing); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.scss index f9aa459c37..b20aeaf7bb 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.scss @@ -1,7 +1,33 @@ @import '../../Styles/extra-variables.scss'; @import '../../../Bit.BlazorUI/Styles/functions.scss'; +// Deliberately outside the library's z-index scale (which tops out at the snackbar's 1400). The four +// bars mask the physical chrome of the device - the notch, the rounded corners, the home indicator - so +// there is nothing an application can render that is allowed to paint over them. +$bit-ash-inset-zindex: 999999; + +// The four bars around the main container are sized from custom properties of their own rather than +// straight off the env() values, so that NoInsets has one place to take all four back to zero and a page +// can override a single edge without having to know how the bar is built. +// The two side ones read the LOGICAL insets: the center is laid out in the reading direction, so its +// first child is the physically right one in a right-to-left app shell - and the physical right edge is +// the one whose inset it has to be sized by. .bit-ash { + --bit-ash-inset-top: #{$bit-env-inset-top}; + --bit-ash-inset-bottom: #{$bit-env-inset-bottom}; + --bit-ash-inset-start: #{$bit-env-inset-inline-start}; + --bit-ash-inset-end: #{$bit-env-inset-inline-end}; + // How much of the shell the on-screen keyboard is covering. It stays 0 unless AvoidKeyboard is set, + // which is what puts the measured height here from the browser side; a page can read it to place + // chrome of its own against the same number. + --bit-ash-keyboard-inset: 0px; + // How much of the bottom inset is still worth keeping while the keyboard is open. The inset is there + // to keep content off the home indicator, and the keyboard is already covering everything that far up + // the screen - so a bar of the shell's background left below the content and above the keyboard is + // room taken from the content for nothing at all. What the keyboard has taken over is taken off here, + // and the middle below is sized from this rather than from the inset itself. + --bit-ash-inset-bottom-visible: max(0px, calc(var(--bit-ash-inset-bottom) - var(--bit-ash-keyboard-inset))); + width: 100%; height: 100%; display: flex; @@ -9,24 +35,71 @@ background-color: $clr-bg-pri; } +// The insets a browser reports change while its own retractable UI slides in and out - the bottom bar of +// an edge-to-edge Chrome on Android does it on every scroll - which moves the two bars sized from them on +// every frame of that slide. Asked for stable insets, the shell is sized from the STATIC maximums of the +// same four instead: the layout is laid out once, for the room left when nothing is retracted, and the +// browser slides its UI over the background of a bar rather than over the content. +.bit-ash-sin { + --bit-ash-inset-top: #{$bit-env-max-inset-top}; + --bit-ash-inset-bottom: #{$bit-env-max-inset-bottom}; + --bit-ash-inset-start: #{$bit-env-max-inset-inline-start}; + --bit-ash-inset-end: #{$bit-env-max-inset-inline-end}; +} + +.bit-ash-nin { + --bit-ash-inset-top: 0px; + --bit-ash-inset-bottom: 0px; + --bit-ash-inset-start: 0px; + --bit-ash-inset-end: 0px; +} + +// And one edge at a time, for the application that insets that edge itself. These come after both rules +// above, so an edge taken away stays away whether the shell was asked for stable insets or for none: a +// shell asking for both has asked for one edge to be gone, and the other three to be sized either way. +.bit-ash-nit { + --bit-ash-inset-top: 0px; +} + +.bit-ash-nib { + --bit-ash-inset-bottom: 0px; +} + +.bit-ash-nis { + --bit-ash-inset-start: 0px; +} + +.bit-ash-nie { + --bit-ash-inset-end: 0px; +} + +// The shell of an application owns the window: nothing is laid out around it and nothing is scrolled +// past it, so it can be positioned against the viewport rather than sized by whatever room the page gives +// it - which is what saves the host page from having to carry a height of its own down through html and +// body. The height above resolves against the viewport here, so both ways of asking agree. +.bit-ash-fsc { + inset: 0; + position: fixed; +} + .bit-ash-top { width: 100%; - z-index: 999999; - height: $bit-env-inset-top; + z-index: $bit-ash-inset-zindex; + height: var(--bit-ash-inset-top); background-color: $clr-bg-pri; } .bit-ash-bottom { width: 100%; - z-index: 999999; - height: $bit-env-inset-bottom; + z-index: $bit-ash-inset-zindex; + height: var(--bit-ash-inset-bottom-visible); background-color: $clr-bg-pri; } .bit-ash-center { width: 100%; display: flex; - height: calc(100% - $bit-env-inset-top - $bit-env-inset-bottom); + height: calc(100% - var(--bit-ash-inset-top) - var(--bit-ash-inset-bottom-visible) - var(--bit-ash-keyboard-inset)); } .bit-ash-main { @@ -34,21 +107,40 @@ display: flex; overflow: auto; position: relative; - scroll-behavior: smooth; overscroll-behavior: none; - width: calc(100% - $bit-env-inset-left - $bit-env-inset-right); + width: calc(100% - var(--bit-ash-inset-start) - var(--bit-ash-inset-end)); +} + +// Applies to every move the main container is not dragged to by the reader: the scrolling API of the +// component, a fragment navigation into it, and the browser bringing a focused element into view. +.bit-ash-smt { + scroll-behavior: smooth; +} + +// The force-animation class opts a whole SUBTREE out of the preference, so a shell carrying it keeps its +// smooth scrolling - which is what every other animated component in the library does with it. +@media (prefers-reduced-motion: reduce) { + .bit-ash-smt:not(.bit-fam):not(.bit-fam *) { + scroll-behavior: auto; + } +} + +// Only the reader's own gestures are stopped. The element stays scrollable through the scrolling API, +// which is what the public methods of the component move it with. +.bit-ash-nsc { + overflow: hidden; } .bit-ash-left { height: 100%; - z-index: 999999; - width: $bit-env-inset-left; + z-index: $bit-ash-inset-zindex; + width: var(--bit-ash-inset-start); background-color: $clr-bg-pri; } .bit-ash-right { height: 100%; - z-index: 999999; - width: $bit-env-inset-right; + z-index: $bit-ash-inset-zindex; + width: var(--bit-ash-inset-end); background-color: $clr-bg-pri; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts index dc2e83e512..da7feb325c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShell.ts @@ -2,40 +2,113 @@ namespace BitBlazorUI { export class AppShell { private static STORE_KEY = 'bit-appshell-scrolls'; + // How many positions are kept. The map is one JSON blob in session storage, so an app that is + // navigated around for an hour would otherwise carry every url it has ever shown into every + // write. The oldest entries are the ones dropped, which is what the insertion order of the object + // gives for free: a url that is visited again is re-inserted at the end by touch(). + private static STORE_MAX = 100; + public static PreScroll: number = 0; private static _currentUrl: string; private static _container: HTMLElement | undefined; private static _scrolls: { [key: string]: number | undefined } = {}; + // The frame a pending store is waiting on, and whether anything has changed since the last one was + // written. A scroll fires many times per gesture and each write is a JSON.stringify plus a + // synchronous session storage write, so the position is only ever written once per animation frame + // - and the final position of a gesture is caught by the flush listeners below, which run even + // when the page is being torn down before that frame ever arrives. + private static _frame = 0; + private static _dirty = false; + + private static _flushBound = false; + + // How long a restore keeps trying to reach a position the content is not tall enough for yet, and + // what it is trying to reach: -1 while nothing is being restored, which is also what tells the + // scroll listener that the moves it is seeing are the reader's own. + private static RESTORE_WINDOW = 2000; + private static _restoreTop = -1; + private static _restoreEnd = 0; + private static _restoreFrame = 0; + private static _restoreEvents = ['wheel', 'touchstart', 'pointerdown', 'keydown']; + public static initScroll(container: HTMLElement, url: string) { AppShell._container = container; AppShell._currentUrl = url; - AppShell._scrolls = JSON.parse(sessionStorage.getItem(AppShell.STORE_KEY) || '{}'); + AppShell._scrolls = AppShell.read(); AppShell.storeScroll(url, AppShell.PreScroll > 0 ? AppShell.PreScroll : AppShell._scrolls[url]); + // Spent. It is where the reader had got to on the shell the SERVER rendered, so it only ever + // belongs to the first url of the session; a shell whose persistence is turned on again later + // would otherwise be handed a position from a page it has long since navigated away from. + AppShell.PreScroll = 0; + // A page opened at a position of 0 is left exactly where it is rather than being sent to the + // top: the browser may already have scrolled the container to the fragment of the url it was + // opened at, and a restore of a position nobody ever stored would undo it. if (AppShell._scrolls[url]! > 0) { - AppShell._container.scrollTo({ top: AppShell._scrolls[url], behavior: 'instant' }); + AppShell.restore(AppShell._scrolls[url]); } AppShell.addScroll(); + AppShell.bindFlush(); } - public static locationChangedScroll(url: string) { + public static locationChangedScroll() { + // Whatever was being restored belongs to the page being left. + AppShell.cancelRestore(); + // The position of the page being left is written out now rather than being left to the frame + // a pending store is waiting on: the next thing to happen is a render of the new page, and a + // scroll of the container to the new page's position would be read by that pending store as + // the position of the OLD url. + AppShell.flush(); AppShell.removeScroll(); } public static afterRenderScroll(url: string) { AppShell._currentUrl = url; AppShell.storeScroll(url, AppShell._scrolls[url]); - AppShell._container?.scrollTo({ top: AppShell._scrolls[url], behavior: 'instant' }); + // As in initScroll: a page with nothing stored, or stored at 0, is left where the browser has + // already put it - which is the fragment of the url it was navigated to, if it had one. + if (AppShell._scrolls[url]! > 0) { + AppShell.restore(AppShell._scrolls[url]); + } AppShell.addScroll(); } public static disposeScroll() { + AppShell.cancelRestore(); + AppShell.flush(); AppShell.removeScroll(); + AppShell.unbindFlush(); + AppShell._container = undefined; + } + + // Empties the stored positions, both the ones in hand and the ones in session storage, so that an + // application that signs a user out does not restore the previous one's place in the pages the + // next one visits. Given a url, only that page is forgotten - a page whose content has been + // replaced under the reader is no longer at the position it was left at. + public static clearScrolls(url?: string) { + AppShell.cancelRestore(); + + if (url) { + delete AppShell._scrolls[url]; + AppShell._dirty = true; + AppShell.flush(); + return; + } + + AppShell._scrolls = {}; + AppShell.cancelFrame(); + AppShell._dirty = false; + + try { + window.sessionStorage.removeItem(AppShell.STORE_KEY); + } catch { /* storage unavailable; the in-memory map is cleared either way */ } } private static addScroll() { - AppShell._container?.addEventListener('scroll', AppShell.onScroll); + // Passive: nothing here ever prevents the scroll, and saying so keeps the browser from waiting + // on this listener before it paints the next frame of the app's primary scroller. + AppShell._container?.addEventListener('scroll', AppShell.onScroll, { passive: true }); } private static removeScroll() { @@ -43,21 +116,340 @@ namespace BitBlazorUI { } private static onScroll() { + // A move this class is making itself is not the reader's place to keep. Without this the + // scroll event of a restore that the content was not tall enough for yet would store the + // position it was CLAMPED to, and the place being restored to would be lost on the way to it. + if (AppShell._restoreTop >= 0) return; + AppShell.storeScroll(AppShell._currentUrl, AppShell._container?.scrollTop); } + // Puts the container back where the url was left. The content of a page being returned to is + // rarely as tall as it will be by the time it has finished arriving - a fetch still in flight, an + // image without a size in its markup, a virtualized list that has only rendered its first screen - + // and a container that is not tall enough yet clamps the move to wherever it can reach. So the + // move is repeated as the content grows, until it lands or until the window below is spent. + private static restore(top: number | undefined) { + AppShell.cancelRestore(); + + const container = AppShell._container; + if (!container) return; + + const target = Math.max(0, top || 0); + + container.scrollTo({ top: target, behavior: 'instant' }); + + // The top of the content is where a page that was never scrolled opens, and it is reachable + // however short the content is, so there is nothing to wait for. + if (target === 0) return; + + AppShell._restoreTop = target; + AppShell._restoreEnd = AppShell.now() + AppShell.RESTORE_WINDOW; + AppShell.bindRestoreCancel(); + AppShell._restoreFrame = requestAnimationFrame(AppShell.restoreStep); + } + + private static restoreStep() { + AppShell._restoreFrame = 0; + + const container = AppShell._container; + const target = AppShell._restoreTop; + if (!container || target < 0) return; + + const max = Math.max(0, container.scrollHeight - container.clientHeight); + const reachable = Math.min(target, max); + + if (Math.abs(container.scrollTop - reachable) > 1) { + container.scrollTo({ top: target, behavior: 'instant' }); + } + + // Landed, or out of time. Either way what was stored for this url is left as it was, so a + // page whose content never grew that far is still put back there the next time it is opened. + if (max >= target - 1 || AppShell.now() >= AppShell._restoreEnd) { + AppShell.cancelRestore(); + return; + } + + AppShell._restoreFrame = requestAnimationFrame(AppShell.restoreStep); + } + + private static cancelRestore() { + if (AppShell._restoreFrame) { + cancelAnimationFrame(AppShell._restoreFrame); + AppShell._restoreFrame = 0; + } + + AppShell._restoreTop = -1; + AppShell.unbindRestoreCancel(); + } + + // Being put back where they left off is worth nothing to a reader who is already going somewhere + // else, so the first thing they do gives up on the restore. The four events are the ways a scroll + // is asked for that are not this class asking for it; the scroll event itself is not one of them, + // since every move the restore makes raises one. + private static bindRestoreCancel() { + AppShell._restoreEvents.forEach(e => + window.addEventListener(e, AppShell.cancelRestore, { passive: true, capture: true })); + } + + private static unbindRestoreCancel() { + AppShell._restoreEvents.forEach(e => + window.removeEventListener(e, AppShell.cancelRestore, { capture: true } as any)); + } + + private static now(): number { + try { + return performance.now(); + } catch { + return Date.now(); + } + } + private static storeScroll(url: string, value: number | undefined) { + if (!url) return; + + const known = url in AppShell._scrolls; + AppShell._scrolls[url] = value || 0; - window.sessionStorage.setItem(AppShell.STORE_KEY, JSON.stringify(AppShell._scrolls)); + + // A url already at the end of the order is where touch() would put it, and the cap was + // enforced when it got there - so the scrolling of one page, which stores a position per + // frame, does not re-key the map and walk its keys for every one of them. + if (known === false || AppShell._mru !== url) { + AppShell.touch(url); + } + + AppShell.schedule(); + } + + // The url at the end of the insertion order, so a repeated store of the same page can tell that + // there is nothing to move. + private static _mru: string | undefined; + + // Moves a url to the end of the insertion order and drops whatever falls out of the cap, so the + // map stays bounded by the pages most recently looked at rather than by every page ever visited. + private static touch(url: string) { + AppShell._mru = url; + + const value = AppShell._scrolls[url]; + delete AppShell._scrolls[url]; + AppShell._scrolls[url] = value; + + const keys = Object.keys(AppShell._scrolls); + for (let i = 0; i < keys.length - AppShell.STORE_MAX; i++) { + delete AppShell._scrolls[keys[i]]; + } + } + + private static schedule() { + AppShell._dirty = true; + + if (AppShell._frame) return; + + AppShell._frame = requestAnimationFrame(() => { + AppShell._frame = 0; + AppShell.write(); + }); + } + + // Writes whatever is pending right now, for the moments there is no next frame to wait for: the + // page being hidden, the tab being closed, the navigation that is about to re-render the shell. + private static flush() { + AppShell.cancelFrame(); + AppShell.write(); + } + + private static cancelFrame() { + if (AppShell._frame === 0) return; + + cancelAnimationFrame(AppShell._frame); + AppShell._frame = 0; + } + + private static write() { + if (AppShell._dirty === false) return; + + AppShell._dirty = false; + + try { + window.sessionStorage.setItem(AppShell.STORE_KEY, JSON.stringify(AppShell._scrolls)); + } catch { /* private mode, disabled storage or a full quota; the positions stay in memory */ } + } + + private static read(): { [key: string]: number | undefined } { + try { + const stored = JSON.parse(sessionStorage.getItem(AppShell.STORE_KEY) || '{}'); + return (stored && typeof stored === 'object') ? stored : {}; + } catch { + // Unavailable storage, or a value another script left behind that is not the map this + // wrote. Either way there is nothing to restore, and starting from an empty map is what + // keeps every write after this one working. + return {}; + } + } + + private static onFlush() { + AppShell.flush(); + } + + private static bindFlush() { + if (AppShell._flushBound) return; + + AppShell._flushBound = true; + + // pagehide is the one teardown notification a mobile browser reliably gives - unload is not + // fired when a tab is discarded or restored from the back/forward cache - and the hidden half + // of visibilitychange covers the app being switched away from without being torn down at all. + window.addEventListener('pagehide', AppShell.onFlush); + document.addEventListener('visibilitychange', AppShell.onFlush); + } + + private static unbindFlush() { + if (AppShell._flushBound === false) return; + + AppShell._flushBound = false; + + window.removeEventListener('pagehide', AppShell.onFlush); + document.removeEventListener('visibilitychange', AppShell.onFlush); + } + + + + // How much of the layout viewport the on-screen keyboard is covering, published on the root of a + // shell as --bit-ash-keyboard-inset so its own stylesheet can take that much off the height of the + // scrolling middle - and so the page can position chrome of its own against the same number. + // + // The visual viewport is the only place this can be read from: opening the keyboard leaves the + // LAYOUT viewport (and therefore every percentage height on the page) exactly as it was on the + // platforms that need this, so a shell measured in percentages goes on believing it owns a screen + // whose bottom is now behind the keyboard. Where the browser does shrink the layout viewport + // instead - a page asking for `interactive-widget=resizes-content`, or a desktop browser - the two + // viewports keep matching and this reports 0, which is the right answer: there is nothing left to + // take off a height that has already been taken off. + private static _keyboards: { [key: string]: { element: HTMLElement, handler: () => void, frame: number, last: number, style: HTMLStyleElement, dotnetObj?: DotNetObject } } = {}; + + public static setupKeyboard(id: string, element: HTMLElement, dotnetObj?: DotNetObject) { + if (!element) return; + + AppShell.disposeKeyboard(id); + + const viewport = window.visualViewport; + if (!viewport) return; + + // The number is published through a stylesheet of this shell's own rather than as an inline + // custom property, because the style attribute of the root is written by Blazor on every + // render that changes it and anything this side had put there would be wiped - after which + // the unchanged-inset check below would never write it again while the keyboard stayed open. + // The shell is addressed by an attribute the renderer never knew about, so nothing removes + // it either, and the class is repeated in the selector to outweigh the 0px the stylesheet + // declares whatever order the two are loaded in. + const style = document.createElement('style'); + document.head.appendChild(style); + element.setAttribute('data-bit-ash-kbd', id); + + const state = { element, handler: () => { }, frame: 0, last: -1, style, dotnetObj }; + + const measure = () => { + state.frame = 0; + + // A pinch-zoomed page shrinks its visual viewport in exactly the way an open keyboard + // does, so measuring through one would report a keyboard that is not there - and taking + // that much off the shell while the reader is zoomed in is the one thing worse than + // ignoring the keyboard. The measurement is left at whatever it was until the zoom is let + // go of, which keeps an open keyboard accounted for through a zoom as well. + if (Math.abs((viewport.scale || 1) - 1) > 0.01) return; + + // The height of the LAYOUT viewport, which is what the keyboard does not shrink on the + // platforms this is for - read off the document element rather than as window.innerHeight + // because that one counts the horizontal scrollbar the visual viewport height leaves out, + // and the difference between the two would be reported as a keyboard of that height. + const layout = document.documentElement?.clientHeight || window.innerHeight; + + // offsetTop is how far the visual viewport has itself been pushed down the layout one, + // which is what a page scrolled by the browser to keep a focused field in view leaves + // behind; without it the keyboard would appear to grow by that much. + const inset = Math.max(0, Math.round(layout - viewport.height - viewport.offsetTop)); + + if (inset === state.last) return; + + state.last = inset; + state.style.textContent = `.bit-ash[data-bit-ash-kbd="${id}"]{--bit-ash-keyboard-inset:${inset}px}`; + + // A marker for the CSS that cannot be written against a length - the bottom bar a shell + // hides while the reader is typing, the map that drops its controls - so a page does not + // have to round-trip through C# to know the keyboard is up. + if (inset > 0) { + state.element.setAttribute('data-bit-ash-keyboard', ''); + } else { + state.element.removeAttribute('data-bit-ash-keyboard'); + } + + // And the same number for the page that has to place something in C# rather than in CSS. + // It is sent on the change rather than per frame, and a failed send is a shell that has + // gone away between the measurement and the call, which is nothing this can act on. + state.dotnetObj?.invokeMethodAsync('OnKeyboardInset', inset).catch(() => { }); + }; + + state.handler = () => { + if (state.frame) return; + state.frame = requestAnimationFrame(measure); + }; + + viewport.addEventListener('resize', state.handler, { passive: true }); + viewport.addEventListener('scroll', state.handler, { passive: true }); + + AppShell._keyboards[id] = state; + + measure(); + } + + public static disposeKeyboard(id: string) { + const state = AppShell._keyboards[id]; + if (!state) return; + + delete AppShell._keyboards[id]; + + if (state.frame) { + cancelAnimationFrame(state.frame); + } + + const viewport = window.visualViewport; + if (viewport) { + viewport.removeEventListener('resize', state.handler); + viewport.removeEventListener('scroll', state.handler); + } + + state.style.remove(); + state.element.removeAttribute('data-bit-ash-kbd'); + state.element.removeAttribute('data-bit-ash-keyboard'); } } } (function () { - const container = document.getElementById('BitAppShell-container'); - if (!container) return; + // The scrolling the reader does before Blazor has started, on the shell rendered by the server. Only + // the FIRST shell of a page can be scrolled at this point, since it is the one in the server's markup + // and a second shell is something only a started application can render. + function bind() { + // The well-known id first, then the attribute every app shell's container carries whatever its id + // is: a shell given an Id of its own derives its container id from that one, and the scrolling + // done before Blazor has started is worth keeping for it too. + const container = document.getElementById('BitAppShell-container') + ?? document.querySelector('[data-bit-ash-main]'); + + if (!container) return false; + + container.addEventListener('scroll', () => { + BitBlazorUI.AppShell.PreScroll = container.scrollTop; + }, { passive: true }); - container.addEventListener('scroll', e => { - BitBlazorUI.AppShell.PreScroll = container.scrollTop; - }); -}()); \ No newline at end of file + return true; + } + + // The script is meant to run after the markup it is looking for, which is where a Blazor host page puts + // it. A page that loads it from the head instead is still served, by looking again once the document + // has been parsed - the scrolling this keeps is the reader's, and it only happens after that anyway. + if (bind() === false && document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { bind(); }, { once: true }); + } +}()); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellClassStyles.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellClassStyles.cs index 703b86ce31..dc82324351 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellClassStyles.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellClassStyles.cs @@ -18,17 +18,20 @@ public class BitAppShellClassStyles public string? Center { get; set; } /// - /// Custom CSS classes/styles for the left area of the BitAppShell. + /// Custom CSS classes/styles for the leading side inset bar of the BitAppShell, which is the one on + /// the left of a left-to-right app shell and on the right of a right-to-left one. /// public string? Left { get; set; } /// - /// Custom CSS classes/styles for the main area of the BitAppShell. + /// Custom CSS classes/styles for the main container of the BitAppShell, which is the one part of it + /// that scrolls and the one the content is rendered into. /// public string? Main { get; set; } /// - /// Custom CSS classes/styles for the right area of the BitAppShell. + /// Custom CSS classes/styles for the trailing side inset bar of the BitAppShell, which is the one on + /// the right of a left-to-right app shell and on the left of a right-to-left one. /// public string? Right { get; set; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellJsRuntimeExtensions.cs index 6b9bf75064..a217507dd9 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/AppShell/BitAppShellJsRuntimeExtensions.cs @@ -1,6 +1,4 @@ -using System.ComponentModel; - -namespace Bit.BlazorUI; +namespace Bit.BlazorUI; internal static class BitAppShellJsRuntimeExtensions { @@ -9,9 +7,9 @@ internal static ValueTask BitAppShellInitScroll(this IJSRuntime jsRuntime, Eleme return jsRuntime.InvokeVoid("BitBlazorUI.AppShell.initScroll", container, url); } - internal static ValueTask BitAppShellLocationChangedScroll(this IJSRuntime jsRuntime, string url) + internal static ValueTask BitAppShellLocationChangedScroll(this IJSRuntime jsRuntime) { - return jsRuntime.InvokeVoid("BitBlazorUI.AppShell.locationChangedScroll", url); + return jsRuntime.InvokeVoid("BitBlazorUI.AppShell.locationChangedScroll"); } internal static ValueTask BitAppShellAfterRenderScroll(this IJSRuntime jsRuntime, string url) @@ -23,4 +21,22 @@ internal static ValueTask BitAppShellDisposeScroll(this IJSRuntime jsRuntime) { return jsRuntime.InvokeVoid("BitBlazorUI.AppShell.disposeScroll"); } + + internal static ValueTask BitAppShellClearScrolls(this IJSRuntime jsRuntime, string? url = null) + { + return jsRuntime.InvokeVoid("BitBlazorUI.AppShell.clearScrolls", url); + } + + internal static ValueTask BitAppShellSetupKeyboard(this IJSRuntime jsRuntime, + string id, + ElementReference element, + DotNetObjectReference dotnetObj) where T : class + { + return jsRuntime.InvokeVoid("BitBlazorUI.AppShell.setupKeyboard", id, element, dotnetObj); + } + + internal static ValueTask BitAppShellDisposeKeyboard(this IJSRuntime jsRuntime, string id) + { + return jsRuntime.InvokeVoid("BitBlazorUI.AppShell.disposeKeyboard", id); + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs index 2517351187..20ec7c98ed 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Extensions/JsInterop/ExtrasJsRuntimeExtensions.cs @@ -12,9 +12,19 @@ internal static ValueTask BitExtrasGoToTop(this IJSRuntime jsRuntime, ElementRef return jsRuntime.InvokeVoid("BitBlazorUI.Extras.goToTop", element, behavior?.ToString().ToLowerInvariant()); } - internal static ValueTask BitExtrasScrollBy(this IJSRuntime jsRuntime, ElementReference element, decimal x, decimal y) + internal static ValueTask BitExtrasGoToBottom(this IJSRuntime jsRuntime, ElementReference element, BitScrollBehavior? behavior = null) { - return jsRuntime.InvokeVoid("BitBlazorUI.Extras.scrollBy", element, x, y); + return jsRuntime.InvokeVoid("BitBlazorUI.Extras.goToBottom", element, behavior?.ToString().ToLowerInvariant()); + } + + internal static ValueTask BitExtrasScrollTo(this IJSRuntime jsRuntime, ElementReference element, double? left, double? top, BitScrollBehavior? behavior = null) + { + return jsRuntime.InvokeVoid("BitBlazorUI.Extras.scrollTo", element, left, top, behavior?.ToString().ToLowerInvariant()); + } + + internal static ValueTask BitExtrasScrollBy(this IJSRuntime jsRuntime, ElementReference element, double x, double y, BitScrollBehavior? behavior = null) + { + return jsRuntime.InvokeVoid("BitBlazorUI.Extras.scrollBy", element, x, y, behavior?.ToString().ToLowerInvariant()); } public static ValueTask BitExtrasInitScripts(this IJSRuntime jsRuntime, IEnumerable scripts, bool isModule = false) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts index dbd5a5630b..7c5136277d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Scripts/Extras.ts @@ -5,18 +5,61 @@ namespace BitBlazorUI { Object.keys(cssVariables).forEach(key => document.documentElement.style.setProperty(key, cssVariables[key])); } + // A behavior handed in from C# overrides the scroll-behavior of the element, so the stylesheet + // rule that takes the animation off under the reduced motion preference is not consulted at all + // for these moves - the preference has to be read here instead, the same way every animated + // component of the library reads it. Left undefined, the element (and therefore the stylesheet) + // still decides, which is why only an asked-for animation is downgraded. + private static behave(element: HTMLElement, behavior: ScrollBehavior | undefined): ScrollBehavior | undefined { + if (behavior !== 'smooth') return behavior ?? undefined; + + return Extras.animates(element) ? 'smooth' : 'instant'; + } + + private static animates(element: HTMLElement): boolean { + try { + // The class opts a whole SUBTREE out of the preference, so an ancestor carrying it counts + // for the element inside it - which is what the ForceAnimation of a container around it + // is asking for. + if (element.closest('.bit-fam')) return true; + + return matchMedia('(prefers-reduced-motion: reduce)').matches === false; + } catch { + return true; + } + } + public static goToTop(element: HTMLElement, behavior: ScrollBehavior | undefined) { if (!element) return; - behavior ??= undefined; + element.scrollTo({ top: 0, behavior: Extras.behave(element, behavior) }); + } + + // scrollHeight is the FULL height of the content, so handing it over as the target lets the + // browser clamp it to wherever the last scrollable pixel actually is - which is the same answer + // as scrollHeight - clientHeight without this side having to read a second property for it. + public static goToBottom(element: HTMLElement, behavior: ScrollBehavior | undefined) { + if (!element) return; + + element.scrollTo({ top: element.scrollHeight, behavior: Extras.behave(element, behavior) }); + } - element.scrollTo({ top: 0, behavior }); + // A null axis is left where it stands rather than being sent to 0, which is what makes one call + // able to serve "scroll to this row", "scroll to this column" and "scroll to both" alike. + public static scrollTo(element: HTMLElement, left: number | null, top: number | null, behavior: ScrollBehavior | undefined) { + if (!element) return; + + element.scrollTo({ + left: left ?? element.scrollLeft, + top: top ?? element.scrollTop, + behavior: Extras.behave(element, behavior) + }); } - public static scrollBy(element: HTMLElement, x: number, y: number) { + public static scrollBy(element: HTMLElement, x: number, y: number, behavior?: ScrollBehavior | undefined) { if (!element) return; - element.scrollBy(x, y); + element.scrollBy({ left: x, top: y, behavior: Extras.behave(element, behavior) }); } // Attaches (or updates) a deterministic keydown listener that calls preventDefault diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-general.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-general.scss index d77e2b499d..6222428f0f 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-general.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-general.scss @@ -13,9 +13,23 @@ //-- --bit-env-inset-inline-start: var(--bit-env-inset-left); --bit-env-inset-inline-end: var(--bit-env-inset-right); + //-- + // The static maximums of the four insets above: what each of them is once every retractable piece of + // browser UI has retracted. The dynamic ones change while that UI slides in and out - the bottom bar + // of an edge-to-edge Chrome on Android does it on every scroll - so a layout sized from them is + // relaid out on every frame of the slide; sized from these it is not. A browser that does not know + // them falls back to the dynamic value, which is what they are the maximum of. + --bit-env-max-inset-top: env(safe-area-max-inset-top, env(safe-area-inset-top, 0px)); + --bit-env-max-inset-left: env(safe-area-max-inset-left, env(safe-area-inset-left, 0px)); + --bit-env-max-inset-right: env(safe-area-max-inset-right, env(safe-area-inset-right, 0px)); + --bit-env-max-inset-bottom: env(safe-area-max-inset-bottom, env(safe-area-inset-bottom, 0px)); + --bit-env-max-inset-inline-start: var(--bit-env-max-inset-left); + --bit-env-max-inset-inline-end: var(--bit-env-max-inset-right); [dir="rtl"] { --bit-env-inset-inline-start: var(--bit-env-inset-right); --bit-env-inset-inline-end: var(--bit-env-inset-left); + --bit-env-max-inset-inline-start: var(--bit-env-max-inset-right); + --bit-env-max-inset-inline-end: var(--bit-env-max-inset-left); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-variables.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-variables.scss index 8f09e8a4e9..f6a2d56ad5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-variables.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-variables.scss @@ -13,5 +13,10 @@ $bit-env-height-available: var(--bit-env-height-avl); $bit-env-inset-inline-start: var(--bit-env-inset-inline-start); $bit-env-inset-inline-end: var(--bit-env-inset-inline-end); //-- +$bit-env-max-inset-top: var(--bit-env-max-inset-top); +$bit-env-max-inset-bottom: var(--bit-env-max-inset-bottom); +$bit-env-max-inset-inline-start: var(--bit-env-max-inset-inline-start); +$bit-env-max-inset-inline-end: var(--bit-env-max-inset-inline-end); +//-- $bit-env-window-width: var(--bit-env-win-width); $bit-env-window-height: var(--bit-env-win-height); diff --git a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/ScrollablePane/BitScrollablePaneJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/ScrollablePane/BitScrollablePaneJsRuntimeExtensions.cs index 6e6578d21a..a4a2c8c2f1 100644 --- a/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/ScrollablePane/BitScrollablePaneJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI/Components/Surfaces/ScrollablePane/BitScrollablePaneJsRuntimeExtensions.cs @@ -8,13 +8,18 @@ internal static class BitScrollablePaneJsRuntimeExtensions // application that never reads the position at a moment of its own choosing would otherwise lose the // members of BitScrollOffset with the GetOffset call below, and every OnScroll report would arrive // with all six of its measurements deserialized into nothing. + // Generic in what holds the callbacks rather than tied to BitScrollablePane, because the browser side + // only ever calls invokeMethodAsync on the reference it is handed: any component that declares the + // four callback names this engine invokes - OnScroll, OnScrollStart, OnScrollEnd, OnReached - can be + // driven by it. BitAppShell is the second such component, and it is in another assembly, which is why + // the constraint is `class` rather than the component base type. [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitScrollablePaneOptions))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(BitScrollOffset))] - internal static ValueTask BitScrollablePaneSetup(this IJSRuntime jsRuntime, - string id, - ElementReference element, - DotNetObjectReference dotnetObj, - BitScrollablePaneOptions options) + internal static ValueTask BitScrollablePaneSetup(this IJSRuntime jsRuntime, + string id, + ElementReference element, + DotNetObjectReference dotnetObj, + BitScrollablePaneOptions options) where T : class { return jsRuntime.InvokeVoid("BitBlazorUI.ScrollablePane.setup", id, element, dotnetObj, options); } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoConsumer.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoConsumer.razor new file mode 100644 index 0000000000..ff90d23f20 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoConsumer.razor @@ -0,0 +1,16 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AppShell + +@* A plain component that reads what the surrounding BitAppShell cascades. It is a separate component + because a cascading parameter can only be declared on one - which is the whole point the example + opposite is making: nothing between the shell and this had to pass any of it along. *@ + +
+
Cascaded by type: @User?.Name (@User?.Role)
+
Cascaded by name: @Tenant
+
+ +@code { + [CascadingParameter] public AppShellDemoUser? User { get; set; } + + [CascadingParameter(Name = "Tenant")] public string? Tenant { get; set; } +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoUser.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoUser.cs new file mode 100644 index 0000000000..fe8ecaf25a --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/AppShellDemoUser.cs @@ -0,0 +1,6 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AppShell; + +/// +/// The value the cascading values example of the AppShell demo page hands down the shell. +/// +public record AppShellDemoUser(string Name, string Role); diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor index 94aac63603..d51952d61c 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor @@ -2,38 +2,581 @@ - - - - - To use this component, you need to install the - - - - nuget package, as described in the Optional steps of the - Getting started page. - - - - -
Since this component is a base layout container, it is not possible to show its capabilities in a demo sample here.
-
- You can always check our Boilerplate project template samples - (AdminPanel & - Todo) - to see the BitAppShell in action. -
-
-
-
\ No newline at end of file + Description="BitAppShell is the outermost container of a cross-platform app: it insets the four edges of the screen by the device safe areas, makes room for the on-screen keyboard, owns the one region the app scrolls in, keeps the reader's place across navigation, and cascades that scroller to every surface inside it." /> + +
+ + + + To use this component, you need to install the + + + + nuget package, as described in the Optional steps of the + Getting started page. + +
+ + The app shell belongs in MainLayout, wrapped around everything the application renders, + and it fills whatever room it is given - so the html and body of the host page + need a height of their own for it to have any, unless FullScreen is set, which pins the + shell to the four edges of the window instead and takes the host page out of the question. The + examples below can do neither: they put a shell inside a box of a fixed height, which is the + only way to show a full-screen container on a page that is already inside one. Each also + carries an Id, since the id of the shell's scrolling container is derived from it and + only one element of a page can carry a given id. + +
+ + A component that has to work on the region the application scrolls in, rather than on one of + its own, is pointed at the shell's container: BitPullToRefresh and + BitInfiniteScrolling take a ScrollerSelector of #BitAppShell-container - + or, for a shell with an Id, the MainContainerId of that shell. A + BitModal, BitPanel, BitDialog or BitOverlay needs none of that: + the shell cascades its container and they read it. + +
+ + The scrolling middle of the shell is a flex row, so a page put inside it is laid out as one + flex item: give the element that holds the page a width of 100% - or make it the flex + column the page is - and it fills the shell rather than shrinking to fit its own content. + +
+ + The four insets come from the CSS env(safe-area-inset-*) variables, which a browser + only reports as anything other than zero on a page whose viewport meta tag carries + viewport-fit=cover. Without it - and on every desktop browser - all four are 0 and the + bars take up no room at all, which is why the example below sizes them by hand to show them. + +
+ + + +
+ A shell is a container with four inset bars around one scrolling middle. Everything put + inside it lands in that middle - bit-ash-main - which is the only part that scrolls, + so a header and a footer declared inside the shell's content stay where they are put while + the content between them moves. +
+

+
+ +
+
Header
+
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
Row @i
+ } +
+
+
+
+
+ + +
+ The four bars are sized from the safe areas of the device and painted in the theme's + background color, so the notch and the home indicator of a phone sit over the shell's own + color instead of over the content. They are 0 on a desktop browser, so the example stands + in for a phone through Styles: its root is handed the safe areas a device would + report - the --bit-env-inset-* variables the bars are sized from - and each bar is + given a color of its own to make it visible. +
+
+
+ NoInsets takes all four back to zero for an edge-to-edge layout - the one where the + application insets its own chrome instead, so a header's background paints behind the + status bar rather than below it. NoTopInset, NoBottomInset, + NoStartInset and NoEndInset do the same to one edge at a time, which is what + a layout that goes edge to edge at the top while still keeping the home indicator clear + asks for. The two side ones are the logical edges, so each stays with the reading + direction the bars are laid out in. +
+
+
+ Each bar is sized from a CSS variable of its own - --bit-ash-inset-top, + --bit-ash-inset-bottom, --bit-ash-inset-start and + --bit-ash-inset-end - which the page can read to inset chrome of its own by the + same amount, or override to give one edge a bigger inset than the device asked for. +
+
+
+ The insets a browser reports are not constants: an edge-to-edge Chrome on Android + retracts its bottom bar as the reader scrolls down and brings it back on the way up, and + the inset follows it the whole way - so everything laid out against it moves on every + frame of that slide. StableInsets sizes the bars from the static maximums instead, + which lays the shell out once for the room left when nothing is retracted and lets the + browser slide its own chrome over the background of a bar rather than over the content. + A browser that does not report those maximums is left reading the insets it does report. +
+

+
+ + + + + +
+
+
+ +
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
Row @i
+ } +
+
+
+
+ + +
+ Opening the on-screen keyboard leaves the layout viewport - and with it every + percentage height on the page - exactly as it was on the platforms where this matters, + so a shell that is not told about it goes on believing it owns a screen whose bottom is + now behind the keyboard, and a bottom bar declared inside it disappears under one. +
+
+
+ AvoidKeyboard measures how much is covered, publishes it on the root of the shell + as the --bit-ash-keyboard-inset CSS variable, and takes that much off the height + of the scrolling middle - so the bar comes back up above the keyboard and the content + is laid out in the room that is actually left. A page can read the same variable to + place chrome of its own against it. It measures 0, and so does nothing at all, wherever + the browser shrinks the layout viewport itself: every desktop browser, and any page + asking for interactive-widget=resizes-content. What the bottom safe area inset was + keeping clear is covered by the keyboard as well, so that inset is given up for as long + as the keyboard is open rather than left as a band of background above it. +
+
+
+ The shell also marks itself with the data-bit-ash-keyboard attribute while the + keyboard is up, which is what a bottom bar that hides itself while the reader is typing + can be styled against, and reports the same measurement to C# through + OnKeyboardInsetChanged for the chrome that cannot be placed in CSS alone. +
+

+
+ Focus the field below on a phone to see the bar ride up with the keyboard. On a desktop + browser there is no keyboard to make room for, so nothing moves. +
+
+
+
Keyboard inset: @keyboardInset.ToString("0") px
+
+
+
+ +
+
+ @foreach (var i in Enumerable.Range(1, 10)) + { +
Row @i
+ } +
+
+ + Send +
+
+
+
+
+ + +
+ A reference to the shell scrolls its main container from anywhere in the application. + GoToTop and GoToBottom go to either end, ScrollTo to a position - + a null axis is left where it stands - ScrollBy by an amount from wherever it + currently is, and ScrollToElement brings a descendant into view by scrolling the + shell itself rather than every scroller the page sits in. +
+
+
+ GetScrollOffset reads where it stands at the moment it is asked, which needs none + of the scroll callbacks to be handled. Each of the moves takes an optional + BitScrollBehavior; left out, the shell's own ScrollBehavior decides, and + that one honors the reduced motion preference on its own. +
+

+
+ GoToTop + GoToBottom + ScrollTo(240) + ScrollBy(+120) + ScrollToElement + GetScrollOffset +
+
+
@offsetText
+
+
+ +
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
+ Row @i @(i == 20 ? "(the ScrollToElement target)" : null) +
+ } +
+
+
+
+ + +
+ OnScroll reports where the shell stands as it moves, and the position it carries + also says which way it went since the last report - which is what a header that folds + away on the way down and comes back on the way up reads. OnScrollStart and + OnScrollEnd bracket a whole gesture, and OnReachedTop / OnReachedBottom + fire once per arrival at either end rather than on every frame that stays there - + ReachOffset decides how near counts as arrived, which is what loading the next page + of an endless list a little before the reader gets there is built on. OnReachedLeft + and OnReachedRight are the same two for the horizontal axis, and they are the edges + on the screen rather than in reading order, so they mean the same thing in a right-to-left + shell. +
+
+
+ None of this costs anything until one of the callbacks is handled: a shell with no handler + has no scroll listener at all. ScrollThrottle caps how often OnScroll reports; + the default of 0 is once per animation frame. +
+

+
+
Top: @scrollTop.ToString("0") px (@((scrollPercent * 100).ToString("0"))%)
+
Direction: @scrollDirection
+
Phase: @scrollPhase
+
Reached: @reachedEdge
+
+
+
+ +
+ @foreach (var i in Enumerable.Range(1, 40)) + { +
Row @i
+ } +
+
+
+
+ + +
+ ScrollBehavior decides how every move the reader does not make by hand is animated: + the methods above, a fragment navigation into the shell, and the browser bringing a focused + element into view. It defaults to Smooth, which is taken back off on its own when the + system asks for reduced motion - use the ForceAnimation toggle at the top of this + page to see it animate anyway. Instant makes every one of those moves a single jump. +
+

+ +
+
+ GoToTop + GoToBottom +
+
+
+ +
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
Row @i
+ } +
+
+
+
+ + +
+ Overscroll is what the browser does with a scroll that has already reached the edge + of the shell. It defaults to None, which is what an app-like layout wants: the scroll + does not carry on into whatever is behind the shell, and the platform's own overscroll + affordances - the rubber band, the pull-to-refresh, the navigation swipe - are suppressed. + Contain stops the chaining and keeps those affordances; Auto gives both back + to a shell that is really a web page. +
+
+
+ NoScroll stops the reader from moving the shell at all - the content that overflows + is clipped instead. The scrolling methods above still move it, since it is only the reader's + own gestures that are taken away, which is what a page that drives its own position wants. +
+

+ +
+
+ ScrollBy(+80) + GoToTop +
+
+
+ +
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
Row @i
+ } +
+
+
+
+ + +
+ A page navigated to in an app that scrolls a region of its own opens wherever the previous + page was left, since the region never moved. AutoGoToTop sends the reader to the top + of each page they arrive at, which is what a browser does on its own for a page that scrolls + the document. +
+
+
+ PersistScroll is the other half of it: it remembers where each url was left and puts + the reader back there, so going back to a long list lands on the row they came from rather + than at its top. The positions are kept per url in session storage, so they survive a reload + and are gone when the tab is; it takes precedence over AutoGoToTop, and + ClearPersistedScroll forgets all of them - which is what an application calls when it + signs a user out - or, given a url, just that one page, for a list that has been filtered or + a search that has been run again under the reader, where the position it was left at no + longer points at anything. Neither of them reacts to a navigation that only changes the + fragment of the url, so an in-page anchor still works. +
+
+
+ A position is put back the moment the page renders, and kept being put back over the next + couple of seconds as the content of that page arrives - a fetch still in flight, an image + without a size in its markup, a list that has only rendered its first screen - since a + container that is not tall enough yet can only be scrolled as far as it goes. The first + thing the reader does gives up on it: being put back where they left off is worth nothing + against where they are going now. +
+

+
+ Both features are about moving between pages, which a shell inside this page cannot show - + the markup opposite is what they look like where they belong. +
+
+ + +
+ The shell wraps everything it contains in cascading values, so an application does not need + a chain of CascadingValue components around its layout to hand the same few things - + the signed-in user, a culture, a feature switch - to every page. Values takes a list + of BitCascadingValue and ValueList a builder for the same thing; the ones in + Values are provided last, so they win over a value of the same type or name in + ValueList. +
+
+
+ A value can be named, fixed, or left changeable - changing one refreshes the components + that read it without the shell having to be re-rendered by hand. +
+

+
+ +
+ +
+
+
+
+ Change the cascaded name +
+ + +
+ The scrolling middle takes whatever overflows it along both axes, which is what an + application that lays a page out wider than the screen needs. OverflowX and + OverflowY decide that per axis: Hidden clips the overflow instead of + offering it, which is how the one element a few pixels too wide is kept from leaving the + whole application scrollable sideways - the commonest layout bug of a mobile web app. + NoScroll takes both axes away at once and wins over either of them. +
+
+
+ Gutter reserves the room the scrollbar takes whether or not there is anything left + to scroll. On the one scroller of an application that is worth having: without it, every + navigation between a page long enough to scroll and a page that is not moves the whole + layout sideways by the width of a scrollbar. It costs nothing where the platform draws + its scrollbars over the content, which is every mobile browser - turn the short page on + and off below to see the difference on a desktop one. +
+

+
+ + + +
+
+
+ +
+
A row wider than the shell
+ @foreach (var i in Enumerable.Range(1, shortOverflowPage ? 1 : 20)) + { +
Row @i
+ } +
+
+
+
+ + +
+ A header stuck to the top of the shell covers whatever is scrolled to underneath it - by + a fragment navigation, by the browser bringing a focused field into view, or by + ScrollToElement. ScrollPadding is the room the shell keeps between its + edges and anything scrolled into view inside it, so the move stops that much short and + what was scrolled to lands below the header rather than behind it. +
+
+
+ It takes any CSS length, and the insets of the shell can be part of it: a padding of + calc(var(--bit-ash-inset-top) + 3rem) is a header as tall as the status bar and a + bar of its own. The moves that take an absolute position or a distance - ScrollTo, + ScrollBy, GoToTop - are left alone by it. +
+

+ +
+
+ ScrollToElement(row 15) + GoToTop +
+
+
+ +
+
A header stuck to the top of the shell
+
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
+ Row @i @(i == 15 ? "(the target)" : null) +
+ } +
+
+
+
+
+ + +
+ AutoScroll keeps the shell pinned to the end of its content as the content grows, + which is what a chat, a log or a console wants. It only keeps pinning while the reader + left it standing at the end, so someone who scrolled up to read something is not dragged + back down by the next arrival, and scrolling back to the end takes the pinning up again; + AutoScrollThreshold says how near the end still counts as being at it. +
+
+
+ PreserveScroll is the other half of an endless list: a page of older content + arriving above what the reader is looking at would otherwise push it that far down the + screen, and with this the shell is moved by exactly what arrived, so nothing appears to + move at all. It is worth pairing with OnReachedTop and a ReachOffset of + about a screenful. Refresh re-measures the shell for the changes neither its own + size nor its content announce - a web font that has finished loading, an image that + settled at a size its markup never named. +
+

+
+ + +
+
+
+ Append to the end + Prepend 5 older + Refresh +
+
+
+ +
+ @foreach (var message in feed) + { +
@message
+ } +
+
+
+
+ + +
+ Style and Class reach the root of the shell, and Styles and + Classes reach each of its seven parts by name: Root, Top, + Center, Left, Main, Right and Bottom - which is how the + inset bars are given a color of their own, and how the scrolling middle is given padding + without a wrapper element inside it. +
+

+
+ +
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
Row @i
+ } +
+
+
+
+ + +
+ Set Dir to Rtl to lay the shell out right to left. The two side bars follow + the reading direction while staying tied to the physical edge each safe area belongs to, + so the inset of the left of the screen is still the one that keeps content off the left of + the screen whichever way the app reads. +
+

+
+ +
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
سطر @i
+ } +
+
+
+
+
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.cs index bc39783f3d..53aab1890a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.cs @@ -2,6 +2,129 @@ public partial class BitAppShellDemo { + private bool noInsets; + private bool noTopInset; + private bool noEndInset; + private bool noStartInset; + private bool noBottomInset; + + private bool noScroll; + private bool instantScroll; + + private bool stableGutter; + private bool clipOverflowX; + private bool shortOverflowPage; + + private bool stickyPadding; + private string? paddingValue => stickyPadding ? "2.5rem 0 0 0" : null; + + private bool autoScroll = true; + private bool preserveScroll = true; + private int feedNext = 13; + private int feedOlder; + private readonly List feed = [.. Enumerable.Range(1, 12).Select(i => $"Message {i}")]; + + private string? message; + private double keyboardInset; + + private BitAppShell? feedShell; + private BitAppShell? clipShell; + private BitAppShell? scrollShell; + private BitAppShell? paddingShell; + private BitAppShell? behaviorShell; + + private string offsetText = "-"; + + private double scrollTop; + private double scrollPercent; + private string scrollPhase = "idle"; + private string reachedEdge = "-"; + private string scrollDirection = "-"; + + private string userName = "Saleh Yusefnejad"; + + // The four bars are 0 tall on a desktop browser, so the example stands in for the device: the root is + // handed the safe areas a phone would report, which the shell sizes its bars AND its middle from - and + // which the No*Inset flags still take back to zero. Sizing the bars themselves inline would do neither, + // since an inline height wins over the inset variables the flags change. The bars get a color each. + private readonly BitAppShellClassStyles insetStyles = new() + { + Root = "--bit-env-inset-top:1.5rem;--bit-env-inset-bottom:1.5rem;--bit-env-inset-inline-start:1rem;--bit-env-inset-inline-end:1rem", + Top = "background:#0d7bbd", + Bottom = "background:#0d7bbd", + Left = "background:#7a3fb5", + Right = "background:#7a3fb5", + }; + + private readonly BitAppShellClassStyles shellStyles = new() + { + Root = "border-radius:0.5rem;overflow:hidden", + Top = "height:0.5rem;background:#3a9b3a", + Bottom = "height:0.5rem;background:#3a9b3a", + Main = "padding:0.75rem", + }; + + private readonly BitAppShellClassStyles shellClasses = new() + { + Main = "styled-main", + }; + + private IEnumerable cascadingValues => + [ + new(new AppShellDemoUser(userName, "Developer")), + new("bit platform", "Tenant"), + ]; + + private void RenameUser() + { + userName = userName == "Saleh Yusefnejad" ? "Yaser Moradi" : "Saleh Yusefnejad"; + } + + private async Task ReadOffset() + { + var offset = await (scrollShell?.GetScrollOffset() ?? Task.FromResult(null)); + + offsetText = offset is null + ? "-" + : $"Top {offset.Top:0} of {offset.MaxTop:0} ({offset.PercentY * 100:0}%), AtTop: {offset.AtTop}, AtBottom: {offset.AtBottom}"; + } + + private Task ScrollToTarget() => scrollShell?.ScrollToElement("target-row") ?? Task.CompletedTask; + + private void HandleScroll(BitScrollOffset offset) + { + scrollTop = offset.Top; + scrollPercent = offset.PercentY; + scrollDirection = offset.ScrollingDown ? "down" : offset.ScrollingUp ? "up" : "-"; + + StateHasChanged(); + } + + private void HandleScrollStart() { scrollPhase = "scrolling"; StateHasChanged(); } + + private void HandleScrollEnd() { scrollPhase = "idle"; StateHasChanged(); } + + private void HandleReachedTop() { reachedEdge = "top"; StateHasChanged(); } + + private void HandleReachedBottom() { reachedEdge = "bottom"; StateHasChanged(); } + + private void HandleKeyboardInset(double inset) { keyboardInset = inset; StateHasChanged(); } + + private Task ScrollToPaddedRow() => paddingShell?.ScrollToElement("padded-row") ?? Task.CompletedTask; + + private void AppendMessage() => feed.Add($"Message {feedNext++}"); + + // Older content lands ABOVE what the reader is looking at, which is the arrival PreserveScroll is for. + private void PrependMessages() + { + for (var i = 0; i < 5; i++) + { + feed.Insert(0, $"Older message {--feedOlder}"); + } + } + + + private readonly List componentParameters = [ new() @@ -9,14 +132,35 @@ public partial class BitAppShellDemo Name = "AutoGoToTop", Type = "bool", DefaultValue = "false", - Description = "Enables auto-scroll to the top of the main container on navigation.", + Description = "Enables auto-scroll to the top of the main container on navigation. A navigation that only changes the fragment of the url (an in-page anchor) is left alone. PersistScroll takes precedence over it.", + }, + new() + { + Name = "AutoScroll", + Type = "bool", + DefaultValue = "false", + Description = "Keeps the main container pinned to the end of its content as the content grows, for as long as the reader left it standing at the end.", + }, + new() + { + Name = "AutoScrollThreshold", + Type = "int", + DefaultValue = "0", + Description = "How near the end of the content (in pixels) the main container has to have been left for AutoScroll to keep pinning it there.", + }, + new() + { + Name = "AvoidKeyboard", + Type = "bool", + DefaultValue = "false", + Description = "Takes the height of the on-screen keyboard off the scrolling area while it is open, publishes it on the root as the --bit-ash-keyboard-inset CSS variable and marks the root with the data-bit-ash-keyboard attribute. It measures 0 wherever the browser shrinks the layout viewport itself.", }, new() { Name = "ChildContent", Type = "RenderFragment?", DefaultValue = "null", - Description = "The content of the app shell.", + Description = "The content of the app shell. It is rendered inside the main (scrolling) container.", }, new() { @@ -28,6 +172,204 @@ public partial class BitAppShellDemo Href = "#class-styles" }, new() + { + Name = "FullScreen", + Type = "bool", + DefaultValue = "false", + Description = "Pins the app shell to the four edges of the screen, so it fills the window whatever height the page around it has - which is what saves the host page from carrying a height of its own down through html and body.", + }, + new() + { + Name = "Gutter", + Type = "BitScrollbarGutter?", + DefaultValue = "null", + Description = "Reserves the room the scrollbar of the main container takes, whether or not there is anything left to scroll, so the layout does not shift between a page that scrolls and a page that does not.", + LinkType = LinkType.Link, + Href = "#scrollbar-gutter-enum" + }, + new() + { + Name = "NoBottomInset", + Type = "bool", + DefaultValue = "false", + Description = "Removes the bottom safe area inset of the app shell, leaving the other three where they are.", + }, + new() + { + Name = "NoEndInset", + Type = "bool", + DefaultValue = "false", + Description = "Removes the trailing side safe area inset of the app shell - the right of a left-to-right shell - leaving the other three where they are.", + }, + new() + { + Name = "NoInsets", + Type = "bool", + DefaultValue = "false", + Description = "Removes the safe area insets, so the four edges of the app shell are not inset at all and the content fills the whole screen.", + }, + new() + { + Name = "NoScroll", + Type = "bool", + DefaultValue = "false", + Description = "Prevents the reader from scrolling the main container at all; the content that overflows is clipped. The scrolling methods of the component still move it.", + }, + new() + { + Name = "NoStartInset", + Type = "bool", + DefaultValue = "false", + Description = "Removes the leading side safe area inset of the app shell - the left of a left-to-right shell - leaving the other three where they are.", + }, + new() + { + Name = "NoTopInset", + Type = "bool", + DefaultValue = "false", + Description = "Removes the top safe area inset of the app shell, leaving the other three where they are.", + }, + new() + { + Name = "OnKeyboardInsetChanged", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for how much of the app shell the on-screen keyboard covers, in pixels, raised as that changes and with 0 as it closes. Only a shell with AvoidKeyboard set measures it at all.", + }, + new() + { + Name = "OnReachedBottom", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when the main container reaches the bottom of its content, raised once per arrival rather than on every frame that stays there.", + }, + new() + { + Name = "OnReachedLeft", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when the main container reaches the visual left edge of its content, which is the same edge whichever way the shell reads.", + }, + new() + { + Name = "OnReachedRight", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when the main container reaches the visual right edge of its content.", + }, + new() + { + Name = "OnReachedTop", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when the main container reaches the top of its content.", + }, + new() + { + Name = "OnScroll", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for the scroll position of the main container, raised as it is scrolled. Nothing is measured or reported until one of the scroll callbacks is handled.", + LinkType = LinkType.Link, + Href = "#scroll-offset" + }, + new() + { + Name = "OnScrollEnd", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when a scroll of the main container comes to a stop.", + LinkType = LinkType.Link, + Href = "#scroll-offset" + }, + new() + { + Name = "OnScrollStart", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when a scroll of the main container begins.", + LinkType = LinkType.Link, + Href = "#scroll-offset" + }, + new() + { + Name = "OverflowX", + Type = "BitOverflow?", + DefaultValue = "null", + Description = "What the main container does with content that overflows it sideways. Hidden clips it instead of offering it, and NoScroll wins over both axes.", + LinkType = LinkType.Link, + Href = "#overflow-enum" + }, + new() + { + Name = "OverflowY", + Type = "BitOverflow?", + DefaultValue = "null", + Description = "What the main container does with content that overflows it downwards. See OverflowX.", + LinkType = LinkType.Link, + Href = "#overflow-enum" + }, + new() + { + Name = "Overscroll", + Type = "BitOverscroll?", + DefaultValue = "null", + Description = "Determines what happens when the main container is scrolled past its edge. It defaults to None: no scroll chaining out of the shell and no pull-to-refresh or rubber-banding.", + LinkType = LinkType.Link, + Href = "#overscroll-enum" + }, + new() + { + Name = "PersistScroll", + Type = "bool", + DefaultValue = "false", + Description = "Persists scroll position of the main container per url in session storage and restores it on navigation. A fragment-only navigation is left alone.", + }, + new() + { + Name = "PreserveScroll", + Type = "bool", + DefaultValue = "false", + Description = "Keeps the place of the reader when content is added above what they are looking at, which is what an endless list growing upwards needs.", + }, + new() + { + Name = "ReachOffset", + Type = "int", + DefaultValue = "0", + Description = "How near an edge (in pixels) counts as having reached it, for OnReachedTop and OnReachedBottom.", + }, + new() + { + Name = "ScrollBehavior", + Type = "BitScrollBehavior?", + DefaultValue = "null", + Description = "The scroll behavior of the main container, which decides how every move the reader does not make by hand is animated. It defaults to Smooth, and is taken back off under the reduced motion preference.", + LinkType = LinkType.Link, + Href = "#scroll-behavior-enum" + }, + new() + { + Name = "ScrollPadding", + Type = "string?", + DefaultValue = "null", + Description = "The room the main container keeps between its edges and anything scrolled into view inside it, as any CSS length - which is what keeps a header stuck to the top of the shell from covering what was just scrolled to.", + }, + new() + { + Name = "ScrollThrottle", + Type = "int", + DefaultValue = "0", + Description = "The shortest interval (in milliseconds) between two OnScroll reports. The default of 0 reports once per animation frame.", + }, + new() + { + Name = "StableInsets", + Type = "bool", + DefaultValue = "false", + Description = "Sizes the four inset bars from the largest safe areas the device can ask for rather than from the ones it is asking for right now, so the layout is not relaid out as the browser slides its own chrome in and out.", + }, + new() { Name = "Styles", Type = "BitAppShellClassStyles?", @@ -41,7 +383,7 @@ public partial class BitAppShellDemo Name = "ValueList", Type = "BitCascadingValueList?", DefaultValue = "null", - Description = "The cascading value list to be provided for the children of the app shell.", + Description = "The cascading value list to be provided for the children of the app shell. Its values are provided before (so they can be overridden by) the ones of the Values parameter.", LinkType = LinkType.Link, Href = "#cascading-value-list" }, @@ -58,15 +400,102 @@ public partial class BitAppShellDemo private readonly List componentPublicMembers = [ + new() + { + Name = "ClearPersistedScroll", + Type = "Func", + DefaultValue = "", + Description = "Forgets every scroll position PersistScroll has kept, for the pages of this app shell and of any other - or, given a url, only the position kept for that one page.", + }, + new() + { + Name = "Container", + Type = "const string", + DefaultValue = "\"BitAppShell.Container\"", + Description = "The name the app shell cascades the element of its main container under, which is what a Modal, a Panel, a Dialog or an Overlay inside the shell reads to hold the right scroller.", + }, + new() + { + Name = "ContainerId", + Type = "const string", + DefaultValue = "\"BitAppShell-container\"", + Description = "The id the main container carries when the app shell has no Id of its own.", + }, + new() + { + Name = "ContainerRef", + Type = "ElementReference?", + DefaultValue = "null", + Description = "The element reference to the main container of the app shell.", + }, + new() + { + Name = "GetScrollOffset", + Type = "Func>", + DefaultValue = "", + Description = "Reads where the main container currently stands, measured in the browser.", + LinkType = LinkType.Link, + Href = "#scroll-offset" + }, + new() + { + Name = "GoToBottom", + Type = "Func", + DefaultValue = "", + Description = "Scrolls the main container to the bottom of its content.", + LinkType = LinkType.Link, + Href = "#scroll-behavior-enum" + }, new() { Name = "GoToTop", - Type = "Func", + Type = "Func", DefaultValue = "", Description = "Scrolls the main container to top.", LinkType = LinkType.Link, Href = "#scroll-behavior-enum" }, + new() + { + Name = "MainContainerId", + Type = "string", + DefaultValue = "", + Description = "The id of the main container element of this app shell: ContainerId, or the Id of the shell with \"-container\" after it.", + }, + new() + { + Name = "Refresh", + Type = "Func", + DefaultValue = "", + Description = "Re-measures the main container and reports whatever has changed since it was last measured - for the changes neither its own size nor its content announce, such as a web font that has finished loading.", + }, + new() + { + Name = "ScrollBy", + Type = "Func", + DefaultValue = "", + Description = "Scrolls the main container by an amount, from wherever it currently stands.", + LinkType = LinkType.Link, + Href = "#scroll-behavior-enum" + }, + new() + { + Name = "ScrollTo", + Type = "Func", + DefaultValue = "", + Description = "Scrolls the main container to a position. A null axis is left where it stands.", + LinkType = LinkType.Link, + Href = "#scroll-behavior-enum" + }, + new() + { + Name = "ScrollToElement", + Type = "Func", + DefaultValue = "", + Description = "Brings an element inside the main container into view by scrolling the container itself rather than every scroller the page sits in.", + LinkType = LinkType.Link, + Href = "#scroll-alignment-enum" + }, ]; private readonly List componentSubClasses = @@ -112,7 +541,7 @@ public partial class BitAppShellDemo { Name = "IsFixed", Type = "bool", - DefaultValue = "null", + DefaultValue = "false", Description = "If true, indicates that Value will not change.", } ] @@ -135,42 +564,149 @@ public partial class BitAppShellDemo Name = "Top", Type = "string?", DefaultValue = "null", - Description = "Custom CSS classes/styles for the top area of the BitAppShell.", + Description = "Custom CSS classes/styles for the top inset bar of the BitAppShell.", }, new() { Name = "Center", Type = "string?", DefaultValue = "null", - Description = "Custom CSS classes/styles for the top center of the BitAppShell.", + Description = "Custom CSS classes/styles for the center row of the BitAppShell, which holds the two side inset bars and the main container.", }, new() { Name = "Left", Type = "string?", DefaultValue = "null", - Description = "Custom CSS classes/styles for the top left of the BitAppShell.", + Description = "Custom CSS classes/styles for the leading side inset bar of the BitAppShell.", }, new() { Name = "Main", Type = "string?", DefaultValue = "null", - Description = "Custom CSS classes/styles for the main area of the BitAppShell.", + Description = "Custom CSS classes/styles for the main (scrolling) container of the BitAppShell.", }, new() { Name = "Right", Type = "string?", DefaultValue = "null", - Description = "Custom CSS classes/styles for the right area of the BitAppShell.", + Description = "Custom CSS classes/styles for the trailing side inset bar of the BitAppShell.", }, new() { Name = "Bottom", Type = "string?", DefaultValue = "null", - Description = "Custom CSS classes/styles for the bottom area of the BitAppShell.", + Description = "Custom CSS classes/styles for the bottom inset bar of the BitAppShell.", + }, + ] + }, + new() + { + Id = "scroll-offset", + Title = "BitScrollOffset", + Description = "Where the main container of the app shell stands, as measured in the browser. Everything is in CSS pixels; the members derived from the measured ones cost nothing to read.", + Parameters = + [ + new() + { + Name = "Left", + Type = "double", + DefaultValue = "0", + Description = "The raw scrollLeft of the container.", + }, + new() + { + Name = "Top", + Type = "double", + DefaultValue = "0", + Description = "How far the content has been scrolled down.", + }, + new() + { + Name = "ScrollWidth", + Type = "double", + DefaultValue = "0", + Description = "The full width of the content, including the part scrolled out of sight.", + }, + new() + { + Name = "ScrollHeight", + Type = "double", + DefaultValue = "0", + Description = "The full height of the content, including the part scrolled out of sight.", + }, + new() + { + Name = "ClientWidth", + Type = "double", + DefaultValue = "0", + Description = "The width of the visible area, without its scrollbar.", + }, + new() + { + Name = "ClientHeight", + Type = "double", + DefaultValue = "0", + Description = "The height of the visible area, without its scrollbar.", + }, + new() + { + Name = "Rtl", + Type = "bool", + DefaultValue = "false", + Description = "Whether the container was laid out right to left when it was measured.", + }, + new() + { + Name = "DeltaLeft / DeltaTop", + Type = "double", + DefaultValue = "0", + Description = "How far the container has moved since the position before this one was reported. Only the OnScroll reports carry them.", + }, + new() + { + Name = "OffsetLeft", + Type = "double", + DefaultValue = "", + Description = "The distance from the visual left edge, which is Left made positive and direction independent.", + }, + new() + { + Name = "MaxLeft / MaxTop", + Type = "double", + DefaultValue = "", + Description = "The largest offset each axis can reach, which is how much of the content is out of sight.", + }, + new() + { + Name = "ScrollableX / ScrollableY", + Type = "bool", + DefaultValue = "", + Description = "Whether there is anything to scroll along each axis at all.", + }, + new() + { + Name = "AtLeft / AtRight / AtTop / AtBottom", + Type = "bool", + DefaultValue = "", + Description = "Whether the container is standing at each edge, within a pixel of slack.", + }, + new() + { + Name = "PercentX / PercentY", + Type = "double", + DefaultValue = "", + Description = "How far the container has been scrolled along each axis, from 0 to 1.", + }, + new() + { + Name = "ScrollingUp / ScrollingDown / ScrollingLeft / ScrollingRight", + Type = "bool", + DefaultValue = "", + Description = "Which way the move this report carries went, derived from the deltas - which is what a header that folds away on the way down reads.", }, ] } @@ -205,5 +741,125 @@ public partial class BitAppShellDemo } ] }, + new() + { + Id = "overflow-enum", + Name = "BitOverflow", + Description = "What the main container of the app shell does with content that overflows it along one axis.", + Items = + [ + new() + { + Name= "Auto", + Description="A scrollbar is offered along that axis when the content overflows, and nothing is shown when it does not.", + Value="0", + }, + new() + { + Name= "Hidden", + Description="The overflow is clipped and no scrollbar is offered, though the axis can still be moved through the scrolling methods of the component.", + Value="1", + }, + new() + { + Name= "Scroll", + Description="A scrollbar is always shown along that axis, whether or not there is anything to scroll.", + Value="2", + }, + new() + { + Name= "Visible", + Description="The overflow is neither clipped nor scrollable, so it is painted outside the container.", + Value="3", + } + ] + }, + new() + { + Id = "scrollbar-gutter-enum", + Name = "BitScrollbarGutter", + Description = "How much room the main container of the app shell reserves for its scrollbar.", + Items = + [ + new() + { + Name= "Auto", + Description="The initial value: a classic scrollbar takes its room only while there is something to scroll, and an overlay scrollbar takes none at all.", + Value="0", + }, + new() + { + Name= "Stable", + Description="The room is reserved whether or not there is anything to scroll, so the layout does not shift as pages of different lengths follow one another.", + Value="1", + }, + new() + { + Name= "BothEdges", + Description="Like Stable, with the same room reserved on the opposite edge as well, so the content stays centered.", + Value="2", + } + ] + }, + new() + { + Id = "overscroll-enum", + Name = "BitOverscroll", + Description = "What the browser does with a scroll that has already reached the edge of the main container.", + Items = + [ + new() + { + Name= "Auto", + Description="The scroll carries on into the nearest scrolling ancestor, and the platform's own overscroll affordance is kept.", + Value="0", + }, + new() + { + Name= "Contain", + Description="The scroll stops at the edge instead of carrying on into the page behind it, while the platform's own overscroll affordance is kept.", + Value="1", + }, + new() + { + Name= "None", + Description="Like Contain, and the platform's own overscroll affordance is suppressed as well, so the container neither bounces nor triggers a pull to refresh.", + Value="2", + } + ] + }, + new() + { + Id = "scroll-alignment-enum", + Name = "BitScrollAlignment", + Description = "Where inside the main container an element is left after ScrollToElement has brought it into view.", + Items = + [ + new() + { + Name= "Start", + Description="The element is brought to the start of the container.", + Value="0", + }, + new() + { + Name= "Center", + Description="The element is centered in the container along both axes.", + Value="1", + }, + new() + { + Name= "End", + Description="The element is brought to the end of the container.", + Value="2", + }, + new() + { + Name= "Nearest", + Description="The container moves as little as it can.", + Value="3", + } + ] + }, ]; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.samples.cs new file mode 100644 index 0000000000..73786e1fb4 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.samples.cs @@ -0,0 +1,369 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.AppShell; + +public partial class BitAppShellDemo +{ + private readonly string example1RazorCode = @" + +
+
Header
+
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
Row @i
+ } +
+
+
"; + + private readonly string example2RazorCode = @" + + + + + + + +
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
Row @i
+ } +
+
"; + private readonly string example2CsharpCode = @" +private bool noInsets; +private bool noTopInset; +private bool noEndInset; +private bool noStartInset; +private bool noBottomInset; + +// The four bars are sized from env(safe-area-inset-*), which is 0 on a desktop browser, +// so this example hands the root the safe areas a phone would report - which the No*Inset +// flags still take back to zero - and gives each bar a color to make it visible. +private readonly BitAppShellClassStyles insetStyles = new() +{ + Root = ""--bit-env-inset-top:1.5rem;--bit-env-inset-bottom:1.5rem;--bit-env-inset-inline-start:1rem;--bit-env-inset-inline-end:1rem"", + Top = ""background:#0d7bbd"", + Bottom = ""background:#0d7bbd"", + Left = ""background:#7a3fb5"", + Right = ""background:#7a3fb5"", +};"; + + private readonly string example3RazorCode = @" +
Keyboard inset: @keyboardInset.ToString(""0"") px
+ + +
+
+ @foreach (var i in Enumerable.Range(1, 10)) + { +
Row @i
+ } +
+ @* Stuck to the bottom of the scrolling middle, whose height AvoidKeyboard is what shortens - + so the composer rides up with the keyboard instead of disappearing behind it. *@ +
+ + Send +
+
+
"; + private readonly string example3CsharpCode = @" +private string? message; +private double keyboardInset; + +private void HandleKeyboardInset(double inset) { keyboardInset = inset; StateHasChanged(); } + +// .composer { position: sticky; bottom: 0; } +// +// A page that places chrome of its own OUTSIDE the shell reads the same measurement: +// .fab { bottom: calc(var(--bit-ash-keyboard-inset) + 1rem); }"; + + private readonly string example4RazorCode = @" + scrollShell?.GoToTop()"">GoToTop + scrollShell?.GoToBottom()"">GoToBottom + scrollShell?.ScrollTo(null, 240)"">ScrollTo(240) + scrollShell?.ScrollBy(0, 120)"">ScrollBy(+120) +ScrollToElement +GetScrollOffset + +
@offsetText
+ + +
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
+ Row @i @(i == 20 ? ""(the ScrollToElement target)"" : null) +
+ } +
+
"; + private readonly string example4CsharpCode = @" +private BitAppShell? scrollShell; +private string offsetText = ""-""; + +private Task ScrollToTarget() => scrollShell?.ScrollToElement(""target-row"") ?? Task.CompletedTask; + +private async Task ReadOffset() +{ + var offset = await (scrollShell?.GetScrollOffset() ?? Task.FromResult(null)); + + offsetText = offset is null + ? ""-"" + : $""Top {offset.Top:0} of {offset.MaxTop:0} ({offset.PercentY * 100:0}%), AtTop: {offset.AtTop}, AtBottom: {offset.AtBottom}""; +}"; + + private readonly string example5RazorCode = @" +
Top: @scrollTop.ToString(""0"") px (@((scrollPercent * 100).ToString(""0""))%)
+
Direction: @scrollDirection
+
Phase: @scrollPhase
+
Reached: @reachedEdge
+ + +
+ @foreach (var i in Enumerable.Range(1, 40)) + { +
Row @i
+ } +
+
"; + private readonly string example5CsharpCode = @" +private double scrollTop; +private double scrollPercent; +private string scrollPhase = ""idle""; +private string reachedEdge = ""-""; +private string scrollDirection = ""-""; + +private void HandleScroll(BitScrollOffset offset) +{ + scrollTop = offset.Top; + scrollPercent = offset.PercentY; + scrollDirection = offset.ScrollingDown ? ""down"" : offset.ScrollingUp ? ""up"" : ""-""; + + StateHasChanged(); +} + +private void HandleScrollStart() { scrollPhase = ""scrolling""; StateHasChanged(); } + +private void HandleScrollEnd() { scrollPhase = ""idle""; StateHasChanged(); } + +private void HandleReachedTop() { reachedEdge = ""top""; StateHasChanged(); } + +private void HandleReachedBottom() { reachedEdge = ""bottom""; StateHasChanged(); }"; + + private readonly string example6RazorCode = @" + + + behaviorShell?.GoToTop()"">GoToTop + behaviorShell?.GoToBottom()"">GoToBottom + + +
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
Row @i
+ } +
+
"; + private readonly string example6CsharpCode = @" +private bool instantScroll; +private BitAppShell? behaviorShell;"; + + private readonly string example7RazorCode = @" + + + clipShell?.ScrollBy(0, 80)"">ScrollBy(+80) + clipShell?.GoToTop()"">GoToTop + + +
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
Row @i
+ } +
+
"; + private readonly string example7CsharpCode = @" +private bool noScroll; +private BitAppShell? clipShell;"; + + private readonly string example8RazorCode = @" +@* MainLayout.razor - the app shell wraps everything the application renders. *@ + + + +
+
@Body
+
+
+ +@code { + private BitAppShell? appShell; + + // Use AutoGoToTop instead to open every page at its top: + // + + // On sign-out, forget where the previous user was left in each page: + private Task SignOut() => appShell?.ClearPersistedScroll() ?? Task.CompletedTask; +}"; + + private readonly string example9RazorCode = @" + + + + +Change the cascaded name + +@* AppShellDemoConsumer.razor - anywhere below the shell, however deep. *@ +
Cascaded by type: @User?.Name (@User?.Role)
+
Cascaded by name: @Tenant
+ +@code { + [CascadingParameter] public AppShellDemoUser? User { get; set; } + + [CascadingParameter(Name = ""Tenant"")] public string? Tenant { get; set; } +}"; + private readonly string example9CsharpCode = @" +public record AppShellDemoUser(string Name, string Role); + +private string userName = ""Saleh Yusefnejad""; + +private IEnumerable cascadingValues => +[ + new(new AppShellDemoUser(userName, ""Developer"")), + new(""bit platform"", ""Tenant""), +]; + +private void RenameUser() +{ + userName = userName == ""Saleh Yusefnejad"" ? ""Yaser Moradi"" : ""Saleh Yusefnejad""; +}"; + + private readonly string example10RazorCode = @" + + + + + +
+
A row wider than the shell
+ @foreach (var i in Enumerable.Range(1, shortOverflowPage ? 1 : 20)) + { +
Row @i
+ } +
+
"; + private readonly string example10CsharpCode = @" +private bool stableGutter; +private bool clipOverflowX; +private bool shortOverflowPage;"; + + private readonly string example11RazorCode = @" + + +ScrollToElement(row 15) + paddingShell?.GoToTop()"">GoToTop + + +
+ @* Stuck to the top of the scrolling middle, which is what the padding leaves room for. *@ +
A header stuck to the top of the shell
+
+ @foreach (var i in Enumerable.Range(1, 30)) + { +
+ Row @i @(i == 15 ? ""(the target)"" : null) +
+ } +
+
+
"; + private readonly string example11CsharpCode = @" +private bool stickyPadding; +private BitAppShell? paddingShell; + +private string? paddingValue => stickyPadding ? ""2.5rem 0 0 0"" : null; + +private Task ScrollToPaddedRow() => paddingShell?.ScrollToElement(""padded-row"") ?? Task.CompletedTask;"; + + private readonly string example12RazorCode = @" + + + +Append to the end +Prepend 5 older + feedShell?.Refresh()"">Refresh + + +
+ @foreach (var message in feed) + { +
@message
+ } +
+
"; + private readonly string example12CsharpCode = @" +private bool autoScroll = true; +private bool preserveScroll = true; +private int feedNext = 13; +private int feedOlder; +private BitAppShell? feedShell; +private readonly List feed = [.. Enumerable.Range(1, 12).Select(i => $""Message {i}"")]; + +private void AppendMessage() => feed.Add($""Message {feedNext++}""); + +// Older content lands ABOVE what the reader is looking at, which is the arrival PreserveScroll is for. +private void PrependMessages() +{ + for (var i = 0; i < 5; i++) + { + feed.Insert(0, $""Older message {--feedOlder}""); + } +}"; + + private readonly string example13RazorCode = @" + +
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
Row @i
+ } +
+
"; + private readonly string example13CsharpCode = @" +private readonly BitAppShellClassStyles shellStyles = new() +{ + Root = ""border-radius:0.5rem;overflow:hidden"", + Top = ""height:0.5rem;background:#3a9b3a"", + Bottom = ""height:0.5rem;background:#3a9b3a"", + Main = ""padding:0.75rem"", +}; + +private readonly BitAppShellClassStyles shellClasses = new() +{ + Main = ""styled-main"", +};"; + + private readonly string example14RazorCode = @" + +
+ @foreach (var i in Enumerable.Range(1, 12)) + { +
سطر @i
+ } +
+
"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.scss index e69de29bb2..da74845dd0 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/AppShell/BitAppShellDemo.razor.scss @@ -0,0 +1,106 @@ +// The app shell fills whatever room it is given, and it belongs in MainLayout where that room is the +// window. A demo page is already inside one, so every example here puts its shell in a box of a fixed +// height instead - which is the only way to show a full-screen container on a page that is not one. +// +// The inset bars sit on a z-index above anything an application can render, which is right for the shell +// of the whole window but lets a shell in a box paint them over the chrome of this page - its sticky header +// and toolbars. The box is made a stacking context of its own, so that z-index only ranks the bars among +// the rest of the shell. +.shell-box { + height: 18rem; + overflow: hidden; + isolation: isolate; + border-radius: 0.375rem; + border: 1px solid var(--bit-clr-brd-sec); + + &.short { + height: 9rem; + } +} + +.page { + width: 100%; + display: flex; + height: fit-content; + + // So a page shorter than the shell still fills it, which is what a sticky header and a sticky + // composer both need to have something to stick to. + min-height: 100%; + flex-direction: column; +} + +.page-head { + top: 0; + z-index: 1; + position: sticky; + font-weight: 600; + padding: 0.5rem 0.75rem; + color: var(--bit-clr-fg-pri); + background-color: var(--bit-clr-bg-sec); + border-bottom: 1px solid var(--bit-clr-brd-sec); +} + +.page-body { + width: 100%; +} + +.row { + padding: 0.5rem 0.75rem; + color: var(--bit-clr-fg-pri); + border-bottom: 1px solid var(--bit-clr-brd-sec); +} + +// The composer of the keyboard example. It is stuck to the bottom of the shell's scrolling middle, +// whose height AvoidKeyboard is what shortens - so it rides up with the keyboard rather than being +// covered by it. +.composer { + bottom: 0; + gap: 0.5rem; + display: flex; + position: sticky; + align-items: center; + padding: 0.5rem 0.75rem; + background-color: var(--bit-clr-bg-sec); + border-top: 1px solid var(--bit-clr-brd-sec); +} + +.btn-row { + gap: 0.5rem; + display: flex; + flex-wrap: wrap; +} + +.readout { + gap: 0.25rem; + display: flex; + flex-wrap: wrap; + column-gap: 1.5rem; + color: var(--bit-clr-fg-sec); +} + +::deep .consumer { + gap: 0.25rem; + display: flex; + padding: 0.75rem; + flex-direction: column; + color: var(--bit-clr-fg-pri); +} + +.note { + padding: 0.75rem; + color: var(--bit-clr-fg-sec); + border-radius: 0.375rem; + background-color: var(--bit-clr-bg-sec); +} + +// The class the Style & Class example hands to the main container through Classes.Main, to show that +// the scrolling middle can be reached by class as well as by inline style. +::deep .styled-main { + background-image: linear-gradient(var(--bit-clr-bg-sec), transparent 6rem); +} + +// The overflow example needs something wider than the shell to overflow it sideways. +.wide-row { + width: 140%; + white-space: nowrap; +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AppShell/BitAppShellTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AppShell/BitAppShellTests.cs index 3085aba21d..f6e6bef1ec 100644 --- a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AppShell/BitAppShellTests.cs +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/AppShell/BitAppShellTests.cs @@ -3,6 +3,7 @@ using System.Reflection; using System.Threading.Tasks; using Bunit; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -21,7 +22,7 @@ public void BitAppShellShouldRenderExpectedElement()
-
+
@@ -1106,6 +1107,1319 @@ public void BitAppShellStyleBuilderShouldCombineStyles() Assert.Contains("visibility:hidden", style); } + + // --------------------------------------------------------------------------------------------- + // Container id + // --------------------------------------------------------------------------------------------- + + [TestMethod] + public void BitAppShellMainContainerShouldUseTheWellKnownIdByDefault() + { + var component = RenderComponent(); + + Assert.AreEqual(BitAppShell.ContainerId, component.Instance.MainContainerId); + Assert.AreEqual(BitAppShell.ContainerId, component.Find(".bit-ash-main").GetAttribute("id")); + } + + [TestMethod] + public void BitAppShellMainContainerIdShouldBeDerivedFromTheIdOfTheShell() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Id, "second-shell"); + }); + + Assert.AreEqual("second-shell-container", component.Instance.MainContainerId); + Assert.AreEqual("second-shell-container", component.Find(".bit-ash-main").GetAttribute("id")); + } + + [TestMethod] + public void BitAppShellTwoShellsWithIdsShouldNotShareTheirContainerId() + { + var first = RenderComponent(parameters => parameters.Add(p => p.Id, "shell-a")); + var second = RenderComponent(parameters => parameters.Add(p => p.Id, "shell-b")); + + Assert.AreNotEqual(first.Instance.MainContainerId, second.Instance.MainContainerId); + } + + // --------------------------------------------------------------------------------------------- + // NoInsets / NoScroll / ScrollBehavior / Overscroll + // --------------------------------------------------------------------------------------------- + + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitAppShellShouldRespectNoInsets(bool noInsets) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.NoInsets, noInsets); + }); + + Assert.AreEqual(noInsets, component.Find(".bit-ash").ClassList.Contains("bit-ash-nin")); + } + + [TestMethod] + public void BitAppShellShouldRespectNoInsetsChangingAfterRender() + { + var component = RenderComponent(); + + Assert.IsFalse(component.Find(".bit-ash").ClassList.Contains("bit-ash-nin")); + + component.Render(parameters => parameters.Add(p => p.NoInsets, true)); + + Assert.IsTrue(component.Find(".bit-ash").ClassList.Contains("bit-ash-nin")); + } + + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitAppShellShouldRespectNoScroll(bool noScroll) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.NoScroll, noScroll); + }); + + Assert.AreEqual(noScroll, component.Find(".bit-ash-main").ClassList.Contains("bit-ash-nsc")); + } + + [TestMethod] + public void BitAppShellNoScrollShouldNotSetUpTheBrowserSideOnItsOwn() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.NoScroll, true); + }); + + // The stylesheet is what stops the reader, so a shell that asked for nothing else does not pay + // for a scroll listener, a ResizeObserver and a measurement per frame to be told to sit still. + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.ScrollablePane.setup")); + + Assert.IsTrue(component.Find(".bit-ash-main").ClassList.Contains("bit-ash-nsc")); + } + + [TestMethod, + DataRow(null, true), + DataRow(BitScrollBehavior.Smooth, true), + DataRow(BitScrollBehavior.Instant, false), + DataRow(BitScrollBehavior.Auto, false) + ] + public void BitAppShellShouldRespectScrollBehavior(BitScrollBehavior? behavior, bool smooth) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScrollBehavior, behavior); + }); + + Assert.AreEqual(smooth, component.Find(".bit-ash-main").ClassList.Contains("bit-ash-smt")); + } + + [TestMethod] + public void BitAppShellShouldRespectScrollBehaviorChangingAfterRender() + { + var component = RenderComponent(); + + Assert.IsTrue(component.Find(".bit-ash-main").ClassList.Contains("bit-ash-smt")); + + component.Render(parameters => parameters.Add(p => p.ScrollBehavior, BitScrollBehavior.Instant)); + + Assert.IsFalse(component.Find(".bit-ash-main").ClassList.Contains("bit-ash-smt")); + } + + [TestMethod, + DataRow(BitOverscroll.Auto, "overscroll-behavior:auto"), + DataRow(BitOverscroll.Contain, "overscroll-behavior:contain"), + DataRow(BitOverscroll.None, "overscroll-behavior:none") + ] + public void BitAppShellShouldRespectOverscroll(BitOverscroll overscroll, string expected) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Overscroll, overscroll); + }); + + Assert.Contains(expected, component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty); + } + + [TestMethod] + public void BitAppShellShouldNotWriteAnOverscrollStyleByDefault() + { + var component = RenderComponent(); + + Assert.IsFalse((component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty).Contains("overscroll-behavior")); + } + + [TestMethod] + public void BitAppShellOverscrollShouldBeAppendedToTheMainStyles() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Overscroll, BitOverscroll.Contain); + parameters.Add(p => p.Styles, new BitAppShellClassStyles { Main = "padding:1rem" }); + }); + + var style = component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty; + + Assert.Contains("padding:1rem", style); + Assert.Contains("overscroll-behavior:contain", style); + } + + [TestMethod] + public void BitAppShellMainShouldKeepItsClassesAlongsideTheClassesParameter() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.NoScroll, true); + parameters.Add(p => p.Classes, new BitAppShellClassStyles { Main = "custom-main" }); + }); + + var main = component.Find(".bit-ash-main"); + + Assert.IsTrue(main.ClassList.Contains("bit-ash-main")); + Assert.IsTrue(main.ClassList.Contains("bit-ash-smt")); + Assert.IsTrue(main.ClassList.Contains("bit-ash-nsc")); + Assert.IsTrue(main.ClassList.Contains("custom-main")); + } + + // --------------------------------------------------------------------------------------------- + // The scrolling API + // --------------------------------------------------------------------------------------------- + + [TestMethod] + public async Task BitAppShellShouldCallGoToBottom() + { + var component = RenderComponent(); + + await component.Instance.GoToBottom(); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.Extras.goToBottom"); + } + + [TestMethod, + DataRow(BitScrollBehavior.Auto, "auto"), + DataRow(BitScrollBehavior.Instant, "instant"), + DataRow(BitScrollBehavior.Smooth, "smooth"), + DataRow(null, null) + ] + public async Task BitAppShellShouldPassTheBehaviorOfGoToBottom(BitScrollBehavior? behavior, string expected) + { + var component = RenderComponent(); + + await component.Instance.GoToBottom(behavior); + + var invocation = Context.JSInterop.Invocations["BitBlazorUI.Extras.goToBottom"].Single(); + + Assert.AreEqual(expected, invocation.Arguments[1]); + } + + [TestMethod] + public async Task BitAppShellShouldCallScrollTo() + { + var component = RenderComponent(); + + await component.Instance.ScrollTo(null, 240); + + var invocation = Context.JSInterop.Invocations["BitBlazorUI.Extras.scrollTo"].Single(); + + Assert.IsNull(invocation.Arguments[1]); + Assert.AreEqual(240d, invocation.Arguments[2]); + } + + [TestMethod] + public async Task BitAppShellShouldCallScrollBy() + { + var component = RenderComponent(); + + await component.Instance.ScrollBy(10, 20, BitScrollBehavior.Instant); + + var invocation = Context.JSInterop.Invocations["BitBlazorUI.Extras.scrollBy"].Single(); + + Assert.AreEqual(10d, invocation.Arguments[1]); + Assert.AreEqual(20d, invocation.Arguments[2]); + Assert.AreEqual("instant", invocation.Arguments[3]); + } + + [TestMethod] + public async Task BitAppShellShouldCallScrollToElement() + { + var component = RenderComponent(); + + await component.Instance.ScrollToElement("row-9", 12, false, BitScrollAlignment.Center); + + var invocation = Context.JSInterop.Invocations["BitBlazorUI.ScrollablePane.scrollToElement"].Single(); + + Assert.AreEqual("row-9", invocation.Arguments[1]); + Assert.AreEqual(12d, invocation.Arguments[2]); + Assert.AreEqual(false, invocation.Arguments[3]); + Assert.AreEqual("center", invocation.Arguments[4]); + } + + [TestMethod] + public async Task BitAppShellShouldNotCallScrollToElementWithoutAnElementId() + { + var component = RenderComponent(); + + await component.Instance.ScrollToElement(" "); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.ScrollablePane.scrollToElement")); + } + + [TestMethod] + public async Task BitAppShellShouldReadTheScrollOffset() + { + var expected = new BitScrollOffset { Top = 120, ScrollHeight = 1000, ClientHeight = 400 }; + + Context.JSInterop.Setup("BitBlazorUI.ScrollablePane.getOffset", _ => true).SetResult(expected); + + var component = RenderComponent(); + + var offset = await component.Instance.GetScrollOffset(); + + Assert.IsNotNull(offset); + Assert.AreEqual(120d, offset!.Top); + Assert.AreEqual(600d, offset.MaxTop); + Assert.IsFalse(offset.AtTop); + } + + [TestMethod] + public async Task BitAppShellShouldClearThePersistedScroll() + { + var component = RenderComponent(); + + await component.Instance.ClearPersistedScroll(); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.clearScrolls"); + } + + // --------------------------------------------------------------------------------------------- + // Scroll reporting + // --------------------------------------------------------------------------------------------- + + [TestMethod] + public void BitAppShellShouldNotSetUpTheBrowserSideWithoutAnyScrollCallback() + { + RenderComponent(); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.ScrollablePane.setup")); + } + + [TestMethod] + public void BitAppShellShouldSetUpTheBrowserSideWhenAScrollCallbackIsHandled() + { + RenderComponent(parameters => + { + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, _ => { })); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.setup"); + } + + [TestMethod] + public void BitAppShellShouldSetUpTheBrowserSideForEachOfTheScrollCallbacks() + { + RenderComponent(parameters => + { + parameters.Add(p => p.OnScrollStart, EventCallback.Factory.Create(this, _ => { })); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.setup"); + } + + [TestMethod] + public void BitAppShellShouldSetUpTheBrowserSideForTheReachedCallbacks() + { + RenderComponent(parameters => + { + parameters.Add(p => p.OnReachedBottom, EventCallback.Factory.Create(this, () => { })); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.setup"); + } + + [TestMethod] + public void BitAppShellShouldSetUpTheBrowserSideOnlyOnceForTheSameOptions() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, _ => { })); + }); + + component.Render(); + component.Render(); + + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.ScrollablePane.setup"].Count); + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.ScrollablePane.update")); + } + + [TestMethod] + public void BitAppShellShouldUpdateTheBrowserSideWhenTheOptionsChange() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, _ => { })); + parameters.Add(p => p.ReachOffset, 0); + }); + + component.Render(parameters => parameters.Add(p => p.ReachOffset, 32)); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.update"); + } + + [TestMethod] + public void BitAppShellShouldDisposeTheBrowserSideWhenTheLastScrollCallbackIsTakenAway() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, _ => { })); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.setup"); + + component.Render(parameters => parameters.Add(p => p.OnScroll, default(EventCallback))); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.dispose"); + } + + [TestMethod] + public async Task BitAppShellShouldRaiseOnScrollFromTheBrowserSide() + { + BitScrollOffset? reported = null; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, o => reported = o)); + }); + + await component.Instance._OnScroll(new BitScrollOffset { Top = 42 }); + + Assert.IsNotNull(reported); + Assert.AreEqual(42d, reported!.Top); + } + + [TestMethod] + public async Task BitAppShellShouldRaiseOnScrollStartAndOnScrollEndFromTheBrowserSide() + { + var started = false; + var ended = false; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnScrollStart, EventCallback.Factory.Create(this, _ => started = true)); + parameters.Add(p => p.OnScrollEnd, EventCallback.Factory.Create(this, _ => ended = true)); + }); + + await component.Instance._OnScrollStart(new BitScrollOffset()); + await component.Instance._OnScrollEnd(new BitScrollOffset()); + + Assert.IsTrue(started); + Assert.IsTrue(ended); + } + + [TestMethod] + public async Task BitAppShellShouldIgnoreANullOffsetFromTheBrowserSide() + { + var raised = false; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, _ => raised = true)); + }); + + await component.Instance._OnScroll(null!); + + Assert.IsFalse(raised); + } + + [TestMethod, + DataRow("top"), + DataRow("bottom") + ] + public async Task BitAppShellShouldRouteTheReachedEdgeToItsOwnCallback(string edge) + { + var reached = string.Empty; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnReachedTop, EventCallback.Factory.Create(this, () => reached = "top")); + parameters.Add(p => p.OnReachedBottom, EventCallback.Factory.Create(this, () => reached = "bottom")); + }); + + await component.Instance._OnReached(edge); + + Assert.AreEqual(edge, reached); + } + + [TestMethod] + public async Task BitAppShellShouldIgnoreAnUnknownReachedEdge() + { + var reached = false; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnReachedTop, EventCallback.Factory.Create(this, () => reached = true)); + }); + + await component.Instance._OnReached("left"); + + Assert.IsFalse(reached); + } + + [TestMethod] + public void BitAppShellShouldDisposeTheBrowserSideOfTheScrollReporting() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, _ => { })); + }); + + component.Instance.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.dispose"); + } + + // --------------------------------------------------------------------------------------------- + // Navigation + // --------------------------------------------------------------------------------------------- + + [TestMethod] + public void BitAppShellShouldNotGoToTopOnAFragmentOnlyNavigation() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoGoToTop, true); + }); + + InvokeLocationChanged(component.Instance, "https://example.com/page"); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.Extras.goToTop"); + + var before = Context.JSInterop.Invocations["BitBlazorUI.Extras.goToTop"].Count; + + InvokeLocationChanged(component.Instance, "https://example.com/page#section-2"); + + Assert.AreEqual(before, Context.JSInterop.Invocations["BitBlazorUI.Extras.goToTop"].Count); + } + + [TestMethod] + public void BitAppShellShouldGoToTopWhenTheFragmentIsLeftBehind() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoGoToTop, true); + }); + + InvokeLocationChanged(component.Instance, "https://example.com/page"); + InvokeLocationChanged(component.Instance, "https://example.com/page#section-2"); + InvokeLocationChanged(component.Instance, "https://example.com/other"); + + // The first and the last are real navigations; the fragment-only one in between is not. + Assert.AreEqual(2, Context.JSInterop.Invocations["BitBlazorUI.Extras.goToTop"].Count); + } + + [TestMethod] + public void BitAppShellShouldNotPersistScrollOnAFragmentOnlyNavigation() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.PersistScroll, true); + }); + + InvokeLocationChanged(component.Instance, "https://example.com/page"); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.locationChangedScroll"); + + InvokeLocationChanged(component.Instance, "https://example.com/page#section-2"); + + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.AppShell.locationChangedScroll"].Count); + } + + [TestMethod] + public void BitAppShellShouldGoToTopWithTheScrollBehaviorItWasGiven() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoGoToTop, true); + parameters.Add(p => p.ScrollBehavior, BitScrollBehavior.Smooth); + }); + + InvokeLocationChanged(component.Instance, "https://example.com/other"); + + var invocation = Context.JSInterop.Invocations["BitBlazorUI.Extras.goToTop"].Single(); + + Assert.AreEqual("smooth", invocation.Arguments[1]); + } + + [TestMethod] + public void BitAppShellAutoGoToTopShouldDefaultToAnInstantMove() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoGoToTop, true); + }); + + InvokeLocationChanged(component.Instance, "https://example.com/other"); + + var invocation = Context.JSInterop.Invocations["BitBlazorUI.Extras.goToTop"].Single(); + + Assert.AreEqual("instant", invocation.Arguments[1]); + } + + [TestMethod] + public void BitAppShellShouldSubscribeWhenAutoGoToTopIsTurnedOnAfterRender() + { + var component = RenderComponent(); + + InvokeLocationChanged(component.Instance, "https://example.com/one"); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.Extras.goToTop")); + + component.Render(parameters => parameters.Add(p => p.AutoGoToTop, true)); + + InvokeLocationChanged(component.Instance, "https://example.com/two"); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.Extras.goToTop"); + } + + [TestMethod] + public void BitAppShellShouldUnsubscribeWhenBothNavigationFeaturesAreTurnedOffAfterRender() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoGoToTop, true); + }); + + component.Render(parameters => parameters.Add(p => p.AutoGoToTop, false)); + + InvokeLocationChanged(component.Instance, "https://example.com/two"); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.Extras.goToTop")); + } + + [TestMethod] + public void BitAppShellShouldInitScrollWhenPersistScrollIsTurnedOnAfterRender() + { + var component = RenderComponent(); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.AppShell.initScroll")); + + component.Render(parameters => parameters.Add(p => p.PersistScroll, true)); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.initScroll"); + } + + [TestMethod] + public void BitAppShellShouldInitScrollOnlyOnce() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.PersistScroll, true); + }); + + component.Render(); + component.Render(); + + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.AppShell.initScroll"].Count); + } + + // --------------------------------------------------------------------------------------------- + // Keyboard inset + // --------------------------------------------------------------------------------------------- + + [TestMethod] + public void BitAppShellShouldNotTrackTheKeyboardByDefault() + { + RenderComponent(); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.AppShell.setupKeyboard")); + } + + [TestMethod] + public void BitAppShellShouldTrackTheKeyboardWhenAvoidKeyboardIsSet() + { + RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.setupKeyboard"); + } + + [TestMethod] + public void BitAppShellShouldTrackTheKeyboardOnlyOnce() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + }); + + component.Render(); + component.Render(); + + Assert.AreEqual(1, Context.JSInterop.Invocations["BitBlazorUI.AppShell.setupKeyboard"].Count); + } + + [TestMethod] + public void BitAppShellShouldStartTrackingTheKeyboardWhenAvoidKeyboardIsTurnedOnAfterRender() + { + var component = RenderComponent(); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.AppShell.setupKeyboard")); + + component.Render(parameters => parameters.Add(p => p.AvoidKeyboard, true)); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.setupKeyboard"); + } + + [TestMethod] + public void BitAppShellShouldStopTrackingTheKeyboardWhenAvoidKeyboardIsTurnedOffAfterRender() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + }); + + component.Render(parameters => parameters.Add(p => p.AvoidKeyboard, false)); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.disposeKeyboard"); + } + + [TestMethod] + public void BitAppShellShouldStopTrackingTheKeyboardOnDispose() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + }); + + component.Instance.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.disposeKeyboard"); + } + + [TestMethod] + public void BitAppShellShouldStopPersistingScrollWhenPersistScrollIsTurnedOffAfterRender() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.PersistScroll, true); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.initScroll"); + + component.Render(parameters => parameters.Add(p => p.PersistScroll, false)); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.AppShell.disposeScroll"); + } + + [TestMethod] + public void BitAppShellShouldPersistScrollAgainAfterItWasTurnedOffAndOn() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.PersistScroll, true); + }); + + component.Render(parameters => parameters.Add(p => p.PersistScroll, false)); + component.Render(parameters => parameters.Add(p => p.PersistScroll, true)); + + Assert.AreEqual(2, Context.JSInterop.Invocations["BitBlazorUI.AppShell.initScroll"].Count); + } + + + [TestMethod, + DataRow(true, false, false, false, "bit-ash-nit"), + DataRow(false, true, false, false, "bit-ash-nib"), + DataRow(false, false, true, false, "bit-ash-nis"), + DataRow(false, false, false, true, "bit-ash-nie") + ] + public void BitAppShellShouldRespectTheSingleEdgeInsetFlags(bool top, bool bottom, bool start, bool end, string expected) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.NoTopInset, top); + parameters.Add(p => p.NoBottomInset, bottom); + parameters.Add(p => p.NoStartInset, start); + parameters.Add(p => p.NoEndInset, end); + }); + + var classes = component.Find(".bit-ash").ClassList; + + Assert.IsTrue(classes.Contains(expected)); + Assert.IsFalse(classes.Contains("bit-ash-nin")); + + foreach (var other in new[] { "bit-ash-nit", "bit-ash-nib", "bit-ash-nis", "bit-ash-nie" }.Where(c => c != expected)) + { + Assert.IsFalse(classes.Contains(other)); + } + } + + [TestMethod] + public void BitAppShellShouldNotWriteAnyEdgeInsetClassByDefault() + { + var component = RenderComponent(); + + var classes = component.Find(".bit-ash").ClassList; + + Assert.IsFalse(classes.Contains("bit-ash-nit")); + Assert.IsFalse(classes.Contains("bit-ash-nib")); + Assert.IsFalse(classes.Contains("bit-ash-nis")); + Assert.IsFalse(classes.Contains("bit-ash-nie")); + } + + [TestMethod] + public void BitAppShellShouldRespectTheEdgeInsetFlagsChangingAfterRender() + { + var component = RenderComponent(); + + Assert.IsFalse(component.Find(".bit-ash").ClassList.Contains("bit-ash-nit")); + + component.Render(parameters => parameters.Add(p => p.NoTopInset, true)); + + Assert.IsTrue(component.Find(".bit-ash").ClassList.Contains("bit-ash-nit")); + + component.Render(parameters => parameters.Add(p => p.NoTopInset, false)); + + Assert.IsFalse(component.Find(".bit-ash").ClassList.Contains("bit-ash-nit")); + } + + [TestMethod] + public void BitAppShellShouldCombineTheEdgeInsetFlagsWithNoInsets() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.NoInsets, true); + parameters.Add(p => p.NoTopInset, true); + }); + + var classes = component.Find(".bit-ash").ClassList; + + Assert.IsTrue(classes.Contains("bit-ash-nin")); + Assert.IsTrue(classes.Contains("bit-ash-nit")); + } + + [TestMethod, + DataRow(BitScrollbarGutter.Auto, ""), + DataRow(BitScrollbarGutter.Stable, "scrollbar-gutter:stable"), + DataRow(BitScrollbarGutter.BothEdges, "scrollbar-gutter:stable both-edges") + ] + public void BitAppShellShouldRespectGutter(BitScrollbarGutter gutter, string expected) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Gutter, gutter); + }); + + var style = component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty; + + if (expected.HasValue()) + { + Assert.Contains(expected, style); + } + else + { + Assert.IsFalse(style.Contains("scrollbar-gutter")); + } + } + + [TestMethod, + DataRow(BitOverflow.Auto, "overflow-x:auto"), + DataRow(BitOverflow.Hidden, "overflow-x:hidden"), + DataRow(BitOverflow.Scroll, "overflow-x:scroll"), + DataRow(BitOverflow.Visible, "overflow-x:visible") + ] + public void BitAppShellShouldRespectOverflowX(BitOverflow overflow, string expected) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OverflowX, overflow); + }); + + var style = component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty; + + Assert.Contains(expected, style); + Assert.IsFalse(style.Contains("overflow-y")); + } + + [TestMethod] + public void BitAppShellShouldRespectOverflowY() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OverflowY, BitOverflow.Hidden); + }); + + var style = component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty; + + Assert.Contains("overflow-y:hidden", style); + Assert.IsFalse(style.Contains("overflow-x")); + } + + [TestMethod] + public void BitAppShellShouldNotWriteAnOverflowStyleByDefault() + { + var component = RenderComponent(); + + Assert.IsFalse((component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty).Contains("overflow")); + } + + [TestMethod] + public void BitAppShellNoScrollShouldWinOverTheOverflowOfAnAxis() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.NoScroll, true); + parameters.Add(p => p.OverflowX, BitOverflow.Auto); + parameters.Add(p => p.Styles, new BitAppShellClassStyles { Main = "overflow:scroll" }); + }); + + var style = component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty; + + Assert.IsTrue(style.EndsWith("overflow:hidden")); + } + + [TestMethod] + public void BitAppShellShouldRespectScrollPadding() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScrollPadding, "3rem 0 0 0"); + }); + + Assert.Contains("scroll-padding:3rem 0 0 0", component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty); + } + + [TestMethod] + public void BitAppShellShouldPutTheMainStylesOfThePageFirst() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.Styles, new BitAppShellClassStyles { Main = "padding:1rem;" }); + parameters.Add(p => p.Gutter, BitScrollbarGutter.Stable); + parameters.Add(p => p.ScrollPadding, "2rem"); + }); + + var style = component.Find(".bit-ash-main").GetAttribute("style") ?? string.Empty; + + Assert.AreEqual("padding:1rem;scrollbar-gutter:stable;scroll-padding:2rem", style); + } + + [TestMethod] + public void BitAppShellShouldSetUpTheBrowserSideForAutoScroll() + { + RenderComponent(parameters => + { + parameters.Add(p => p.AutoScroll, true); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.setup"); + } + + [TestMethod] + public void BitAppShellShouldPinAnAutoScrollingShellToTheEndOnItsFirstRender() + { + RenderComponent(parameters => + { + parameters.Add(p => p.AutoScroll, true); + }); + + // The browser side has nothing to compare against on its very first measurement, so a shell that + // opens with content already in it would be left standing at the top without this one call. + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.ScrollablePane.autoScroll"); + + Assert.AreEqual(true, invocation.Arguments[1]); + } + + [TestMethod] + public void BitAppShellShouldPinAnAutoScrollingShellOnlyOnce() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoScroll, true); + }); + + component.Render(parameters => parameters.Add(p => p.AutoScrollThreshold, 64)); + + Assert.AreEqual(1, Context.JSInterop.Invocations.Count(i => i.Identifier == "BitBlazorUI.ScrollablePane.autoScroll")); + } + + [TestMethod] + public void BitAppShellShouldNotPinAShellThatNeverAskedForAutoScroll() + { + RenderComponent(parameters => + { + parameters.Add(p => p.PreserveScroll, true); + }); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.ScrollablePane.autoScroll")); + } + + [TestMethod] + public void BitAppShellShouldSetUpTheBrowserSideForPreserveScroll() + { + RenderComponent(parameters => + { + parameters.Add(p => p.PreserveScroll, true); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.setup"); + } + + [TestMethod] + public void BitAppShellShouldSetUpTheBrowserSideForTheHorizontalReachedCallbacks() + { + RenderComponent(parameters => + { + parameters.Add(p => p.OnReachedLeft, EventCallback.Factory.Create(this, () => { })); + }); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.setup"); + } + + [TestMethod] + public void BitAppShellShouldUpdateTheBrowserSideWhenTheAutoScrollThresholdChanges() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoScroll, true); + parameters.Add(p => p.AutoScrollThreshold, 0); + }); + + component.Render(parameters => parameters.Add(p => p.AutoScrollThreshold, 64)); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.update"); + } + + [TestMethod, + DataRow("left"), + DataRow("right") + ] + public async Task BitAppShellShouldRouteTheHorizontalReachedEdgeToItsOwnCallback(string edge) + { + var reached = string.Empty; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.OnReachedLeft, EventCallback.Factory.Create(this, () => reached = "left")); + parameters.Add(p => p.OnReachedRight, EventCallback.Factory.Create(this, () => reached = "right")); + }); + + await component.Instance._OnReached(edge); + + Assert.AreEqual(edge, reached); + } + + [TestMethod] + public async Task BitAppShellShouldNotRefreshTheBrowserSideWhenItWasNeverSetUp() + { + var component = RenderComponent(); + + await component.Instance.Refresh(); + + Assert.IsFalse(Context.JSInterop.Invocations.Identifiers.Contains("BitBlazorUI.ScrollablePane.refresh")); + } + + [TestMethod] + public async Task BitAppShellShouldRefreshTheBrowserSide() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AutoScroll, true); + }); + + await component.Instance.Refresh(); + + Context.JSInterop.VerifyInvoke("BitBlazorUI.ScrollablePane.refresh"); + } + + [TestMethod] + public void BitAppShellShouldHandTheKeyboardTrackingADotnetReference() + { + RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + }); + + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.AppShell.setupKeyboard"); + + Assert.HasCount(3, invocation.Arguments); + Assert.IsNotNull(invocation.Arguments[2]); + } + + [TestMethod] + public async Task BitAppShellShouldRaiseTheKeyboardInsetFromTheBrowserSide() + { + double? reported = null; + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + parameters.Add(p => p.OnKeyboardInsetChanged, EventCallback.Factory.Create(this, i => reported = i)); + }); + + await component.Instance._OnKeyboardInset(291); + + Assert.AreEqual(291d, reported); + } + + [TestMethod] + public async Task BitAppShellShouldIgnoreTheKeyboardInsetWithoutAHandler() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + }); + + await component.Instance._OnKeyboardInset(291); + } + + [TestMethod] + public async Task BitAppShellShouldPassTheSmoothFlagOfScrollToElement() + { + Context.JSInterop.SetupVoid("BitBlazorUI.ScrollablePane.scrollToElement"); + + var component = RenderComponent(); + + await component.Instance.ScrollToElement("row", smooth: false); + + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.ScrollablePane.scrollToElement"); + + Assert.AreEqual(false, invocation.Arguments[3]); + } + + [TestMethod, + DataRow(BitScrollBehavior.Smooth, true), + DataRow(BitScrollBehavior.Instant, false), + DataRow(BitScrollBehavior.Auto, true) + ] + public async Task BitAppShellShouldLetTheBehaviorOfScrollToElementWinOverTheSmoothFlag(BitScrollBehavior behavior, bool expected) + { + Context.JSInterop.SetupVoid("BitBlazorUI.ScrollablePane.scrollToElement"); + + var component = RenderComponent(); + + await component.Instance.ScrollToElement("row", smooth: false, behavior: behavior); + + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.ScrollablePane.scrollToElement"); + + Assert.AreEqual(expected, invocation.Arguments[3]); + } + + [TestMethod, + DataRow(null, true), + DataRow(BitScrollBehavior.Smooth, true), + DataRow(BitScrollBehavior.Instant, false), + DataRow(BitScrollBehavior.Auto, false) + ] + public async Task BitAppShellShouldReadTheScrollBehaviorOfTheShellForAScrollToElementWithoutOne(BitScrollBehavior? behavior, bool expected) + { + Context.JSInterop.SetupVoid("BitBlazorUI.ScrollablePane.scrollToElement"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScrollBehavior, behavior); + }); + + await component.Instance.ScrollToElement("row"); + + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.ScrollablePane.scrollToElement"); + + Assert.AreEqual(expected, invocation.Arguments[3]); + } + + [TestMethod] + public async Task BitAppShellShouldReadTheScrollBehaviorOfTheShellForAnAutoScrollToElement() + { + Context.JSInterop.SetupVoid("BitBlazorUI.ScrollablePane.scrollToElement"); + + var component = RenderComponent(parameters => + { + parameters.Add(p => p.ScrollBehavior, BitScrollBehavior.Instant); + }); + + await component.Instance.ScrollToElement("row", behavior: BitScrollBehavior.Auto); + + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.ScrollablePane.scrollToElement"); + + Assert.AreEqual(false, invocation.Arguments[3]); + } + + + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitAppShellShouldRespectFullScreen(bool fullScreen) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.FullScreen, fullScreen); + }); + + Assert.AreEqual(fullScreen, component.Find(".bit-ash").ClassList.Contains("bit-ash-fsc")); + } + + [TestMethod] + public void BitAppShellShouldRespectFullScreenChangingAfterRender() + { + var component = RenderComponent(); + + Assert.IsFalse(component.Find(".bit-ash").ClassList.Contains("bit-ash-fsc")); + + component.Render(parameters => parameters.Add(p => p.FullScreen, true)); + + Assert.IsTrue(component.Find(".bit-ash").ClassList.Contains("bit-ash-fsc")); + } + + [TestMethod] + public void BitAppShellShouldNotCreateADotnetReferenceForAKeyboardItIsNotTracking() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.AvoidKeyboard, true); + }); + + component.Render(parameters => parameters.Add(p => p.AvoidKeyboard, false)); + + var invocation = Context.JSInterop.Invocations.Last(i => i.Identifier == "BitBlazorUI.AppShell.disposeKeyboard"); + + Assert.HasCount(1, invocation.Arguments); + } + + + [TestMethod] + public async Task BitAppShellShouldClearThePersistedScrollOfOneUrl() + { + var component = RenderComponent(); + + await component.Instance.ClearPersistedScroll("https://example.com/list"); + + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.AppShell.clearScrolls"); + + Assert.AreEqual("https://example.com/list", invocation.Arguments[0]); + } + + [TestMethod] + public async Task BitAppShellShouldClearEveryPersistedScrollWithoutAUrl() + { + var component = RenderComponent(); + + await component.Instance.ClearPersistedScroll(); + + var invocation = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.AppShell.clearScrolls"); + + Assert.IsNull(invocation.Arguments[0]); + } + + + [TestMethod] + public void BitAppShellShouldDriveTheBrowserSideWithTheOptionsItWasGiven() + { + RenderComponent(parameters => + { + parameters.Add(p => p.AutoScroll, true); + parameters.Add(p => p.AutoScrollThreshold, 48); + parameters.Add(p => p.PreserveScroll, true); + parameters.Add(p => p.NoScroll, true); + parameters.Add(p => p.ReachOffset, 16); + parameters.Add(p => p.ScrollThrottle, 100); + parameters.Add(p => p.ScrollBehavior, BitScrollBehavior.Instant); + parameters.Add(p => p.OnReachedLeft, EventCallback.Factory.Create(this, () => { })); + parameters.Add(p => p.OnScroll, EventCallback.Factory.Create(this, _ => { })); + }); + + var options = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.ScrollablePane.setup").Arguments[3]; + + Assert.IsNotNull(options); + + Assert.AreEqual(true, OptionOf(options!, "AutoScroll")); + Assert.AreEqual(48, OptionOf(options!, "AutoScrollThreshold")); + Assert.AreEqual(true, OptionOf(options!, "Preserve")); + Assert.AreEqual(true, OptionOf(options!, "NoScroll")); + Assert.AreEqual(16, OptionOf(options!, "Offset")); + Assert.AreEqual(100, OptionOf(options!, "Throttle")); + Assert.AreEqual(false, OptionOf(options!, "Smooth")); + Assert.AreEqual(true, OptionOf(options!, "Left")); + Assert.AreEqual(true, OptionOf(options!, "Scroll")); + Assert.AreEqual(false, OptionOf(options!, "Right")); + Assert.AreEqual(false, OptionOf(options!, "Top")); + Assert.AreEqual(false, OptionOf(options!, "Bottom")); + Assert.AreEqual(false, OptionOf(options!, "ScrollStart")); + Assert.AreEqual(false, OptionOf(options!, "ScrollEnd")); + } + + [TestMethod] + public void BitAppShellShouldNotAskTheBrowserSideForTheWorkItWasNotGivenAHandlerFor() + { + RenderComponent(parameters => + { + parameters.Add(p => p.OnReachedBottom, EventCallback.Factory.Create(this, () => { })); + }); + + var options = Context.JSInterop.Invocations.Single(i => i.Identifier == "BitBlazorUI.ScrollablePane.setup").Arguments[3]; + + Assert.IsNotNull(options); + + Assert.AreEqual(true, OptionOf(options!, "Bottom")); + Assert.AreEqual(false, OptionOf(options!, "Scroll")); + Assert.AreEqual(false, OptionOf(options!, "AutoScroll")); + Assert.AreEqual(false, OptionOf(options!, "Preserve")); + Assert.AreEqual(true, OptionOf(options!, "Smooth")); + } + + private static object? OptionOf(object options, string name) + { + var property = options.GetType().GetProperty(name, BindingFlags.Instance | BindingFlags.Public); + + Assert.IsNotNull(property, $"The options of the browser side carry no {name}."); + + return property!.GetValue(options); + } + + + [TestMethod, + DataRow(true), + DataRow(false) + ] + public void BitAppShellShouldRespectStableInsets(bool stable) + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.StableInsets, stable); + }); + + Assert.AreEqual(stable, component.Find(".bit-ash").ClassList.Contains("bit-ash-sin")); + } + + [TestMethod] + public void BitAppShellShouldRespectStableInsetsChangingAfterRender() + { + var component = RenderComponent(); + + Assert.IsFalse(component.Find(".bit-ash").ClassList.Contains("bit-ash-sin")); + + component.Render(parameters => parameters.Add(p => p.StableInsets, true)); + + Assert.IsTrue(component.Find(".bit-ash").ClassList.Contains("bit-ash-sin")); + } + + [TestMethod] + public void BitAppShellShouldKeepBothInsetClassesWhenStableInsetsMeetsNoInsets() + { + var component = RenderComponent(parameters => + { + parameters.Add(p => p.StableInsets, true); + parameters.Add(p => p.NoInsets, true); + }); + + var classes = component.Find(".bit-ash").ClassList; + + Assert.IsTrue(classes.Contains("bit-ash-sin")); + Assert.IsTrue(classes.Contains("bit-ash-nin")); + } + private static void InvokeLocationChanged(BitAppShell instance, string uri) { var method = instance.GetType().GetMethod("LocationChanged", BindingFlags.Instance | BindingFlags.NonPublic); From 95ee6ce3510915925bbbea9f7d4bef22bc3b1dd5 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 14 Sep 2026 19:16:18 +0330 Subject: [PATCH 04/43] feat(blazorui): apply BitChart improvements #13154 (#13158) --- .../Components/Chart/BitChart.Export.cs | 172 ++ .../Components/Chart/BitChart.razor | 172 +- .../Components/Chart/BitChart.razor.cs | 1024 ++++++-- .../Components/Chart/BitChart.scss | 357 +++ .../Components/Chart/BitChart.ts | 206 +- .../Chart/BitChartJsRuntimeExtensions.cs | 33 +- .../Chart/BitChartSvgPrimitive.razor | 84 +- .../Chart/Models/BitChartDataLabelOptions.cs | 23 +- .../Chart/Models/BitChartDataset.cs | 65 +- .../Chart/Models/BitChartDecimationOptions.cs | 6 +- .../Chart/Models/BitChartElementOptions.cs | 13 +- .../Chart/Models/BitChartErrorBar.cs | 15 + .../Chart/Models/BitChartFillMode.cs | 6 + .../Chart/Models/BitChartInteractionMode.cs | 22 + .../Models/BitChartInteractionOptions.cs | 28 +- .../Models/BitChartLegendLabelOptions.cs | 1 + .../Chart/Models/BitChartLegendOptions.cs | 9 + .../Chart/Models/BitChartOptions.cs | 20 +- .../Chart/Models/BitChartPosition.cs | 7 + .../Chart/Models/BitChartScaleOptions.cs | 10 +- .../Chart/Models/BitChartScriptable.cs | 24 - .../Chart/Models/BitChartTickOptions.cs | 8 + .../Chart/Models/BitChartTooltipOptions.cs | 24 +- .../Chart/Models/BitChartZoomOptions.cs | 9 + .../Chart/Rendering/BitChartAxisScale.cs | 46 +- .../Chart/Rendering/BitChartColorUtil.cs | 39 +- .../Chart/Rendering/BitChartDataElement.cs | 12 +- .../Chart/Rendering/BitChartHitBand.cs | 17 + .../Chart/Rendering/BitChartLegendModel.cs | 2 + .../Chart/Rendering/BitChartPointShapes.cs | 9 + .../Chart/Rendering/BitChartRenderState.cs | 5 +- .../Rendering/BitChartRenderer.Cartesian.cs | 332 ++- .../BitChartRenderer.CartesianDraw.cs | 113 +- .../Rendering/BitChartRenderer.Circular.cs | 300 ++- .../Chart/Rendering/BitChartRenderer.Radar.cs | 51 +- .../Rendering/BitChartRenderer.Series.cs | 492 +++- .../Chart/Rendering/BitChartRenderer.cs | 189 +- .../Chart/Rendering/BitChartScene.cs | 18 + .../Chart/Rendering/BitChartSvgNode.cs | 3 + .../Chart/Rendering/BitChartSvgPath.cs | 1 + .../Chart/Rendering/BitChartTextMeasure.cs | 36 +- .../Chart/Rendering/BitChartTimeAxis.cs | 79 +- .../Chart/Rendering/BitChartTitleModel.cs | 3 +- .../Rendering/Plugins/BitChartAnnotation.cs | 12 + .../Plugins/BitChartAnnotationKind.cs | 23 +- .../Plugins/BitChartAnnotationPlugin.cs | 81 +- .../Plugins/BitChartCenterTextPlugin.cs | 2 +- .../Plugins/BitChartPluginContext.cs | 33 +- .../Rendering/Plugins/BitChartTrendline.cs | 42 + .../Plugins/BitChartTrendlineKind.cs | 15 + .../Plugins/BitChartTrendlinePlugin.cs | 175 ++ .../Styles/extra-components.scss | 1 + .../_BitButtonGroupCustomDemo.razor | 4 +- .../ButtonGroup/_BitButtonGroupItemDemo.razor | 4 +- .../_BitButtonGroupOptionDemo.razor | 4 +- .../Extras/Chart/BitChartDemo.razor | 147 +- .../Extras/Chart/BitChartDemo.razor.cs | 584 ++++- .../Extras/Chart/BitChartDemo.razor.scss | 77 + .../Chart/_BitChartAnimationsDemo.razor | 17 +- .../Chart/_BitChartAnnotationsDemo.razor | 25 +- .../Chart/_BitChartAnnotationsDemo.razor.cs | 65 + .../Extras/Chart/_BitChartAreaDemo.razor | 35 +- .../Extras/Chart/_BitChartBarDemo.razor | 115 +- .../Extras/Chart/_BitChartBarDemo.razor.cs | 296 +++ .../Chart/_BitChartDataLabelsDemo.razor | 35 + .../Chart/_BitChartDataLabelsDemo.razor.cs | 185 ++ .../Extras/Chart/_BitChartExportDemo.razor | 55 + .../Extras/Chart/_BitChartExportDemo.razor.cs | 114 + .../Chart/_BitChartInteractionDemo.razor | 63 + .../Chart/_BitChartInteractionDemo.razor.cs | 140 ++ .../Extras/Chart/_BitChartLegendDemo.razor | 33 +- .../Extras/Chart/_BitChartLegendDemo.razor.cs | 45 + .../Extras/Chart/_BitChartLineDemo.razor | 69 +- .../Extras/Chart/_BitChartLineDemo.razor.cs | 91 + .../Extras/Chart/_BitChartLiveDemo.razor | 39 + .../Extras/Chart/_BitChartLiveDemo.razor.cs | 169 ++ .../Chart/_BitChartLocalizationDemo.razor | 69 + .../Chart/_BitChartLocalizationDemo.razor.cs | 174 ++ .../Extras/Chart/_BitChartMixedDemo.razor | 14 +- .../Extras/Chart/_BitChartMultiAxisDemo.razor | 22 +- .../Extras/Chart/_BitChartPieDemo.razor | 56 +- .../Extras/Chart/_BitChartPieDemo.razor.cs | 83 + .../Extras/Chart/_BitChartPolarDemo.razor | 14 +- .../Extras/Chart/_BitChartRadarDemo.razor | 28 +- .../Extras/Chart/_BitChartScalesDemo.razor | 50 +- .../Extras/Chart/_BitChartScalesDemo.razor.cs | 46 + .../Extras/Chart/_BitChartScatterDemo.razor | 14 +- .../Chart/_BitChartScriptableDemo.razor | 15 +- .../Extras/Chart/_BitChartTimeDemo.razor | 15 +- .../Extras/Chart/_BitChartTitlesDemo.razor | 46 + .../Extras/Chart/_BitChartTitlesDemo.razor.cs | 103 + .../Extras/Chart/_BitChartTooltipsDemo.razor | 45 +- .../Chart/_BitChartTooltipsDemo.razor.cs | 43 + .../Chart/_BitChartTrendlinesDemo.razor | 30 + .../Chart/_BitChartTrendlinesDemo.razor.cs | 189 ++ .../Extras/Chart/_BitChartZoomDemo.razor | 45 +- .../Extras/Chart/_BitChartZoomDemo.razor.cs | 118 +- .../Dropdown/_BitDropdownCustomDemo.razor | 102 +- .../_BitDropdownCustomDemo.razor.samples.cs | 2 +- .../Dropdown/_BitDropdownItemDemo.razor | 102 +- .../_BitDropdownItemDemo.razor.samples.cs | 2 +- .../Dropdown/_BitDropdownOptionDemo.razor | 94 +- .../_BitDropdownOptionDemo.razor.samples.cs | 2 +- .../Navs/NavBar/BitNavBarDemo.razor | 8 +- .../Navs/NavBar/_BitNavBarCustomDemo.razor | 12 +- .../Navs/NavBar/_BitNavBarItemDemo.razor | 12 +- .../Navs/NavBar/_BitNavBarOptionDemo.razor | 12 +- .../Pages/Iconography/IconographyPage.razor | 30 +- .../Pages/Theming/ThemingPage.razor | 252 +- .../Scripts/app.ts | 13 + .../Extras/Chart/BitChartAxisScaleTests.cs | 296 +++ .../Extras/Chart/BitChartRendererTests.cs | 2116 +++++++++++++++++ .../Components/Extras/Chart/BitChartTests.cs | 1323 +++++++++++ .../Extras/Chart/BitChartUtilsTests.cs | 456 ++++ 114 files changed, 11665 insertions(+), 1268 deletions(-) create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.Export.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.scss create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartErrorBar.cs delete mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScriptable.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartHitBand.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendline.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlineKind.cs create mode 100644 src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlinePlugin.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor.cs create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor create mode 100644 src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor.cs create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartAxisScaleTests.cs create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartRendererTests.cs create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartTests.cs create mode 100644 src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartUtilsTests.cs diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.Export.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.Export.cs new file mode 100644 index 0000000000..91746d7eb9 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.Export.cs @@ -0,0 +1,172 @@ +using System.Globalization; +using System.Text; + +namespace Bit.BlazorUI; + +/// +/// Export helpers. The chart is already a live SVG element, so exporting is a matter of serializing it +/// (SVG), rasterizing that serialization (PNG), or writing the underlying values out (CSV). +/// +public partial class BitChart +{ + /// + /// Downloads the chart as a standalone .svg file. The theme tokens the chart references are + /// resolved into the exported file so it looks the same outside the app. + /// + /// File name to save as; defaults to chart.svg. + /// Optional background painted behind the chart (SVG is transparent by default). + /// True when the file was produced. + public async Task ExportSvgAsync(string? fileName = null, string? backgroundColor = null) + { + try + { + return await JS.BitChartExportSvg(_plotEl, fileName ?? "chart.svg", backgroundColor); + } + catch + { + return false; + } + } + + /// + /// Downloads the chart as a .png image, rasterized from the live SVG. + /// + /// File name to save as; defaults to chart.png. + /// Pixel ratio; 2 (the default) produces a crisp image on high-density displays. + /// Background painted behind the chart; PNG is transparent without it. + /// True when the file was produced. + public async Task ExportPngAsync(string? fileName = null, double scale = 2, string? backgroundColor = "#ffffff") + { + try + { + return await JS.BitChartExportPng(_plotEl, fileName ?? "chart.png", scale <= 0 ? 1 : scale, backgroundColor); + } + catch + { + return false; + } + } + + /// + /// Returns the chart as standalone SVG markup instead of downloading it - for embedding it in a + /// report, mailing it, or storing it - with the theme tokens it references resolved into the markup + /// so it looks the same outside the app. + /// + /// Optional background painted behind the chart (SVG is transparent by default). + /// The SVG markup, or null when the chart has not been rendered in a browser yet. + public async Task ToSvgStringAsync(string? backgroundColor = null) + { + try + { + return await JS.BitChartToSvgString(_plotEl, backgroundColor); + } + catch + { + return null; + } + } + + /// + /// Returns the chart as a rasterized data: URL - the same picture + /// downloads - ready to drop into an img src or a PDF. Mirrors Chart.js's toBase64Image. + /// + /// Image type to encode; image/png by default (image/jpeg and image/webp also work). + /// Pixel ratio; 2 (the default) produces a crisp image on high-density displays. + /// Background painted behind the chart; PNG is transparent without it. + /// The data URL, or null when the chart has not been rendered in a browser yet. + public async Task ToBase64ImageAsync(string mimeType = "image/png", double scale = 2, + string? backgroundColor = "#ffffff") + { + try + { + return await JS.BitChartToDataUrl(_plotEl, mimeType, scale <= 0 ? 1 : scale, backgroundColor); + } + catch + { + return null; + } + } + + /// Downloads the chart's data as a .csv file. + /// File name to save as; defaults to chart.csv. + /// True when the file was produced. + public async Task ExportCsvAsync(string? fileName = null) + { + try + { + await JS.BitChartDownloadText(fileName ?? "chart.csv", ToCsv(), "text/csv;charset=utf-8"); + return true; + } + catch + { + return false; + } + } + + /// + /// Renders the chart's data as CSV. Value datasets become one row per series with a column per + /// label; point datasets (scatter/bubble) become one row per point. + /// + public string ToCsv() + { + var data = _config.Data; + var culture = Culture; + var sb = new StringBuilder(); + + if (HasPointData) + { + sb.AppendLine("Series,X,Y,R"); + foreach (var ds in data.Datasets) + { + if (ds.Points is not { } pts) continue; + foreach (var p in pts) + sb.Append(CsvText(ds.Label ?? "Series")).Append(',') + .Append(Csv(p.X.ToString(culture))).Append(',') + .Append(Csv(p.Y.ToString(culture))).Append(',') + .AppendLine(p.R is { } r ? Csv(r.ToString(culture)) : ""); + } + return sb.ToString(); + } + + sb.Append("Series"); + foreach (var label in data.Labels) sb.Append(',').Append(CsvText(label)); + sb.AppendLine(); + + foreach (var ds in data.Datasets) + { + sb.Append(CsvText(ds.Label ?? "Series")); + if (ds.RangeData is { } ranges) + { + foreach (var r in ranges) + sb.Append(',').Append(r is { } rr ? Csv($"{rr.Low.ToString(culture)} - {rr.High.ToString(culture)}") : ""); + } + else + { + foreach (var v in ds.Data) + sb.Append(',').Append(v is { } vv ? Csv(vv.ToString(culture)) : ""); + } + sb.AppendLine(); + } + return sb.ToString(); + } + + /// + /// Quotes a caller-supplied text field - a series or category label - and neutralizes the leading + /// characters a spreadsheet reads as the start of a formula, so a label taken from user data cannot + /// become executable content when the file is opened. Formatted numbers keep going through + /// , where a leading minus sign is a sign rather than an injection. + /// + private static string CsvText(string value) + { + if (value.Length > 0 && value[0] is '=' or '+' or '-' or '@' or '\t' or '\r') + value = "'" + value; + return Csv(value); + } + + /// Quotes a CSV field when it contains a separator, quote or newline. + private static string Csv(string value) + { + if (value.IndexOfAny([',', '"', '\n', '\r']) < 0) return value; + return '"' + value.Replace("\"", "\"\"") + '"'; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor index ba420897c3..21d4594327 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor @@ -1,19 +1,13 @@ @namespace Bit.BlazorUI -
- @((MarkupString)AnimationStyles) - @if (RespectReducedMotion) +
+ @if (_scene.Title is { } title && TitleSide(title) == BitChartPosition.Top) { - @((MarkupString)ReducedMotionStyles) + @RenderTitle(title, "bit-cht-ttl") } - - @if (_scene.Title is { Position: not BitChartPosition.Bottom } title) - { - @RenderTitle(title, "bc-title") - } - @if (_scene.Subtitle is { Position: not BitChartPosition.Bottom } sub) + @if (_scene.Subtitle is { } sub && TitleSide(sub) == BitChartPosition.Top) { - @RenderTitle(sub, "bc-subtitle") + @RenderTitle(sub, "bit-cht-sub") } @if (_scene.Legend is { Position: BitChartPosition.Top }) @@ -21,14 +15,22 @@ @RenderLegend(_scene.Legend) } -
+
+ @if (_scene.Title is { } leftTitle && TitleSide(leftTitle) == BitChartPosition.Left) + { + @RenderTitle(leftTitle, "bit-cht-ttl bit-cht-ttl-v") + } @if (_scene.Legend is { Position: BitChartPosition.Left }) { @RenderLegend(_scene.Legend) } -
- +
+ @if (_scene.PlotArea is { } pa) { @@ -64,12 +66,27 @@ @RenderPattern(pat) } - + @foreach (var n in _scene.Background) { } + @* Invisible per-index hit areas. They sit below the data so an element under the + pointer always wins, and make the chart hoverable across the whole plot. The + pointerdown handler is what gives a touch screen - which never hovers - the same + tooltip on a tap. *@ + + @foreach (var band in _scene.HitBands) + { + + } + @foreach (var n in _scene.Series) { @@ -82,6 +99,7 @@ var el = _scene.Elements[ei]; @if (el.BorderShape is { } bs) @@ -91,7 +109,7 @@ } - + @foreach (var n in _hoverNodes) { @@ -99,10 +117,10 @@ @if (_dragBox is { } db) { + fill="@_config.Options.Zoom.DragBoxColor" stroke="@_config.Options.Zoom.DragBoxBorderColor" stroke-width="1" /> } - + @foreach (var n in _scene.Foreground) { @@ -110,40 +128,55 @@ + @if (_scene.IsEmpty) + { +
+ @if (NoDataTemplate is not null) + { + @NoDataTemplate + } + else + { + @NoDataText + } +
+ } + @if (_activeTooltip is { } tt && _config.Options.Plugins.Tooltip.Enabled) { var t = _config.Options.Plugins.Tooltip; + var pos = TooltipPlacement(tt); @if (TooltipTemplate is not null && _tooltipContext is { } ctx) { -
+
@TooltipTemplate(ctx)
} else { -
+
@if (!string.IsNullOrEmpty(tt.Title)) { -
@tt.Title
+
@tt.Title
} @foreach (var line in tt.BeforeBody) { -
@line
+
@line
} @foreach (var item in tt.Items) { -
+
@if (t.DisplayColors) { @if (t.UsePointStyle && item.PointStyle is { } ps && BitChartPointShapes.Build(ps, 7, 7, 5, item.Color, item.Color, 1) is { } marker) { - + } } @item.Text @@ -151,17 +184,21 @@ } @foreach (var line in tt.AfterBody) { -
@line
+
@line
} @if (tt.Footer.Count > 0) { - } } @@ -171,6 +208,10 @@ { @RenderLegend(_scene.Legend) } + @if (_scene.Title is { } rightTitle && TitleSide(rightTitle) == BitChartPosition.Right) + { + @RenderTitle(rightTitle, "bit-cht-ttl bit-cht-ttl-v") + }
@if (_scene.Legend is { Position: BitChartPosition.Bottom }) @@ -178,13 +219,18 @@ @RenderLegend(_scene.Legend) } - @if (_scene.Subtitle is { Position: BitChartPosition.Bottom } subBottom) + @if (_scene.Subtitle is { } subBottom && TitleSide(subBottom) == BitChartPosition.Bottom) + { + @RenderTitle(subBottom, "bit-cht-sub") + } + @if (_scene.Title is { } titleBottom && TitleSide(titleBottom) == BitChartPosition.Bottom) { - @RenderTitle(subBottom, "bc-subtitle") + @RenderTitle(titleBottom, "bit-cht-ttl") } - @if (_scene.Title is { Position: BitChartPosition.Bottom } titleBottom) + + @if (ShowNavigationHint) { - @RenderTitle(titleBottom, "bc-title") +
@NavigationHint
} @if (GenerateTable) @@ -192,15 +238,15 @@ @RenderDataTable() } -
@_liveMessage
+
@_liveMessage
@code { private RenderFragment RenderTitle(BitChartTitleModel title, string cls) => __builder => { var lines = title.Text.Split('\n'); -
-
+
+
@for (int i = 0; i < lines.Length; i++) { @lines[i] @@ -252,21 +298,27 @@ private RenderFragment RenderLegend(BitChartLegendModel legend) => __builder => { bool vertical = legend.Position is BitChartPosition.Left or BitChartPosition.Right; -
+
@if (!string.IsNullOrEmpty(legend.Title)) { -
@legend.Title
+
@legend.Title
} @foreach (var item in legend.Items) { -
+ @* A toggle button: the label is the accessible name and aria-pressed carries the state, + so a screen reader announces "Alpha, toggle button, pressed". *@ +
+ @item.Text + }
}; @@ -286,11 +338,12 @@ private RenderFragment RenderDataTable() => __builder => { var data = _config.Data; -
- - +
+
@ChartAriaLabel
+ @if (HasPointData) { + var budget = MaxTableRows > 0 ? MaxTableRows : int.MaxValue; @@ -299,46 +352,51 @@ { @if (ds.Points is { } pts) { - @foreach (var p in pts) + @foreach (var p in pts.Take(budget)) { - - + + } + budget -= Math.Min(budget, pts.Count); } } } else { + // Every row renders this many cells, so the header has to as well - a dataset can carry + // more values than there are category names, and a header short of them shifts the whole + // row against its labels for a screen reader. + var columns = Math.Min(TableColumnCount, TableColumnLimit); - @foreach (var label in data.Labels) + @for (int ci = 0; ci < columns; ci++) { - + } - @foreach (var ds in data.Datasets) + @foreach (var ds in MaxTableRows > 0 ? data.Datasets.Take(MaxTableRows) : data.Datasets) { @if (ds.RangeData is { } ranges) { - @foreach (var r in ranges) + @for (int ci = 0; ci < columns; ci++) { - + } } else { - @foreach (var v in ds.Data) + @for (int ci = 0; ci < columns; ci++) { - + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor.cs index 171571a67e..4fa8941899 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.razor.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using Microsoft.JSInterop; @@ -11,40 +12,104 @@ namespace Bit.BlazorUI; /// public partial class BitChart : ComponentBase, IAsyncDisposable { + /// Accessible label for the chart. When null a summary is generated. + [Parameter] public string? AriaLabel { get; set; } + + /// Custom CSS class applied to the root element. + [Parameter] public string? Class { get; set; } + /// Full configuration (type + data + options). Takes precedence when set. [Parameter] public BitChartConfig? Config { get; set; } - [Parameter] public BitChartType Type { get; set; } = BitChartType.Line; + /// The chart data: labels and datasets. [Parameter] public BitChartData? Data { get; set; } - [Parameter] public BitChartOptions? Options { get; set; } - /// CSS width of the chart container. - [Parameter] public string Width { get; set; } = "100%"; + /// Text direction of the chrome around the plot (title, legend, tooltip, data table). + [Parameter] public BitDir? Dir { get; set; } + + /// + /// Plays the entry and update animations even when the user has asked for reduced motion. Like every + /// other animated component in the library, the chart honors prefers-reduced-motion: reduce by + /// default and draws itself straight in its final state; this renders the library-wide bit-fam + /// opt-out class, which an ancestor can carry as well to opt a whole subtree back in. + /// + [Parameter] public bool ForceAnimation { get; set; } + + /// Render a visually-hidden data table for screen readers (default true). + [Parameter] public bool GenerateTable { get; set; } = true; + /// Optional CSS height. When null the height follows the aspect ratio. [Parameter] public string? Height { get; set; } - [Parameter] public string? Class { get; set; } - [Parameter] public string? Style { get; set; } + /// + /// Additional HTML attributes applied to the root element, following the same convention as the rest + /// of the library: assign the dictionary explicitly rather than relying on unmatched-value capture. + /// + [Parameter] public Dictionary HtmlAttributes { get; set; } = []; - /// Accessible label for the chart. When null a summary is generated. - [Parameter] public string? AriaLabel { get; set; } + /// Id of the root element. + [Parameter] public string? Id { get; set; } - /// Render a visually-hidden data table for screen readers (default true). - [Parameter] public bool GenerateTable { get; set; } = true; + /// + /// Upper bound on the columns the screen-reader table renders. A value series is one table row with + /// a cell per category, so a long series is wide rather than tall and the row cap alone would not + /// stop it; past this limit the table shows the first columns and its caption says how many were + /// left out. Ignored for point (scatter/bubble) data, whose table is three fixed columns. + /// + [Parameter] public int MaxTableColumns { get; set; } = 100; /// - /// When true (the default), entry/update animations are disabled for users who have requested - /// reduced motion (the prefers-reduced-motion: reduce media query). Set to false to always - /// animate regardless of the OS setting. + /// Upper bound on the rows the screen-reader table renders. A long series would otherwise put tens + /// of thousands of hidden nodes in the DOM for no one's benefit; past the limit the table shows the + /// first rows and its caption says how many were left out. /// - [Parameter] public bool RespectReducedMotion { get; set; } = true; + [Parameter] public int MaxTableRows { get; set; } = 500; - /// Optional custom tooltip template. When set it replaces the default tooltip body. - [Parameter] public RenderFragment? TooltipTemplate { get; set; } + /// + /// A visually hidden sentence telling a screen-reader user how to walk the data, pointed at by the + /// chart's aria-describedby alongside the data table. Without it the chart announces itself + /// as a picture and nothing says the arrow keys do anything. Set it to null or an empty string to + /// leave it out; it is only rendered when there is data to navigate. + /// + [Parameter] public string? NavigationHint { get; set; } = + "Interactive chart. Use the left and right arrow keys to move through a series, " + + "the up and down arrow keys to move between series, Home and End for the first and last value, " + + "Enter to select, and Escape to leave."; + + /// Custom content shown in place of the plot when there is nothing to draw. + [Parameter] public RenderFragment? NoDataTemplate { get; set; } + + /// Message shown in place of the plot when there is nothing to draw. + [Parameter] public string NoDataText { get; set; } = "No data to display"; /// Raised when a data element is clicked: (datasetIndex, dataIndex). [Parameter] public EventCallback<(int DatasetIndex, int DataIndex)> OnElementClick { get; set; } + /// Raised when the active (hovered or keyboard-focused) element set changes. The context + /// is null when nothing is active any more. + [Parameter] public EventCallback OnElementHover { get; set; } + + /// Raised when a legend item is clicked, before the default visibility toggle runs. + [Parameter] public EventCallback OnLegendItemClick { get; set; } + + /// Raised after zoom or pan changes the visible axis ranges. + [Parameter] public EventCallback OnZoomChange { get; set; } + + /// The chart options: scales, plugins, interaction, animation, culture and zoom. + [Parameter] public BitChartOptions? Options { get; set; } + + /// Custom CSS style applied to the root element. + [Parameter] public string? Style { get; set; } + + /// Optional custom tooltip template. When set it replaces the default tooltip body. + [Parameter] public RenderFragment? TooltipTemplate { get; set; } + + /// The chart type. Ignored when is set. + [Parameter] public BitChartType Type { get; set; } = BitChartType.Line; + + /// CSS width of the chart container. + [Parameter] public string Width { get; set; } = "100%"; + private readonly BitChartRenderState _state = new(); private BitChartConfig _config = new(); private BitChartScene _scene = new(); @@ -61,7 +126,6 @@ public partial class BitChart : ComponentBase, IAsyncDisposable private bool _suppressTransition; // Interaction state (does not trigger a scene rebuild). - private BitChartDataElement? _hovered; private readonly HashSet _active = new(); private BitChartTooltipInfo? _activeTooltip; private BitChartTooltipContext? _tooltipContext; @@ -71,6 +135,13 @@ public partial class BitChart : ComponentBase, IAsyncDisposable private int _focusIndex = -1; private string? _liveMessage; + // What the current hover/focus points at, in data coordinates rather than by element identity. + // A rebuild replaces every element object, so this is what lets an active tooltip - or the + // keyboard position - survive a re-render driven by the parent, a resize, or a zoom. + private (int Ds, int Di)? _hoverAnchor; + private bool _hoverForceIndex; + private (int Ds, int Di)? _focusKey; + // Increments to (re)play entry animations: on data change and after the first size measurement. private int _animKey; private long _lastSig = long.MinValue; @@ -81,12 +152,13 @@ public partial class BitChart : ComponentBase, IAsyncDisposable private ElementReference _plotEl; private IJSObjectReference? _zoomHandle; private DotNetObjectReference? _dotRef; - private bool _zoomRegistered; + /// What the gesture bridge is currently registered for; null until the first attempt. + private string? _zoomSignature; // Drag-zoom selection box in viewBox coordinates (x, y, w, h). private (double X, double Y, double W, double H)? _dragBox; - // Unique id for this instance's SVG defs (clip paths, gradients). + // Unique id for this instance's SVG defs (clip paths, gradients, patterns) and the a11y table. private readonly string _instanceId = "bc" + Guid.NewGuid().ToString("N")[..8]; protected override void OnParametersSet() @@ -100,14 +172,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // A resize-triggered render suppresses geometry transitions; re-enable for later updates. if (_suppressTransition) _suppressTransition = false; - // Responsive sizing: observe the container so we can render at real device pixels. - if (_config.Options.Responsive && !_sizeRegistered) + // The pointer/keyboard bridge is attached for every chart - it is what stops the arrow keys + // scrolling the page while the chart has focus - and additionally observes the container size + // when the chart is responsive, so it can render at real device pixels. + if (!_sizeRegistered) { _sizeRegistered = true; try { _dotRef ??= DotNetObjectReference.Create(this); - _sizeHandle = await JS.BitChartObserve(_plotEl, _dotRef); + _sizeHandle = await JS.BitChartObserve(_plotEl, _dotRef, _config.Options.Responsive); } catch { @@ -116,10 +190,25 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } - if (_zoomRegistered || !_config.Options.Zoom.Enabled || _scene.IsRadialOrCircular) - return; - _zoomRegistered = true; + // The gesture bridge is keyed by what it was asked to listen for, so turning zoom off (or + // switching between panning and drag-to-zoom) at runtime tears the old listeners down instead + // of leaving the chart reacting to gestures it no longer offers. var z = _config.Options.Zoom; + bool wantZoom = z.Enabled && !_scene.IsRadialOrCircular; + bool pan = z.Pan && !z.DragZoom; + string signature = wantZoom ? $"{z.Wheel}|{pan}|{z.DragZoom}" : ""; + if (_zoomSignature == signature) return; + + if (_zoomHandle is not null) + { + try { await _zoomHandle.InvokeVoidAsync("dispose"); await _zoomHandle.DisposeAsync(); } + catch (JSDisconnectedException) { } + catch (Exception) { } + _zoomHandle = null; + } + _zoomSignature = signature; + if (!wantZoom) return; + try { _dotRef ??= DotNetObjectReference.Create(this); @@ -127,12 +216,12 @@ protected override async Task OnAfterRenderAsync(bool firstRender) // anonymous type members in release builds, which breaks System.Text.Json // serialization during the JS interop call. _zoomHandle = await JS.BitChartRegister(_plotEl, _dotRef, - new BitChartZoomPayload { Wheel = z.Wheel, Pan = z.Pan && !z.DragZoom, Drag = z.DragZoom }); + new BitChartZoomPayload { Wheel = z.Wheel, Pan = pan, Drag = z.DragZoom }); } catch { - // Interop unavailable (e.g. during prerender) - zoom stays inert. - _zoomRegistered = false; + // Interop unavailable (e.g. during prerender) - zoom stays inert until the next render. + _zoomSignature = null; } } @@ -163,11 +252,29 @@ public void OnWheelZoom(double fracX, double fracY, double deltaY) double cursor = min + t * (max - min); double nMin = cursor - (cursor - min) * factor; double nMax = cursor + (max - cursor) * factor; - if (nMax - nMin > 1e-9) _state.AxisRanges[id] = (nMin, nMax); + ApplyRange(id, nMin, nMax); } - _suppressTransition = true; - Recompute(); - StateHasChanged(); + AfterZoom(); + } + + /// + /// Invoked while two fingers pinch the chart. Unlike the wheel, which arrives in notches and steps by + /// a fixed fraction, a pinch reports how far apart the fingers have moved, so the zoom follows it + /// continuously: spreading them (a scale above 1) zooms in around the point between them. + /// + [JSInvokable] + public void OnPinchZoom(double fracX, double fracY, double scale) + { + if (!double.IsFinite(scale) || scale <= 0) return; + double factor = 1 / scale; + foreach (var id in AxesForMode()) + { + double t = AxisFraction(id, fracX, fracY); + var (min, max) = CurrentRange(id); + double cursor = min + t * (max - min); + ApplyRange(id, cursor - (cursor - min) * factor, cursor + (max - cursor) * factor); + } + AfterZoom(); } [JSInvokable] @@ -192,25 +299,44 @@ public void OnDragEnd(double x0, double y0, double x1, double y1) double lo = Math.Min(ta, tb), hi = Math.Max(ta, tb); var (min, max) = CurrentRange(id); double span = max - min; - double nMin = min + lo * span, nMax = min + hi * span; - if (nMax - nMin > 1e-9) _state.AxisRanges[id] = (nMin, nMax); + ApplyRange(id, min + lo * span, min + hi * span); } - _suppressTransition = true; - Recompute(); - StateHasChanged(); + AfterZoom(); } - /// Converts an element fraction (0..1) to a 0..1 position along an axis, via the plot area. + /// + /// How an axis is laid out. Which axis runs across the plot follows the chart's index axis, not the + /// axis' name: the value axes of a horizontal-bar chart are the horizontal ones. The fallback covers + /// a scene with no cartesian layout, where nothing will be zoomed anyway. + /// + private (bool Horizontal, bool MinAtFar) Orientation(string id) + => _scene.AxisOrientations.TryGetValue(id, out var o) ? o : (id == "x", id != "x"); + + /// + /// Converts an element fraction (0..1) to a 0..1 position along an axis, via the plot area. A + /// reversed axis runs the other way, so the fraction is flipped with it - otherwise wheel zoom and + /// pan would move away from the pointer. + /// private double AxisFraction(string id, double fracX, double fracY) { - if (_scene.PlotArea is not { } p) return id == "x" ? fracX : 1 - fracY; - if (id == "x") + var (horizontal, minAtFar) = Orientation(id); + double t; + if (_scene.PlotArea is not { } p) + { + t = horizontal ? fracX : minAtFar ? 1 - fracY : fracY; + } + else if (horizontal) { double x = fracX * _vw; - return p.Width <= 0 ? 0 : Math.Clamp((x - p.Left) / p.Width, 0, 1); + t = p.Width <= 0 ? 0 : Math.Clamp((x - p.Left) / p.Width, 0, 1); + } + else + { + double y = fracY * _vh; + double along = p.Height <= 0 ? 0 : Math.Clamp((y - p.Top) / p.Height, 0, 1); + t = minAtFar ? 1 - along : along; } - double y = fracY * _vh; - return p.Height <= 0 ? 0 : Math.Clamp(1 - (y - p.Top) / p.Height, 0, 1); + return _scene.ReversedAxes.Contains(id) ? 1 - t : t; } [JSInvokable] @@ -220,31 +346,159 @@ public void OnPan(double dx, double dy) { var (min, max) = CurrentRange(id); double span = max - min; - double delta = id == "x" ? -dx * span : dy * span; - _state.AxisRanges[id] = (min + delta, max + delta); + var (horizontal, minAtFar) = Orientation(id); + // Dragging moves the data with the pointer, so the range moves against it. + double delta = horizontal ? -dx * span : (minAtFar ? dy : -dy) * span; + if (_scene.ReversedAxes.Contains(id)) delta = -delta; + ApplyRange(id, min + delta, max + delta); } - _suppressTransition = true; - Recompute(); - StateHasChanged(); + AfterZoom(); } [JSInvokable] - public void OnResetZoom() + public void OnResetZoom() => ResetZoom(); + + /// Clears every zoom/pan override and returns the chart to the full data range. + public void ResetZoom() { + if (_state.AxisRanges.Count == 0) return; _state.AxisRanges.Clear(); + AfterZoom(); + } + + /// Zooms an axis to an explicit value range. Pass null to clear that axis's override. + public void ZoomTo(string axisId, double? min, double? max) + { + if (min is { } lo && max is { } hi && hi > lo) ApplyRange(axisId, lo, hi); + else _state.AxisRanges.Remove(axisId); + AfterZoom(); + } + + /// The visible range of an axis (its zoomed range when zoomed, else the full data range). + public (double Min, double Max)? GetAxisRange(string axisId) + => _scene.AxisRanges.TryGetValue(axisId, out var r) ? r : null; + + // ---- imperative API ---- + + /// + /// Rebuilds and redraws the chart from its current data and options. Blazor only re-renders a + /// component when a parameter it can compare changes, so mutating the object in + /// place - appending a point to a live series, editing a value - leaves the chart showing the old + /// scene until this is called. Mirrors Chart.js's chart.update(). + /// + public void Refresh() + { + Recompute(); + StateHasChanged(); + } + + /// Whether a dataset is currently drawn (neither hidden through the legend nor by + /// ). + public bool IsDatasetVisible(int datasetIndex) + { + if (datasetIndex < 0 || datasetIndex >= _config.Data.Datasets.Count) return false; + return !_config.Data.Datasets[datasetIndex].Hidden && !_state.IsDatasetHidden(datasetIndex); + } + + /// + /// Shows or hides a dataset, exactly as clicking its legend entry would. A dataset whose + /// is set stays hidden: that is the data's own answer, and + /// this only drives the chart's own visibility state. + /// + public void SetDatasetVisible(int datasetIndex, bool visible) + { + if (datasetIndex < 0 || datasetIndex >= _config.Data.Datasets.Count) return; + bool changed = visible ? _state.HiddenDatasets.Remove(datasetIndex) : _state.HiddenDatasets.Add(datasetIndex); + if (!changed) return; + Refresh(); + } + + /// Flips a dataset between shown and hidden. Mirrors Chart.js's hide/show pair. + public void ToggleDataset(int datasetIndex) => SetDatasetVisible(datasetIndex, !IsDatasetVisible(datasetIndex)); + + /// Whether a data index (a pie/doughnut/polar-area slice) is currently drawn. + public bool IsDataIndexVisible(int dataIndex) => !_state.IsIndexHidden(dataIndex); + + /// + /// Shows or hides one data index across the chart - the slice-level counterpart of + /// , used by the pie/doughnut/polar-area legend. Mirrors Chart.js's + /// toggleDataVisibility. + /// + public void SetDataIndexVisible(int dataIndex, bool visible) + { + if (dataIndex < 0) return; + bool changed = visible ? _state.HiddenIndices.Remove(dataIndex) : _state.HiddenIndices.Add(dataIndex); + if (!changed) return; + Refresh(); + } + + /// Flips one data index between shown and hidden. + public void ToggleDataIndex(int dataIndex) => SetDataIndexVisible(dataIndex, IsDataIndexVisible(dataIndex) is false); + + /// Brings back every dataset and data index hidden through the legend or the API. + public void ResetVisibility() + { + if (_state.HiddenDatasets.Count == 0 && _state.HiddenIndices.Count == 0) return; + _state.HiddenDatasets.Clear(); + _state.HiddenIndices.Clear(); + Refresh(); + } + + /// + /// Stores a new range for an axis, honoring the configured zoom limits so the chart can neither be + /// zoomed in past nor dragged outside the data. + /// + private void ApplyRange(string id, double min, double max) + { + if (double.IsNaN(min) || double.IsNaN(max) || max - min <= 1e-9) return; + var z = _config.Options.Zoom; + if (_scene.DataRanges.TryGetValue(id, out var full)) + { + double fullSpan = full.Max - full.Min; + if (fullSpan > 0) + { + double minSpan = fullSpan * Math.Clamp(z.MinRangeFraction, 0, 1); + if (minSpan > 0 && max - min < minSpan) + { + double c = (min + max) / 2; + min = c - minSpan / 2; + max = c + minSpan / 2; + } + if (z.LimitToData) + { + double span = Math.Min(max - min, fullSpan); + if (min < full.Min) { min = full.Min; max = min + span; } + if (max > full.Max) { max = full.Max; min = max - span; } + min = Math.Max(min, full.Min); + max = Math.Min(max, full.Max); + if (max - min <= 1e-9) return; + } + } + } + _state.AxisRanges[id] = (min, max); + } + + private void AfterZoom() + { _suppressTransition = true; Recompute(); StateHasChanged(); + if (OnZoomChange.HasDelegate) _ = OnZoomChange.InvokeAsync(); } + /// + /// The axes a gesture moves. The mode names a direction on screen, not an axis id: X is whatever + /// runs across the plot, which on a horizontal-bar chart is the value axis and not the one called + /// "x", and which includes a secondary x axis rather than only the primary one. + /// private IEnumerable AxesForMode() { var mode = _config.Options.Zoom.Mode; foreach (var id in _scene.ZoomableAxes) { - bool isX = id == "x"; - if (mode == BitChartZoomMode.X && !isX) continue; - if (mode == BitChartZoomMode.Y && isX) continue; + bool horizontal = Orientation(id).Horizontal; + if (mode == BitChartZoomMode.X && !horizontal) continue; + if (mode == BitChartZoomMode.Y && horizontal) continue; yield return id; } } @@ -273,9 +527,8 @@ private void Recompute() else _vh = responsive && _measuredHeight is { } mh && mh > 0 ? mh : basis / aspect; - _scene = new BitChartRenderer(_config, _state, _vw, _vh).Render(); - ClearHover(); - _focusIndex = -1; + _scene = new BitChartRenderer(_config, _state, _vw, _vh, _instanceId).Render(); + RestoreInteraction(); // Decide whether to (re)play the entry animation. We key off a signature of the data // values (not pixel positions), so data changes replay the animation while resize/zoom/pan @@ -293,6 +546,50 @@ private void Recompute() _initialized = true; } + /// + /// Re-points the hover and keyboard position at the freshly built scene. Every element object is + /// new after a render, so an active tooltip would otherwise blink out on any re-render the reader + /// did not ask for - a parent's StateHasChanged, a container resize, a zoom step. The + /// position is re-found by (dataset, index); when the data it pointed at is gone (hidden through + /// the legend, say) the interaction is simply dropped. Nothing is raised: no one interacted. + /// + private void RestoreInteraction() + { + var anchor = _hoverAnchor; + bool forceIndex = _hoverForceIndex; + var focusKey = _focusKey; + + ClearHover(); + _focusIndex = -1; + + if (focusKey is { } fk) + { + int at = _scene.Elements.FindIndex(e => e.DatasetIndex == fk.Ds && e.DataIndex == fk.Di); + if (at >= 0) + { + _focusIndex = at; + var el = _scene.Elements[at]; + BuildHover(el); + _hoverNodes.Add(FocusOutline(el)); + _liveMessage = Describe(el); + return; + } + _focusKey = null; + _liveMessage = null; + // The data being walked is gone - hidden through the legend, or removed. Anyone tracking the + // active element has to be told, or they keep showing a reading the chart no longer has. + NotifyHover(); + return; + } + + if (anchor is not { } a) return; + + if (_scene.Elements.FirstOrDefault(e => e.DatasetIndex == a.Ds && e.DataIndex == a.Di) is { } hovered) + BuildHover(hovered, forceIndex); + else + NotifyHover(); + } + /// A cheap signature of the data values driving the chart (changes when data changes). /// Animation settings are folded in so that changing duration/easing/stagger replays the entry /// animation, giving immediate visual feedback when those options are tweaked. @@ -320,35 +617,105 @@ private long ComputeSignature() private void OnEnter(BitChartDataElement e) { - _hovered = e; BuildHover(e); + NotifyHover(); + } + + /// + /// Hovering the empty part of the plot activates the whole index under the pointer, which is what + /// makes lines drawn without markers - and thin bars - reachable. + /// + private void OnEnterBand(BitChartHitBand band) + { + if (RepresentativeOf(band) is not { } rep) return; + BuildHover(rep, forceIndexGroup: true); + NotifyHover(); + } + + /// The element a band stands for: the first one at that index. + private BitChartDataElement? RepresentativeOf(BitChartHitBand band) + => _scene.Elements.FirstOrDefault(el => el.DataIndex == band.DataIndex); + + /// + /// Clicking the plate between the elements reports the index under the pointer, matching what + /// hovering there already does - with non-intersecting interaction the whole plot is the target, + /// so a click that lands beside a thin line should not be silently dropped. + /// + private async Task OnClickBand(BitChartHitBand band) + { + if (RepresentativeOf(band) is { } rep) await OnClickElement(rep); + } + + private void OnLeave() + { + ClearHover(); + NotifyHover(); + } + + /// + /// A touch screen never hovers, so a tap has to do the work mouseenter does with a mouse. + /// Only non-mouse pointers are handled: a mouse has already hovered by the time it presses, and + /// re-running the hover there would rebuild the tooltip twice for one click. + /// + private void OnPointerDownElement(PointerEventArgs args, BitChartDataElement e) + { + if (IsMouse(args)) return; + OnEnter(e); + } + + private void OnPointerDownBand(PointerEventArgs args, BitChartHitBand band) + { + if (IsMouse(args)) return; + OnEnterBand(band); } - private void OnLeave() => ClearHover(); + private static bool IsMouse(PointerEventArgs args) + => string.IsNullOrEmpty(args.PointerType) || args.PointerType == "mouse"; + + private void OnBlur() + { + if (_focusIndex < 0) return; + _focusIndex = -1; + _focusKey = null; + ClearHover(); + _liveMessage = null; + NotifyHover(); + } + + private void NotifyHover() + { + if (OnElementHover.HasDelegate) _ = OnElementHover.InvokeAsync(_tooltipContext); + } private void ClearHover() { - _hovered = null; _active.Clear(); _activeTooltip = null; _tooltipContext = null; _hoverNodes.Clear(); + _hoverAnchor = null; } - private void BuildHover(BitChartDataElement e) + private void BuildHover(BitChartDataElement e, bool forceIndexGroup = false) { _active.Clear(); _hoverNodes.Clear(); + _hoverAnchor = (e.DatasetIndex, e.DataIndex); + _hoverForceIndex = forceIndexGroup; var tip = _config.Options.Plugins.Tooltip; + var mode = tip.Mode ?? _config.Options.Interaction.Mode; - IEnumerable group = tip.Mode switch - { - BitChartInteractionMode.Index or BitChartInteractionMode.X or BitChartInteractionMode.Y when !_scene.IsRadialOrCircular - => _scene.Elements.Where(x => x.DataIndex == e.DataIndex), - BitChartInteractionMode.Dataset - => _scene.Elements.Where(x => x.DatasetIndex == e.DatasetIndex), - _ => new[] { e } - }; + IEnumerable group = + forceIndexGroup && !_scene.IsRadialOrCircular && mode != BitChartInteractionMode.Dataset + ? _scene.Elements.Where(x => x.DataIndex == e.DataIndex) + : mode switch + { + BitChartInteractionMode.Index or BitChartInteractionMode.X or BitChartInteractionMode.Y when !_scene.IsRadialOrCircular + => _scene.Elements.Where(x => x.DataIndex == e.DataIndex), + BitChartInteractionMode.Dataset + => _scene.Elements.Where(x => x.DatasetIndex == e.DatasetIndex), + _ => new[] { e } + }; foreach (var el in group) _active.Add(el); @@ -360,39 +727,67 @@ BitChartInteractionMode.Index or BitChartInteractionMode.X or BitChartInteractio AnchorY = e.Tooltip.AnchorY }; var ordered = _active.OrderBy(a => a.DatasetIndex).ToList(); - foreach (var el in ordered) - combined.Items.AddRange(el.Tooltip.Items); - // ---- Tooltip callbacks (title / body extras / footer / label color) ---- + // ---- Tooltip callbacks (title / body extras / footer / label color) + filter/sort ---- var cb = tip.Callbacks; - if (cb.Title is not null || cb.BeforeBody is not null || cb.AfterBody is not null - || cb.Footer is not null || cb.LabelColor is not null) + var items = ordered.Select(a => new BitChartTooltipItemContext { - var items = ordered.Select(a => new BitChartTooltipItemContext - { - DatasetIndex = a.DatasetIndex, - DataIndex = a.DataIndex, - DatasetLabel = a.SeriesLabel, - Label = a.Tooltip.Title, - Value = a.Value, - Color = a.Tooltip.Items.FirstOrDefault()?.Color ?? "#000", - FormattedValue = a.Tooltip.Items.FirstOrDefault()?.Text ?? "" - }).ToList(); + DatasetIndex = a.DatasetIndex, + DataIndex = a.DataIndex, + DatasetLabel = a.SeriesLabel, + Label = a.Tooltip.Title, + Value = a.Value, + Color = a.Tooltip.Items.FirstOrDefault()?.Color ?? "#000", + FormattedValue = a.Tooltip.Items.FirstOrDefault()?.Text ?? "" + }).ToList(); + + if (tip.Filter is { } filter) + { + for (int i = items.Count - 1; i >= 0; i--) + if (!filter(items[i])) { items.RemoveAt(i); ordered.RemoveAt(i); } - if (cb.Title?.Invoke(items) is { } titleText) combined.Title = titleText; - if (cb.BeforeBody?.Invoke(items) is { } bb) combined.BeforeBody.AddRange(bb.Split('\n')); - if (cb.AfterBody?.Invoke(items) is { } ab) combined.AfterBody.AddRange(ab.Split('\n')); - if (cb.Footer?.Invoke(items) is { } ft) combined.Footer.AddRange(ft.Split('\n')); - if (cb.LabelColor is not null) - for (int i = 0; i < combined.Items.Count && i < items.Count; i++) - if (cb.LabelColor(items[i]) is { } lc) combined.Items[i].Color = lc; + // A filtered-out item is not part of the hover any more: the active set is what the + // Average positioner reads and what paints the active class, so it is trimmed too. + if (ordered.Count != _active.Count) + { + _active.Clear(); + foreach (var el in ordered) _active.Add(el); + } + } + if (tip.ItemSort is { } sort) + { + var pairs = items.Zip(ordered).ToList(); + pairs.Sort((a, b) => sort(a.First, b.First)); + items = pairs.Select(p => p.First).ToList(); + ordered = pairs.Select(p => p.Second).ToList(); } - // Positioner. + foreach (var el in ordered) + combined.Items.AddRange(el.Tooltip.Items); + + if (cb.Title?.Invoke(items) is { } titleText) combined.Title = titleText; + if (cb.BeforeBody?.Invoke(items) is { } bb) combined.BeforeBody.AddRange(bb.Split('\n')); + if (cb.AfterBody?.Invoke(items) is { } ab) combined.AfterBody.AddRange(ab.Split('\n')); + if (cb.Footer?.Invoke(items) is { } ft) combined.Footer.AddRange(ft.Split('\n')); + if (cb.LabelColor is not null) + for (int i = 0; i < combined.Items.Count && i < items.Count; i++) + if (cb.LabelColor(items[i]) is { } lc) combined.Items[i].Color = lc; + + // Positioner. Averaging runs along the axis the active items share - the index axis - so on a + // horizontal-bar chart that is the vertical one, and the tooltip is put beside the group rather + // than in the middle of it. if (tip.Position == BitChartTooltipPositioner.Average && _active.Count > 0) { - combined.AnchorX = _active.Average(a => a.CenterX); - combined.AnchorY = _active.Min(a => a.Tooltip.AnchorY); + if (_config.Options.IndexAxis == BitChartIndexAxis.Y && !_scene.IsRadialOrCircular) + { + combined.AnchorX = _active.Max(a => a.Tooltip.AnchorX); + combined.AnchorY = _active.Average(a => a.CenterY); + } + else + { + combined.AnchorX = _active.Average(a => a.CenterX); + combined.AnchorY = _active.Min(a => a.Tooltip.AnchorY); + } } _activeTooltip = combined; @@ -400,7 +795,7 @@ BitChartInteractionMode.Index or BitChartInteractionMode.X or BitChartInteractio _tooltipContext = new BitChartTooltipContext { Title = combined.Title, - Points = _active.OrderBy(a => a.DatasetIndex).Select(a => new BitChartTooltipPoint + Points = ordered.Select(a => new BitChartTooltipPoint { DatasetIndex = a.DatasetIndex, DataIndex = a.DataIndex, @@ -411,24 +806,74 @@ BitChartInteractionMode.Index or BitChartInteractionMode.X or BitChartInteractio }).ToList() }; - // Highlight overlay. - bool indexMode = tip.Mode is BitChartInteractionMode.Index or BitChartInteractionMode.X && !_scene.IsRadialOrCircular; - if (indexMode && _scene.PlotArea is { } pa) - _hoverNodes.Add(new BitChartSvgLine - { - X1 = e.CenterX, Y1 = pa.Top, X2 = e.CenterX, Y2 = pa.Bottom, - Stroke = "rgba(0,0,0,0.35)", StrokeWidth = 1, Dash = "4,3" - }); - - foreach (var el in _active) + // Crosshair for index-style highlighting. + var interaction = _config.Options.Interaction; + bool indexMode = (forceIndexGroup || mode is BitChartInteractionMode.Index or BitChartInteractionMode.X) + && !_scene.IsRadialOrCircular; + if (indexMode && interaction.Crosshair && _scene.PlotArea is { } pa) { - if (el.Shape is BitChartSvgCircle c) - _hoverNodes.Add(new BitChartSvgCircle + bool horizontalIndex = _config.Options.IndexAxis == BitChartIndexAxis.Y; + _hoverNodes.Add(horizontalIndex + ? new BitChartSvgLine { - Cx = c.Cx, Cy = c.Cy, R = c.R + 4, - Fill = "none", Stroke = c.Fill, StrokeWidth = 2, Opacity = 0.6 + X1 = pa.Left, Y1 = e.CenterY, X2 = pa.Right, Y2 = e.CenterY, + Stroke = interaction.CrosshairColor, StrokeWidth = 1, Dash = "4,3" + } + : new BitChartSvgLine + { + X1 = e.CenterX, Y1 = pa.Top, X2 = e.CenterX, Y2 = pa.Bottom, + Stroke = interaction.CrosshairColor, StrokeWidth = 1, Dash = "4,3" }); + + if (interaction.CrosshairLabel && e.Tooltip.Title is { Length: > 0 } indexLabel) + AddCrosshairLabel(indexLabel, e, pa, horizontalIndex); + } + + // The renderer precomputed each element's hover appearance, so highlighting costs no re-layout. + foreach (var el in ordered) + if (el.HoverShape is { } hs) + _hoverNodes.Add(hs); + } + + private const string FocusRingColor = "var(--bit-clr-pri, #0078d4)"; + + /// + /// Draws the active index in a chip where the crosshair meets the index axis. It reuses the tooltip + /// colors so the two read as one piece of chrome, and is clamped into the plot so it cannot spill + /// out at either end. + /// + private void AddCrosshairLabel(string text, BitChartDataElement e, BitChartArea pa, bool horizontalIndex) + { + var t = _config.Options.Plugins.Tooltip; + double fontSize = t.BodyFont.Size; + double w = BitChartTextMeasure.Width(text, fontSize) + 10; + double h = fontSize + 6; + + double cx, cy; + if (horizontalIndex) + { + // A chip wider or taller than the room it is clamped into would leave Math.Clamp with a + // minimum above its maximum, so each upper bound is held at or above its lower one. + cx = Math.Clamp(pa.Left - w / 2 - 4, w / 2, Math.Max(w / 2, _vw - w / 2)); + cy = Math.Clamp(e.CenterY, pa.Top + h / 2, Math.Max(pa.Top + h / 2, pa.Bottom - h / 2)); } + else + { + cx = Math.Clamp(e.CenterX, pa.Left + w / 2, Math.Max(pa.Left + w / 2, pa.Right - w / 2)); + cy = Math.Min(pa.Bottom + h / 2 + 3, _vh - h / 2); + } + + _hoverNodes.Add(new BitChartSvgRect + { + X = cx - w / 2, Y = cy - h / 2, Width = w, Height = h, + Rx = 3, Fill = t.BackgroundColor + }); + _hoverNodes.Add(new BitChartSvgText + { + X = cx, Y = cy, Text = text, Fill = t.TitleColor, + FontFamily = t.BodyFont.Family, FontSize = fontSize, + Anchor = "middle", Baseline = "central" + }); } private async Task OnClickElement(BitChartDataElement e) @@ -445,13 +890,19 @@ private async Task OnKeyDown(KeyboardEventArgs e) if (n == 0) return; switch (e.Key) { + // Left/right walk the series the reader is on; up/down step between the series at the same + // category, which is how a multi-series chart is actually compared. case "ArrowRight": - case "ArrowDown": - Move(1); + MoveWithinSeries(1); break; case "ArrowLeft": + MoveWithinSeries(-1); + break; + case "ArrowDown": + MoveAcrossSeries(1); + break; case "ArrowUp": - Move(-1); + MoveAcrossSeries(-1); break; case "Home": SetFocus(0); @@ -465,46 +916,113 @@ private async Task OnKeyDown(KeyboardEventArgs e) break; case "Escape": _focusIndex = -1; + _focusKey = null; ClearHover(); _liveMessage = null; + NotifyHover(); break; } } - private void Move(int dir) + /// Steps to the next/previous element of the series the reader is currently on, wrapping + /// within it. With nothing focused yet it enters the chart at the appropriate end. + private void MoveWithinSeries(int dir) { int n = _scene.Elements.Count; - if (_focusIndex < 0) SetFocus(dir > 0 ? 0 : n - 1); - else SetFocus((_focusIndex + dir + n) % n); + if (_focusIndex < 0 || _focusIndex >= n) { SetFocus(dir > 0 ? 0 : n - 1); return; } + + int current = _scene.Elements[_focusIndex].DatasetIndex; + var series = new List(); + for (int i = 0; i < n; i++) + if (_scene.Elements[i].DatasetIndex == current) series.Add(i); + + int at = series.IndexOf(_focusIndex); + if (at < 0) { SetFocus((_focusIndex + dir + n) % n); return; } + SetFocus(series[(at + dir + series.Count) % series.Count]); + } + + /// + /// Steps to the neighbouring series at the same category. When that series has nothing at this + /// category - a null, or a shorter series - the nearest category it does have is taken, so the keys + /// never dead-end. A chart of one series has nothing to step between, so the vertical keys keep + /// walking the data instead of doing nothing. + /// + private void MoveAcrossSeries(int dir) + { + int n = _scene.Elements.Count; + if (_focusIndex < 0 || _focusIndex >= n) { SetFocus(dir > 0 ? 0 : n - 1); return; } + + var current = _scene.Elements[_focusIndex]; + var datasets = _scene.Elements.Select(x => x.DatasetIndex).Distinct().OrderBy(i => i).ToList(); + if (datasets.Count < 2) { MoveWithinSeries(dir); return; } + + int at = datasets.IndexOf(current.DatasetIndex); + int target = datasets[(at + dir + datasets.Count) % datasets.Count]; + + int best = -1, bestDistance = int.MaxValue; + for (int i = 0; i < n; i++) + { + var el = _scene.Elements[i]; + if (el.DatasetIndex != target) continue; + int distance = Math.Abs(el.DataIndex - current.DataIndex); + if (distance >= bestDistance) continue; + bestDistance = distance; + best = i; + } + if (best >= 0) SetFocus(best); } private void SetFocus(int i) { _focusIndex = i; var el = _scene.Elements[i]; - _hovered = el; + _focusKey = (el.DatasetIndex, el.DataIndex); BuildHover(el); _hoverNodes.Add(FocusOutline(el)); _liveMessage = Describe(el); + NotifyHover(); } + /// + /// The ring drawn around the keyboard position. It traces the element's own outline rather than a + /// stand-in shape: a rounded bar and an arc are both paths, and a circle around an arc's centroid + /// would sit inside the ring it is supposed to be marking. + /// private static BitChartSvgNode FocusOutline(BitChartDataElement el) => el.Shape switch { - BitChartSvgRect r => new BitChartSvgRect { X = r.X - 2, Y = r.Y - 2, Width = r.Width + 4, Height = r.Height + 4, Fill = "none", Stroke = "#1a1a1a", StrokeWidth = 2, CssClass = "bc-focus-ring" }, - BitChartSvgCircle c => new BitChartSvgCircle { Cx = c.Cx, Cy = c.Cy, R = c.R + 5, Fill = "none", Stroke = "#1a1a1a", StrokeWidth = 2, CssClass = "bc-focus-ring" }, - _ => new BitChartSvgCircle { Cx = el.CenterX, Cy = el.CenterY, R = 8, Fill = "none", Stroke = "#1a1a1a", StrokeWidth = 2, CssClass = "bc-focus-ring" } + BitChartSvgRect r => new BitChartSvgRect { X = r.X - 2, Y = r.Y - 2, Width = r.Width + 4, Height = r.Height + 4, Fill = "none", Stroke = FocusRingColor, StrokeWidth = 2, CssClass = "bit-cht-focus-ring" }, + BitChartSvgCircle c => new BitChartSvgCircle { Cx = c.Cx, Cy = c.Cy, R = c.R + 5, Fill = "none", Stroke = FocusRingColor, StrokeWidth = 2, CssClass = "bit-cht-focus-ring" }, + BitChartSvgPath p => new BitChartSvgPath { D = p.D, Fill = "none", Stroke = FocusRingColor, StrokeWidth = 2, CssClass = "bit-cht-focus-ring" }, + BitChartSvgPolygon poly => new BitChartSvgPolygon { Points = [.. poly.Points], Closed = poly.Closed, Fill = "none", Stroke = FocusRingColor, StrokeWidth = 2, CssClass = "bit-cht-focus-ring" }, + _ => new BitChartSvgCircle { Cx = el.CenterX, Cy = el.CenterY, R = 8, Fill = "none", Stroke = FocusRingColor, StrokeWidth = 2, CssClass = "bit-cht-focus-ring" } }; + /// + /// What the live region says about the focused element. The position is included because a reader + /// stepping through the data has no other way to tell how far along they are, and it is counted + /// within the series - the run the left/right keys actually walk. Which series they are on is + /// announced only when there is more than one to be on. + /// private string Describe(BitChartDataElement el) { var parts = new List(); if (!string.IsNullOrEmpty(el.Tooltip.Title)) parts.Add(el.Tooltip.Title!); foreach (var item in el.Tooltip.Items) parts.Add(item.Text); + + var series = _scene.Elements.Where(x => x.DatasetIndex == el.DatasetIndex).ToList(); + int at = series.IndexOf(el); + parts.Add($"{(at < 0 ? 1 : at + 1)} of {series.Count}"); + + var datasets = _scene.Elements.Select(x => x.DatasetIndex).Distinct().OrderBy(i => i).ToList(); + if (datasets.Count > 1) + parts.Add($"series {datasets.IndexOf(el.DatasetIndex) + 1} of {datasets.Count}"); + return string.Join(", ", parts); } - private void ToggleLegend(BitChartLegendItemModel item) + private async Task ToggleLegend(BitChartLegendItemModel item) { + if (OnLegendItemClick.HasDelegate) await OnLegendItemClick.InvokeAsync(item); if (_scene.Legend is null || !_scene.Legend.OnClickToggle) return; if (item.IsDataIndex) { @@ -522,9 +1040,49 @@ private void ToggleLegend(BitChartLegendItemModel item) private string ViewBox => $"0 0 {BitChartSvg.N(_vw)} {BitChartSvg.N(_vh)}"; + private string TableId => $"{_instanceId}-table"; + + private string HintId => $"{_instanceId}-hint"; + + /// True when the keyboard hint is worth rendering: there is data to walk and text to say it with. + private bool ShowNavigationHint => !string.IsNullOrWhiteSpace(NavigationHint) && _scene.Elements.Count > 0; + + /// + /// What the chart points its aria-describedby at: the how-to-navigate sentence and the data + /// table, in that order, and null when it has neither. + /// + private string? DescribedBy + { + get + { + if (ShowNavigationHint && GenerateTable) return $"{HintId} {TableId}"; + if (ShowNavigationHint) return HintId; + return GenerateTable ? TableId : null; + } + } + private string? ClipId => _scene.PlotArea is null ? null : $"{_instanceId}-clip"; private string? ClipRef => ClipId is null ? null : $"url(#{ClipId})"; + private CultureInfo Culture => _config.Options.Culture ?? CultureInfo.InvariantCulture; + + private string Fmt(double v) => v.ToString(Culture); + + /// + /// One cell of the screen-reader table. The tooltip names an error interval beside the value, so the + /// table - which is what a reader has instead of the tooltip - has to name it too. + /// + private string CellText(BitChartDataset ds, int dataIndex) + { + if (dataIndex >= ds.Data.Count || ds.Data[dataIndex] is not { } value) return ""; + string text = Fmt(value); + if (ds.ErrorData is not { } errors || dataIndex >= errors.Count || errors[dataIndex] is not { } e) return text; + + double minus = Math.Abs(e.Minus), plus = Math.Abs(e.Plus); + if (minus <= 0 && plus <= 0) return text; + return e.IsSymmetric ? $"{text} ±{Fmt(plus)}" : $"{text} +{Fmt(plus)}/-{Fmt(minus)}"; + } + private bool AnimationEnabled => _config.Options.Animation.Animate; /// @@ -545,96 +1103,37 @@ private void ToggleLegend(BitChartLegendItemModel item) /// private bool ProgressiveDraw => CanAnimate && _scene.ProgressiveDraw; - /// - /// Global (unscoped) animation rules emitted once per chart. Kept out of the component's - /// isolated stylesheet so the rules reliably match the SVG shapes rendered by the child - /// SvgPrimitive component (which carries a different CSS-isolation scope). - /// - private const string AnimationStyles = """ - - """; - - /// - /// Opt-in reduced-motion overrides. Injected only when is true, - /// so animations/transitions are disabled for users who requested reduced motion. - /// - private const string ReducedMotionStyles = """ - - """; + private string RootClass + { + get + { + string c = "bit-cht"; + // bit-fam is the library-wide reduced-motion opt-out: without it (here or on an ancestor) + // the chart's keyframes are taken out under prefers-reduced-motion. + if (ForceAnimation) c += " bit-fam"; + if (!string.IsNullOrEmpty(Class)) c += " " + Class; + return c; + } + } private string DataGroupClass { get { - if (!CanAnimate) return "bc-data"; + if (!CanAnimate) return "bit-cht-data"; // Progressive draw: points reveal individually (per-element delay tied to x position), // so the group itself carries no animation. - if (ProgressiveDraw) return "bc-data"; + if (ProgressiveDraw) return "bit-cht-data"; // When staggering, the individual elements animate (with per-element delays) instead of // the whole group, so the group itself carries no animation. - if (Staggered) return "bc-data"; + if (Staggered) return "bit-cht-data"; if (_scene.IsRadialOrCircular) - return "bc-data bc-animate bc-anim-grow"; + return "bit-cht-data bit-cht-anim bit-cht-anim-grow"; // Bars grow from the baseline as a size change, in the correct direction for the orientation. if (_scene.HasBars) - return _scene.HorizontalBars ? "bc-data bc-animate bc-anim-bars-h" : "bc-data bc-animate bc-anim-bars-v"; + return _scene.HorizontalBars ? "bit-cht-data bit-cht-anim bit-cht-anim-bars-h" : "bit-cht-data bit-cht-anim bit-cht-anim-bars-v"; // Line/scatter points rise in. - return "bc-data bc-animate bc-anim-rise"; + return "bit-cht-data bit-cht-anim bit-cht-anim-rise"; } } @@ -661,36 +1160,39 @@ private string? DataGroupStyle private string SeriesGroupClass => CanAnimate ? ProgressiveDraw - // The stroke draws itself on (bc-draw) and fills fade in, both at the path level, + // The stroke draws itself on (bit-cht-draw) and fills fade in, both at the path level, // so the group must not also rise. - ? "bc-series" + ? "bit-cht-series" : _scene.IsRadialOrCircular - ? "bc-series bc-animate bc-anim-grow" - : "bc-series bc-animate bc-anim-rise" - : "bc-series"; + ? "bit-cht-series bit-cht-anim bit-cht-anim-grow" + : "bit-cht-series bit-cht-anim bit-cht-anim-rise" + : "bit-cht-series"; private string ElementClass(BitChartDataElement el) { - string c = "bc-el"; - if (IsActive(el)) c += " bc-active"; + string c = "bit-cht-el"; + // A state hook only: the active look itself is the precomputed hover shape drawn over the + // element, so the class carries no rule of its own and is there for consumers to style. + if (IsActive(el)) c += " bit-cht-active"; if (ProgressiveDraw) { // Each point pops in as the drawing stroke reaches it (delay set in ElementStyle). - c += " bc-el-anim bc-el-rise"; + c += " bit-cht-el-anim bit-cht-el-rise"; } else if (Staggered) { // Bars reuse the proven view-box scaling classes (with an explicit per-element pixel // transform-origin set in ElementStyle); points/markers rise in via the fill-box class. c += _scene.HasBars - ? _scene.HorizontalBars ? " bc-animate bc-anim-bars-h" : " bc-animate bc-anim-bars-v" - : " bc-el-anim bc-el-rise"; + ? _scene.HorizontalBars ? " bit-cht-anim bit-cht-anim-bars-h" : " bit-cht-anim bit-cht-anim-bars-v" + : " bit-cht-el-anim bit-cht-el-rise"; } return c; } private string ElementStyle(int index) { + string cursor = OnElementClick.HasDelegate ? "cursor:pointer" : "cursor:default"; if (ProgressiveDraw) { // Reveal each point in time with the stroke as it sweeps left to right. The delay is tied @@ -701,14 +1203,14 @@ private string ElementStyle(int index) if (_scene.PlotArea is { Width: > 0 } pa) frac = Math.Clamp((_scene.Elements[index].CenterX - pa.Left) / pa.Width, 0, 1); double pDelay = frac * Math.Max(0, dur - elDur); - return $"cursor:pointer;animation-delay:{BitChartSvg.N(pDelay)}ms;animation-duration:{BitChartSvg.N(elDur)}ms"; + return $"{cursor};animation-delay:{BitChartSvg.N(pDelay)}ms;animation-duration:{BitChartSvg.N(elDur)}ms"; } - if (!Staggered) return "cursor:pointer"; + if (!Staggered) return cursor; double delay = index * _config.Options.Animation.DelayBetween; - string s = $"cursor:pointer;animation-delay:{BitChartSvg.N(delay)}ms"; + string s = $"{cursor};animation-delay:{BitChartSvg.N(delay)}ms"; if (_scene.HasBars) { - // bc-anim-bars-* use transform-box: view-box, so the origin must be given in view-box + // bit-cht-anim-bars-* use transform-box: view-box, so the origin must be given in view-box // pixels pinned to the value-axis baseline (matching the non-staggered group behaviour). var el = _scene.Elements[index]; s += _scene.HorizontalBars @@ -719,10 +1221,64 @@ private string ElementStyle(int index) } private string AnimStyle => - $"--bc-dur:{_config.Options.Animation.Duration}ms;--bc-ease:{_config.Options.Animation.Easing}"; + $"--bit-cht-dur:{_config.Options.Animation.Duration}ms;--bit-cht-ease:{_config.Options.Animation.Easing}"; private bool IsActive(BitChartDataElement e) => _active.Count > 0 && _active.Contains(e); + /// + /// Places the tooltip above its anchor and keeps the whole box inside the plot: it flips below when + /// there is no room above, and slides horizontally so it never spills out of (and gets clipped by) + /// the chart container. The caret follows the anchor so it keeps pointing at the data. + /// + private (string Style, string CaretStyle) TooltipPlacement(BitChartTooltipInfo tt) + { + var t = _config.Options.Plugins.Tooltip; + double caret = t.Caret ? t.CaretSize : 0; + double gap = caret + 4; + + // Estimated box size: the tooltip is measured from its own text because the browser layout is + // not available on the server, and this only needs to be good enough to pick a side. + double fontW = t.BodyFont.Size; + double width = 0; + if (!string.IsNullOrEmpty(tt.Title)) + width = BitChartTextMeasure.Width(tt.Title, t.TitleFont.Size, t.TitleFont.Weight); + foreach (var item in tt.Items) + width = Math.Max(width, BitChartTextMeasure.Width(item.Text, fontW) + (t.DisplayColors ? 16 : 0)); + foreach (var line in tt.BeforeBody.Concat(tt.AfterBody).Concat(tt.Footer)) + width = Math.Max(width, BitChartTextMeasure.Width(line, fontW)); + width += t.Padding * 3; + + int lines = (string.IsNullOrEmpty(tt.Title) ? 0 : 1) + tt.Items.Count + + tt.BeforeBody.Count + tt.AfterBody.Count + tt.Footer.Count; + double height = Math.Max(1, lines) * (fontW * 1.5) + t.Padding * 2 + (tt.Footer.Count > 0 ? 8 : 0); + + // Anchor in view-box units, then clamp the box into the plot box. + double ax = tt.AnchorX, ay = tt.AnchorY; + bool below = ay - height - gap < 0; + double top = below ? ay + gap : ay - height - gap; + double left = ax - width / 2; + left = Math.Clamp(left, 2, Math.Max(2, _vw - width - 2)); + top = Math.Clamp(top, 2, Math.Max(2, _vh - height - 2)); + + // Percentages keep the tooltip aligned with the SVG, which scales with the container. An + // explicit MaxWidth also lets the text wrap, which is what makes a prose tooltip readable. + double maxWidth = t.MaxWidth ?? Math.Max(80, _vw - 8); + string style = + $"left:{BitChartSvg.N(Pct(left, _vw))}%;top:{BitChartSvg.N(Pct(top, _vh))}%;" + + $"transform:translateZ(0);max-width:{BitChartSvg.N(maxWidth)}px" + + (t.MaxWidth is null ? "" : ";white-space:normal"); + + // The caret sits on the edge facing the anchor, at the anchor's horizontal position. + double caretLeft = Math.Clamp(ax - left, caret + 2, Math.Max(caret + 2, width - caret - 2)); + string caretStyle = caret <= 0 + ? "display:none" + : below + ? $"left:{BitChartSvg.N(caretLeft - caret)}px;top:{BitChartSvg.N(-caret * 2)}px;border-width:{BitChartSvg.N(caret)}px;border-bottom-color:{t.BackgroundColor}" + : $"left:{BitChartSvg.N(caretLeft - caret)}px;bottom:{BitChartSvg.N(-caret * 2)}px;border-width:{BitChartSvg.N(caret)}px;border-top-color:{t.BackgroundColor}"; + + return (style, caretStyle); + } + private string RootStyle { get @@ -734,7 +1290,7 @@ private string RootStyle } } - private double Pct(double v, double total) => total <= 0 ? 0 : v / total * 100; + private static double Pct(double v, double total) => total <= 0 ? 0 : v / total * 100; private string ChartAriaLabel { @@ -749,6 +1305,51 @@ private string ChartAriaLabel private bool HasPointData => _config.Data.Datasets.Any(d => d.Points is { Count: > 0 }); + /// Total rows the data table would render without a cap. + private int TableRowCount => HasPointData + ? _config.Data.Datasets.Sum(d => d.Points?.Count ?? 0) + : _config.Data.Datasets.Count; + + private bool TableTruncated => MaxTableRows > 0 && TableRowCount > MaxTableRows; + + /// Total columns the value table would render without a cap (one per category). + private int TableColumnCount => HasPointData + ? 3 + : Math.Max(_config.Data.Labels.Count, _config.Data.Datasets.Count == 0 ? 0 : _config.Data.Datasets.Max(d => d.Count)); + + /// The number of category columns actually rendered. + private int TableColumnLimit => HasPointData || MaxTableColumns <= 0 + ? int.MaxValue + : MaxTableColumns; + + private bool TableColumnsTruncated => !HasPointData && MaxTableColumns > 0 && TableColumnCount > MaxTableColumns; + + /// Caption of the screen-reader table, which also carries the truncation notice. + private string TableCaption + { + get + { + var caption = ChartAriaLabel; + if (TableTruncated) + caption += $" Showing the first {MaxTableRows.ToString("N0", Culture)} of {TableRowCount.ToString("N0", Culture)} rows."; + if (TableColumnsTruncated) + caption += $" Showing the first {MaxTableColumns.ToString("N0", Culture)} of {TableColumnCount.ToString("N0", Culture)} columns."; + return caption; + } + } + + /// + /// The side a title actually renders on. Left and right titles run down the side of the plot + /// (rotated); anything that is not one of the four sides falls back to the top. + /// + private static BitChartPosition TitleSide(BitChartTitleModel title) => title.Position switch + { + BitChartPosition.Bottom => BitChartPosition.Bottom, + BitChartPosition.Left => BitChartPosition.Left, + BitChartPosition.Right => BitChartPosition.Right, + _ => BitChartPosition.Top + }; + private static string AlignToFlex(BitChartAlign a) => a switch { BitChartAlign.Start => "flex-start", @@ -773,5 +1374,6 @@ public async ValueTask DisposeAsync() catch (JSDisconnectedException) { } catch (Exception) { } _dotRef?.Dispose(); + GC.SuppressFinalize(this); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.scss new file mode 100644 index 0000000000..c2545f52f4 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.scss @@ -0,0 +1,357 @@ +@import '../../../Bit.BlazorUI/Styles/functions.scss'; + +.bit-cht { + --bit-cht-dur: 600ms; + --bit-cht-ease: ease-out; + --bit-cht-focus: #{$clr-pri}; + + display: flex; + box-sizing: border-box; + flex-direction: column; + font-family: #{$tg-font-family}; +} + +.bit-cht-ttl, +.bit-cht-sub { + width: 100%; + display: flex; + text-align: center; +} + +// A title on the left or right runs down the side of the plot instead of across the top. +.bit-cht-ttl-v { + width: auto; + align-items: center; + writing-mode: vertical-rl; + + &:first-child { + rotate: 180deg; + } +} + +.bit-cht-mid { + flex: 1 1 auto; + display: flex; + min-height: 0; + flex-direction: row; + align-items: stretch; +} + +.bit-cht-plot { + flex: 1 1 auto; + min-width: 0; + position: relative; +} + +.bit-cht-svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; + + &:focus { + outline: none; + } + + &:focus-visible { + border-radius: 4px; + outline-offset: 2px; + outline: 2px solid var(--bit-cht-focus); + } +} + +// Only the data elements and the hit bands are interactive. Without this, the line drawn over a +// band would take the pointer, and the tooltip would blink out exactly where the data is. +.bit-cht-hover, +.bit-cht-fg, +.bit-cht-bg, +.bit-cht-series { + pointer-events: none; +} + +// The cursor is set inline, from whether the chart has a click handler at all. +.bit-cht-band { + outline: none; +} + +// ---- legend ---- + +.bit-cht-lgd { + display: flex; + padding: 6px 8px; + flex-wrap: wrap; + gap: 4px 14px; +} + +.bit-cht-lgd-h { + flex-direction: row; +} + +.bit-cht-lgd-v { + flex-direction: column; + align-content: center; + justify-content: center; +} + +.bit-cht-lgd-itm { + gap: 6px; + border: none; + padding: 2px 4px; + margin: 0; + color: inherit; + font: inherit; + display: inline-flex; + background: none; + line-height: 1.4; + user-select: none; + align-items: center; + border-radius: #{$shp-radius-sm}; + + &:focus-visible { + outline-offset: 1px; + outline: 2px solid var(--bit-cht-focus); + } + + &.bit-cht-hdn { + opacity: 0.45; + text-decoration: line-through; + } +} + +.bit-cht-lgd-box { + flex: 0 0 auto; + display: inline-block; + border: 2px solid; + border-radius: 2px; +} + +.bit-cht-lgd-mrk { + flex: 0 0 auto; + overflow: visible; +} + +.bit-cht-lgd-ttl { + width: 100%; + text-align: center; + font-weight: #{$tg-fw-bold}; +} + +// ---- tooltip ---- + +.bit-cht-tt { + top: 0; + left: 0; + z-index: 10; + position: absolute; + white-space: nowrap; + pointer-events: none; + box-shadow: #{$box-shadow-popup}; + transition: left #{$mot-duration-short} linear, top #{$mot-duration-short} linear; +} + +.bit-cht-tt-ttl { + margin-bottom: 3px; +} + +.bit-cht-tt-ftr { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid rgba(255, 255, 255, 0.25); +} + +.bit-cht-tt-custom { + padding: 8px 10px; + color: #{$clr-fg-pri}; + background: #{$clr-bg-pri}; + border: 1px solid #{$clr-brd-sec}; + border-radius: #{$shp-radius-popup}; + font-size: #{$tg-fs-xs}; +} + +.bit-cht-tt-itm { + gap: 6px; + display: flex; + line-height: 1.5; + align-items: center; +} + +.bit-cht-tt-swt { + width: 10px; + height: 10px; + flex: 0 0 auto; + display: inline-block; + border-radius: 2px; +} + +.bit-cht-tt-swt-svg { + flex: 0 0 auto; + overflow: visible; +} + +// The caret is a rotated square peeking out of the tooltip box. +.bit-cht-tt-caret { + width: 0; + height: 0; + position: absolute; + border: 6px solid transparent; +} + +// ---- no data / overlay ---- + +.bit-cht-nodata { + inset: 0; + display: flex; + position: absolute; + align-items: center; + justify-content: center; + color: #{$clr-fg-sec}; + font-size: #{$tg-fs-sm}; +} + +.bit-cht-sr { + top: auto; + left: auto; + width: 1px; + border: 0; + height: 1px; + margin: -1px; + padding: 0; + position: absolute; + overflow: hidden; + white-space: nowrap; + clip: rect(0, 0, 0, 0); +} + +// ---- animations ---- + +@keyframes bit-cht-rise { + from { + opacity: 0; + transform: translateY(12px) scaleY(0.9); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes bit-cht-grow { + from { + opacity: 0; + transform: scale(0.82); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes bit-cht-draw { + from { + stroke-dashoffset: 1; + } + + to { + stroke-dashoffset: 0; + } +} + +@keyframes bit-cht-fade { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes bit-cht-scale-y { + from { + transform: scaleY(0); + } + + to { + transform: scaleY(1); + } +} + +@keyframes bit-cht-scale-x { + from { + transform: scaleX(0); + } + + to { + transform: scaleX(1); + } +} + +.bit-cht-anim { + animation-fill-mode: both; + animation-duration: var(--bit-cht-dur, 600ms); + animation-timing-function: var(--bit-cht-ease, ease-out); +} + +.bit-cht-anim-rise { + transform-box: view-box; + transform-origin: center bottom; + animation-name: bit-cht-rise; +} + +.bit-cht-anim-grow { + transform-box: view-box; + transform-origin: center; + animation-name: bit-cht-grow; +} + +.bit-cht-anim-bars-v { + transform-box: view-box; + transform-origin: center bottom; + animation-name: bit-cht-scale-y; +} + +.bit-cht-anim-bars-h { + transform-box: view-box; + transform-origin: left center; + animation-name: bit-cht-scale-x; +} + +.bit-cht-el-anim { + transform-box: fill-box; + animation-fill-mode: both; + animation-duration: var(--bit-cht-dur, 600ms); + animation-timing-function: var(--bit-cht-ease, ease-out); +} + +.bit-cht-el-rise { + transform-origin: center bottom; + animation-name: bit-cht-rise; +} + +.bit-cht-draw { + stroke-dasharray: 1; + animation: bit-cht-draw var(--bit-cht-dur, 600ms) var(--bit-cht-ease, ease-out) both; +} + +.bit-cht-fade { + animation: bit-cht-fade var(--bit-cht-dur, 600ms) var(--bit-cht-ease, ease-out) both; +} + +.bit-cht-focus-ring { + stroke-dasharray: 3 2; +} + +// The entry animations take their duration from the chart's own options rather than from the +// --bit-mot-duration* tokens, so they opt out of reduced motion here instead: the keyframes are taken +// out and every element is left drawn in its final state. .bit-fam is the shared opt-out class +// rendered by ForceAnimation, honored on an ancestor as well as on the chart itself, so opting a whole +// subtree back in behaves the same here as it does for the token-driven animations. +@media (prefers-reduced-motion: reduce) { + .bit-cht:not(.bit-fam):not(.bit-fam *) { + :is(.bit-cht-anim, .bit-cht-el-anim, .bit-cht-draw, .bit-cht-fade) { + animation: none; + } + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts index c59eda4d39..c0c8725acf 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChart.ts @@ -11,7 +11,7 @@ namespace BitBlazorUI { export class BitChart { // Observe an element's pixel size and report changes to .NET so the chart can render at real // device pixels (keeping font sizes constant, like Chart.js) instead of scaling a fixed viewBox. - public static observe(element: HTMLElement, dotnet: DotNetObject) { + public static observe(element: HTMLElement, dotnet: DotNetObject, responsive: boolean) { let lastW = 0, lastH = 0; function report() { @@ -23,19 +23,35 @@ namespace BitBlazorUI { } } + // Arrow/Home/End/Space navigate the focused chart. Blazor evaluates @onkeydown:preventDefault + // at render time, so it cannot decide per key - and cancelling every keydown would break Tab. + // A capture listener decides per key, and only while the SVG itself has focus. + const NAV_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', ' ', 'Spacebar']; + function onKeyDown(e: KeyboardEvent) { + const target = e.target as Element | null; + if (!target || target.tagName.toLowerCase() !== 'svg') return; + if (NAV_KEYS.indexOf(e.key) >= 0) e.preventDefault(); + } + element.addEventListener('keydown', onKeyDown, true); + let ro: ResizeObserver | null = null; - if (typeof ResizeObserver !== 'undefined') { - ro = new ResizeObserver(report); - ro.observe(element); - } else { - window.addEventListener('resize', report); + let listening = false; + if (responsive) { + if (typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(report); + ro.observe(element); + } else { + window.addEventListener('resize', report); + listening = true; + } + report(); } - report(); return { dispose() { + element.removeEventListener('keydown', onKeyDown, true); if (ro) ro.disconnect(); - else window.removeEventListener('resize', report); + if (listening) window.removeEventListener('resize', report); } }; } @@ -43,6 +59,15 @@ namespace BitBlazorUI { public static register(element: HTMLElement, dotnet: DotNetObject, opts: BitChartZoomOptions) { const state = { panning: false, lastX: 0, lastY: 0, startX: 0, startY: 0 }; + // Live touch/pen contacts, so a second finger can be recognised as a pinch. + const contacts = new Map(); + let pinchDistance = 0; + + // The browser's own pan/zoom would otherwise consume the gesture before it reaches us, + // which is why a touch drag does nothing on a chart that has not opted out of it. + const previousTouchAction = element.style.touchAction; + if (opts.pan || opts.drag || opts.wheel) element.style.touchAction = 'none'; + function frac(e: { clientX: number, clientY: number }) { const r = element.getBoundingClientRect(); return { @@ -58,8 +83,26 @@ namespace BitBlazorUI { dotnet.invokeMethodAsync('OnWheelZoom', f.x, f.y, e.deltaY); } + function distance() { + const points = Array.from(contacts.values()); + const dx = points[0].x - points[1].x; + const dy = points[0].y - points[1].y; + return Math.sqrt(dx * dx + dy * dy); + } + function onDown(e: PointerEvent) { if (e.button !== 0) return; + // Contacts are tracked whatever the pan settings are: pinching is the touch equivalent + // of the wheel, so it belongs to zoom rather than to panning. + if (e.pointerType !== 'mouse' && (opts.wheel || opts.pan || opts.drag)) { + contacts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (contacts.size === 2) { + // A second finger turns the gesture into a pinch, so the pan stops here. + state.panning = false; + pinchDistance = distance(); + return; + } + } if (!opts.pan && !opts.drag) return; state.panning = true; state.lastX = e.clientX; @@ -71,6 +114,24 @@ namespace BitBlazorUI { } function onMove(e: PointerEvent) { + if (contacts.has(e.pointerId)) contacts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + if (contacts.size === 2 && opts.wheel) { + const next = distance(); + if (pinchDistance > 0 && next > 0) { + const points = Array.from(contacts.values()); + const r = element.getBoundingClientRect(); + const midX = (points[0].x + points[1].x) / 2; + const midY = (points[0].y + points[1].y) / 2; + dotnet.invokeMethodAsync('OnPinchZoom', + r.width ? (midX - r.left) / r.width : 0.5, + r.height ? (midY - r.top) / r.height : 0.5, + next / pinchDistance); + } + pinchDistance = next; + return; + } + if (!state.panning) return; const r = element.getBoundingClientRect(); if (opts.drag) { @@ -89,6 +150,8 @@ namespace BitBlazorUI { } function onUp(e: PointerEvent) { + contacts.delete(e.pointerId); + if (contacts.size < 2) pinchDistance = 0; if (!state.panning) return; state.panning = false; element.style.cursor = ''; @@ -110,6 +173,7 @@ namespace BitBlazorUI { element.addEventListener('pointerdown', onDown); element.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); element.addEventListener('dblclick', onDouble); return { @@ -118,9 +182,135 @@ namespace BitBlazorUI { element.removeEventListener('pointerdown', onDown); element.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); element.removeEventListener('dblclick', onDouble); + element.style.touchAction = previousTouchAction; } }; } + + // ---- export ---- + + // Theme tokens the chart references from SVG attributes as var(--bit-...). They resolve against + // the document, so an exported (standalone) SVG has to carry their computed values with it. + private static readonly THEME_VARS = [ + '--bit-clr-fg-pri', '--bit-clr-fg-sec', '--bit-clr-brd-pri', '--bit-clr-brd-sec', + '--bit-clr-bg-pri', '--bit-clr-pri', '--bit-tpg-font-family' + ]; + + private static serialize(element: HTMLElement, background: string | null): string | null { + const svg = element.querySelector('svg') as SVGSVGElement | null; + if (!svg) return null; + + const clone = svg.cloneNode(true) as SVGSVGElement; + const box = svg.getBoundingClientRect(); + const width = Math.max(1, Math.round(box.width)); + const height = Math.max(1, Math.round(box.height)); + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + clone.setAttribute('width', String(width)); + clone.setAttribute('height', String(height)); + clone.style.overflow = 'visible'; + + // Interaction-only layers are not part of the picture. + clone.querySelectorAll('.bit-cht-hover, .bit-cht-bands').forEach(n => n.remove()); + + const computed = getComputedStyle(svg); + for (const name of BitChart.THEME_VARS) { + const value = computed.getPropertyValue(name); + if (value) clone.style.setProperty(name, value.trim()); + } + if (background) { + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('width', '100%'); + rect.setAttribute('height', '100%'); + rect.setAttribute('fill', background); + clone.insertBefore(rect, clone.firstChild); + } + return new XMLSerializer().serializeToString(clone); + } + + public static exportSvg(element: HTMLElement, fileName: string, background: string | null) { + const markup = BitChart.serialize(element, background); + if (!markup) return false; + BitChart.downloadBlob(fileName || 'chart.svg', new Blob([markup], { type: 'image/svg+xml;charset=utf-8' })); + return true; + } + + // Rasterizes the serialized SVG onto a canvas the caller can then read as a blob or a data URL. + private static async rasterize(element: HTMLElement, scale: number, background: string | null) { + const markup = BitChart.serialize(element, background); + if (!markup) return null; + const svg = element.querySelector('svg') as SVGSVGElement; + const box = svg.getBoundingClientRect(); + const ratio = Math.max(1, scale || 1); + const width = Math.max(1, Math.round(box.width * ratio)); + const height = Math.max(1, Math.round(box.height * ratio)); + + const url = URL.createObjectURL(new Blob([markup], { type: 'image/svg+xml;charset=utf-8' })); + try { + const image = new Image(); + image.width = width; + image.height = height; + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error('svg load failed')); + image.src = url; + }); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + if (background) { + ctx.fillStyle = background; + ctx.fillRect(0, 0, width, height); + } + ctx.drawImage(image, 0, 0, width, height); + return canvas; + } finally { + URL.revokeObjectURL(url); + } + } + + public static async exportPng(element: HTMLElement, fileName: string, scale: number, background: string | null) { + const canvas = await BitChart.rasterize(element, scale, background); + if (!canvas) return false; + const blob: Blob | null = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); + if (!blob) return false; + BitChart.downloadBlob(fileName || 'chart.png', blob); + return true; + } + + // Returns the standalone SVG markup instead of downloading it, so the caller can embed, + // upload or store the picture itself. + public static toSvgString(element: HTMLElement, background: string | null) { + return BitChart.serialize(element, background); + } + + // Returns the rasterized chart as a data URL (the same picture exportPng downloads). + public static async toDataUrl(element: HTMLElement, mimeType: string, scale: number, background: string | null) { + const canvas = await BitChart.rasterize(element, scale, background); + if (!canvas) return null; + return canvas.toDataURL(mimeType || 'image/png'); + } + + public static downloadText(fileName: string, content: string, mimeType: string) { + const type = mimeType || 'text/plain;charset=utf-8'; + // Spreadsheets read a CSV as the local codepage unless it opens with a byte order mark, so a + // file of Japanese or Persian labels arrives as mojibake without one. + const parts = type.indexOf('text/csv') === 0 ? ['', content] : [content]; + BitChart.downloadBlob(fileName || 'chart.csv', new Blob(parts, { type })); + } + + private static downloadBlob(fileName: string, blob: Blob) { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + setTimeout(() => URL.revokeObjectURL(url), 0); + } } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartJsRuntimeExtensions.cs index 419c858a8a..d623dec943 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartJsRuntimeExtensions.cs @@ -6,9 +6,10 @@ internal static class BitChartJsRuntimeExtensions { public static ValueTask BitChartObserve(this IJSRuntime jsRuntime, ElementReference element, - DotNetObjectReference dotnetObj) + DotNetObjectReference dotnetObj, + bool responsive) { - return jsRuntime.InvokeAsync("BitBlazorUI.BitChart.observe", element, dotnetObj); + return jsRuntime.InvokeAsync("BitBlazorUI.BitChart.observe", element, dotnetObj, responsive); } // The zoom payload is only ever constructed (never read) from C#, so without this hint the @@ -22,4 +23,32 @@ public static ValueTask BitChartRegister(this IJSRuntime jsR { return jsRuntime.InvokeAsync("BitBlazorUI.BitChart.register", element, dotnetObj, options); } + + public static ValueTask BitChartExportSvg(this IJSRuntime jsRuntime, ElementReference element, + string fileName, string? background) + { + return jsRuntime.InvokeAsync("BitBlazorUI.BitChart.exportSvg", element, fileName, background); + } + + public static ValueTask BitChartExportPng(this IJSRuntime jsRuntime, ElementReference element, + string fileName, double scale, string? background) + { + return jsRuntime.InvokeAsync("BitBlazorUI.BitChart.exportPng", element, fileName, scale, background); + } + + public static ValueTask BitChartToSvgString(this IJSRuntime jsRuntime, ElementReference element, string? background) + { + return jsRuntime.InvokeAsync("BitBlazorUI.BitChart.toSvgString", element, background); + } + + public static ValueTask BitChartToDataUrl(this IJSRuntime jsRuntime, ElementReference element, + string mimeType, double scale, string? background) + { + return jsRuntime.InvokeAsync("BitBlazorUI.BitChart.toDataUrl", element, mimeType, scale, background); + } + + public static ValueTask BitChartDownloadText(this IJSRuntime jsRuntime, string fileName, string content, string mimeType) + { + return jsRuntime.InvokeVoidAsync("BitBlazorUI.BitChart.downloadText", fileName, content, mimeType); + } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartSvgPrimitive.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartSvgPrimitive.razor index 4cc7f041e6..d786a00cfd 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartSvgPrimitive.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/BitChartSvgPrimitive.razor @@ -5,62 +5,110 @@ case BitChartSvgLine l: + stroke-dasharray="@l.Dash" transform="@l.Transform" class="@l.CssClass"> + @if (!string.IsNullOrEmpty(l.Title)) { @l.Title } + break; case BitChartSvgRect r: + opacity="@BitChartSvg.N(r.Opacity)" transform="@r.Transform" class="@r.CssClass"> @if (!string.IsNullOrEmpty(r.Title)) { @r.Title } break; case BitChartSvgCircle c: + stroke="@c.Stroke" stroke-width="@BitChartSvg.N(c.StrokeWidth)" opacity="@BitChartSvg.N(c.Opacity)" + transform="@c.Transform" class="@c.CssClass"> + @if (!string.IsNullOrEmpty(c.Title)) { @c.Title } + break; case BitChartSvgPath p: + stroke-dasharray="@p.Dash" stroke-dashoffset="@(p.DashOffset != 0 ? BitChartSvg.N(p.DashOffset) : null)" + stroke-linecap="@p.LineCap" stroke-linejoin="@p.LineJoin" + opacity="@BitChartSvg.N(p.Opacity)" transform="@p.Transform" + class="@PathClass(p)" + pathLength="@(p.AnimateDraw ? "1" : null)"> + @if (!string.IsNullOrEmpty(p.Title)) { @p.Title } + break; case BitChartSvgPolygon poly: @if (poly.Closed) { + stroke-width="@BitChartSvg.N(poly.StrokeWidth)" stroke-linejoin="round" + opacity="@BitChartSvg.N(poly.Opacity)" transform="@poly.Transform" class="@poly.CssClass"> + @if (!string.IsNullOrEmpty(poly.Title)) { @poly.Title } + } else { + stroke-width="@BitChartSvg.N(poly.StrokeWidth)" opacity="@BitChartSvg.N(poly.Opacity)" + transform="@poly.Transform" class="@poly.CssClass"> + @if (!string.IsNullOrEmpty(poly.Title)) { @poly.Title } + } break; case BitChartSvgText t: - @((MarkupString)BuildText(t)) + @RenderText(t) break; } @code { [Parameter, EditorRequired] public BitChartSvgNode Node { get; set; } = default!; + // An animation class is added to the path's own class rather than replacing it, so a path that + // both animates in and carries a class of its own keeps both. + private static string? PathClass(BitChartSvgPath p) + { + string? anim = p.AnimateDraw ? "bit-cht-draw" : p.AnimateFade ? "bit-cht-fade" : null; + if (anim is null) return p.CssClass; + return string.IsNullOrEmpty(p.CssClass) ? anim : $"{p.CssClass} {anim}"; + } + private static string PolyPoints(BitChartSvgPolygon p) => string.Join(" ", p.Points.Select(pt => $"{BitChartSvg.N(pt.X)},{BitChartSvg.N(pt.Y)}")); - private static string BuildText(BitChartSvgText t) + // "text" is a reserved tag name in Razor markup, so the SVG text element is emitted through the + // render tree directly. Values go through AddAttribute/AddContent, which encode them - unlike the + // hand-built markup string this used to be. + private static RenderFragment RenderText(BitChartSvgText t) => builder => + { + builder.OpenElement(0, "text"); + builder.AddAttribute(1, "x", BitChartSvg.N(t.X)); + builder.AddAttribute(2, "y", BitChartSvg.N(t.Y)); + builder.AddAttribute(3, "fill", t.Fill); + builder.AddAttribute(4, "font-family", t.FontFamily); + builder.AddAttribute(5, "font-size", BitChartSvg.N(t.FontSize)); + builder.AddAttribute(6, "font-weight", t.FontWeight); + builder.AddAttribute(7, "font-style", t.FontStyle); + builder.AddAttribute(8, "text-anchor", t.Anchor); + builder.AddAttribute(9, "dominant-baseline", t.Baseline); + builder.AddAttribute(10, "opacity", BitChartSvg.N(t.Opacity)); + if (TextTransform(t) is { } tr) builder.AddAttribute(11, "transform", tr); + if (!string.IsNullOrEmpty(t.CssClass)) builder.AddAttribute(12, "class", t.CssClass); + builder.AddContent(13, t.Text); + if (!string.IsNullOrEmpty(t.Title)) + { + builder.OpenElement(14, "title"); + builder.AddContent(15, t.Title); + builder.CloseElement(); + } + builder.CloseElement(); + }; + + private static string? TextTransform(BitChartSvgText t) { - string transform = t.Rotation != 0 - ? $" transform=\"rotate({BitChartSvg.N(t.Rotation)} {BitChartSvg.N(t.X)} {BitChartSvg.N(t.Y)})\"" - : ""; - string content = System.Net.WebUtility.HtmlEncode(t.Text); - return $"{content}"; + string? rot = t.Rotation != 0 + ? $"rotate({BitChartSvg.N(t.Rotation)} {BitChartSvg.N(t.X)} {BitChartSvg.N(t.Y)})" + : null; + return string.IsNullOrEmpty(t.Transform) ? rot : string.IsNullOrEmpty(rot) ? t.Transform : $"{t.Transform} {rot}"; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataLabelOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataLabelOptions.cs index e7af6d603d..0c35424324 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataLabelOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataLabelOptions.cs @@ -1,10 +1,10 @@ namespace Bit.BlazorUI; -/// Data label plugin options (renders values on the chart). +/// Data label plugin options (renders the values on the chart itself). public sealed class BitChartDataLabelOptions { public bool Display { get; set; } - public string Color { get; set; } = "#333"; + public string Color { get; set; } = "var(--bit-clr-fg-pri, #1A1A1A)"; public BitChartFont Font { get; set; } = new(); /// Simple value formatter. public Func? Formatter { get; set; } @@ -12,13 +12,26 @@ public sealed class BitChartDataLabelOptions public Func? FormatterCtx { get; set; } /// Per-element display predicate (value, datasetIndex, dataIndex) => show. public Func? DisplayFn { get; set; } - /// Anchor of the label relative to the element (start = baseline, center, end = tip). - public BitChartAlign Anchor { get; set; } = BitChartAlign.Center; + /// + /// Where the label sits relative to the element: at the baseline + /// end, in the middle, at the tip. + /// + public BitChartAlign Anchor { get; set; } = BitChartAlign.End; + /// + /// Which side of the anchor the label is drawn on: pulls it inside + /// the element, pushes it outside, + /// centers it on the anchor. + /// + public BitChartAlign Align { get; set; } = BitChartAlign.End; + /// Extra distance (px) between the anchor and the label. + public double Offset { get; set; } = 4; + /// Also draw labels on line/scatter/radar point markers (bars and arcs always get them). + public bool ShowOnPoints { get; set; } = true; /// Optional background color drawn behind the label. public string? BackgroundColor { get; set; } /// Corner radius of the label background. public double BorderRadius { get; set; } = 3; - /// BitChartPadding inside the label background. + /// Padding inside the label background. public double Padding { get; set; } = 2; /// Rotation of the label text in degrees. public double Rotation { get; set; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataset.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataset.cs index 8859c0d46f..e45cafbf0e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataset.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDataset.cs @@ -19,6 +19,22 @@ public sealed class BitChartDataset /// Point data for scatter/bubble charts. When set, takes precedence over . public List? Points { get; set; } + /// + /// Per-index uncertainty, drawn as a whisker through the value: a bar's tip, a line or scatter + /// point, each with a cap at both ends. A null entry leaves that point without one. The interval is + /// also named in the tooltip. Cartesian charts only. + /// + public List? ErrorData { get; set; } + + /// Color of the error-bar whisker. When null it follows the primary foreground token. + public string? ErrorBarColor { get; set; } + + /// Thickness of the error-bar whisker and its caps. + public double ErrorBarWidth { get; set; } = 1.5; + + /// Full width of the caps at the ends of an error bar. Zero draws a bare whisker. + public double ErrorBarCapWidth { get; set; } = 8; + /// Optional per-dataset type override for mixed charts. public BitChartType? Type { get; set; } @@ -29,11 +45,22 @@ public sealed class BitChartDataset public BitChartFillPattern? BackgroundPattern { get; set; } public string? BorderColor { get; set; } public List? BorderColors { get; set; } - public double BorderWidth { get; set; } = 1; + /// + /// Border/line thickness. When null a per-type default applies: + /// for lines and radar, for arcs, and for bars + /// unless a border color was supplied (then 1). + /// + public double? BorderWidth { get; set; } + + /// Fill color used while the element is hovered (bars and arcs). public string? HoverBackgroundColor { get; set; } + /// Border color used while the element is hovered (bars and arcs). public string? HoverBorderColor { get; set; } + /// Border width used while the element is hovered (bars and arcs). public double? HoverBorderWidth { get; set; } + /// Extra pixels a hovered arc is pushed out from the center (pie/doughnut/polar area). + public double HoverOffset { get; set; } = 6; // ---- BitChartScriptable options (evaluated per element; take precedence over the constants above) ---- /// BitChartScriptable background color: ctx => color. @@ -58,12 +85,14 @@ public sealed class BitChartDataset public BitChartGradientBase? FillGradient { get; set; } /// Target dataset index when is . public int? FillTargetIndex { get; set; } - /// Bezier curve tension (0 = straight lines). - public double Tension { get; set; } + /// Bezier curve tension (0 = straight lines). Falls back to when unset. + public double? Tension { get; set; } /// Cubic interpolation mode. avoids overshoot. public BitChartCubicInterpolationMode CubicInterpolationMode { get; set; } = BitChartCubicInterpolationMode.Default; public BitChartSteppedLine Stepped { get; set; } = BitChartSteppedLine.False; public List? BorderDash { get; set; } + /// Offset (px) of the first dash in . + public double BorderDashOffset { get; set; } public string BorderJoinStyle { get; set; } = "round"; public string BorderCapStyle { get; set; } = "round"; public bool ShowLine { get; set; } = true; @@ -74,13 +103,17 @@ public sealed class BitChartDataset public double? FillValue { get; set; } // ---- Point element options ---- - public double PointRadius { get; set; } = 3; + /// Marker radius. Zero hides the marker but keeps the point hoverable; null falls back to + /// . + public double? PointRadius { get; set; } public double PointHoverRadius { get; set; } = 4; public double PointBorderWidth { get; set; } = 1; public string? PointBackgroundColor { get; set; } public string? PointBorderColor { get; set; } public BitChartPointStyle PointStyle { get; set; } = BitChartPointStyle.Circle; - /// Pixel radius around a point that still counts as a hit for hover/tooltip. + /// Rotation of the point marker in degrees. + public double PointRotation { get; set; } + /// Extra pixel radius around a point that still counts as a hit for hover/tooltip. public double HitRadius { get; set; } = 1; /// Point fill color when hovered (falls back to ). public string? PointHoverBackgroundColor { get; set; } @@ -94,18 +127,40 @@ public sealed class BitChartDataset public double? MaxBarThickness { get; set; } public double BarPercentage { get; set; } = 0.9; public double CategoryPercentage { get; set; } = 0.8; + /// + /// Corner radius in pixels. On a bar it rounds the corners away from the baseline (see + /// ); on a pie, doughnut or polar-area arc it rounds the arc's own + /// corners, mirroring Chart.js's shared borderRadius. + /// public double BorderRadius { get; set; } /// Optional per-corner bar radius. When set, overrides . public BitChartBorderRadiusCorners? BorderRadiusCorners { get; set; } /// Pixels to grow each bar by to avoid anti-aliasing gaps between stacked bars. public double? InflateAmount { get; set; } + /// Minimum bar length in pixels, so very small values stay visible. + public double? MinBarLength { get; set; } + /// The value bars start from. Defaults to zero clamped into the axis range. + public double? Base { get; set; } + /// When false the dataset is not grouped with the other bar datasets and keeps the full band. + public bool Grouped { get; set; } = true; + /// When true, null values leave no gap: the remaining bars in the group expand to fill the band. + public bool SkipNull { get; set; } /// Which bar edge omits its border. Default skips the baseline edge. public BitChartBorderSkipped BorderSkipped { get; set; } = BitChartBorderSkipped.Start; // ---- Arc (pie/doughnut/polar) element options ---- + /// Pixels every arc of this dataset is pushed out from the center. public double Offset { get; set; } + /// Angular gap (in pixels along the outer edge) left between neighbouring arcs. public double SpacingArc { get; set; } + /// + /// Relative thickness of this dataset's ring in a multi-dataset pie or doughnut. The available + /// radius is shared out in proportion to the weights, so a dataset with weight 2 gets a band twice + /// as thick as one left at the default of 1. Mirrors Chart.js's arc dataset weight. + /// + public double Weight { get; set; } = 1; + // ---- Axis assignment / stacking / ordering ---- public string XAxisID { get; set; } = "x"; public string YAxisID { get; set; } = "y"; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDecimationOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDecimationOptions.cs index d5af7969ac..7fd5df8a9a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDecimationOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartDecimationOptions.cs @@ -1,6 +1,10 @@ namespace Bit.BlazorUI; -/// BitChartDecimation (downsampling) options for large line datasets. +/// +/// Downsampling options for large line datasets. Decimation applies to unstacked lines and areas: +/// stacked ones are accumulated index by index across their datasets, so thinning them separately +/// would leave the layers no longer adding up. +/// public sealed class BitChartDecimationOptions { public bool Enabled { get; set; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartElementOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartElementOptions.cs index 33a98fa008..2fa93c4661 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartElementOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartElementOptions.cs @@ -1,12 +1,21 @@ namespace Bit.BlazorUI; -/// Default element options, mirroring Chart.js options.elements. +/// +/// Default element options, mirroring Chart.js options.elements. Every value here is the +/// fallback used when the matching dataset property is left unset. +/// public sealed class BitChartElementOptions { + /// Default point radius for datasets that do not set . public double PointRadius { get; set; } = 3; + /// Default bezier tension for datasets that do not set . public double LineTension { get; set; } + /// Default line thickness for line and radar datasets. public double LineBorderWidth { get; set; } = 3; + /// Default bar border thickness (Chart.js draws no bar border by default). public double BarBorderWidth { get; set; } + /// Default arc border thickness for pie/doughnut/polar area. public double ArcBorderWidth { get; set; } = 2; - public string ArcBorderColor { get; set; } = "#fff"; + /// Default arc border color; follows the theme background so arcs separate cleanly. + public string ArcBorderColor { get; set; } = "var(--bit-clr-bg-pri, #fff)"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartErrorBar.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartErrorBar.cs new file mode 100644 index 0000000000..dcd22045dc --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartErrorBar.cs @@ -0,0 +1,15 @@ +namespace Bit.BlazorUI; + +/// +/// The uncertainty around one data point, drawn as a whisker through it. The two arms are given +/// separately so an asymmetric interval - a confidence band that is not centered on the estimate - +/// can be expressed; assigning a single number gives a symmetric one. +/// +public readonly record struct BitChartErrorBar(double Minus, double Plus) +{ + /// A symmetric interval of either side of the value. + public static implicit operator BitChartErrorBar(double amount) => new(amount, amount); + + /// True when both arms are the same length. + public bool IsSymmetric => Math.Abs(Minus - Plus) < 1e-9; +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartFillMode.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartFillMode.cs index 2c3b40edbc..b293e7caf6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartFillMode.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartFillMode.cs @@ -3,10 +3,16 @@ namespace Bit.BlazorUI; /// How a line/area dataset fills relative to a baseline. public enum BitChartFillMode { + /// No area fill. None, + /// Fill to zero (clamped into the axis range). Origin, + /// Fill to the low end of the axis. Start, + /// Fill to the high end of the axis. End, + /// Fill to the series stacked below this one. Needs a stacked value axis; without one it + /// behaves like . Stack, /// Fill to another dataset's line (see ). Dataset, diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionMode.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionMode.cs index 41669d08db..59a116da2b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionMode.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionMode.cs @@ -3,10 +3,32 @@ namespace Bit.BlazorUI; /// Interaction mode used to determine which items are active on hover. public enum BitChartInteractionMode { + /// + /// Only the single nearest element becomes active. With + /// true that is the element directly under the + /// pointer; with it false the pointer only has to be inside the plot area, and the element nearest + /// to it activates. + /// Nearest, + + /// Every element sharing the hovered element's data index becomes active. Index, + + /// Every element of the hovered element's dataset becomes active. Dataset, + + /// + /// Same as : a single element, which is the one under the pointer only when + /// is true. + /// Point, + + /// Groups by position along the index axis, which for this renderer is the data index. X, + + /// + /// Groups by each element's coordinate along the value axis. This renderer resolves it the same way + /// as , so the hovered element's whole index group becomes active. + /// Y } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionOptions.cs index 7ffa66f9e0..2f47fba649 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartInteractionOptions.cs @@ -1,8 +1,32 @@ namespace Bit.BlazorUI; -/// Interaction options, mirroring Chart.js options.interaction. +/// +/// Interaction options, mirroring Chart.js options.interaction. They decide which elements +/// become active when the pointer moves over the chart, and the tooltip inherits them unless it +/// overrides / . +/// public sealed class BitChartInteractionOptions { + /// How the active element set is derived from the hovered element. public BitChartInteractionMode Mode { get; set; } = BitChartInteractionMode.Nearest; - public bool Intersect { get; set; } = true; + + /// + /// When true the pointer must be directly over an element for it to activate. When false (the + /// default) the chart also reacts anywhere inside the plot area: an invisible hit band per + /// category/x position activates that index, so thin lines and datasets drawn without point + /// markers stay hoverable. + /// + public bool Intersect { get; set; } + + /// Draw a crosshair line through the active index (index-style interactions only). + public bool Crosshair { get; set; } = true; + + /// Color of the crosshair line. + public string CrosshairColor { get; set; } = "var(--bit-clr-fg-sec, rgba(0,0,0,0.45))"; + + /// + /// Show the active index in a small chip where the crosshair meets the index axis, so the reader can + /// see which category is being compared without leaving the plot. + /// + public bool CrosshairLabel { get; set; } = true; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendLabelOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendLabelOptions.cs index 79184f235f..de02d099b1 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendLabelOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendLabelOptions.cs @@ -7,6 +7,7 @@ public sealed class BitChartLegendLabelOptions public BitChartFont Font { get; set; } = new(); public double BoxWidth { get; set; } = 40; public double BoxHeight { get; set; } = 12; + /// Gap (px) between legend items. public double Padding { get; set; } = 10; public bool UsePointStyle { get; set; } public BitChartPointStyle PointStyle { get; set; } = BitChartPointStyle.Circle; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendOptions.cs index 832714b7e8..6048bcd09a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartLegendOptions.cs @@ -11,4 +11,13 @@ public sealed class BitChartLegendOptions public bool OnClickToggle { get; set; } = true; public BitChartLegendLabelOptions Labels { get; set; } = new(); public string? Title { get; set; } + + /// + /// Caps the legend's height in pixels and lets it scroll past that. A chart of twenty series would + /// otherwise give most of its box to the legend; with a cap the plot keeps its space and the + /// remaining entries stay one scroll away rather than pushed off the chart. + /// + public double? MaxHeight { get; set; } + /// Keeps only the items this predicate accepts, mirroring Chart.js legend.labels.filter. + public Func? Filter { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartOptions.cs index 17bd30cdb3..69fcc68acb 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartOptions.cs @@ -12,6 +12,15 @@ public sealed class BitChartOptions public BitChartIndexAxis IndexAxis { get; set; } = BitChartIndexAxis.X; + /// + /// Draws the chart as a sparkline: every piece of chrome around the data - axes, grid lines, tick + /// labels, legend, title and subtitle - is dropped so the series fills the whole box. It is a + /// presentation switch only: nothing is removed from the data, so tooltips, keyboard navigation and + /// the screen-reader table still describe the full series. Meant for the small inline trend charts + /// that sit inside a KPI tile or a table cell. + /// + public bool Sparkline { get; set; } + public BitChartLayoutOptions Layout { get; set; } = new(); public BitChartInteractionOptions Interaction { get; set; } = new(); public BitChartAnimationOptions Animation { get; set; } = new(); @@ -19,9 +28,18 @@ public sealed class BitChartOptions public BitChartPluginOptions Plugins { get; set; } = new(); public BitChartZoomOptions Zoom { get; set; } = new(); - /// Named scales, keyed by id (e.g. "x", "y", "r", "y2"). + /// Named scales, keyed by id (e.g. "x", "y", "r", "y2"). Missing scales are created on the + /// fly by the renderer without mutating this dictionary, so the same options instance can safely be + /// shared between charts of different types. public Dictionary Scales { get; set; } = new(); + /// + /// Culture used to format every number and date the chart renders (tick labels, tooltips and data + /// labels). When null the invariant culture is used, so output stays stable regardless of the + /// thread culture. Set it to CultureInfo.CurrentCulture to follow the user's locale. + /// + public System.Globalization.CultureInfo? Culture { get; set; } + // ---- Doughnut / pie / polar specific ---- /// Inner radius as a percentage string for doughnut charts (0-100). public double CutoutPercentage { get; set; } = 50; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartPosition.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartPosition.cs index d4411c2c61..d253820276 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartPosition.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartPosition.cs @@ -7,6 +7,13 @@ public enum BitChartPosition Left, Bottom, Right, + + /// + /// Cartesian axes only: draw the axis where the other one reads zero instead of along an edge, so it + /// costs no layout space. Legends and titles have no center placement and fall back to the top. + /// Center, + + /// Reserved. Nothing places itself here yet; it falls back to the default side. Chart } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScaleOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScaleOptions.cs index bf5a1d360a..6135d8088e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScaleOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScaleOptions.cs @@ -22,9 +22,15 @@ public sealed class BitChartScaleOptions /// When stacked, normalize each category to 100% (percentage stack). public bool Stacked100 { get; set; } - /// BitChartPadding (fraction of a step) applied at the ends of a category axis. + /// Padding (half a step) applied at the ends of a category axis. public bool Offset { get; set; } + /// + /// When true (the default) the tick count is additionally capped by how many labels actually fit + /// along the axis, so short charts do not end up with overlapping tick labels. + /// + public bool AutoSkipTicks { get; set; } = true; + public BitChartGridOptions Grid { get; set; } = new(); public BitChartTickOptions Ticks { get; set; } = new(); public BitChartScaleTitleOptions Title { get; set; } = new(); @@ -43,7 +49,7 @@ public sealed class BitChartScaleOptions /// Show a filled backdrop behind radial tick labels. public bool ShowLabelBackdrop { get; set; } = true; /// Backdrop color for radial tick labels. - public string BackdropColor { get; set; } = "rgba(255,255,255,0.75)"; + public string BackdropColor { get; set; } = "var(--bit-clr-bg-pri, #fff)"; // ---- Time scale ---- /// The unit for a time axis. Auto picks a sensible unit from the data range. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScriptable.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScriptable.cs deleted file mode 100644 index ba773b90c0..0000000000 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartScriptable.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Bit.BlazorUI; - -/// -/// A value that is either a constant or a function of . -/// Provides implicit conversions so existing constant assignments keep working while also -/// accepting a scriptable function, mirroring Chart.js scriptable options. -/// -/// The resolved value type. -public readonly struct BitChartScriptable -{ - private readonly T? _constant; - private readonly Func? _fn; - - public BitChartScriptable(T? constant) { _constant = constant; _fn = null; } - public BitChartScriptable(Func fn) { _fn = fn; _constant = default; } - - public bool HasValue => _fn is not null || _constant is not null; - - /// Resolves the value for the given context (function takes precedence). - public T? Resolve(BitChartScriptableContext ctx) => _fn is not null ? _fn(ctx) : _constant; - - public static implicit operator BitChartScriptable(T value) => new(value); - public static implicit operator BitChartScriptable(Func fn) => new(fn); -} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTickOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTickOptions.cs index 90945508e1..1a40ef3a5b 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTickOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTickOptions.cs @@ -18,11 +18,19 @@ public sealed class BitChartTickOptions public double MinRotation { get; set; } /// Tick label alignment relative to the tick (start/center/end). public BitChartAlign Align { get; set; } = BitChartAlign.Center; + /// Extra pixel shift of the tick label along the axis, mirroring Chart.js ticks.labelOffset. + public double LabelOffset { get; set; } /// Render value-axis tick labels inside the chart area. public bool Mirror { get; set; } + /// + /// Drop labels that would not fit. Category axes skip whole labels; value axes reduce the tick count + /// to what the axis length can show without the labels colliding. + /// public bool AutoSkip { get; set; } = true; /// Optional formatting callback applied to each numeric/category tick value. public Func? Callback { get; set; } + /// Standard or custom .NET numeric format string used for numeric tick labels (e.g. "N0", "P1", "C"). + public string? Format { get; set; } public string? Prefix { get; set; } public string? Suffix { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTooltipOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTooltipOptions.cs index d8db8c42ef..d152b77ebf 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTooltipOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartTooltipOptions.cs @@ -4,8 +4,13 @@ namespace Bit.BlazorUI; public sealed class BitChartTooltipOptions { public bool Enabled { get; set; } = true; - public BitChartInteractionMode Mode { get; set; } = BitChartInteractionMode.Nearest; - public bool Intersect { get; set; } = true; + + /// Overrides for the tooltip only. + public BitChartInteractionMode? Mode { get; set; } + + /// Overrides for the tooltip only. + public bool? Intersect { get; set; } + /// Where the tooltip is anchored when multiple items are active. public BitChartTooltipPositioner Position { get; set; } = BitChartTooltipPositioner.Average; public string BackgroundColor { get; set; } = "rgba(0,0,0,0.8)"; @@ -16,6 +21,13 @@ public sealed class BitChartTooltipOptions public BitChartFont BodyFont { get; set; } = new(); public BitChartFont FooterFont { get; set; } = new() { Weight = "bold" }; public double Padding { get; set; } = 6; + + /// + /// Caps the tooltip's width in pixels and wraps its text at that point. Without one the box stays on + /// a single line per row, which is right for a value but wrong for a sentence, so a callback that + /// returns prose wants a width here. + /// + public double? MaxWidth { get; set; } public double CornerRadius { get; set; } = 6; public bool DisplayColors { get; set; } = true; /// Render the color swatch using the dataset point style instead of a square. @@ -24,6 +36,10 @@ public sealed class BitChartTooltipOptions public string? BorderColor { get; set; } /// Border width of the tooltip box. public double BorderWidth { get; set; } + /// Draw a caret (arrow) pointing at the anchored element. Chart.js draws one by default. + public bool Caret { get; set; } = true; + /// Size of the caret in pixels. + public double CaretSize { get; set; } = 6; /// Text alignment of the title (left/center/right). public BitChartAlign TitleAlign { get; set; } = BitChartAlign.Start; /// Text alignment of the body (left/center/right). @@ -32,4 +48,8 @@ public sealed class BitChartTooltipOptions public BitChartTooltipCallbacks Callbacks { get; set; } = new(); /// Optional label formatter: (datasetLabel, value) => text. Shorthand for Callbacks.Label. public Func? LabelFormatter { get; set; } + /// Filters which active items are listed, mirroring Chart.js tooltip.filter. + public Func? Filter { get; set; } + /// Sorts the listed items, mirroring Chart.js tooltip.itemSort. + public Comparison? ItemSort { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartZoomOptions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartZoomOptions.cs index bf94e52f2b..ed394ecad5 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartZoomOptions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Models/BitChartZoomOptions.cs @@ -12,8 +12,17 @@ public sealed class BitChartZoomOptions public bool DragZoom { get; set; } /// Fill color of the drag-zoom selection box. public string DragBoxColor { get; set; } = "rgba(54,162,235,0.2)"; + /// Border color of the drag-zoom selection box. + public string DragBoxBorderColor { get; set; } = "rgba(54,162,235,0.8)"; /// Axis/axes affected by zoom and pan. public BitChartZoomMode Mode { get; set; } = BitChartZoomMode.X; /// Wheel zoom sensitivity (fraction per wheel notch). public double Speed { get; set; } = 0.15; + /// + /// When true (the default) zooming and panning stay inside the data range, so the chart can never + /// be dragged into empty space or zoomed out past the full series. + /// + public bool LimitToData { get; set; } = true; + /// Smallest visible span, as a fraction of the full data range (guards against zooming in forever). + public double MinRangeFraction { get; set; } = 0.001; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartAxisScale.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartAxisScale.cs index ddbf872d3a..775920f1e6 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartAxisScale.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartAxisScale.cs @@ -21,6 +21,14 @@ public sealed class BitChartAxisScale /// Computed label rotation in degrees (auto-fit for category axes). public double LabelRotation { get; set; } + /// Culture used to format tick labels. Defaults to the invariant culture. + public CultureInfo Culture { get; set; } = CultureInfo.InvariantCulture; + + /// The pixel range currently mapped onto, as last passed to . + public double PixelStart => _pixelStart; + /// + public double PixelEnd => _pixelEnd; + private double _pixelStart; private double _pixelEnd; private readonly List? _categories; @@ -96,6 +104,26 @@ public void SetPixelRange(double start, double end) BuildTicks(); } + /// + /// The most ticks that fit along the axis without their labels colliding. Value axes read labels + /// perpendicular to the axis, so the limit follows the line height; along a horizontal axis it + /// follows a conservative label width instead. + /// + private int FitTickLimit(int requested) + { + if (!Options.AutoSkipTicks) return requested; + // A single-category axis - or an explicit tick count of one - asks for fewer than the two the + // fit is floored at, and there is nothing to thin out there anyway. + if (requested < 2) return requested; + double length = Math.Abs(_pixelEnd - _pixelStart); + if (length <= 0) return requested; + double perTick = Horizontal + ? Math.Max(24, Options.Ticks.Font.Size * 3.2) // room for a short numeric label + : Options.Ticks.Font.LineHeightPx * 1.6; // room for one line of text + int fits = (int)Math.Floor(length / perTick) + 1; + return Math.Clamp(fits, 2, requested); + } + public double PixelFor(double value) { double t = NormalizedPosition(value); @@ -164,8 +192,8 @@ private void BuildTicks() private void BuildTimeTicks() { - int maxTicks = Options.Ticks.Count ?? Options.Ticks.MaxTicksLimit ?? 11; - foreach (var (value, label) in BitChartTimeAxis.Ticks(Min, Max, Options.TimeUnit, Options.TimeFormat, maxTicks)) + int maxTicks = FitTickLimit(Options.Ticks.Count ?? Options.Ticks.MaxTicksLimit ?? 11); + foreach (var (value, label) in BitChartTimeAxis.Ticks(Min, Max, Options.TimeUnit, Options.TimeFormat, maxTicks, Culture)) Ticks.Add(new BitChartAxisTick(value, label, PixelFor(value))); } @@ -177,7 +205,7 @@ private void BuildCategoryTicks() int end = Math.Min(n - 1, (int)Math.Floor(Max + 1e-9)); if (end < start) return; int visible = end - start + 1; - int maxLabels = Options.Ticks.MaxTicksLimit ?? visible; + int maxLabels = FitTickLimit(Options.Ticks.MaxTicksLimit ?? visible); int skip = Options.Ticks.AutoSkip && visible > maxLabels ? (int)Math.Ceiling((double)visible / maxLabels) : 1; for (int i = start; i <= end; i++) { @@ -189,7 +217,7 @@ private void BuildCategoryTicks() private void BuildLinearTicks() { - int maxTicks = Options.Ticks.Count ?? Options.Ticks.MaxTicksLimit ?? 11; + int maxTicks = FitTickLimit(Options.Ticks.Count ?? Options.Ticks.MaxTicksLimit ?? 11); maxTicks = Math.Max(2, maxTicks); double range = Max - Min; @@ -213,12 +241,19 @@ private void BuildLinearTicks() Max = niceMax; } + // A step far smaller than the range (an explicit StepSize of 1 over millions, say) would + // otherwise generate ticks until the browser gives up, so the emitted count is bounded. + int limit = Math.Max(maxTicks * 4, 64); + if ((niceMax - niceMin) / rawStep > limit) + rawStep = (niceMax - niceMin) / limit; + int decimals = DecimalsFor(rawStep); for (double v = niceMin; v <= niceMax + rawStep * 0.5; v += rawStep) { double val = Math.Round(v, 8); if (val < Min - 1e-9 || val > Max + 1e-9) continue; Ticks.Add(new BitChartAxisTick(val, FormatValue(val, decimals), PixelFor(val))); + if (Ticks.Count > limit) break; } } @@ -253,7 +288,8 @@ public string FormatValue(double value, int decimals) if (Options.Ticks.Callback is { } cb) return cb(value, Ticks.Count); if (Options.Ticks.Precision is { } p) decimals = p; - string s = value.ToString("N" + Math.Max(0, decimals), CultureInfo.InvariantCulture); + string format = Options.Ticks.Format ?? "N" + Math.Max(0, decimals); + string s = value.ToString(format, Culture); return $"{Options.Ticks.Prefix}{s}{Options.Ticks.Suffix}"; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartColorUtil.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartColorUtil.cs index 3237a4fae9..9aecbbcae0 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartColorUtil.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartColorUtil.cs @@ -50,6 +50,21 @@ public static string Adjust(string color, double factor) private static int Clamp(int v) => Math.Max(0, Math.Min(255, v)); + /// Expands one hex digit into a byte (the #rgb short form). + private static bool Nibble(char c, out int value) + { + if (!int.TryParse(stackalloc char[] { c }, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value)) + { + value = 0; + return false; + } + value = value * 17; + return true; + } + + private static bool Byte(ReadOnlySpan pair, out int value) + => int.TryParse(pair, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value); + public static bool TryParse(string color, out int r, out int g, out int b, out double a) { r = g = b = 0; a = 1; @@ -58,21 +73,27 @@ public static bool TryParse(string color, out int r, out int g, out int b, out d if (color.StartsWith('#')) { - var hex = color[1..]; - if (hex.Length == 3) + // Parsed rather than converted: a stray character in a color string is a typo, not a reason + // for the whole chart to throw out of its render. + var hex = color.AsSpan(1); + if (hex.Length == 3 || hex.Length == 4) { - r = Convert.ToInt32($"{hex[0]}{hex[0]}", 16); - g = Convert.ToInt32($"{hex[1]}{hex[1]}", 16); - b = Convert.ToInt32($"{hex[2]}{hex[2]}", 16); + if (!Nibble(hex[0], out r) || !Nibble(hex[1], out g) || !Nibble(hex[2], out b)) return false; + if (hex.Length == 4) + { + if (!Nibble(hex[3], out var na)) return false; + a = na / 255.0; + } return true; } if (hex.Length == 6 || hex.Length == 8) { - r = Convert.ToInt32(hex.Substring(0, 2), 16); - g = Convert.ToInt32(hex.Substring(2, 2), 16); - b = Convert.ToInt32(hex.Substring(4, 2), 16); + if (!Byte(hex[..2], out r) || !Byte(hex.Slice(2, 2), out g) || !Byte(hex.Slice(4, 2), out b)) return false; if (hex.Length == 8) - a = Convert.ToInt32(hex.Substring(6, 2), 16) / 255.0; + { + if (!Byte(hex.Slice(6, 2), out var ba)) return false; + a = ba / 255.0; + } return true; } return false; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartDataElement.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartDataElement.cs index 3f69e663b8..ef7ea27a0d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartDataElement.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartDataElement.cs @@ -11,17 +11,15 @@ public sealed class BitChartDataElement /// Hit-test centroid in chart pixel coordinates. public double CenterX { get; init; } public double CenterY { get; init; } - /// Optional shape used when this element is the active/hovered one. + /// + /// The shape drawn on top of this element while it is active (hovered or keyboard-focused). It is + /// precomputed by the renderer with the dataset's hover styling - and with + /// set - so hovering never costs a re-layout. + /// public BitChartSvgNode? HoverShape { get; init; } /// Optional secondary shape drawn with the element (e.g. a bar's skipped-edge border) /// so it animates together with the fill. public BitChartSvgNode? BorderShape { get; init; } - /// Per-element entry animation CSS class (e.g. grow from baseline for bars, pop for points). - public string? EnterAnim { get; init; } - /// Transform origin (in view-box/pixel coordinates) for the entry animation - the bar baseline - /// or the point center. - public double AnimOriginX { get; init; } - public double AnimOriginY { get; init; } /// Primary numeric value of this element (for tooltip templates). public double Value { get; init; } /// Series (dataset) label, for tooltip templates. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartHitBand.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartHitBand.cs new file mode 100644 index 0000000000..821be4d0bd --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartHitBand.cs @@ -0,0 +1,17 @@ +namespace Bit.BlazorUI; + +/// +/// An invisible rectangle covering one index (category / x position) across the whole plot area. +/// Hovering it activates that index even when the pointer is nowhere near a bar or a point, which is +/// what keeps thin lines and marker-less datasets interactive. Bands are painted underneath the data +/// elements so an element directly under the pointer always wins. +/// +public sealed class BitChartHitBand +{ + public double X { get; init; } + public double Y { get; init; } + public double Width { get; init; } + public double Height { get; init; } + /// The data index this band represents. + public int DataIndex { get; init; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartLegendModel.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartLegendModel.cs index 668cc98d7b..6af931cb25 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartLegendModel.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartLegendModel.cs @@ -9,4 +9,6 @@ public sealed class BitChartLegendModel public BitChartLegendLabelOptions Labels { get; set; } = new(); public string? Title { get; set; } public bool OnClickToggle { get; set; } = true; + /// Height cap in pixels past which the legend scrolls. + public double? MaxHeight { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartPointShapes.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartPointShapes.cs index e2e0ae91f7..4013482af0 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartPointShapes.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartPointShapes.cs @@ -5,6 +5,15 @@ namespace Bit.BlazorUI; public static class BitChartPointShapes { public static BitChartSvgNode? Build(BitChartPointStyle style, double x, double y, double r, + string fill, string stroke, double strokeWidth, double rotation = 0) + { + var node = BuildCore(style, x, y, r, fill, stroke, strokeWidth); + if (node is not null && Math.Abs(rotation) > 1e-3) + node.Transform = $"rotate({BitChartSvg.N(rotation)} {BitChartSvg.N(x)} {BitChartSvg.N(y)})"; + return node; + } + + private static BitChartSvgNode? BuildCore(BitChartPointStyle style, double x, double y, double r, string fill, string stroke, double strokeWidth) { switch (style) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderState.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderState.cs index cf7f448326..16d703af5c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderState.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderState.cs @@ -1,6 +1,6 @@ namespace Bit.BlazorUI; -/// Mutable interaction state shared with the component (hover + legend toggles). +/// Mutable interaction state shared with the component (legend toggles + zoom ranges). public sealed class BitChartRenderState { /// Datasets hidden via the legend (by dataset index). @@ -9,9 +9,6 @@ public sealed class BitChartRenderState /// Data indices hidden via the legend (pie/doughnut/polarArea). public HashSet HiddenIndices { get; } = new(); - /// The currently hovered element, if any. - public (int Dataset, int Index)? Active { get; set; } - /// Zoom/pan range overrides per axis id (min, max in data coordinates). public Dictionary AxisRanges { get; } = new(); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Cartesian.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Cartesian.cs index 8e1b7f466c..dab8122748 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Cartesian.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Cartesian.cs @@ -1,4 +1,3 @@ - namespace Bit.BlazorUI; public sealed partial class BitChartRenderer @@ -12,28 +11,46 @@ private void RenderCartesian(BitChartScene scene) // Identify which scale ids are x-axes (datasets' XAxisID plus the default "x"). var xIds = new HashSet(_data.Datasets.Select(d => string.IsNullOrEmpty(d.XAxisID) ? "x" : d.XAxisID)) { "x" }; - var indexScaleOpts = _options.Scales["x"]; + var indexScaleOpts = Scale("x"); bool indexIsCategory = indexScaleOpts.Type == BitChartScaleType.Category; // Value (y) axes used by datasets. var leftAxes = new List(); var rightAxes = new List(); + // Axes drawn at the other axis' zero line instead of along an edge. They cost no layout space, + // which is exactly the point: they live inside the plot. + var centerAxes = new List(); var valueScales = new Dictionary(); + // The raw extent behind each axis, kept so the full range can be recomputed once the axes are + // laid out - a zoomed axis is pinned to its zoom range and cannot report it itself. + var dataExtents = new Dictionary(); - foreach (var (id, so) in _options.Scales) + foreach (var (id, so) in _scales) { if (xIds.Contains(id) || so.Type == BitChartScaleType.RadialLinear) continue; var (mn, mx) = ComputeValueExtent(id); - var scale = new BitChartAxisScale(so, horizontal: !IsVertical); + dataExtents[id] = (mn, mx); + var scale = new BitChartAxisScale(so, horizontal: !IsVertical) { Culture = Culture }; + scale.SetDataRange(mn, mx); if (so.Type != BitChartScaleType.Category && _state.AxisRanges.TryGetValue(id, out var ov)) + { scale.Forced = ov; - scale.SetDataRange(mn, mx); - scale.SetPixelRange(0, 100); // provisional, for tick labels + scale.SetDataRange(mn, mx); + } + // Provisional pixel range spanning the content box: close enough to the final one that + // tick-label measurement (and the fit-based tick limit) reserves the right amount of space. + if (IsVertical) scale.SetPixelRange(area.Bottom, area.Top); + else scale.SetPixelRange(area.Left, area.Right); valueScales[id] = scale; + if (so.Reverse) scene.ReversedAxes.Add(id); if (so.Type != BitChartScaleType.Category) scene.ZoomableAxes.Add(id); - if (!so.Display) continue; - if ((so.Position ?? BitChartPosition.Left) == BitChartPosition.Right) rightAxes.Add(scale); - else leftAxes.Add(scale); + if (!ScaleVisible(so)) continue; + switch (PositionOf(so, IsVertical ? BitChartPosition.Left : BitChartPosition.Bottom)) + { + case BitChartPosition.Right or BitChartPosition.Top: rightAxes.Add(scale); break; + case BitChartPosition.Center: centerAxes.Add(scale); break; + default: leftAxes.Add(scale); break; + } } // X axes ("x" is the primary index scale; others are secondary, point-bound). @@ -44,46 +61,72 @@ private void RenderCartesian(BitChartScene scene) foreach (var id in xIds.OrderBy(s => s == "x" ? 0 : 1)) { - var so = _options.Scales[id]; + var so = Scale(id); BitChartAxisScale xs; if (id == "x" && indexIsCategory) { - xs = new BitChartAxisScale(so, horizontal: IsVertical, categories: _data.Labels); - if (_state.AxisRanges.TryGetValue("x", out var cov)) xs.Forced = cov; + xs = new BitChartAxisScale(so, horizontal: IsVertical, categories: _data.Labels) { Culture = Culture }; + dataExtents[id] = (0, Math.Max(0, _data.Labels.Count - 1)); xs.SetDataRange(0, Math.Max(0, _data.Labels.Count - 1)); + if (_state.AxisRanges.TryGetValue("x", out var cov)) + { + xs.Forced = cov; + xs.SetDataRange(0, Math.Max(0, _data.Labels.Count - 1)); + } } else { var (mn, mx) = id == "x" ? ComputeIndexExtent() : ComputeXExtent(id); - xs = new BitChartAxisScale(so, horizontal: IsVertical); - if (_state.AxisRanges.TryGetValue(id, out var ov)) xs.Forced = ov; + dataExtents[id] = (mn, mx); + xs = new BitChartAxisScale(so, horizontal: IsVertical) { Culture = Culture }; xs.SetDataRange(mn, mx); + if (_state.AxisRanges.TryGetValue(id, out var ov)) + { + xs.Forced = ov; + xs.SetDataRange(mn, mx); + } } - xs.SetPixelRange(0, 100); + if (IsVertical) xs.SetPixelRange(area.Left, area.Right); + else xs.SetPixelRange(area.Top, area.Bottom); xScales[id] = xs; + if (so.Reverse) scene.ReversedAxes.Add(id); scene.ZoomableAxes.Add(id); if (id == "x") indexScale = xs; - if (so.Display) + if (ScaleVisible(so)) { - if ((so.Position ?? BitChartPosition.Bottom) == BitChartPosition.Top) topXAxes.Add(xs); + if (PositionOf(so, BitChartPosition.Bottom) == BitChartPosition.Top) topXAxes.Add(xs); else bottomXAxes.Add(xs); } } // ---- Reserve space for axes ---- - double leftReserve = leftAxes.Sum(a => ReserveValueAxis(a)); - double rightReserve = rightAxes.Sum(a => ReserveValueAxis(a)); + // Which side an axis lives on follows the orientation, not the axis' role: with a vertical + // index axis (horizontal bars) the categories run down the left edge and the values along the + // bottom, so the reservations swap - a side axis needs label *width*, an edge axis needs + // label *height*. + double leftReserve, rightReserve, bottomReserve, topReserve; + if (IsVertical) + { + leftReserve = leftAxes.Sum(ReserveAxisWidth); + rightReserve = rightAxes.Sum(ReserveAxisWidth); - // Auto-rotate category labels on the bottom (x) axis when they don't fit. - if (IsVertical && indexIsCategory && indexScaleOpts.Display && indexScaleOpts.Ticks.Display) + // Auto-rotate category labels on the bottom (x) axis when they don't fit. + if (indexIsCategory && ScaleVisible(indexScaleOpts) && indexScaleOpts.Ticks.Display) + indexScale.LabelRotation = ComputeIndexLabelRotation(indexScale, area.Width - leftReserve - rightReserve); + + bottomReserve = bottomXAxes.Sum(ReserveAxisHeight); + topReserve = topXAxes.Sum(ReserveAxisHeight); + } + else { - double availW = area.Width - leftReserve - rightReserve; - indexScale.LabelRotation = ComputeIndexLabelRotation(indexScale, availW); + leftReserve = ReserveAxisWidth(indexScale); + rightReserve = 0; + // "left"/"right" name the near/far edge, which here is the bottom and the top, so each + // stack reserves height on its own side. + bottomReserve = leftAxes.Sum(ReserveAxisHeight); + topReserve = rightAxes.Sum(ReserveAxisHeight); } - double bottomReserve = IsVertical ? bottomXAxes.Sum(ReserveIndexAxis) : ReserveIndexAxis(indexScale); - double topReserve = IsVertical ? topXAxes.Sum(ReserveIndexAxis) : 0; - var plot = new BitChartArea(area.Left + leftReserve, area.Top + topReserve, area.Right - rightReserve, area.Bottom - bottomReserve); // ---- Final pixel ranges ---- @@ -98,24 +141,31 @@ private void RenderCartesian(BitChartScene scene) foreach (var s in valueScales.Values) s.SetPixelRange(plot.Left, plot.Right); } + // ---- Full (un-zoomed) ranges, for the zoom clamp ---- + // Recorded only now: building the ticks nice-rounds a linear axis outwards, so anything read + // off a scale before it is laid out is narrower than what the axis displays - and clamping a + // gesture to that would snap the first zoom-out inside the visible range with no way back. + foreach (var (id, s) in xScales) RecordDataRange(id, s); + foreach (var (id, s) in valueScales) RecordDataRange(id, s); + // ---- Grid + axes ---- - DrawGrid(scene, plot, indexScale, valueScales, leftAxes, rightAxes); + DrawGrid(scene, plot, indexScale, leftAxes, rightAxes, centerAxes); // Secondary x axes (display only; stacked outside the plot, no chart-area grid). if (IsVertical) { - double belowOffset = ReserveIndexAxis(indexScale); + double belowOffset = ReserveAxisHeight(indexScale); foreach (var xs in bottomXAxes) { if (ReferenceEquals(xs, indexScale)) continue; DrawSecondaryXAxis(scene, plot, xs, plot.Bottom + belowOffset, atBottom: true); - belowOffset += ReserveIndexAxis(xs); + belowOffset += ReserveAxisHeight(xs); } double aboveOffset = 0; foreach (var xs in topXAxes) { if (ReferenceEquals(xs, indexScale)) continue; - aboveOffset += ReserveIndexAxis(xs); + aboveOffset += ReserveAxisHeight(xs); DrawSecondaryXAxis(scene, plot, xs, plot.Top - aboveOffset, atBottom: false); } } @@ -123,8 +173,18 @@ private void RenderCartesian(BitChartScene scene) scene.PlotArea = plot; scene.AxisRanges["x"] = (indexScale.Min, indexScale.Max); - foreach (var (id, s) in xScales) scene.AxisRanges[id] = (s.Min, s.Max); - foreach (var (id, s) in valueScales) scene.AxisRanges[id] = (s.Min, s.Max); + foreach (var (id, s) in xScales) + { + scene.AxisRanges[id] = (s.Min, s.Max); + // An index axis always starts at the near pixel: left of a vertical chart, top of a horizontal one. + scene.AxisOrientations[id] = (IsVertical, false); + } + foreach (var (id, s) in valueScales) + { + scene.AxisRanges[id] = (s.Min, s.Max); + // A vertical value axis is the one drawn bottom-up, so its minimum sits at the far pixel. + scene.AxisOrientations[id] = (!IsVertical, IsVertical); + } var ctx = new BitChartPluginContext { @@ -134,13 +194,32 @@ private void RenderCartesian(BitChartScene scene) IsCartesian = true, IndexScale = indexScale, ValueScales = valueScales, - IndexIsCategory = indexIsCategory + HiddenDatasets = _state.HiddenDatasets, + IndexIsCategory = indexIsCategory, + IndexCentered = HasBars() }; foreach (var plugin in _options.Plugins.Custom) plugin.BeforeDatasetsDraw(ctx); + // A zoomed axis is pinned to its zoom range, so its full range is read off a scratch scale + // laid out exactly as it is but without the zoom. A category axis is never rounded, so its + // extent is already the answer. + void RecordDataRange(string id, BitChartAxisScale s) + { + if (s.Forced is null) { scene.DataRanges[id] = (s.Min, s.Max); return; } + var ext = dataExtents[id]; + if (s.Type == BitChartScaleType.Category) { scene.DataRanges[id] = ext; return; } + var probe = new BitChartAxisScale(s.Options, s.Horizontal) { Culture = Culture }; + probe.SetDataRange(ext.Min, ext.Max); + probe.SetPixelRange(s.PixelStart, s.PixelEnd); + scene.DataRanges[id] = (probe.Min, probe.Max); + } + BitChartAxisScale XScaleFor(BitChartDataset ds) => xScales.TryGetValue(string.IsNullOrEmpty(ds.XAxisID) ? "x" : ds.XAxisID, out var s) ? s : indexScale; + BitChartAxisScale ValueScaleFor(BitChartDataset ds) + => valueScales.TryGetValue(ds.YAxisID, out var vs) ? vs : valueScales.Values.First(); + // ---- Datasets (respect Order) ---- var ordered = _data.Datasets .Select((d, i) => (d, i)) @@ -168,7 +247,7 @@ BitChartAxisScale XScaleFor(BitChartDataset ds) { if (IsHidden(i, ds)) continue; var type = EffectiveType(ds); - var vScale = valueScales.TryGetValue(ds.YAxisID, out var vs) ? vs : valueScales.Values.First(); + var vScale = ValueScaleFor(ds); var xScale = XScaleFor(ds); switch (type) { @@ -184,16 +263,81 @@ BitChartAxisScale XScaleFor(BitChartDataset ds) } } + BuildHitBands(scene, plot, indexScale, indexIsCategory, centered); + foreach (var plugin in _options.Plugins.Custom) plugin.AfterDatasetsDraw(ctx); } + /// + /// Builds the invisible per-index hit areas that make hovering work anywhere inside the plot. + /// Skipped when the interaction is configured to require an intersection with a real element. + /// + private void BuildHitBands(BitChartScene scene, BitChartArea plot, BitChartAxisScale indexScale, + bool indexIsCategory, bool centered) + { + if (EffectiveIntersect() || scene.Elements.Count == 0) return; + + // Bands map one index to one slice of the plot, which only makes sense when the datasets share + // that index. Scatter/bubble points are placed by their own x value, so they keep pure + // nearest-element hovering instead. + bool indexAligned = _data.Datasets + .Where((d, i) => !IsHidden(i, d)) + .All(d => EffectiveType(d) is BitChartType.Bar or BitChartType.Line); + if (!indexAligned) return; + + // One band per distinct index, ordered by where that index actually sits along the axis. + // Summed and counted rather than averaged pair by pair, which would weight the last element of + // an index far more heavily than the first. + var sums = new Dictionary(); + foreach (var el in scene.Elements) + { + double c = IsVertical ? el.CenterX : el.CenterY; + var (sum, n) = sums.GetValueOrDefault(el.DataIndex); + sums[el.DataIndex] = (sum + c, n + 1); + } + if (sums.Count is 0 or > 1000) return; + + var ordered = sums.Select(kv => new KeyValuePair(kv.Key, kv.Value.Sum / kv.Value.Count)) + .OrderBy(kv => kv.Value).ToList(); + double lo = IsVertical ? plot.Left : plot.Top; + double hi = IsVertical ? plot.Right : plot.Bottom; + + for (int k = 0; k < ordered.Count; k++) + { + double c = ordered[k].Value; + double prev = k > 0 ? ordered[k - 1].Value : lo - (c - lo); + double next = k < ordered.Count - 1 ? ordered[k + 1].Value : hi + (hi - c); + double start = Math.Max(lo, (prev + c) / 2); + double end = Math.Min(hi, (c + next) / 2); + if (end <= start) continue; + + scene.HitBands.Add(IsVertical + ? new BitChartHitBand { X = start, Y = plot.Top, Width = end - start, Height = plot.Height, DataIndex = ordered[k].Key } + : new BitChartHitBand { X = plot.Left, Y = start, Width = plot.Width, Height = end - start, DataIndex = ordered[k].Key }); + } + } + + /// The interaction mode in effect for hover/tooltip (the tooltip may override the global one). + internal BitChartInteractionMode EffectiveMode() + => _options.Plugins.Tooltip.Mode ?? _options.Interaction.Mode; + + /// Whether the pointer must intersect an element for it to activate. + internal bool EffectiveIntersect() + => _options.Plugins.Tooltip.Intersect ?? _options.Interaction.Intersect; + private BitChartType EffectiveType(BitChartDataset ds) => ds.Type ?? _config.Type; private bool IsHidden(int i, BitChartDataset ds) => ds.Hidden || _state.IsDatasetHidden(i); - private double ReserveIndexAxis(BitChartAxisScale scale) + /// The height an axis drawn along the top or bottom edge needs for its ticks and title. + private double ReserveAxisHeight(BitChartAxisScale scale) { + // Memoized for the same two reasons ReserveAxisWidth is: the label measuring is repeated on + // every later call, and the drawing code has to place the axis at exactly the height the + // layout reserved for it, even though the tick set is rebuilt once the plot area is known. + if (_heightReserve.TryGetValue(scale, out var cached)) return cached; + var o = scale.Options; - if (!o.Display) return 0; + if (!ScaleVisible(o)) return _heightReserve[scale] = 0; double h = 0; if (o.Grid.DrawTicks) h += o.Grid.TickLength; if (o.Ticks.Display) @@ -213,9 +357,12 @@ private double ReserveIndexAxis(BitChartAxisScale scale) } } if (o.Title.Display) h += o.Title.Font.LineHeightPx + o.Title.Padding.Vertical; + _heightReserve[scale] = h; return h; } + private readonly Dictionary _heightReserve = new(); + /// Computes an auto label rotation (degrees) so category labels fit their band width. private static double ComputeIndexLabelRotation(BitChartAxisScale scale, double availWidth) { @@ -229,7 +376,9 @@ private static double ComputeIndexLabelRotation(BitChartAxisScale scale, double if (maxLabel <= 0) return 0; double band = availWidth / Math.Max(1, scale.Ticks.Count); - if (maxLabel <= band * 0.95) return 0; // fits horizontally + // Labels that fit still honour MinRotation, which is how a caller asks for slanted labels + // unconditionally rather than only once they collide. + if (maxLabel <= band * 0.95) return Math.Clamp(tk.MinRotation, 0, tk.MaxRotation); // Rotate just enough so the horizontal footprint fits the band, clamped to limits. double ratio = Math.Clamp(band / maxLabel, -1, 1); @@ -237,48 +386,113 @@ private static double ComputeIndexLabelRotation(BitChartAxisScale scale, double return Math.Clamp(needed, Math.Max(tk.MinRotation, 1), tk.MaxRotation); } - private double ReserveValueAxis(BitChartAxisScale scale) + /// The width an axis drawn along the left or right edge needs for its ticks and title. + private double ReserveAxisWidth(BitChartAxisScale scale) { + // Memoized per render, for two reasons: measuring every tick label of every axis again on each + // of the later calls adds up, and the drawing code must place the axis using exactly the width + // that was reserved for it - even though the tick set is rebuilt once the plot area is known. + if (_widthReserve.TryGetValue(scale, out var cached)) return cached; + var o = scale.Options; - double maxLabel = 0; - if (o.Ticks.Display) - foreach (var t in scale.Ticks) - maxLabel = Math.Max(maxLabel, EstimateTextWidth(t.Label, o.Ticks.Font.Size)); - double w = maxLabel + o.Ticks.Padding; - if (o.Grid.DrawTicks) w += o.Grid.TickLength; - if (o.Title.Display) w += o.Title.Font.LineHeightPx + o.Title.Padding.Horizontal; - return w + 2; + double w; + if (!ScaleVisible(o)) + { + w = 0; + } + else + { + double maxLabel = 0; + if (o.Ticks.Display && !o.Ticks.Mirror) + foreach (var t in scale.Ticks) + maxLabel = Math.Max(maxLabel, EstimateTextWidth(t.Label, o.Ticks.Font.Size)); + w = maxLabel + o.Ticks.Padding; + if (o.Grid.DrawTicks) w += o.Grid.TickLength; + if (o.Title.Display) w += o.Title.Font.LineHeightPx + o.Title.Padding.Horizontal; + w += 2; + } + _widthReserve[scale] = w; + return w; } + private readonly Dictionary _widthReserve = new(); + // ---- extents ---- + /// + /// The stack bucket a dataset belongs to. It has to match how the drawing code groups: bars stack + /// among bars (DrawBars) and lines among lines (DrawStackedAreas), each within its own Stack id. + /// + private string StackKey(BitChartDataset ds) + => (EffectiveType(ds) == BitChartType.Bar ? "bar:" : "line:") + (ds.Stack ?? "default"); + + /// + /// The extent of a percentage-stacked axis. Each category is normalized against the sum of the + /// absolute values in it, so the positive share of a category can never exceed 100 and its negative + /// share can never fall below -100. Taking a flat 0..100 would clip every negative contribution out + /// of the plot, so the real shares are measured and the axis is sized to what is actually drawn. + /// + private (double, double) ComputePercentStackExtent(string axisId) + { + var totals = new Dictionary<(string stack, int index), double>(); + var shares = new Dictionary<(string stack, int index, int sign), double>(); + + for (int d = 0; d < _data.Datasets.Count; d++) + { + var ds = _data.Datasets[d]; + if (ds.YAxisID != axisId || IsHidden(d, ds)) continue; + string key = StackKey(ds); + for (int i = 0; i < ds.Data.Count; i++) + { + if (ds.Data[i] is not { } v) continue; + totals[(key, i)] = totals.GetValueOrDefault((key, i), 0) + Math.Abs(v); + int sign = v >= 0 ? 1 : -1; + shares[(key, i, sign)] = shares.GetValueOrDefault((key, i, sign), 0) + v; + } + } + + double min = 0, max = 0; + foreach (var ((stack, index, _), sum) in shares) + { + double total = totals.GetValueOrDefault((stack, index), 0); + if (total <= 0) continue; + double pct = sum / total * 100; + min = Math.Min(min, pct); + max = Math.Max(max, pct); + } + + // Nothing (or nothing but zeroes) to measure: keep the classic full-height 0..100 axis. + if (min == 0 && max == 0) return (0, 100); + return (min, max); + } + private (double, double) ComputeValueExtent(string axisId) { double min = double.PositiveInfinity, max = double.NegativeInfinity; - var scaleOpts = _options.Scales[axisId]; + var scaleOpts = Scale(axisId); if (scaleOpts.Stacked && scaleOpts.Stacked100) - return (0, 100); + return ComputePercentStackExtent(axisId); if (scaleOpts.Stacked) { - var posSums = new Dictionary(); - var negSums = new Dictionary(); + // Sums are tracked per (stack group, index, sign) so two independent stacks standing side by + // side do not add up into one oversized range. + var sums = new Dictionary<(string stack, int index, int sign), double>(); for (int d = 0; d < _data.Datasets.Count; d++) { var ds = _data.Datasets[d]; if (ds.YAxisID != axisId || IsHidden(d, ds)) continue; + string key = StackKey(ds); for (int i = 0; i < ds.Data.Count; i++) { - double v = ds.Data[i] ?? 0; - var bucket = v >= 0 ? posSums : negSums; - bucket[i] = (bucket.TryGetValue(i, out var s) ? s : 0) + v; + if (ds.Data[i] is not { } v) continue; + int sign = v >= 0 ? 1 : -1; + sums[(key, i, sign)] = sums.GetValueOrDefault((key, i, sign), 0) + v; } } - foreach (var v in posSums.Values) { min = Math.Min(min, v); max = Math.Max(max, v); } - foreach (var v in negSums.Values) { min = Math.Min(min, v); max = Math.Max(max, v); } - if (posSums.Count > 0) min = Math.Min(min, 0); - if (negSums.Count > 0) max = Math.Max(max, 0); + foreach (var v in sums.Values) { min = Math.Min(min, v); max = Math.Max(max, v); } + if (sums.Count > 0) { min = Math.Min(min, 0); max = Math.Max(max, 0); } // Floating bars / point datasets on a stacked axis still contribute their own extent. for (int d = 0; d < _data.Datasets.Count; d++) @@ -305,6 +519,7 @@ private double ReserveValueAxis(BitChartAxisScale scale) foreach (var p in pts) { min = Math.Min(min, p.Y); max = Math.Max(max, p.Y); } else foreach (var v in ds.Data) if (v is { } val) { min = Math.Min(min, val); max = Math.Max(max, val); } + if (ds.Base is { } b) { min = Math.Min(min, b); max = Math.Max(max, b); } } } @@ -321,6 +536,9 @@ private double ReserveValueAxis(BitChartAxisScale scale) if (IsHidden(d, ds)) continue; if (ds.Points is { } pts) foreach (var p in pts) { min = Math.Min(min, p.X); max = Math.Max(max, p.X); } + // A value dataset on a numeric index axis is laid out by its index, so that is its extent. + // Without this it would contribute nothing and the axis would fall back to a bare 0..1. + else if (ds.Data.Count > 0) { min = Math.Min(min, 0); max = Math.Max(max, ds.Data.Count - 1); } } if (double.IsInfinity(min)) { min = 0; max = 1; } return (min, max); diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.CartesianDraw.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.CartesianDraw.cs index ac6b9f0e4f..1381b36532 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.CartesianDraw.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.CartesianDraw.cs @@ -4,27 +4,53 @@ namespace Bit.BlazorUI; public sealed partial class BitChartRenderer { private void DrawGrid(BitChartScene scene, BitChartArea plot, BitChartAxisScale indexScale, - Dictionary valueScales, List leftAxes, List rightAxes) + List leftAxes, List rightAxes, List centerAxes) { bool firstValueGridDrawn = false; - // Value axes. - double leftX = plot.Left; + // Value axes. In a vertical chart they run down the sides and stack outwards by their width; + // with a vertical index axis (horizontal bars) they run along the bottom/top and stack by height. + double near = IsVertical ? plot.Left : plot.Bottom; foreach (var axis in leftAxes) { - DrawValueAxis(scene, plot, axis, leftX, isRight: false, drawArea: !firstValueGridDrawn); + DrawValueAxis(scene, plot, axis, near, isRight: false, drawArea: !firstValueGridDrawn); firstValueGridDrawn = true; - leftX -= ReserveValueAxis(axis); + near += IsVertical ? -ReserveAxisWidth(axis) : ReserveAxisHeight(axis); } - double rightX = plot.Right; + double far = IsVertical ? plot.Right : plot.Top; foreach (var axis in rightAxes) { - DrawValueAxis(scene, plot, axis, rightX, isRight: true, drawArea: false); - rightX += ReserveValueAxis(axis); + DrawValueAxis(scene, plot, axis, far, isRight: true, drawArea: false); + far += IsVertical ? ReserveAxisWidth(axis) : -ReserveAxisHeight(axis); } - // Index axis. - DrawIndexAxis(scene, plot, indexScale); + // Axes pinned to the other axis' zero line, drawn inside the plot. + foreach (var axis in centerAxes) + DrawValueAxis(scene, plot, axis, ZeroLineAlong(indexScale, plot), isRight: false, drawArea: false); + + // Index axis. It too can sit on the value axis' zero line rather than along the edge. + double indexBaseline = PositionOf(indexScale.Options, IsVertical ? BitChartPosition.Bottom : BitChartPosition.Left) == BitChartPosition.Center + ? ZeroLineAcross(leftAxes.Concat(rightAxes).Concat(centerAxes).FirstOrDefault(), plot) + : IsVertical ? plot.Bottom : plot.Left; + DrawIndexAxis(scene, plot, indexScale, indexBaseline); + } + + /// The pixel along the index axis where its value is zero, clamped into the plot. + private double ZeroLineAlong(BitChartAxisScale indexScale, BitChartArea plot) + { + double p = indexScale.Type == BitChartScaleType.Category + ? indexScale.PixelForIndex(0, HasBars()) + : indexScale.PixelFor(0); + return IsVertical ? Math.Clamp(p, plot.Left, plot.Right) : Math.Clamp(p, plot.Top, plot.Bottom); + } + + /// The pixel across the plot where the given value axis reads zero, clamped into the plot. + private double ZeroLineAcross(BitChartAxisScale? valueScale, BitChartArea plot) + { + double fallback = IsVertical ? plot.Bottom : plot.Left; + if (valueScale is null) return fallback; + double p = valueScale.PixelFor(0); + return IsVertical ? Math.Clamp(p, plot.Top, plot.Bottom) : Math.Clamp(p, plot.Left, plot.Right); } private void DrawValueAxis(BitChartScene scene, BitChartArea plot, BitChartAxisScale axis, double axisPos, bool isRight, bool drawArea) @@ -55,18 +81,24 @@ private void DrawValueAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS if (g.DrawTicks) scene.Background.Add(new BitChartSvgLine { - X1 = isRight ? axisPos : axisPos, Y1 = y, + X1 = axisPos, Y1 = y, X2 = isRight ? axisPos + g.TickLength : axisPos - g.TickLength, Y2 = y, Stroke = g.TickColor, StrokeWidth = g.LineWidth }); if (tk.Display) + { + // Mirrored labels sit inside the plot area, on the other side of the axis line. + double lx = tk.Mirror + ? isRight ? axisPos - g.TickLength - tk.Padding : axisPos + g.TickLength + tk.Padding + : isRight ? axisPos + g.TickLength + tk.Padding : axisPos - g.TickLength - tk.Padding; + bool anchorStart = tk.Mirror ? !isRight : isRight; scene.Background.Add(new BitChartSvgText { - X = isRight ? axisPos + g.TickLength + tk.Padding : axisPos - g.TickLength - tk.Padding, - Y = y, Text = tick.Label, Fill = tk.Color, + X = lx, Y = y + TickLabelOffset(tk), Text = tick.Label, Fill = tk.Color, FontFamily = tk.Font.Family, FontSize = tk.Font.Size, FontWeight = tk.Font.Weight, - Anchor = isRight ? "start" : "end", Baseline = "central" + Anchor = anchorStart ? "start" : "end", Baseline = "central" }); + } } else { @@ -85,19 +117,20 @@ private void DrawValueAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS Stroke = Math.Abs(tick.Value) < 1e-9 ? (g.ZeroLineColor ?? g.Color) : g.Color, StrokeWidth = g.LineWidth, Dash = BitChartSvg.Dash(g.BorderDash) }); + int dir = isRight ? -1 : 1; // "right" means the far edge, which is the top here if (g.DrawTicks) scene.Background.Add(new BitChartSvgLine { - X1 = x, Y1 = plot.Bottom, X2 = x, Y2 = plot.Bottom + g.TickLength, + X1 = x, Y1 = axisPos, X2 = x, Y2 = axisPos + dir * g.TickLength, Stroke = g.TickColor, StrokeWidth = g.LineWidth }); if (tk.Display) scene.Background.Add(new BitChartSvgText { - X = x, Y = plot.Bottom + g.TickLength + tk.Padding + tk.Font.Size * 0.5, + X = x + TickLabelOffset(tk), Y = axisPos + dir * (g.TickLength + tk.Padding + tk.Font.Size * 0.5), Text = tick.Label, Fill = tk.Color, FontFamily = tk.Font.Family, FontSize = tk.Font.Size, FontWeight = tk.Font.Weight, - Anchor = "middle", Baseline = "central" + Anchor = TickAnchor(tk), Baseline = "central" }); } } @@ -107,7 +140,7 @@ private void DrawValueAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS if (IsVertical) scene.Background.Add(new BitChartSvgText { - X = isRight ? axisPos + ReserveValueAxis(axis) - o.Title.Font.Size : axisPos - ReserveValueAxis(axis) + o.Title.Font.Size, + X = isRight ? axisPos + ReserveAxisWidth(axis) - o.Title.Font.Size : axisPos - ReserveAxisWidth(axis) + o.Title.Font.Size, Y = plot.CenterY, Text = o.Title.Text, Fill = o.Title.Color, FontFamily = o.Title.Font.Family, FontSize = o.Title.Font.Size, FontWeight = o.Title.Font.Weight, Anchor = "middle", Baseline = "central", Rotation = isRight ? 90 : -90 @@ -115,7 +148,11 @@ private void DrawValueAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS else scene.Background.Add(new BitChartSvgText { - X = plot.CenterX, Y = plot.Bottom + ReserveValueAxis(axis) - o.Title.Font.Size * 0.3, + // A value axis under a horizontal-bar chart reserves height, not width - and along + // the far (top) edge it reserves that height upwards, the same direction its ticks + // are drawn in. + X = plot.CenterX, + Y = axisPos + (isRight ? -1 : 1) * (ReserveAxisHeight(axis) - o.Title.Font.Size * 0.3), Text = o.Title.Text, Fill = o.Title.Color, FontFamily = o.Title.Font.Family, FontSize = o.Title.Font.Size, FontWeight = o.Title.Font.Weight, Anchor = "middle", Baseline = "central" @@ -129,14 +166,14 @@ private void DrawValueAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS if (IsVertical) scene.Background.Add(new BitChartSvgLine { X1 = axisPos, Y1 = plot.Top, X2 = axisPos, Y2 = plot.Bottom, Stroke = b.Color, StrokeWidth = b.Width, Dash = BitChartSvg.Dash(b.Dash) }); else - scene.Background.Add(new BitChartSvgLine { X1 = plot.Left, Y1 = plot.Bottom, X2 = plot.Right, Y2 = plot.Bottom, Stroke = b.Color, StrokeWidth = b.Width, Dash = BitChartSvg.Dash(b.Dash) }); + scene.Background.Add(new BitChartSvgLine { X1 = plot.Left, Y1 = axisPos, X2 = plot.Right, Y2 = axisPos, Stroke = b.Color, StrokeWidth = b.Width, Dash = BitChartSvg.Dash(b.Dash) }); } } - private void DrawIndexAxis(BitChartScene scene, BitChartArea plot, BitChartAxisScale axis) + private void DrawIndexAxis(BitChartScene scene, BitChartArea plot, BitChartAxisScale axis, double baseline) { var o = axis.Options; - if (!o.Display) return; + if (!ScaleVisible(o)) return; var g = o.Grid; var tk = o.Ticks; bool centered = HasBars(); @@ -158,7 +195,7 @@ private void DrawIndexAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS if (g.DrawTicks) scene.Background.Add(new BitChartSvgLine { - X1 = px, Y1 = plot.Bottom, X2 = px, Y2 = plot.Bottom + g.TickLength, + X1 = px, Y1 = baseline, X2 = px, Y2 = baseline + g.TickLength, Stroke = g.TickColor, StrokeWidth = g.LineWidth }); if (tk.Display) @@ -166,10 +203,10 @@ private void DrawIndexAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS double rot = axis.LabelRotation; scene.Background.Add(new BitChartSvgText { - X = px, Y = plot.Bottom + g.TickLength + tk.Padding + (Math.Abs(rot) > 1e-3 ? tk.Font.Size * 0.35 : tk.Font.Size * 0.7), + X = px + TickLabelOffset(tk), Y = baseline + g.TickLength + tk.Padding + (Math.Abs(rot) > 1e-3 ? tk.Font.Size * 0.35 : tk.Font.Size * 0.7), Text = tick.Label, Fill = tk.Color, FontFamily = tk.Font.Family, FontSize = tk.Font.Size, FontWeight = tk.Font.Weight, - Anchor = Math.Abs(rot) > 1e-3 ? "end" : "middle", Baseline = "auto", Rotation = rot + Anchor = Math.Abs(rot) > 1e-3 ? "end" : TickAnchor(tk), Baseline = "auto", Rotation = rot }); } } @@ -184,15 +221,16 @@ private void DrawIndexAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS if (g.DrawTicks) scene.Background.Add(new BitChartSvgLine { - X1 = plot.Left, Y1 = px, X2 = plot.Left - g.TickLength, Y2 = px, + X1 = baseline, Y1 = px, X2 = baseline - g.TickLength, Y2 = px, Stroke = g.TickColor, StrokeWidth = g.LineWidth }); if (tk.Display) scene.Background.Add(new BitChartSvgText { - X = plot.Left - g.TickLength - tk.Padding, Y = px, Text = tick.Label, Fill = tk.Color, + X = tk.Mirror ? baseline + g.TickLength + tk.Padding : baseline - g.TickLength - tk.Padding, + Y = px + TickLabelOffset(tk), Text = tick.Label, Fill = tk.Color, FontFamily = tk.Font.Family, FontSize = tk.Font.Size, FontWeight = tk.Font.Weight, - Anchor = "end", Baseline = "central" + Anchor = tk.Mirror ? "start" : "end", Baseline = "central" }); } } @@ -222,19 +260,30 @@ private void DrawIndexAxis(BitChartScene scene, BitChartArea plot, BitChartAxisS { var b = o.Border; if (IsVertical) - scene.Background.Add(new BitChartSvgLine { X1 = plot.Left, Y1 = plot.Bottom, X2 = plot.Right, Y2 = plot.Bottom, Stroke = b.Color, StrokeWidth = b.Width, Dash = BitChartSvg.Dash(b.Dash) }); + scene.Background.Add(new BitChartSvgLine { X1 = plot.Left, Y1 = baseline, X2 = plot.Right, Y2 = baseline, Stroke = b.Color, StrokeWidth = b.Width, Dash = BitChartSvg.Dash(b.Dash) }); else - scene.Background.Add(new BitChartSvgLine { X1 = plot.Left, Y1 = plot.Top, X2 = plot.Left, Y2 = plot.Bottom, Stroke = b.Color, StrokeWidth = b.Width, Dash = BitChartSvg.Dash(b.Dash) }); + scene.Background.Add(new BitChartSvgLine { X1 = baseline, Y1 = plot.Top, X2 = baseline, Y2 = plot.Bottom, Stroke = b.Color, StrokeWidth = b.Width, Dash = BitChartSvg.Dash(b.Dash) }); } } private bool HasBars() => _data.Datasets.Where((d, i) => !IsHidden(i, d)).Any(d => EffectiveType(d) == BitChartType.Bar); + /// Text anchor for a tick label honoring . + private static string TickAnchor(BitChartTickOptions tk) => tk.Align switch + { + BitChartAlign.Start => "start", + BitChartAlign.End => "end", + _ => "middle" + }; + + /// Extra pixel shift applied to a tick label along the axis. + private static double TickLabelOffset(BitChartTickOptions tk) => tk.LabelOffset; + /// Draws a secondary x-axis (ticks/labels/border/title) at a baseline outside the plot. private void DrawSecondaryXAxis(BitChartScene scene, BitChartArea plot, BitChartAxisScale axis, double baselineY, bool atBottom) { var o = axis.Options; - if (!o.Display) return; + if (!ScaleVisible(o)) return; var g = o.Grid; var tk = o.Ticks; int dir = atBottom ? 1 : -1; @@ -254,7 +303,7 @@ private void DrawSecondaryXAxis(BitChartScene scene, BitChartArea plot, BitChart X = px, Y = baselineY + dir * (g.TickLength + tk.Padding + tk.Font.Size * (atBottom ? 0.7 : 0.1)), Text = tick.Label, Fill = tk.Color, FontFamily = tk.Font.Family, FontSize = tk.Font.Size, FontWeight = tk.Font.Weight, - Anchor = "middle", Baseline = atBottom ? "auto" : "auto" + Anchor = "middle", Baseline = "auto" }); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Circular.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Circular.cs index c8b10715de..fcd7d69ee0 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Circular.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Circular.cs @@ -1,5 +1,3 @@ -using System.Globalization; - namespace Bit.BlazorUI; public sealed partial class BitChartRenderer @@ -18,36 +16,53 @@ private void RenderCircular(BitChartScene scene) return; } - var datasets = _data.Datasets.Where((d, i) => !d.Hidden && !_state.IsDatasetHidden(i)).ToList(); + var datasets = _data.Datasets + .Select((d, i) => (ds: d, index: i)) + .Where(t => !t.ds.Hidden && !_state.IsDatasetHidden(t.index)) + .ToList(); if (datasets.Count == 0) return; double rotation = _options.RotationDegrees * Math.PI / 180; double circumference = _options.CircumferenceDegrees * Math.PI / 180; - double cutout = _config.Type == BitChartType.Doughnut ? _options.CutoutPercentage / 100.0 : 0; + // Clamped: a cutout at or past 100% would put the inner radius outside the outer one and + // invert every arc. + double cutout = _config.Type == BitChartType.Doughnut + ? Math.Clamp(_options.CutoutPercentage / 100.0, 0, 0.95) + : 0; double ringOuter = maxR; double ringInner = maxR * cutout; - double ringThickness = (ringOuter - ringInner) / datasets.Count; + + // Ring thickness is shared out by weight, so one dataset can be given a thicker band than the + // others. With every weight left at its default of 1 this is an even split, exactly as before. + double ringSpan = ringOuter - ringInner; + double totalWeight = datasets.Sum(t => Math.Max(0, t.ds.Weight)); + if (totalWeight <= 0) totalWeight = datasets.Count; var ctx = new BitChartPluginContext { - Scene = scene, Config = _config, IsCartesian = false, + Scene = scene, Config = _config, IsCartesian = false, HiddenDatasets = _state.HiddenDatasets, CenterX = cx, CenterY = cy, InnerRadius = ringInner, OuterRadius = ringOuter }; foreach (var plugin in _options.Plugins.Custom) plugin.BeforeDatasetsDraw(ctx); + double ringCursor = ringOuter; for (int ri = 0; ri < datasets.Count; ri++) { - var ds = datasets[ri]; - int dsIndex = _data.Datasets.IndexOf(ds); - double outer = ringOuter - ringThickness * ri; - double inner = outer - ringThickness; + var (ds, dsIndex) = datasets[ri]; + double outer = ringCursor; + double inner = outer - ringSpan * (Math.Max(0, ds.Weight) / totalWeight); + ringCursor = inner; double total = 0; for (int i = 0; i < ds.Data.Count; i++) if (!_state.IsIndexHidden(i) && ds.Data[i] is { } v) total += Math.Abs(v); if (total <= 0) continue; + double arcBorderWidth = ResolveBorderWidth(ds, _config.Type, dsIndex); + // The angular half-gap that realizes the requested pixel spacing at the outer edge. + double halfGap = ds.SpacingArc > 0 && outer > 0 ? ds.SpacingArc / 2 / outer : 0; + double angle = rotation; for (int i = 0; i < ds.Data.Count; i++) { @@ -57,44 +72,76 @@ private void RenderCircular(BitChartScene scene) double a1 = angle + slice; angle = a1; + // Apply the inter-arc gap without letting it swallow a very thin slice. + double gap = Math.Min(halfGap, Math.Max(0, (a1 - a0) / 4)); + double d0 = a0 + gap, d1 = a1 - gap; + string bg = ResolveBackground(ds, dsIndex, i, true); - bool active = _state.Active == (dsIndex, i); - double offset = ds.Offset + (active ? 6 : 0); + string arcBorder = ResolveBorder(ds, dsIndex, i, true, v, fallbackToBackground: false); + string borderColor = HasExplicitBorder(ds) ? arcBorder : _options.Elements.ArcBorderColor; double mid = (a0 + a1) / 2; - double ox = active || ds.Offset > 0 ? Math.Cos(mid) * offset : 0; - double oy = active || ds.Offset > 0 ? Math.Sin(mid) * offset : 0; + double ox = ds.Offset > 0 ? Math.Cos(mid) * ds.Offset : 0; + double oy = ds.Offset > 0 ? Math.Sin(mid) * ds.Offset : 0; var path = new BitChartSvgPath { - D = ArcPath(cx + ox, cy + oy, inner, outer, a0, a1), - Fill = active ? BitChartColorUtil.Adjust(bg, 0.08) : bg, - Stroke = _options.Elements.ArcBorderColor, - StrokeWidth = _options.Elements.ArcBorderWidth + D = ArcPath(cx + ox, cy + oy, inner, outer, d0, d1, ds.BorderRadius), + Fill = bg, + Stroke = borderColor, + StrokeWidth = arcBorderWidth + }; + + // Hover: the arc is pushed further out and repainted with the hover colors. Precomputed + // so hovering an arc never costs a full re-layout. + double hoverOffset = ds.Offset + ds.HoverOffset; + string hoverBg = ds.HoverBackgroundColor + ?? (ds.BackgroundColorFn is not null ? ResolveBackground(ds, dsIndex, i, true, v, active: true) : BitChartColorUtil.Adjust(bg, 0.08)); + var hoverPath = new BitChartSvgPath + { + D = ArcPath(cx + Math.Cos(mid) * hoverOffset, cy + Math.Sin(mid) * hoverOffset, inner, outer, d0, d1, ds.BorderRadius), + Fill = hoverBg, + Stroke = ds.HoverBorderColor ?? borderColor, + StrokeWidth = ds.HoverBorderWidth ?? arcBorderWidth }; - double pct = total > 0 ? Math.Abs(v) / total * 100 : 0; + double pct = Math.Abs(v) / total * 100; + string label = i < _data.Labels.Count ? _data.Labels[i] : ds.Label ?? ""; + string text = _options.Plugins.Tooltip.Callbacks.Label is not null || _options.Plugins.Tooltip.LabelFormatter is not null + ? BuildItemText(ds, dsIndex, i, v, bg) + : $"{FormatNumber(v, "0.##")} ({FormatNumber(pct, "0.#")}%)"; + scene.Elements.Add(new BitChartDataElement { Shape = path, + HoverShape = hoverPath, DatasetIndex = dsIndex, DataIndex = i, - CenterX = cx + Math.Cos(mid) * (inner + outer) / 2, - CenterY = cy + Math.Sin(mid) * (inner + outer) / 2, + // Offset like the arc itself, so an exploded slice keeps its hover shape and + // its tooltip over the slice rather than over where it would sit unexploded. + CenterX = cx + ox + Math.Cos(mid) * (inner + outer) / 2, + CenterY = cy + oy + Math.Sin(mid) * (inner + outer) / 2, Value = v, - SeriesLabel = i < _data.Labels.Count ? _data.Labels[i] : ds.Label, + SeriesLabel = label, Tooltip = new BitChartTooltipInfo { Title = i < _data.Labels.Count ? _data.Labels[i] : null, - AnchorX = cx + Math.Cos(mid) * outer, - AnchorY = cy + Math.Sin(mid) * outer, - Items = { new BitChartTooltipItem { Color = bg, Text = $"{v.ToString("0.##", CultureInfo.InvariantCulture)} ({pct.ToString("0.#", CultureInfo.InvariantCulture)}%)" } } + AnchorX = cx + ox + Math.Cos(mid) * outer, + AnchorY = cy + oy + Math.Sin(mid) * outer, + Items = { new BitChartTooltipItem { Color = bg, Text = text } } } }); if (_options.Plugins.DataLabels.Display && slice > 0.15) { - double lr = (inner + outer) / 2; - AddDataLabel(scene, v, cx + Math.Cos(mid) * lr, cy + Math.Sin(mid) * lr + 4, dsIndex, i); + var dl = _options.Plugins.DataLabels; + double lr = dl.Anchor switch + { + BitChartAlign.Start => inner + (inner > 0 ? 0 : 0.35 * outer), + BitChartAlign.End => outer, + _ => (inner + outer) / 2 + }; + lr += AlignShift(dl, 1); + AddDataLabel(scene, v, cx + ox + Math.Cos(mid) * lr, cy + oy + Math.Sin(mid) * lr, dsIndex, i); } } } @@ -104,9 +151,13 @@ private void RenderCircular(BitChartScene scene) private void RenderPolarArea(BitChartScene scene, double cx, double cy, double maxR) { - var ds = _data.Datasets.FirstOrDefault(); - if (ds is null) return; - int dsIndex = 0; + // The polar scale is shared by the whole chart, so it draws the first *visible* dataset - + // hiding it from the legend has to empty the chart rather than leave it drawn. + int dsIndex = -1; + for (int k = 0; k < _data.Datasets.Count; k++) + if (!IsHidden(k, _data.Datasets[k])) { dsIndex = k; break; } + if (dsIndex < 0) return; + var ds = _data.Datasets[dsIndex]; int n = ds.Data.Count; if (n == 0) return; @@ -115,58 +166,67 @@ private void RenderPolarArea(BitChartScene scene, double cx, double cy, double m if (!_state.IsIndexHidden(i) && ds.Data[i] is { } v) maxVal = Math.Max(maxVal, v); if (maxVal <= 0) maxVal = 1; - var rOpts0 = _options.Scales["r"]; - // Reserve room for perimeter point labels. - if (rOpts0.PointLabels.Display && _data.Labels.Count > 0) - maxR -= rOpts0.PointLabels.Font.Size + rOpts0.PointLabels.Padding + 6; + var rOpts = Scale(RadialScaleId); + double sliceAngle = 2 * Math.PI / n; + double rotation = (_options.RotationDegrees + rOpts.StartAngle) * Math.PI / 180; + + // Reserve room for perimeter point labels, measured the same way the radar chart does. + if (PointLabelsVisible(rOpts) && _data.Labels.Count > 0) + maxR -= PointLabelReserve(rOpts.PointLabels, _data.Labels, n, rotation + sliceAngle / 2, sliceAngle); if (maxR <= 0) return; - var rScale = new BitChartAxisScale(_options.Scales["r"], horizontal: false); + var rScale = new BitChartAxisScale(rOpts, horizontal: false) { Culture = Culture }; rScale.SetDataRange(0, maxVal); rScale.SetPixelRange(0, maxR); + var pctx = new BitChartPluginContext + { + Scene = scene, Config = _config, IsCartesian = false, HiddenDatasets = _state.HiddenDatasets, + CenterX = cx, CenterY = cy, InnerRadius = 0, OuterRadius = maxR + }; + foreach (var plugin in _options.Plugins.Custom) plugin.BeforeDatasetsDraw(pctx); + // Radial grid circles. - var rOpts = _options.Scales["r"]; - if (rOpts.Display && rOpts.Grid.Display) + if (ScaleVisible(rOpts) && rOpts.Grid.Display) { foreach (var t in rScale.Ticks) { double rr = t.Pixel; if (rr <= 0) continue; scene.Background.Add(new BitChartSvgCircle { Cx = cx, Cy = cy, R = rr, Fill = "none", Stroke = rOpts.Grid.Color, StrokeWidth = rOpts.Grid.LineWidth }); - if (rOpts.Ticks.Display) - { - if (rOpts.ShowLabelBackdrop) - { - double w = BitChartTextMeasure.Width(t.Label, rOpts.Ticks.Font.Size) + 4; - scene.Background.Add(new BitChartSvgRect { X = cx + 2, Y = cy - rr - rOpts.Ticks.Font.Size * 0.55, Width = w, Height = rOpts.Ticks.Font.Size + 2, Fill = rOpts.BackdropColor }); - } - scene.Background.Add(new BitChartSvgText { X = cx + 4, Y = cy - rr, Text = t.Label, Fill = rOpts.Ticks.Color, FontSize = rOpts.Ticks.Font.Size, FontFamily = rOpts.Ticks.Font.Family, Anchor = "start", Baseline = "central" }); - } + if (rOpts.Ticks.Display) AddRadialTickLabel(scene, rOpts, cx, cy, rr, t.Label); } } - double rotation = (_options.RotationDegrees + rOpts.StartAngle) * Math.PI / 180; - double sliceAngle = 2 * Math.PI / n; + double arcBorderWidth = ResolveBorderWidth(ds, BitChartType.PolarArea, dsIndex); double angle = rotation; for (int i = 0; i < n; i++) { if (_state.IsIndexHidden(i) || ds.Data[i] is not { } v) { angle += sliceAngle; continue; } double a0 = angle, a1 = angle + sliceAngle; angle = a1; - double r = rScale.PixelFor(v); + double halfGap = ds.SpacingArc > 0 && maxR > 0 ? Math.Min(ds.SpacingArc / 2 / maxR, sliceAngle / 4) : 0; + // A value below the scale minimum maps to a negative radius, which would draw the wedge + // inside out through the center; a polar wedge simply has no length there. + double r = Math.Clamp(rScale.PixelFor(v), 0, maxR); string bg = ResolveBackground(ds, dsIndex, i, true); - bool active = _state.Active == (dsIndex, i); var path = new BitChartSvgPath { - D = ArcPath(cx, cy, 0, r, a0, a1), - Fill = BitChartColorUtil.WithAlpha(active ? BitChartColorUtil.Adjust(bg, -0.1) : bg, 0.7), - Stroke = bg, StrokeWidth = _options.Elements.ArcBorderWidth + D = ArcPath(cx, cy, 0, r, a0 + halfGap, a1 - halfGap, ds.BorderRadius), + Fill = BitChartColorUtil.WithAlpha(bg, 0.7), + Stroke = bg, StrokeWidth = arcBorderWidth + }; + var hoverPath = new BitChartSvgPath + { + D = ArcPath(cx, cy, 0, r, a0 + halfGap, a1 - halfGap, ds.BorderRadius), + Fill = BitChartColorUtil.WithAlpha(ds.HoverBackgroundColor ?? BitChartColorUtil.Adjust(bg, -0.1), 0.85), + Stroke = ds.HoverBorderColor ?? bg, StrokeWidth = ds.HoverBorderWidth ?? arcBorderWidth }; double mid = (a0 + a1) / 2; scene.Elements.Add(new BitChartDataElement { Shape = path, + HoverShape = hoverPath, DatasetIndex = dsIndex, DataIndex = i, CenterX = cx + Math.Cos(mid) * r / 2, @@ -178,13 +238,28 @@ private void RenderPolarArea(BitChartScene scene, double cx, double cy, double m Title = i < _data.Labels.Count ? _data.Labels[i] : null, AnchorX = cx + Math.Cos(mid) * r, AnchorY = cy + Math.Sin(mid) * r, - Items = { new BitChartTooltipItem { Color = bg, Text = v.ToString("0.##", CultureInfo.InvariantCulture) } } + Items = { new BitChartTooltipItem { Color = bg, Text = BuildItemText(ds, dsIndex, i, v, bg) } } } }); + + if (_options.Plugins.DataLabels.Display) + { + // Placed the way a doughnut places its labels, with the wedge running from the center + // out to its own radius. + var dl = _options.Plugins.DataLabels; + double lr = dl.Anchor switch + { + BitChartAlign.Start => 0.35 * r, + BitChartAlign.End => r, + _ => r / 2 + }; + lr += AlignShift(dl, 1); + AddDataLabel(scene, v, cx + Math.Cos(mid) * lr, cy + Math.Sin(mid) * lr, dsIndex, i); + } } // Perimeter category (point) labels. - if (rOpts.PointLabels.Display && _data.Labels.Count > 0) + if (PointLabelsVisible(rOpts) && _data.Labels.Count > 0) { var pl = rOpts.PointLabels; double a = rotation; @@ -206,6 +281,121 @@ private void RenderPolarArea(BitChartScene scene, double cx, double cy, double m }); } } + + foreach (var plugin in _options.Plugins.Custom) plugin.AfterDatasetsDraw(pctx); + } + + /// + /// How much room the perimeter labels of a radial chart need. Each label is measured and projected + /// onto its own angle - a label at the side sticks out by its full width, one at the top or bottom + /// only by half - so the plot shrinks by what the longest label actually needs and no more. + /// + private static double PointLabelReserve(BitChartPointLabelOptions pl, List labels, int count, + double startAngle, double angleStep) + { + double needed = pl.Font.LineHeightPx; + for (int i = 0; i < count && i < labels.Count; i++) + { + double a = startAngle + angleStep * i; + double w = BitChartTextMeasure.Width(pl.Callback?.Invoke(labels[i], i) ?? labels[i], pl.Font.Size, pl.Font.Weight); + double cos = Math.Abs(Math.Cos(a)); + // Near-vertical labels are centered on the spoke, so only half of them sticks out. + double horizontal = cos * (cos < 0.3 ? w / 2 : w); + double vertical = Math.Abs(Math.Sin(a)) * pl.Font.LineHeightPx; + needed = Math.Max(needed, horizontal + vertical); + } + return needed + pl.Padding + 6; + } + + /// Draws one radial tick label, optionally over a backdrop so it stays readable on the grid. + private static void AddRadialTickLabel(BitChartScene scene, BitChartScaleOptions rOpts, double cx, double cy, double rr, string label) + { + if (rOpts.ShowLabelBackdrop) + { + double w = BitChartTextMeasure.Width(label, rOpts.Ticks.Font.Size) + 4; + scene.Background.Add(new BitChartSvgRect + { + X = cx + 2, Y = cy - rr - rOpts.Ticks.Font.Size * 0.55, + Width = w, Height = rOpts.Ticks.Font.Size + 2, Fill = rOpts.BackdropColor + }); + } + scene.Background.Add(new BitChartSvgText + { + X = cx + 4, Y = cy - rr, Text = label, Fill = rOpts.Ticks.Color, + FontSize = rOpts.Ticks.Font.Size, FontFamily = rOpts.Ticks.Font.Family, Anchor = "start", Baseline = "central" + }); + } + + /// + /// Builds an arc path, rounding its corners when the dataset asks for it. A zero radius - or an arc + /// that has come round to a full circle, which has no corners to round - falls through to the plain + /// path, so nothing changes for the charts that never set one. + /// + private static string ArcPath(double cx, double cy, double inner, double outer, double a0, double a1, double cornerRadius) + => cornerRadius > 0 && a1 - a0 < 2 * Math.PI - 1e-3 + ? RoundedArcPath(cx, cy, inner, outer, a0, a1, cornerRadius) + : ArcPath(cx, cy, inner, outer, a0, a1); + + /// + /// Builds an arc/ring path whose corners are rounded, mirroring Chart.js's arc borderRadius. + /// Each corner is cut back along both edges that meet there and reconnected with a quadratic curve + /// through the true corner point, which reads as a fillet at any radius and cannot self-intersect: + /// the radius is clamped to half the ring's thickness and half its arc length first. A pie arc has + /// no inner edge, so only the two outer corners are rounded - its point sits at the center. + /// + private static string RoundedArcPath(double cx, double cy, double inner, double outer, double a0, double a1, double radius) + { + double span = a1 - a0; + double thickness = outer - Math.Max(0, inner); + if (span <= 0 || thickness <= 0) return ArcPath(cx, cy, inner, outer, a0, a1); + + // The outer edge is the longest, so it decides how much of a corner there is room for. + double r = Math.Min(radius, thickness / 2); + r = Math.Min(r, span * outer / 2); + if (r <= 0.01) return ArcPath(cx, cy, inner, outer, a0, a1); + + (double X, double Y) P(double rad, double ang) => (cx + rad * Math.Cos(ang), cy + rad * Math.Sin(ang)); + static string M((double X, double Y) p) => $"M {BitChartSvg.N(p.X)} {BitChartSvg.N(p.Y)} "; + static string L((double X, double Y) p) => $"L {BitChartSvg.N(p.X)} {BitChartSvg.N(p.Y)} "; + static string Q((double X, double Y) c, (double X, double Y) p) + => $"Q {BitChartSvg.N(c.X)} {BitChartSvg.N(c.Y)}, {BitChartSvg.N(p.X)} {BitChartSvg.N(p.Y)} "; + string A(double rad, (double X, double Y) p, int sweep, double from, double to) + => $"A {BitChartSvg.N(rad)} {BitChartSvg.N(rad)} 0 {(Math.Abs(to - from) > Math.PI ? 1 : 0)} {sweep} {BitChartSvg.N(p.X)} {BitChartSvg.N(p.Y)} "; + + // Angular size of the corner on each edge: the same arc length r, so a tighter radius covers + // more angle on the inner edge than on the outer one. + double outerCut = r / outer; + double oa0 = a0 + outerCut, oa1 = a1 - outerCut; + if (oa1 < oa0) { double mid = (a0 + a1) / 2; oa0 = oa1 = mid; } + + var sb = new System.Text.StringBuilder(); + if (inner <= 0.01) + { + // Pie wedge: center → rounded outer start → outer arc → rounded outer end → center. + sb.Append(M((cx, cy))); + sb.Append(L(P(outer - r, a0))); + sb.Append(Q(P(outer, a0), P(outer, oa0))); + sb.Append(A(outer, P(outer, oa1), 1, oa0, oa1)); + sb.Append(Q(P(outer, a1), P(outer - r, a1))); + sb.Append('Z'); + return sb.ToString(); + } + + double innerCut = Math.Min(r / inner, span / 2); + double ia0 = a0 + innerCut, ia1 = a1 - innerCut; + if (ia1 < ia0) { double mid = (a0 + a1) / 2; ia0 = ia1 = mid; } + + sb.Append(M(P(inner + r, a0))); + sb.Append(L(P(outer - r, a0))); + sb.Append(Q(P(outer, a0), P(outer, oa0))); + sb.Append(A(outer, P(outer, oa1), 1, oa0, oa1)); + sb.Append(Q(P(outer, a1), P(outer - r, a1))); + sb.Append(L(P(inner + r, a1))); + sb.Append(Q(P(inner, a1), P(inner, ia1))); + sb.Append(A(inner, P(inner, ia0), 0, ia1, ia0)); + sb.Append(Q(P(inner, a0), P(inner + r, a0))); + sb.Append('Z'); + return sb.ToString(); } /// Builds an SVG arc/ring path. Handles full circles. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Radar.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Radar.cs index 65ca0864a8..3c07711408 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Radar.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Radar.cs @@ -9,11 +9,15 @@ private void RenderRadar(BitChartScene scene) int n = _data.Labels.Count; if (n == 0) return; - var rOpts = _options.Scales["r"]; + var rOpts = Scale(RadialScaleId); - // Reserve room for the point labels around the perimeter. - double labelPad = rOpts.PointLabels.Display - ? rOpts.PointLabels.Font.Size + rOpts.PointLabels.Padding + 14 + double angleStepForLabels = 2 * Math.PI / n; + double startForLabels = -Math.PI / 2 + rOpts.StartAngle * Math.PI / 180; + + // Reserve room for the point labels around the perimeter. The labels are measured rather than + // assumed: a long category name reaching out sideways would otherwise be cut off by the chart box. + double labelPad = PointLabelsVisible(rOpts) + ? PointLabelReserve(rOpts.PointLabels, _data.Labels, n, startForLabels, angleStepForLabels) : 8; double cx = area.CenterX; double cy = area.CenterY; @@ -29,15 +33,22 @@ private void RenderRadar(BitChartScene scene) } if (double.IsInfinity(max)) max = 1; - var rScale = new BitChartAxisScale(rOpts, horizontal: false); + var rScale = new BitChartAxisScale(rOpts, horizontal: false) { Culture = Culture }; rScale.SetDataRange(rOpts.BeginAtZero ? 0 : min, max); rScale.SetPixelRange(0, maxR); - double angleStep = 2 * Math.PI / n; - double start = -Math.PI / 2 + rOpts.StartAngle * Math.PI / 180; + double angleStep = angleStepForLabels; + double start = startForLabels; + + var pctx = new BitChartPluginContext + { + Scene = scene, Config = _config, IsCartesian = false, HiddenDatasets = _state.HiddenDatasets, + CenterX = cx, CenterY = cy, InnerRadius = 0, OuterRadius = maxR + }; + foreach (var plugin in _options.Plugins.Custom) plugin.BeforeDatasetsDraw(pctx); // Grid rings (polygons by default, circles when grid.circular). - if (rOpts.Display && rOpts.Grid.Display) + if (ScaleVisible(rOpts) && rOpts.Grid.Display) { foreach (var t in rScale.Ticks) { @@ -66,7 +77,7 @@ private void RenderRadar(BitChartScene scene) double a = start + angleStep * i; double ex = cx + Math.Cos(a) * maxR; double ey = cy + Math.Sin(a) * maxR; - if (rOpts.Display && rOpts.AngleLines) + if (ScaleVisible(rOpts) && rOpts.AngleLines) scene.Background.Add(new BitChartSvgLine { X1 = cx, Y1 = cy, X2 = ex, Y2 = ey, @@ -74,7 +85,7 @@ private void RenderRadar(BitChartScene scene) Dash = BitChartSvg.Dash(rOpts.AngleLineDash) }); - if (rOpts.PointLabels.Display) + if (PointLabelsVisible(rOpts)) { var pl = rOpts.PointLabels; double lx = cx + Math.Cos(a) * (maxR + pl.Padding + 4); @@ -91,18 +102,13 @@ private void RenderRadar(BitChartScene scene) } // Radial tick labels (with optional backdrop). - if (rOpts.Display && rOpts.Ticks.Display) + if (ScaleVisible(rOpts) && rOpts.Ticks.Display) { foreach (var t in rScale.Ticks) { double rr = t.Pixel; if (rr <= 0.01) continue; - if (rOpts.ShowLabelBackdrop) - { - double w = BitChartTextMeasure.Width(t.Label, rOpts.Ticks.Font.Size) + 4; - scene.Background.Add(new BitChartSvgRect { X = cx + 2, Y = cy - rr - rOpts.Ticks.Font.Size * 0.55, Width = w, Height = rOpts.Ticks.Font.Size + 2, Fill = rOpts.BackdropColor }); - } - scene.Background.Add(new BitChartSvgText { X = cx + 4, Y = cy - rr, Text = t.Label, Fill = rOpts.Ticks.Color, FontSize = rOpts.Ticks.Font.Size, FontFamily = rOpts.Ticks.Font.Family, Anchor = "start", Baseline = "central" }); + AddRadialTickLabel(scene, rOpts, cx, cy, rr, t.Label); } } @@ -112,7 +118,7 @@ private void RenderRadar(BitChartScene scene) var ds = _data.Datasets[d]; if (IsHidden(d, ds)) continue; string border = ResolveBorder(ds, d, 0, false); - string fill = ds.FillColor ?? BitChartColorUtil.WithAlpha(border, 0.2); + string fill = ResolveFill(scene, ds, border); var verts = new List<(double x, double y, int di)>(); for (int i = 0; i < n && i < ds.Data.Count; i++) @@ -131,11 +137,14 @@ private void RenderRadar(BitChartScene scene) Points = verts.Select(p => (p.x, p.y)).ToList(), Fill = ds.Fill != BitChartFillMode.None ? fill : "none", Stroke = border, - StrokeWidth = ds.BorderWidth <= 1 ? 2 : ds.BorderWidth + StrokeWidth = ResolveBorderWidth(ds, BitChartType.Radar, d) }); - foreach (var p in verts) - AddPoint(scene, ds, d, p.di, p.x, p.y, ds.Data[p.di] ?? 0, Math.Max(3, ds.PointRadius), border); + if (ds.PointStyle != BitChartPointStyle.None) + foreach (var p in verts) + AddPoint(scene, ds, d, p.di, p.x, p.y, ds.Data[p.di] ?? 0, Math.Max(3, ResolvePointRadius(ds)), border); } + + foreach (var plugin in _options.Plugins.Custom) plugin.AfterDatasetsDraw(pctx); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Series.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Series.cs index 813f16f910..84abb87539 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Series.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.Series.cs @@ -9,22 +9,43 @@ private void DrawBars(BitChartScene scene, BitChartArea plot, BitChartAxisScale { if (barItems.Count == 0) return; - // Build slots: stacked datasets share a slot, others get their own. + // Build slots: stacked datasets share a slot, others get their own. A dataset with + // Grouped = false opts out of the layout entirely and spans the whole category band. var slotKeys = new List(); var dsSlot = new Dictionary(); foreach (var (ds, i) in barItems) { + if (!ds.Grouped) { dsSlot[i] = -1; continue; } bool stacked = valueScales[ds.YAxisID].Options.Stacked; string key = stacked ? $"stack:{ds.Stack ?? "default"}:{ds.YAxisID}" : $"ds:{i}"; - if (!slotKeys.Contains(key)) slotKeys.Add(key); - dsSlot[i] = slotKeys.IndexOf(key); + int at = slotKeys.IndexOf(key); + if (at < 0) { slotKeys.Add(key); at = slotKeys.Count - 1; } + dsSlot[i] = at; } int slotCount = Math.Max(1, slotKeys.Count); double band = indexScale.BandWidth(); var first = barItems[0].d; double categorySize = band * first.CategoryPercentage; - double slotSize = categorySize / slotCount; + + // Per-index slot layout. With SkipNull the slots whose datasets have no value at an index are + // dropped, so the remaining bars widen to fill the category instead of leaving a hole. + bool skipNull = barItems.Any(t => t.d.SkipNull); + var allSlots = Enumerable.Range(0, slotKeys.Count).ToList(); + var perIndexSlots = new Dictionary>(); + List SlotsAt(int di) + { + if (!skipNull) return allSlots; + if (perIndexSlots.TryGetValue(di, out var cached)) return cached; + var live = new List(); + for (int k = 0; k < slotKeys.Count; k++) + { + bool any = barItems.Any(t => dsSlot[t.i] == k && HasValueAt(t.d, di)); + if (any) live.Add(k); + } + perIndexSlots[di] = live; + return live; + } var stackOffset = new Dictionary<(int slot, int di, int sign), double>(); @@ -40,18 +61,25 @@ private void DrawBars(BitChartScene scene, BitChartArea plot, BitChartAxisScale stack100Totals[(slot, di)] = stack100Totals.GetValueOrDefault((slot, di), 0) + Math.Abs(v); } + // The value-axis baseline every bar grows out of. Taken from the first bar dataset (they share + // the axis) so the group entry animation always scales out of the axis line - not out of + // whatever the last drawn bar happened to sit on. + var firstScale = valueScales[first.YAxisID]; + double axisBaseValue = first.Base ?? Math.Clamp(0, Math.Min(firstScale.Min, firstScale.Max), Math.Max(firstScale.Min, firstScale.Max)); + scene.BarBaseline = firstScale.PixelFor(axisBaseValue); + foreach (var (ds, i) in barItems) { var vScale = valueScales[ds.YAxisID]; bool stacked = vScale.Options.Stacked; bool stacked100 = stacked && vScale.Options.Stacked100; int slot = dsSlot[i]; - double barSize = slotSize * ds.BarPercentage; - if (ds.BarThickness is { } bt) barSize = bt; - if (ds.MaxBarThickness is { } mbt) barSize = Math.Min(barSize, mbt); + var barType = EffectiveType(ds); int count = ds.Count; string? patternFill = ds.BackgroundPattern is { } pat ? RegisterPattern(scene, pat) : null; + double borderWidth = ResolveBorderWidth(ds, BitChartType.Bar, i); + for (int di = 0; di < count; di++) { double baseVal, topVal, tooltipVal; @@ -81,139 +109,254 @@ private void DrawBars(BitChartScene scene, BitChartArea plot, BitChartAxisScale } else { - baseVal = Math.Clamp(0, Math.Min(vScale.Min, vScale.Max), Math.Max(vScale.Min, vScale.Max)); + baseVal = ds.Base ?? Math.Clamp(0, Math.Min(vScale.Min, vScale.Max), Math.Max(vScale.Min, vScale.Max)); topVal = value; } } + // Slot geometry for this index. + double slotSize, slotCenter; double centerAlong = indexIsCategory ? indexScale.PixelForIndex(di, true) : indexScale.PixelFor(di); - double slotCenter = centerAlong - categorySize / 2 + slot * slotSize + slotSize / 2; + if (slot < 0) + { + slotSize = categorySize; + slotCenter = centerAlong; + } + else + { + var live = SlotsAt(di); + int ordinal = live.IndexOf(slot); + int liveCount = Math.Max(1, live.Count); + if (ordinal < 0) { ordinal = slot; liveCount = slotCount; } + slotSize = categorySize / liveCount; + slotCenter = centerAlong - categorySize / 2 + ordinal * slotSize + slotSize / 2; + } + + double barSize = slotSize * ds.BarPercentage; + if (ds.BarThickness is { } bt) barSize = bt; + if (ds.MaxBarThickness is { } mbt) barSize = Math.Min(barSize, mbt); string bg = ResolveBackground(ds, i, di, false, tooltipVal); - string border = ResolveBorder(ds, i, di, false, tooltipVal); + string border = ResolveBorder(ds, i, di, false, tooltipVal, fallbackToBackground: true); if (patternFill is not null) bg = patternFill; - int signFinal = topVal >= baseVal ? 1 : -1; double inflate = ds.InflateAmount ?? 0; + double minLen = ds.MinBarLength ?? 1; BitChartSvgRect rect; - double cx, cy, originX, originY; + double cx, cy; + + // Which way the bar grows is a question about pixels, not about the sign of the value: a + // reversed axis puts a positive bar below its baseline. Everything downstream - the + // skipped edge, the rounded corners, the data label - follows this, not the raw sign. + int signFinal; if (IsVertical) { double yBase = vScale.PixelFor(baseVal); double yTop = vScale.PixelFor(topVal); + signFinal = yTop <= yBase ? 1 : -1; + // A minimum length still grows away from the baseline rather than straddling it. + double height = Math.Max(minLen, Math.Abs(yBase - yTop)); + double y = signFinal >= 0 ? yBase - height : yBase; rect = new BitChartSvgRect { X = slotCenter - barSize / 2 - inflate, - Y = Math.Min(yBase, yTop) - inflate, + Y = y - inflate, Width = barSize + inflate * 2, - Height = Math.Max(1, Math.Abs(yBase - yTop)) + inflate * 2, - Fill = bg, - Rx = ds.BorderRadius + Height = height + inflate * 2, + Fill = bg }; - cx = slotCenter; cy = yTop; - originX = slotCenter; originY = yBase; // grow from the baseline - scene.BarBaseline = yBase; + cx = slotCenter; cy = signFinal >= 0 ? rect.Y : rect.Y + rect.Height; } else { double xBase = vScale.PixelFor(baseVal); double xTop = vScale.PixelFor(topVal); + signFinal = xTop >= xBase ? 1 : -1; + double width = Math.Max(minLen, Math.Abs(xBase - xTop)); + double x = signFinal >= 0 ? xBase : xBase - width; rect = new BitChartSvgRect { - X = Math.Min(xBase, xTop) - inflate, + X = x - inflate, Y = slotCenter - barSize / 2 - inflate, - Width = Math.Max(1, Math.Abs(xBase - xTop)) + inflate * 2, + Width = width + inflate * 2, Height = barSize + inflate * 2, - Fill = bg, - Rx = ds.BorderRadius + Fill = bg }; - cx = xTop; cy = slotCenter; - originX = xBase; originY = slotCenter; // grow from the baseline - scene.BarBaseline = xBase; + cx = signFinal >= 0 ? rect.X + rect.Width : rect.X; cy = slotCenter; } - // Per-corner radius emits a rounded path instead of a plain rect. - BitChartSvgNode shapeNode = rect; - bool perCorner = ds.BorderRadiusCorners is { } c0 && - (c0.TopLeft > 0 || c0.TopRight > 0 || c0.BottomRight > 0 || c0.BottomLeft > 0); - - // Effective corner radii (explicit per-corner, else uniform BorderRadius) used so the - // border follows the same rounded outline as the fill. - BitChartBorderRadiusCorners? roundedCorners = null; - if (perCorner) roundedCorners = ds.BorderRadiusCorners!.Value; - else if (ds.BorderRadius > 0) roundedCorners = ds.BorderRadius; // implicit double -> all corners - var skip = ResolveSkip(isRange ? BitChartBorderSkipped.None : ds.BorderSkipped, IsVertical, signFinal); - if (perCorner) + // Effective corner radii. Chart.js only rounds the corners that are not adjacent to the + // skipped (baseline) edge, so a bar with a uniform BorderRadius rounds its tip and keeps + // a flat foot on the axis. BorderSkipped.None opts back into rounding all four corners. + BitChartBorderRadiusCorners? roundedCorners = ds.BorderRadiusCorners + ?? (ds.BorderRadius > 0 ? CornersForSkip(ds.BorderRadius, skip) : null); + bool rounded = roundedCorners is { } rc0 && + (rc0.TopLeft > 0 || rc0.TopRight > 0 || rc0.BottomRight > 0 || rc0.BottomLeft > 0); + + BitChartSvgNode shapeNode = rect; + if (rounded) shapeNode = new BitChartSvgPath { - D = RoundedRectPath(rect.X, rect.Y, rect.Width, rect.Height, ds.BorderRadiusCorners!.Value), + D = RoundedRectPath(rect.X, rect.Y, rect.Width, rect.Height, roundedCorners!.Value), Fill = bg }; // Border honoring borderSkipped. Rounded bars get a matching rounded border path so the // stroke follows the corner radius instead of cutting square corners. BitChartSvgNode? borderNode = null; - if (ds.BorderWidth > 0) + if (borderWidth > 0) { - if (roundedCorners is { } rc) + if (rounded) { borderNode = new BitChartSvgPath { - D = RoundedBarBorderPath(rect.X, rect.Y, rect.Width, rect.Height, rc, skip), - Fill = "none", Stroke = border, StrokeWidth = ds.BorderWidth + D = RoundedBarBorderPath(rect.X, rect.Y, rect.Width, rect.Height, roundedCorners!.Value, skip), + Fill = "none", Stroke = border, StrokeWidth = borderWidth }; } else if (skip == BitChartBorderSkipped.None) { rect.Stroke = border; - rect.StrokeWidth = ds.BorderWidth; + rect.StrokeWidth = borderWidth; } else { // Drawn as part of the element so it animates together with the fill. borderNode = new BitChartSvgPath { - D = BarBorderPath(rect, skip), Fill = "none", Stroke = border, StrokeWidth = ds.BorderWidth + D = BarBorderPath(rect, skip), Fill = "none", Stroke = border, StrokeWidth = borderWidth }; } } - string text = isRange - ? $"{(ds.Label is null ? "" : ds.Label + ": ")}[{baseVal.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}, {topVal.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}]" - : BuildItemText(ds, i, di, tooltipVal, bg); + // Hover appearance, precomputed so hovering never triggers a re-layout. + string hoverBg = ds.HoverBackgroundColor + ?? (ds.BackgroundColorFn is not null ? ResolveBackground(ds, i, di, false, tooltipVal, active: true) : BitChartColorUtil.Adjust(bg, -0.08)); + if (patternFill is not null) hoverBg = bg; + string hoverBorder = ds.HoverBorderColor ?? border; + double hoverBorderWidth = ds.HoverBorderWidth ?? borderWidth; + BitChartSvgNode hoverNode = rounded + ? new BitChartSvgPath + { + D = RoundedRectPath(rect.X, rect.Y, rect.Width, rect.Height, roundedCorners!.Value), + Fill = hoverBg, Stroke = hoverBorderWidth > 0 ? hoverBorder : null, StrokeWidth = hoverBorderWidth + } + : new BitChartSvgRect + { + X = rect.X, Y = rect.Y, Width = rect.Width, Height = rect.Height, + Fill = hoverBg, Stroke = hoverBorderWidth > 0 ? hoverBorder : null, StrokeWidth = hoverBorderWidth + }; - // Each bar grows from its own baseline in the correct direction (size change, not a slide). - string enterAnim = IsVertical ? "bc-scale-y" : "bc-scale-x"; + string text = isRange + ? $"{(ds.Label is null ? "" : ds.Label + ": ")}[{FormatNumber(baseVal, "0.##")}, {FormatNumber(topVal, "0.##")}]" + : BuildItemText(ds, i, di, tooltipVal, bg) + ErrorSuffix(ds, di); scene.Elements.Add(new BitChartDataElement { Shape = shapeNode, BorderShape = borderNode, - EnterAnim = enterAnim, - AnimOriginX = originX, - AnimOriginY = originY, + HoverShape = hoverNode, DatasetIndex = i, DataIndex = di, - CenterX = cx, - CenterY = cy, + CenterX = IsVertical ? cx : (rect.X + rect.Width / 2), + CenterY = IsVertical ? (rect.Y + rect.Height / 2) : cy, Value = tooltipVal, SeriesLabel = ds.Label, Tooltip = new BitChartTooltipInfo { Title = di < _data.Labels.Count ? _data.Labels[di] : null, + // Anchored at the bar's tip, so a negative bar points at its own end and the + // tooltip flips below it rather than hovering over the baseline. AnchorX = cx, - AnchorY = IsVertical ? Math.Min(rect.Y, cy) : cy, + AnchorY = cy, Items = { new BitChartTooltipItem { Color = bg, Text = text } } } }); - if (!isRange) AddDataLabel(scene, tooltipVal, cx, IsVertical ? rect.Y - 4 : cx, i, di); + if (!isRange) + { + // Centered on the bar's own tip, so a grouped bar keeps its whisker over itself. + // A percentage stack rescales every value, which would leave the interval - still in + // the original units - pointing at the wrong place, so it is left out there. + if (!stacked100) AddErrorBar(scene, ds, di, topVal, slotCenter, vScale); + AddBarDataLabel(scene, tooltipVal, rect, signFinal, i, di); + } } } } + /// The error bar attached to one index, if the dataset has one there. + private static BitChartErrorBar? ErrorAt(BitChartDataset ds, int dataIndex) + => ds.ErrorData is { } errors && dataIndex >= 0 && dataIndex < errors.Count ? errors[dataIndex] : null; + + /// + /// The interval a tooltip names after the value, so the uncertainty is readable and not only + /// visible. A symmetric interval reads as a single plus-minus; an asymmetric one names both arms. + /// + private string ErrorSuffix(BitChartDataset ds, int dataIndex) + { + if (ErrorAt(ds, dataIndex) is not { } e) return ""; + double minus = Math.Abs(e.Minus), plus = Math.Abs(e.Plus); + if (minus <= 0 && plus <= 0) return ""; + return e.IsSymmetric + ? $" ±{FormatNumber(plus, "0.##")}" + : $" +{FormatNumber(plus, "0.##")}/-{FormatNumber(minus, "0.##")}"; + } + + /// + /// Draws the whisker for one value: a line spanning the interval along the value axis, with a cap at + /// each end. It goes in the foreground so it stays legible over the bar or point it belongs to. + /// + private void AddErrorBar(BitChartScene scene, BitChartDataset ds, int dataIndex, double value, + double centerAlongIndexAxis, BitChartAxisScale valueScale) + { + if (ErrorAt(ds, dataIndex) is not { } e) return; + double minus = Math.Abs(e.Minus), plus = Math.Abs(e.Plus); + if (minus <= 0 && plus <= 0) return; + + double low = valueScale.PixelFor(value - minus); + double high = valueScale.PixelFor(value + plus); + string color = ds.ErrorBarColor ?? "var(--bit-clr-fg-pri, #1A1A1A)"; + double width = ds.ErrorBarWidth; + double cap = Math.Max(0, ds.ErrorBarCapWidth) / 2; + double c = centerAlongIndexAxis; + + if (IsVertical) + { + scene.Foreground.Add(new BitChartSvgLine { X1 = c, Y1 = low, X2 = c, Y2 = high, Stroke = color, StrokeWidth = width }); + if (cap <= 0) return; + scene.Foreground.Add(new BitChartSvgLine { X1 = c - cap, Y1 = low, X2 = c + cap, Y2 = low, Stroke = color, StrokeWidth = width }); + scene.Foreground.Add(new BitChartSvgLine { X1 = c - cap, Y1 = high, X2 = c + cap, Y2 = high, Stroke = color, StrokeWidth = width }); + } + else + { + scene.Foreground.Add(new BitChartSvgLine { X1 = low, Y1 = c, X2 = high, Y2 = c, Stroke = color, StrokeWidth = width }); + if (cap <= 0) return; + scene.Foreground.Add(new BitChartSvgLine { X1 = low, Y1 = c - cap, X2 = low, Y2 = c + cap, Stroke = color, StrokeWidth = width }); + scene.Foreground.Add(new BitChartSvgLine { X1 = high, Y1 = c - cap, X2 = high, Y2 = c + cap, Stroke = color, StrokeWidth = width }); + } + } + + private static bool HasValueAt(BitChartDataset ds, int di) + { + // A dataset carrying ranges still falls back to its plain values where a range is missing, + // which is exactly how DrawBars picks the value it draws. + if (ds.RangeData is { } rd && di < rd.Count && rd[di].HasValue) return true; + return di < ds.Data.Count && ds.Data[di].HasValue; + } + + /// Rounds only the corners that are not adjacent to the skipped edge (Chart.js semantics). + private static BitChartBorderRadiusCorners CornersForSkip(double r, BitChartBorderSkipped skip) => skip switch + { + BitChartBorderSkipped.Bottom => new(r, r, 0, 0), + BitChartBorderSkipped.Top => new(0, 0, r, r), + BitChartBorderSkipped.Left => new(0, r, r, 0), + BitChartBorderSkipped.Right => new(r, 0, 0, r), + _ => new(r, r, r, r) + }; + /// Resolves Start/End border-skip to a concrete edge based on orientation and sign. private static BitChartBorderSkipped ResolveSkip(BitChartBorderSkipped s, bool vertical, int sign) => s switch { @@ -317,39 +460,49 @@ private void DrawLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale return; } + // A single fill paint is registered per dataset so a series broken by nulls does not add a + // duplicate gradient/pattern definition for every segment. + string? fillPaint = null; for (int di = 0; di < ds.Data.Count; di++) { if (ds.Data[di] is not { } v) { - if (!ds.SpanGaps) { FlushLine(scene, plot, vScale, ds, dsIndex, pts, indexScale, indexIsCategory, centered); pts.Clear(); } + if (!ds.SpanGaps) + { + fillPaint = FlushLine(scene, plot, vScale, ds, dsIndex, pts, indexScale, indexIsCategory, centered, fillPaint); + pts.Clear(); + } continue; } double x = indexIsCategory ? indexScale.PixelForIndex(di, centered) : indexScale.PixelFor(di); double y = vScale.PixelFor(v); pts.Add((x, y, di, v)); } - FlushLine(scene, plot, vScale, ds, dsIndex, pts, indexScale, indexIsCategory, centered); + FlushLine(scene, plot, vScale, ds, dsIndex, pts, indexScale, indexIsCategory, centered, fillPaint); } - private void FlushLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale vScale, BitChartDataset ds, int dsIndex, + private string? FlushLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale vScale, BitChartDataset ds, int dsIndex, List<(double x, double y, int di, double v)> pts, - BitChartAxisScale? indexScale = null, bool indexIsCategory = false, bool centered = false) + BitChartAxisScale? indexScale = null, bool indexIsCategory = false, bool centered = false, string? fillPaint = null) { - if (pts.Count == 0) return; + if (pts.Count == 0) return fillPaint; var dec = _options.Plugins.Decimation; if (dec.Enabled && pts.Count > dec.Threshold && dec.Samples >= 2 && dec.Samples < pts.Count) pts = BitChartDecimation.Lttb(pts, dec.Samples); string border = ResolveBorder(ds, dsIndex, 0, false); + double lineWidth = ResolveBorderWidth(ds, BitChartType.Line, dsIndex); + double tension = ResolveTension(ds); var xy = pts.Select(p => (p.x, p.y)).ToList(); - string d = BuildPath(xy, ds.Tension, ds.Stepped, ds.CubicInterpolationMode); + string d = BuildPath(xy, tension, ds.Stepped, ds.CubicInterpolationMode); bool progressive = _options.Animation.Animate && _options.Animation.Progressive; if (progressive) scene.ProgressiveDraw = true; if (ds.Fill != BitChartFillMode.None && ds.ShowLine) { + fillPaint ??= ResolveFill(scene, ds, border); string? fillD = null; // Fill to another dataset's line (range area). @@ -358,7 +511,7 @@ private void FlushLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale { var target = ComputeLinePoints(_data.Datasets[ti], indexScale, vScale, indexIsCategory, centered); if (target.Count > 0) - fillD = AreaBetween(xy, ds.Tension, ds.Stepped, target.Select(p => (p.x, p.y)).ToList()); + fillD = AreaBetween(xy, tension, ds.Stepped, target.Select(p => (p.x, p.y)).ToList()); } if (fillD is null) @@ -374,7 +527,7 @@ private void FlushLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale fillD = d + $" L {BitChartSvg.N(xy[^1].x)} {BitChartSvg.N(baseY)} L {BitChartSvg.N(xy[0].x)} {BitChartSvg.N(baseY)} Z"; } - scene.Series.Add(new BitChartSvgPath { D = fillD, Fill = ResolveFill(scene, ds, border, plot), Stroke = null, AnimateFade = progressive }); + scene.Series.Add(new BitChartSvgPath { D = fillD, Fill = fillPaint, Stroke = null, AnimateFade = progressive }); } if (ds.ShowLine) @@ -382,20 +535,20 @@ private void FlushLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale if (ds.Segment is { } seg) { // Draw each consecutive segment with its own resolved style. - double defWidth = ds.BorderWidth <= 1 ? _options.Elements.LineBorderWidth : ds.BorderWidth; for (int k = 0; k < pts.Count - 1; k++) { var a = pts[k]; var b = pts[k + 1]; var sctx = new BitChartSegmentContext(a.di, b.di, a.v, b.v); string color = seg.BorderColor?.Invoke(sctx) ?? border; - double width = seg.BorderWidth?.Invoke(sctx) ?? defWidth; + double width = seg.BorderWidth?.Invoke(sctx) ?? lineWidth; var dash = seg.BorderDash?.Invoke(sctx); scene.Series.Add(new BitChartSvgPath { D = $"M {BitChartSvg.N(a.x)} {BitChartSvg.N(a.y)} L {BitChartSvg.N(b.x)} {BitChartSvg.N(b.y)}", Fill = "none", Stroke = color, StrokeWidth = width, Dash = dash is null ? "" : BitChartSvg.Dash(dash), + DashOffset = ds.BorderDashOffset, LineCap = ds.BorderCapStyle, LineJoin = ds.BorderJoinStyle, AnimateFade = progressive }); @@ -406,9 +559,9 @@ private void FlushLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale bool dashed = ds.BorderDash is { Count: > 0 }; scene.Series.Add(new BitChartSvgPath { - D = d, Fill = "none", Stroke = border, - StrokeWidth = ds.BorderWidth <= 1 ? _options.Elements.LineBorderWidth : ds.BorderWidth, - Dash = BitChartSvg.Dash(ds.BorderDash), LineCap = ds.BorderCapStyle, LineJoin = ds.BorderJoinStyle, + D = d, Fill = "none", Stroke = border, StrokeWidth = lineWidth, + Dash = BitChartSvg.Dash(ds.BorderDash), DashOffset = ds.BorderDashOffset, + LineCap = ds.BorderCapStyle, LineJoin = ds.BorderJoinStyle, // Draw-on reveals the stroke left to right; dashed strokes can't (dasharray is in use), so they fade. AnimateDraw = progressive && !dashed, AnimateFade = progressive && dashed @@ -416,17 +569,25 @@ private void FlushLine(BitChartScene scene, BitChartArea plot, BitChartAxisScale } } - if (ds.PointRadius > 0 || ds.PointStyle != BitChartPointStyle.None) + if (ds.PointStyle != BitChartPointStyle.None) foreach (var p in pts) - AddPoint(scene, ds, dsIndex, p.di, p.x, p.y, p.v, ds.PointRadius, border); + AddPoint(scene, ds, dsIndex, p.di, p.x, p.y, p.v, ResolvePointRadius(ds), border, valueScale: vScale); + + return fillPaint; } - /// Resolves an area fill paint (pattern, gradient, explicit color, or translucent border). - private string ResolveFill(BitChartScene scene, BitChartDataset ds, string border, BitChartArea plot) + /// + /// Resolves an area fill paint. A pattern or gradient wins; then the dataset's explicit + /// , then its + /// (which is what Chart.js paints an area with), and finally a translucent tint of the line color. + /// + private string ResolveFill(BitChartScene scene, BitChartDataset ds, string border) { if (ds.BackgroundPattern is { } pat) return RegisterPattern(scene, pat); if (ds.FillGradient is { Stops.Count: > 0 } g) return RegisterGradient(scene, g); - return ds.FillColor ?? BitChartColorUtil.WithAlpha(border, 0.2); + if (!string.IsNullOrEmpty(ds.FillColor)) return ds.FillColor!; + if (!string.IsNullOrEmpty(ds.BackgroundColor)) return ds.BackgroundColor!; + return BitChartColorUtil.WithAlpha(border, 0.2); } /// Computes the pixel polyline for a dataset's line (nulls skipped). @@ -453,6 +614,7 @@ private string ResolveFill(BitChartScene scene, BitChartDataset ds, string borde private static string AreaBetween(List<(double x, double y)> top, double tension, BitChartSteppedLine stepped, List<(double x, double y)> bottom) { + if (bottom.Count == 0) return BuildPath(top, tension, stepped); var sb = new StringBuilder(BuildPath(top, tension, stepped)); var rev = new List<(double x, double y)>(bottom); rev.Reverse(); @@ -473,25 +635,42 @@ private void DrawStackedAreas(BitChartScene scene, BitChartArea plot, BitChartAx foreach (var group in items.GroupBy(t => (t.d.YAxisID, t.d.Stack ?? "default"))) { var vScale = valueScales[group.Key.YAxisID]; + bool stacked100 = vScale.Options.Stacked100; var cumulative = new Dictionary(); + // 100% stacking normalizes every index against the group's absolute total. + var totals = new Dictionary(); + if (stacked100) + foreach (var (ds, _) in group) + for (int di = 0; di < ds.Data.Count; di++) + if (ds.Data[di] is { } v) + totals[di] = totals.GetValueOrDefault(di, 0) + Math.Abs(v); + foreach (var (ds, i) in group) { - var topPts = new List<(double x, double y, int di, double v)>(); + var topPts = new List<(double x, double y, int di, double v, double top)>(); var basePts = new List<(double x, double y)>(); for (int di = 0; di < ds.Data.Count; di++) { - if (ds.Data[di] is not { } v) continue; + if (ds.Data[di] is not { } raw) continue; + double v = raw; + if (stacked100) + { + double total = totals.GetValueOrDefault(di, 0); + if (total > 0) v = v / total * 100; + } double baseVal = cumulative.GetValueOrDefault(di, 0); double topVal = baseVal + v; cumulative[di] = topVal; double x = indexIsCategory ? indexScale.PixelForIndex(di, centered) : indexScale.PixelFor(di); - topPts.Add((x, vScale.PixelFor(topVal), di, topVal)); + topPts.Add((x, vScale.PixelFor(topVal), di, raw, topVal)); basePts.Add((x, vScale.PixelFor(baseVal))); } if (topPts.Count == 0) continue; string border = ResolveBorder(ds, i, 0, false); + double lineWidth = ResolveBorderWidth(ds, BitChartType.Line, i); + double tension = ResolveTension(ds); var topXy = topPts.Select(p => (p.x, p.y)).ToList(); bool progressive = _options.Animation.Animate && _options.Animation.Progressive; @@ -500,22 +679,28 @@ private void DrawStackedAreas(BitChartScene scene, BitChartArea plot, BitChartAx if (ds.Fill != BitChartFillMode.None) { - string fillD = AreaBetween(topXy, ds.Tension, ds.Stepped, basePts); - scene.Series.Add(new BitChartSvgPath { D = fillD, Fill = ResolveFill(scene, ds, border, plot), Stroke = null, AnimateFade = progressive }); + string fillD = AreaBetween(topXy, tension, ds.Stepped, basePts); + scene.Series.Add(new BitChartSvgPath { D = fillD, Fill = ResolveFill(scene, ds, border), Stroke = null, AnimateFade = progressive }); } scene.Series.Add(new BitChartSvgPath { - D = BuildPath(topXy, ds.Tension, ds.Stepped), Fill = "none", Stroke = border, - StrokeWidth = ds.BorderWidth <= 1 ? _options.Elements.LineBorderWidth : ds.BorderWidth, - Dash = BitChartSvg.Dash(ds.BorderDash), LineCap = ds.BorderCapStyle, LineJoin = ds.BorderJoinStyle, + D = BuildPath(topXy, tension, ds.Stepped, ds.CubicInterpolationMode), Fill = "none", Stroke = border, + StrokeWidth = lineWidth, + Dash = BitChartSvg.Dash(ds.BorderDash), DashOffset = ds.BorderDashOffset, + LineCap = ds.BorderCapStyle, LineJoin = ds.BorderJoinStyle, AnimateDraw = progressive && !dashed, AnimateFade = progressive && dashed }); - if (ds.PointRadius > 0) + if (ds.PointStyle != BitChartPointStyle.None) foreach (var p in topPts) - AddPoint(scene, ds, i, p.di, p.x, p.y, ds.Data[p.di] ?? 0, ds.PointRadius, border); + // The marker sits at the cumulative top, so the whisker is centered there too + // rather than at the raw value it is drawn from. A percentage stack rescales + // every value, which would leave the interval - still in the original units - + // pointing at the wrong place, so it is left out there. + AddPoint(scene, ds, i, p.di, p.x, p.y, p.v, ResolvePointRadius(ds), border, + valueScale: stacked100 ? null : vScale, errorValue: p.top); } } } @@ -530,46 +715,50 @@ private void DrawScatter(BitChartScene scene, BitChartArea plot, BitChartAxisSca var p = points[di]; double x = indexScale.PixelFor(p.X); double y = vScale.PixelFor(p.Y); - double r = bubble ? (p.R ?? 5) : ds.PointRadius <= 3 ? 4 : ds.PointRadius; - AddPoint(scene, ds, dsIndex, di, x, y, p.Y, r, border, p.X); + double r = bubble ? (p.R ?? 5) : Math.Max(4, ResolvePointRadius(ds)); + AddPoint(scene, ds, dsIndex, di, x, y, p.Y, r, border, p.X, vScale); } } private void AddPoint(BitChartScene scene, BitChartDataset ds, int dsIndex, int di, - double x, double y, double value, double radius, string border, double? xValue = null) + double x, double y, double value, double radius, string border, double? xValue = null, + BitChartAxisScale? valueScale = null, double? errorValue = null) { var ctx = Ctx(ds, dsIndex, di, value); - bool active = _state.Active == (dsIndex, di); - double r = ds.PointRadiusFn?.Invoke(ctx) ?? radius; var style = ds.PointStyleFn?.Invoke(ctx) ?? ds.PointStyle; string fill = ds.PointBackgroundColorFn?.Invoke(ctx) ?? ds.PointBackgroundColor ?? ResolveBackground(ds, dsIndex, di, false, value); string stroke = ds.PointBorderColorFn?.Invoke(ctx) ?? ds.PointBorderColor ?? border; double bw = ds.PointBorderWidth; - if (active) - { - r = Math.Max(r, ds.PointHoverRadius); - if (ds.PointHoverBackgroundColor is { } hb) fill = hb; - if (ds.PointHoverBorderColor is { } hbc) stroke = hbc; - if (ds.PointHoverBorderWidth is { } hbw) bw = hbw; - } - - var shape = BitChartPointShapes.Build(style, x, y, r, fill, stroke, bw); - if (shape is null) return; - - bool cartesian = _config.Type is not (BitChartType.Pie or BitChartType.Doughnut or BitChartType.PolarArea or BitChartType.Radar); - - string text = xValue is { } xv - ? $"({xv.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}, {value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)})" - : BuildItemText(ds, dsIndex, di, value, fill); + // A marker that is hidden or has no radius still needs something to hover: an invisible hit disc + // keeps the data point reachable (this is what Chart.js's pointHitRadius does). + BitChartSvgNode? shape = r > 0 ? BitChartPointShapes.Build(style, x, y, r, fill, stroke, bw, ds.PointRotation) : null; + double hitR = Math.Max(ds.HitRadius, 4); + shape ??= new BitChartSvgCircle { Cx = x, Cy = y, R = hitR, Fill = "transparent" }; + + // The hover appearance is precomputed with Active = true so scriptable options can react to it. + var hctx = Ctx(ds, dsIndex, di, value, active: true); + double hr = Math.Max(ds.PointRadiusFn?.Invoke(hctx) ?? r, ds.PointHoverRadius); + string hFill = ds.PointHoverBackgroundColor ?? ds.PointBackgroundColorFn?.Invoke(hctx) ?? fill; + string hStroke = ds.PointHoverBorderColor ?? ds.PointBorderColorFn?.Invoke(hctx) ?? stroke; + double hbw = ds.PointHoverBorderWidth ?? Math.Max(bw, 2); + var hoverShape = BitChartPointShapes.Build(style == BitChartPointStyle.None ? BitChartPointStyle.Circle : style, + x, y, Math.Max(hr, 3), hFill, hStroke, hbw, ds.PointRotation); + + string text = (xValue is { } xv + ? $"({FormatNumber(xv, "0.##")}, {FormatNumber(value, "0.##")})" + : BuildItemText(ds, dsIndex, di, value, fill)) + ErrorSuffix(ds, di); + + // A whisker needs the value axis to convert its interval, so it is only drawn where the caller + // could hand one over - which is every cartesian call site, but not the radar's. + if (valueScale is not null) + AddErrorBar(scene, ds, di, errorValue ?? value, IsVertical ? x : y, valueScale); scene.Elements.Add(new BitChartDataElement { Shape = shape, - EnterAnim = cartesian ? "bc-pop" : null, - AnimOriginX = x, - AnimOriginY = y, + HoverShape = hoverShape, DatasetIndex = dsIndex, DataIndex = di, CenterX = x, @@ -584,6 +773,75 @@ private void AddPoint(BitChartScene scene, BitChartDataset ds, int dsIndex, int Items = { new BitChartTooltipItem { Color = fill, Text = text, PointStyle = style } } } }); + + if (_options.Plugins.DataLabels is { Display: true, ShowOnPoints: true }) + AddPointDataLabel(scene, value, x, y, r, dsIndex, di); + } + + /// Places a bar's data label from the anchor/align/offset options. + private void AddBarDataLabel(BitChartScene scene, double value, BitChartSvgRect rect, int sign, int dsIndex, int dataIndex) + { + var dl = _options.Plugins.DataLabels; + if (!dl.Display) return; + + // Outward is the direction away from the baseline: up for positive vertical bars, right for + // positive horizontal ones. + double x, y; + if (IsVertical) + { + double tip = sign >= 0 ? rect.Y : rect.Y + rect.Height; + double baseline = sign >= 0 ? rect.Y + rect.Height : rect.Y; + double outward = sign >= 0 ? -1 : 1; + y = dl.Anchor switch + { + BitChartAlign.Start => baseline, + BitChartAlign.Center => rect.Y + rect.Height / 2, + _ => tip + }; + y += AlignShift(dl, outward); + x = rect.X + rect.Width / 2; + } + else + { + double tip = sign >= 0 ? rect.X + rect.Width : rect.X; + double baseline = sign >= 0 ? rect.X : rect.X + rect.Width; + double outward = sign >= 0 ? 1 : -1; + x = dl.Anchor switch + { + BitChartAlign.Start => baseline, + BitChartAlign.Center => rect.X + rect.Width / 2, + _ => tip + }; + x += AlignShift(dl, outward); + y = rect.Y + rect.Height / 2; + } + AddDataLabel(scene, value, x, y, dsIndex, dataIndex); + } + + /// Places a point's data label from the anchor/align/offset options (default: above the marker). + private void AddPointDataLabel(BitChartScene scene, double value, double x, double y, double radius, int dsIndex, int dataIndex) + { + var dl = _options.Plugins.DataLabels; + double anchorY = dl.Anchor switch + { + BitChartAlign.Start => y + radius, + BitChartAlign.Center => y, + _ => y - radius + }; + double outward = dl.Anchor == BitChartAlign.Start ? 1 : -1; + AddDataLabel(scene, value, x, anchorY + AlignShift(dl, outward), dsIndex, dataIndex); + } + + /// How far (and in which direction) the label sits from its anchor. + private static double AlignShift(BitChartDataLabelOptions dl, double outward) + { + double dist = dl.Offset + dl.Font.Size * 0.5 + dl.Padding; + return dl.Align switch + { + BitChartAlign.End => outward * dist, + BitChartAlign.Start => -outward * dist, + _ => 0 + }; } private void AddDataLabel(BitChartScene scene, double value, double x, double y, int dsIndex = 0, int dataIndex = 0) @@ -594,15 +852,21 @@ private void AddDataLabel(BitChartScene scene, double value, double x, double y, string text = dl.FormatterCtx?.Invoke(value, dsIndex, dataIndex) ?? dl.Formatter?.Invoke(value) - ?? value.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); + ?? FormatNumber(value, "0.##"); + + // Kept inside the content box: a label pushed past the tip of a bar at the top of the axis + // would otherwise be clipped by the edge of the chart. + double halfW = BitChartTextMeasure.Width(text, dl.Font.Size, dl.Font.Weight) / 2 + dl.Padding; + double halfH = dl.Font.Size / 2 + dl.Padding; + var box = ContentArea(); + if (box.Width > halfW * 2) x = Math.Clamp(x, box.Left + halfW, box.Right - halfW); + if (box.Height > halfH * 2) y = Math.Clamp(y, box.Top + halfH, box.Bottom - halfH); if (dl.BackgroundColor is { } bgc) { - double w = BitChartTextMeasure.Width(text, dl.Font.Size, dl.Font.Weight) + dl.Padding * 2; - double h = dl.Font.Size + dl.Padding * 2; scene.Foreground.Add(new BitChartSvgRect { - X = x - w / 2, Y = y - h / 2, Width = w, Height = h, Rx = dl.BorderRadius, Fill = bgc + X = x - halfW, Y = y - halfH, Width = halfW * 2, Height = halfH * 2, Rx = dl.BorderRadius, Fill = bgc }); } @@ -611,7 +875,7 @@ private void AddDataLabel(BitChartScene scene, double value, double x, double y, X = x, Y = y, Text = text, Fill = dl.Color, FontFamily = dl.Font.Family, FontSize = dl.Font.Size, FontWeight = dl.Font.Weight, - Anchor = "middle", Baseline = dl.BackgroundColor is null ? "auto" : "central", Rotation = dl.Rotation + Anchor = "middle", Baseline = "central", Rotation = dl.Rotation }); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.cs index a4c97eb3a4..5dfdbbe98c 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartRenderer.cs @@ -1,3 +1,4 @@ +using System.Globalization; namespace Bit.BlazorUI; @@ -14,8 +15,16 @@ public sealed partial class BitChartRenderer private readonly BitChartRenderState _state; private readonly double _w; private readonly double _h; + private readonly string _uid; - public BitChartRenderer(BitChartConfig config, BitChartRenderState state, double width, double height) + /// + /// The scales actually used for this render. Seeded from and + /// completed with defaults here, so the caller's options object is never mutated and can safely be + /// shared between charts of different types. + /// + private readonly Dictionary _scales = new(); + + public BitChartRenderer(BitChartConfig config, BitChartRenderState state, double width, double height, string? uid = null) { _config = config; _data = config.Data; @@ -23,6 +32,7 @@ public BitChartRenderer(BitChartConfig config, BitChartRenderState state, double _state = state; _w = width; _h = height; + _uid = uid ?? "bc"; } public BitChartScene Render() @@ -50,25 +60,34 @@ public BitChartScene Render() } BuildLegend(scene); + scene.IsEmpty = scene.Elements.Count == 0 && scene.Series.Count == 0; return scene; } // ---- shared helpers ---- - private int _gradSeq; + private int _defSeq; + + /// The culture every number/date is formatted with (invariant unless the caller opts in). + private CultureInfo Culture => _options.Culture ?? CultureInfo.InvariantCulture; + + /// Formats a data value the way tooltips and data labels present it. + private string FormatNumber(double value, string format = "0.###") => value.ToString(format, Culture); - /// Registers a gradient on the scene and returns a url(#id) fill reference. + /// Registers a gradient on the scene and returns a url(#id) fill reference. + /// Ids are namespaced per chart instance so several charts can share a page. private string RegisterGradient(BitChartScene scene, BitChartGradientBase grad) { - string id = $"bcgrad{_gradSeq++}"; + string id = $"{_uid}-g{_defSeq++}"; scene.Defs.Add(new BitChartGradientDef(id, grad)); return $"url(#{id})"; } - /// Registers a pattern on the scene and returns a url(#id) fill reference. + /// Registers a pattern on the scene and returns a url(#id) fill reference. + /// Ids are namespaced per chart instance so several charts can share a page. private string RegisterPattern(BitChartScene scene, BitChartFillPattern pattern) { - string id = $"bcpat{_gradSeq++}"; + string id = $"{_uid}-p{_defSeq++}"; scene.Patterns.Add(new BitChartPatternDef(id, pattern)); return $"url(#{id})"; } @@ -83,21 +102,19 @@ private BitChartArea ContentArea() } /// Builds a scriptable-options context for a data element. - private BitChartScriptableContext Ctx(BitChartDataset ds, int dsIndex, int dataIndex, double? value = null) + private BitChartScriptableContext Ctx(BitChartDataset ds, int dsIndex, int dataIndex, double? value = null, bool active = false) { - bool active = _state.Active == (dsIndex, dataIndex); double? v = value; double? vx = null, vr = null; - if (v is null) + if (ds.Points is { } pts && dataIndex < pts.Count) { - if (ds.Points is { } pts && dataIndex < pts.Count) - { - v = pts[dataIndex].Y; vx = pts[dataIndex].X; vr = pts[dataIndex].R; - } - else if (dataIndex < ds.Data.Count) - { - v = ds.Data[dataIndex]; - } + vx = pts[dataIndex].X; + vr = pts[dataIndex].R; + v ??= pts[dataIndex].Y; + } + else if (v is null && dataIndex < ds.Data.Count) + { + v = ds.Data[dataIndex]; } return new BitChartScriptableContext { @@ -114,32 +131,68 @@ private BitChartScriptableContext Ctx(BitChartDataset ds, int dsIndex, int dataI } /// Resolves the effective color for a data element, honoring dataset palettes. - private string ResolveBackground(BitChartDataset ds, int dsIndex, int dataIndex, bool perIndexPalette, double? value = null) + private string ResolveBackground(BitChartDataset ds, int dsIndex, int dataIndex, bool perIndexPalette, double? value = null, bool active = false) { - if (ds.BackgroundColorFn is { } fn && fn(Ctx(ds, dsIndex, dataIndex, value)) is { } c) return c; + if (ds.BackgroundColorFn is { } fn && fn(Ctx(ds, dsIndex, dataIndex, value, active)) is { } c) return c; if (ds.BackgroundColors is { Count: > 0 } list) - return list[dataIndex % list.Count]; + return list[((dataIndex % list.Count) + list.Count) % list.Count]; if (!string.IsNullOrEmpty(ds.BackgroundColor)) return ds.BackgroundColor!; return perIndexPalette ? BitChartColorUtil.Palette(dataIndex) : BitChartColorUtil.Palette(dsIndex); } - private string ResolveBorder(BitChartDataset ds, int dsIndex, int dataIndex, bool perIndexPalette, double? value = null) + /// + /// Resolves the border color of an element. is used by the + /// filled element types (bars, arcs): with no explicit border color they take the fill color rather + /// than an unrelated palette entry, which would otherwise outline them in a different hue. + /// + private string ResolveBorder(BitChartDataset ds, int dsIndex, int dataIndex, bool perIndexPalette, + double? value = null, bool fallbackToBackground = false, bool active = false) { - if (ds.BorderColorFn is { } fn && fn(Ctx(ds, dsIndex, dataIndex, value)) is { } c) return c; + if (ds.BorderColorFn is { } fn && fn(Ctx(ds, dsIndex, dataIndex, value, active)) is { } c) return c; if (ds.BorderColors is { Count: > 0 } list) - return list[dataIndex % list.Count]; + return list[((dataIndex % list.Count) + list.Count) % list.Count]; if (!string.IsNullOrEmpty(ds.BorderColor)) return ds.BorderColor!; + if (fallbackToBackground) + return ResolveBackground(ds, dsIndex, dataIndex, perIndexPalette, value, active); return perIndexPalette ? BitChartColorUtil.Palette(dataIndex) : BitChartColorUtil.Palette(dsIndex); } + /// True when the dataset asks for a border color in any form. + private static bool HasExplicitBorder(BitChartDataset ds) + => ds.BorderColorFn is not null || ds.BorderColors is { Count: > 0 } || !string.IsNullOrEmpty(ds.BorderColor); + + /// + /// Resolves the border/line width for a dataset, falling back to the per-type defaults on + /// when the dataset leaves unset. + /// + private double ResolveBorderWidth(BitChartDataset ds, BitChartType type, int dsIndex = 0, int dataIndex = 0, double? value = null, bool active = false) + { + if (active && ds.HoverBorderWidth is { } hbw) return hbw; + if (ds.BorderWidthFn is { } fn && fn(Ctx(ds, dsIndex, dataIndex, value, active)) is { } w) return w; + if (ds.BorderWidth is { } bw) return bw; + var e = _options.Elements; + return type switch + { + BitChartType.Bar => HasExplicitBorder(ds) ? Math.Max(e.BarBorderWidth, 1) : e.BarBorderWidth, + BitChartType.Pie or BitChartType.Doughnut or BitChartType.PolarArea => e.ArcBorderWidth, + _ => e.LineBorderWidth + }; + } + + /// Marker radius for a dataset, falling back to the element default. + private double ResolvePointRadius(BitChartDataset ds) => ds.PointRadius ?? _options.Elements.PointRadius; + + /// Line smoothing for a dataset, falling back to the element default. + private double ResolveTension(BitChartDataset ds) => ds.Tension ?? _options.Elements.LineTension; + private string FormatTooltipValue(BitChartDataset ds, double value) { var t = _options.Plugins.Tooltip; if (t.LabelFormatter is { } f) return f(ds.Label ?? "", value); string label = string.IsNullOrEmpty(ds.Label) ? "" : ds.Label + ": "; - return label + value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture); + return label + FormatNumber(value); } /// Builds a tooltip item context for callbacks. @@ -155,7 +208,7 @@ private BitChartTooltipItemContext BuildTooltipItem(BitChartDataset ds, int dsIn Value = value, ValueX = vx, Color = color, - FormattedValue = value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + FormattedValue = FormatNumber(value) }; } @@ -168,56 +221,99 @@ private string BuildItemText(BitChartDataset ds, int dsIndex, int dataIndex, dou return FormatTooltipValue(ds, value); } + // ---- scales ---- + + /// The scale options for an id within this render (never null once EnsureScales has run). + private BitChartScaleOptions Scale(string id) => _scales[id]; + + /// + /// The radial scale a radar or polar-area chart reads. One radial scale serves the whole chart, so + /// it is the first dataset's that names it. + /// + private string RadialScaleId + { + get + { + var ds = _data.Datasets.FirstOrDefault(d => !string.IsNullOrEmpty(d.RAxisID)); + return ds?.RAxisID ?? "r"; + } + } + + /// + /// Whether a scale draws itself. A sparkline is defined by having no chrome at all, so it silences + /// every axis here rather than asking each caller to remember the option. + /// + private bool ScaleVisible(BitChartScaleOptions o) => o.Display && !_options.Sparkline; + + /// Whether a radial scale draws the category labels around its perimeter. + private bool PointLabelsVisible(BitChartScaleOptions o) => o.PointLabels.Display && !_options.Sparkline; + + /// Effective position of a scale, without writing the default back onto the caller's object. + private static BitChartPosition PositionOf(BitChartScaleOptions o, BitChartPosition fallback) => o.Position ?? fallback; + + /// Returns the caller's scale for an id, or a fresh default one - added to the local map only. + private BitChartScaleOptions GetOrAddScale(string id, BitChartScaleType type) + { + if (_scales.TryGetValue(id, out var s)) return s; + if (_options.Scales.TryGetValue(id, out var user)) + { + _scales[id] = user; + return user; + } + s = new BitChartScaleOptions { Id = id, Type = type }; + _scales[id] = s; + return s; + } + private void EnsureScales() { + _scales.Clear(); + foreach (var (id, so) in _options.Scales) _scales[id] = so; + if (_config.Type is BitChartType.Pie or BitChartType.Doughnut) return; if (_config.Type is BitChartType.PolarArea or BitChartType.Radar) { - _options.GetOrAddScale("r", BitChartScaleType.RadialLinear); + GetOrAddScale(RadialScaleId, BitChartScaleType.RadialLinear); return; } // Cartesian: ensure x and y exist with sensible defaults. - var x = _options.GetOrAddScale("x", _config.Type is BitChartType.Scatter or BitChartType.Bubble + GetOrAddScale("x", _config.Type is BitChartType.Scatter or BitChartType.Bubble ? BitChartScaleType.Linear : BitChartScaleType.Category); - x.Position ??= BitChartPosition.Bottom; - // Additional x axes referenced by datasets (default linear, bottom). + // Additional x axes referenced by datasets (default linear). foreach (var id in _data.Datasets.Select(d => d.XAxisID).Distinct()) { if (id == "x" || string.IsNullOrEmpty(id)) continue; - var x2 = _options.GetOrAddScale(id, BitChartScaleType.Linear); - x2.Position ??= BitChartPosition.Bottom; + GetOrAddScale(id, BitChartScaleType.Linear); } // Gather y axis ids referenced by datasets. - var yIds = _data.Datasets.Select(d => d.YAxisID).Distinct().ToList(); - foreach (var id in yIds) - { - var y = _options.GetOrAddScale(id, BitChartScaleType.Linear); - y.Position ??= BitChartPosition.Left; - } - if (!_options.Scales.ContainsKey("y")) + foreach (var id in _data.Datasets.Select(d => d.YAxisID).Distinct()) { - var y = _options.GetOrAddScale("y", BitChartScaleType.Linear); - y.Position ??= BitChartPosition.Left; + if (string.IsNullOrEmpty(id)) continue; + GetOrAddScale(id, BitChartScaleType.Linear); } + GetOrAddScale("y", BitChartScaleType.Linear); } private void BuildLegend(BitChartScene scene) { var lo = _options.Plugins.Legend; - if (!lo.Display) return; + if (!lo.Display || _options.Sparkline) return; var legend = new BitChartLegendModel { - Position = lo.Position, + // The legend only has four sides to live on; anything else would silently render nowhere. + Position = lo.Position is BitChartPosition.Bottom or BitChartPosition.Left or BitChartPosition.Right + ? lo.Position : BitChartPosition.Top, Align = lo.Align, Labels = lo.Labels, Title = lo.Title, - OnClickToggle = lo.OnClickToggle + OnClickToggle = lo.OnClickToggle, + MaxHeight = lo.MaxHeight }; if (_config.Type is BitChartType.Pie or BitChartType.Doughnut or BitChartType.PolarArea) @@ -257,20 +353,23 @@ private void BuildLegend(BitChartScene scene) } } + if (lo.Filter is { } filter) + legend.Items.RemoveAll(it => !filter(it)); if (lo.Reverse) legend.Items.Reverse(); scene.Legend = legend; } private BitChartTitleModel? BuildTitle(BitChartTitleOptions o) { - if (!o.Display || string.IsNullOrEmpty(o.Text)) return null; + if (!o.Display || _options.Sparkline || string.IsNullOrEmpty(o.Text)) return null; return new BitChartTitleModel { Text = o.Text, Color = o.Color, Position = o.Position, Align = o.Align, - Font = o.Font + Font = o.Font, + Padding = o.Padding }; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartScene.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartScene.cs index f087cf4df1..61994c16d1 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartScene.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartScene.cs @@ -12,6 +12,8 @@ public sealed class BitChartScene /// Rendered in an animated group so every line - solid, dashed or per-segment - animates uniformly. public List Series { get; } = new(); public List Elements { get; } = new(); + /// Invisible per-index hit areas spanning the plot, used for non-intersecting hover. + public List HitBands { get; } = new(); public List Foreground { get; } = new(); /// Gradient definitions referenced via url(#id). @@ -45,4 +47,20 @@ public sealed class BitChartScene public Dictionary AxisRanges { get; } = new(); /// Axis ids that support zoom/pan (linear/time/logarithmic). public HashSet ZoomableAxes { get; } = new(); + /// The full (un-zoomed) data range per axis id, used to keep zoom/pan inside the data. + public Dictionary DataRanges { get; } = new(); + + /// Axis ids drawn in reverse, so pointer gestures can be mapped back the right way round. + public HashSet ReversedAxes { get; } = new(); + + /// + /// How each axis is laid out, which is what lets a pointer gesture be mapped back onto it. An axis + /// runs across the plot or down it depending on the chart's index axis - the value axes of a + /// horizontal-bar chart are the horizontal ones - and a vertical value axis additionally has its + /// minimum at the bottom, i.e. at the far pixel rather than the near one. + /// + public Dictionary AxisOrientations { get; } = new(); + + /// True when the configuration produced nothing to draw (no datasets, or all empty/hidden). + public bool IsEmpty { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgNode.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgNode.cs index 17c69c405d..fa1746ad61 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgNode.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgNode.cs @@ -3,7 +3,10 @@ namespace Bit.BlazorUI; /// Base class for renderable SVG primitives produced by the renderer. public abstract class BitChartSvgNode { + /// Native SVG tooltip text, rendered as a child <title> element. public string? Title { get; set; } public double Opacity { get; set; } = 1; public string? CssClass { get; set; } + /// Optional SVG transform applied to the primitive (e.g. a marker rotation). + public string? Transform { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgPath.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgPath.cs index 9e52d19a22..5f439fd64a 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgPath.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartSvgPath.cs @@ -7,6 +7,7 @@ public sealed class BitChartSvgPath : BitChartSvgNode public string? Stroke; public double StrokeWidth = 0; public string? Dash; + public double DashOffset; public string LineCap = "butt"; public string LineJoin = "miter"; /// When true the path animates a "draw-on" effect via stroke-dashoffset. diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTextMeasure.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTextMeasure.cs index c713180d70..535074d37d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTextMeasure.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTextMeasure.cs @@ -1,3 +1,5 @@ +using System.Globalization; + namespace Bit.BlazorUI; /// @@ -39,13 +41,45 @@ public static double Width(string? text, double fontSize, string weight = "norma if (string.IsNullOrEmpty(text)) return 0; double em = 0; foreach (char c in text) - em += Widths.TryGetValue(c, out var w) ? w : Default; + em += Advance(c); // Bold text is a touch wider. bool bold = weight is "bold" or "600" or "700" or "800" or "900"; if (bold) em *= 1.06; return em * fontSize; } + /// + /// How wide one character is, as a fraction of the font size. The table above covers Latin; beyond + /// it, a CJK, Kana or Hangul glyph is a full em rather than the Latin average - assuming otherwise + /// under-measures a Japanese axis label by nearly half, and the axis then reserves too little space + /// and the labels collide. A combining mark sits on the glyph before it and adds nothing. + /// + private static double Advance(char c) + { + if (Widths.TryGetValue(c, out var w)) return w; + if (IsFullWidth(c)) return 1; + return CharUnicodeInfo.GetUnicodeCategory(c) switch + { + UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark => 0, + UnicodeCategory.Format or UnicodeCategory.Control => 0, + _ => Default + }; + } + + /// The blocks whose glyphs are drawn on a full-width em square. + private static bool IsFullWidth(char c) => + c is >= 'ᄀ' and <= 'ᅟ' // Hangul Jamo + or >= '⺀' and <= '〾' // CJK radicals, Kangxi, CJK symbols and punctuation + or >= 'ぁ' and <= '㏿' // Kana, Bopomofo, Hangul compatibility Jamo, CJK compatibility + or >= '㐀' and <= '䶿' // CJK extension A + or >= '一' and <= '鿿' // CJK unified ideographs + or >= 'ꀀ' and <= '꓏' // Yi + or >= '가' and <= '힣' // Hangul syllables + or >= '豈' and <= '﫿' // CJK compatibility ideographs + or >= '︰' and <= '﹯' // CJK compatibility forms, small form variants + or >= '＀' and <= '⦆' // Full-width forms + or >= '¢' and <= '₩'; // Full-width signs + /// Width of the widest line in a multi-line string. public static double MultilineWidth(string? text, double fontSize, string weight = "normal") { diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTimeAxis.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTimeAxis.cs index b429215e50..10165de5d8 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTimeAxis.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTimeAxis.cs @@ -21,18 +21,27 @@ public static BitChartTimeUnit ChooseUnit(DateTime min, DateTime max) return BitChartTimeUnit.Year; } - public static string DefaultFormat(DateTime d, BitChartTimeUnit unit) => unit switch + /// + /// The label a tick gets when the caller supplies no formatter. Month and day names come from the + /// chart's culture, so a localized chart does not end up printing French numbers next to English + /// months; the invariant culture is the default, which keeps output stable. + /// + public static string DefaultFormat(DateTime d, BitChartTimeUnit unit, CultureInfo? culture = null) { - BitChartTimeUnit.Millisecond => d.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture), - BitChartTimeUnit.Second => d.ToString("HH:mm:ss", CultureInfo.InvariantCulture), - BitChartTimeUnit.Minute => d.ToString("HH:mm", CultureInfo.InvariantCulture), - BitChartTimeUnit.Hour => d.ToString("HH:mm", CultureInfo.InvariantCulture), - BitChartTimeUnit.Day => d.ToString("MMM d", CultureInfo.InvariantCulture), - BitChartTimeUnit.Week => d.ToString("MMM d", CultureInfo.InvariantCulture), - BitChartTimeUnit.Month => d.ToString("MMM yyyy", CultureInfo.InvariantCulture), - BitChartTimeUnit.Quarter => $"Q{(d.Month - 1) / 3 + 1} {d.Year}", - _ => d.Year.ToString(CultureInfo.InvariantCulture) - }; + var c = culture ?? CultureInfo.InvariantCulture; + return unit switch + { + BitChartTimeUnit.Millisecond => d.ToString("HH:mm:ss.fff", c), + BitChartTimeUnit.Second => d.ToString("HH:mm:ss", c), + BitChartTimeUnit.Minute => d.ToString("HH:mm", c), + BitChartTimeUnit.Hour => d.ToString("HH:mm", c), + BitChartTimeUnit.Day => d.ToString("MMM d", c), + BitChartTimeUnit.Week => d.ToString("MMM d", c), + BitChartTimeUnit.Month => d.ToString("MMM yyyy", c), + BitChartTimeUnit.Quarter => $"Q{(d.Month - 1) / 3 + 1} {d.Year.ToString(c)}", + _ => d.Year.ToString(c) + }; + } private static DateTime Floor(DateTime d, BitChartTimeUnit unit) => unit switch { @@ -46,6 +55,24 @@ public static BitChartTimeUnit ChooseUnit(DateTime min, DateTime max) _ => new DateTime(d.Year, 1, 1) }; + /// + /// Advances a tick by one step, saturating at . Returns false when it + /// could not move, which is what stops the tick loops at the end of the calendar instead of throwing + /// or spinning. + /// + private static bool TryNext(DateTime d, BitChartTimeUnit unit, int step, out DateTime next) + { + try + { + next = Next(d, unit, step); + } + catch (ArgumentOutOfRangeException) + { + next = DateTime.MaxValue; + } + return next > d; + } + private static DateTime Next(DateTime d, BitChartTimeUnit unit, int step) => unit switch { BitChartTimeUnit.Millisecond => d.AddMilliseconds(step), @@ -59,19 +86,33 @@ public static BitChartTimeUnit ChooseUnit(DateTime min, DateTime max) _ => d.AddYears(step) }; + /// The OLE Automation date range accepts. + private const double MinOaDate = -657434.0; + private const double MaxOaDate = 2958465.99999999; + + /// + /// Converts an axis value to a date. Values outside the OLE Automation range - a time scale pointed + /// at data that is not dates - are clamped instead of throwing out of the render. + /// + private static DateTime ToDate(double oa) + => DateTime.FromOADate(Math.Clamp(double.IsFinite(oa) ? oa : 0, MinOaDate, MaxOaDate)); + /// Generates (oaDateValue, label) ticks between min and max. public static List<(double Value, string Label)> Ticks(double minOa, double maxOa, BitChartTimeUnit unit, - Func? format, int maxTicks = 11) + Func? format, int maxTicks = 11, CultureInfo? culture = null) { - var min = DateTime.FromOADate(minOa); - var max = DateTime.FromOADate(maxOa); + var min = ToDate(minOa); + var max = ToDate(maxOa); + if (max < min) (min, max) = (max, min); if (unit == BitChartTimeUnit.Auto) unit = ChooseUnit(min, max); // Choose a step so we don't exceed maxTicks. int step = 1; - var probe = Floor(min, unit); int count = 0; - for (var t = probe; t <= max; t = Next(t, unit, 1)) { count++; if (count > 5000) break; } + for (var t = Floor(min, unit); t <= max; count++) + { + if (count > 5000 || !TryNext(t, unit, 1, out t)) break; + } if (count > maxTicks) step = (int)Math.Ceiling((double)count / maxTicks); var ticks = new List<(double, string)>(); @@ -79,12 +120,12 @@ public static BitChartTimeUnit ChooseUnit(DateTime min, DateTime max) while (cur <= max) { if (cur >= min) - ticks.Add((cur.ToOADate(), (format ?? (d => DefaultFormat(d, unit)))(cur))); - cur = Next(cur, unit, step); + ticks.Add((cur.ToOADate(), (format ?? (d => DefaultFormat(d, unit, culture)))(cur))); if (ticks.Count > maxTicks * 3) break; + if (!TryNext(cur, unit, step, out cur)) break; } if (ticks.Count == 0) - ticks.Add((minOa, (format ?? (d => DefaultFormat(d, unit)))(min))); + ticks.Add((minOa, (format ?? (d => DefaultFormat(d, unit, culture)))(min))); return ticks; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTitleModel.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTitleModel.cs index 57a0debc9f..586d23c29e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTitleModel.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/BitChartTitleModel.cs @@ -4,8 +4,9 @@ namespace Bit.BlazorUI; public sealed class BitChartTitleModel { public string Text { get; set; } = ""; - public string Color { get; set; } = "#333"; + public string Color { get; set; } = "var(--bit-clr-fg-pri, #1A1A1A)"; public BitChartPosition Position { get; set; } = BitChartPosition.Top; public BitChartAlign Align { get; set; } = BitChartAlign.Center; public BitChartFont Font { get; set; } = new(); + public BitChartPadding Padding { get; set; } = 10; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotation.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotation.cs index 4ff04424cd..78ed6b7b1d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotation.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotation.cs @@ -24,8 +24,20 @@ public sealed class BitChartAnnotation public double LineWidth { get; set; } = 2; public List? Dash { get; set; } + /// Number of sides of a (3 = triangle). + public int Sides { get; set; } = 3; + + /// Radius in pixels of a or a + /// . When null a point sizes itself from its line width. + public double? Radius { get; set; } + + /// Rotation in degrees of a . + public double Rotation { get; set; } + public string? Label { get; set; } public string LabelColor { get; set; } = "#fff"; public string LabelBackground { get; set; } = "#ff6384"; + /// Font of the annotation label. + public BitChartFont LabelFont { get; set; } = new() { Size = 11, Weight = "bold" }; public bool DrawBehindDatasets { get; set; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationKind.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationKind.cs index ce5bdb1843..8d8e5da8a7 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationKind.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationKind.cs @@ -1,3 +1,24 @@ namespace Bit.BlazorUI; -public enum BitChartAnnotationKind { Line, Box, Point, Label } +/// The shape an annotation draws, mirroring chartjs-plugin-annotation's annotation types. +public enum BitChartAnnotationKind +{ + /// A horizontal or vertical rule across the plot - a threshold, a target, a marker date. + Line, + + /// A rectangle bounded by XMin/XMax and YMin/YMax - a highlighted region. + Box, + + /// A dot at one coordinate. + Point, + + /// Text in a pill at one coordinate, with no shape of its own. + Label, + + /// An ellipse inscribed in the XMin/XMax and YMin/YMax bounds, for circling a region. + Ellipse, + + /// A regular polygon of sides, centered on the + /// annotation's coordinate with a pixel . + Polygon +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationPlugin.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationPlugin.cs index 5b6437593b..8ee416a0bf 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationPlugin.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartAnnotationPlugin.cs @@ -81,7 +81,71 @@ private void Draw(BitChartPluginContext ctx, BitChartAnnotation a, bool behind) { double x = a.XMin is { } xv ? X(xv, a.XIsIndex) : plot.CenterX; double y = ctx.YForValue(a.Value, a.AxisId); - add(new BitChartSvgCircle { Cx = x, Cy = y, R = a.LineWidth * 2 + 2, Fill = a.FillColor ?? a.Color, Stroke = a.Color, StrokeWidth = a.LineWidth }); + add(new BitChartSvgCircle + { + Cx = x, Cy = y, R = a.Radius ?? a.LineWidth * 2 + 2, + Fill = a.FillColor ?? a.Color, Stroke = a.Color, StrokeWidth = a.LineWidth + }); + if (!string.IsNullOrEmpty(a.Label)) + AddLabel(add, a, x, y - (a.Radius ?? a.LineWidth * 2 + 2) - a.LabelFont.LineHeightPx, "middle"); + break; + } + + case BitChartAnnotationKind.Ellipse: + { + double x1 = a.XMin is { } xm ? X(xm, a.XIsIndex) : plot.Left; + double x2 = a.XMax is { } xM ? X(xM, a.XIsIndex) : plot.Right; + double y1 = a.YMax is { } yM ? ctx.YForValue(yM, a.AxisId) : plot.Top; + double y2 = a.YMin is { } ym ? ctx.YForValue(ym, a.AxisId) : plot.Bottom; + + // Same clamp as the box: bounds outside the visible axis range must not spill past the chart. + x1 = Math.Clamp(x1, plot.Left, plot.Right); + x2 = Math.Clamp(x2, plot.Left, plot.Right); + y1 = Math.Clamp(y1, plot.Top, plot.Bottom); + y2 = Math.Clamp(y2, plot.Top, plot.Bottom); + + double cx = (x1 + x2) / 2, cy = (y1 + y2) / 2; + double rx = Math.Abs(x2 - x1) / 2, ry = Math.Abs(y2 - y1) / 2; + if (rx <= 0 || ry <= 0) break; + + // Two half-turn arcs make a closed ellipse without needing a primitive of its own. + add(new BitChartSvgPath + { + D = $"M {BitChartSvg.N(cx - rx)} {BitChartSvg.N(cy)} " + + $"A {BitChartSvg.N(rx)} {BitChartSvg.N(ry)} 0 1 0 {BitChartSvg.N(cx + rx)} {BitChartSvg.N(cy)} " + + $"A {BitChartSvg.N(rx)} {BitChartSvg.N(ry)} 0 1 0 {BitChartSvg.N(cx - rx)} {BitChartSvg.N(cy)} Z", + Fill = a.FillColor ?? BitChartColorUtil.WithAlpha(a.Color, 0.15), + Stroke = a.Color, StrokeWidth = a.LineWidth, + Dash = BitChartSvg.Dash(a.Dash) + }); + if (!string.IsNullOrEmpty(a.Label)) + AddLabel(add, a, cx, cy, "middle"); + break; + } + + case BitChartAnnotationKind.Polygon: + { + double cx = a.XMin is { } xv ? X(xv, a.XIsIndex) : plot.CenterX; + double cy = ctx.YForValue(a.Value, a.AxisId); + double r = a.Radius ?? 12; + int sides = Math.Max(3, a.Sides); + if (r <= 0) break; + + var poly = new BitChartSvgPolygon + { + Fill = a.FillColor ?? BitChartColorUtil.WithAlpha(a.Color, 0.2), + Stroke = a.Color, StrokeWidth = a.LineWidth + }; + // Starts at the top so a triangle points up, which is what a reader expects of one. + double start = -Math.PI / 2 + a.Rotation * Math.PI / 180; + for (int i = 0; i < sides; i++) + { + double angle = start + 2 * Math.PI * i / sides; + poly.Points.Add((cx + Math.Cos(angle) * r, cy + Math.Sin(angle) * r)); + } + add(poly); + if (!string.IsNullOrEmpty(a.Label)) + AddLabel(add, a, cx, cy - r - a.LabelFont.LineHeightPx, "middle"); break; } @@ -97,8 +161,17 @@ private void Draw(BitChartPluginContext ctx, BitChartAnnotation a, bool behind) private static void AddLabel(Action add, BitChartAnnotation a, double x, double y, string anchor) { - double w = (a.Label!.Length * 7) + 10; - add(new BitChartSvgRect { X = anchor == "end" ? x - w : anchor == "middle" ? x - w / 2 : x, Y = y - 9, Width = w, Height = 18, Rx = 4, Fill = a.LabelBackground }); - add(new BitChartSvgText { X = anchor == "end" ? x - w / 2 : x, Y = y, Text = a.Label!, Fill = a.LabelColor, FontSize = 11, Anchor = "middle", Baseline = "central", FontWeight = "bold" }); + // Measured rather than guessed from the character count, so the pill fits the text at any length. + double fontSize = a.LabelFont.Size; + double w = BitChartTextMeasure.Width(a.Label, fontSize, a.LabelFont.Weight) + 12; + double h = a.LabelFont.LineHeightPx + 6; + double left = anchor == "end" ? x - w : anchor == "middle" ? x - w / 2 : x; + add(new BitChartSvgRect { X = left, Y = y - h / 2, Width = w, Height = h, Rx = 4, Fill = a.LabelBackground }); + add(new BitChartSvgText + { + X = left + w / 2, Y = y, Text = a.Label!, Fill = a.LabelColor, + FontFamily = a.LabelFont.Family, FontSize = fontSize, FontWeight = a.LabelFont.Weight, + Anchor = "middle", Baseline = "central" + }); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartCenterTextPlugin.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartCenterTextPlugin.cs index 39778eaf40..d799ab167d 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartCenterTextPlugin.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartCenterTextPlugin.cs @@ -13,7 +13,7 @@ public sealed class BitChartCenterTextPlugin : IBitChartPlugin public string Text { get; set; } = ""; /// Optional secondary line shown beneath the main text. public string? Subtext { get; set; } - public string Color { get; set; } = "#333"; + public string Color { get; set; } = "var(--bit-clr-fg-pri, #1A1A1A)"; public string? SubtextColor { get; set; } public BitChartFont Font { get; set; } = new() { Size = 28, Weight = "bold" }; public BitChartFont SubtextFont { get; set; } = new() { Size = 13 }; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartPluginContext.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartPluginContext.cs index 4753decb96..958a7c300e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartPluginContext.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartPluginContext.cs @@ -20,11 +20,36 @@ public sealed class BitChartPluginContext internal BitChartAxisScale? IndexScale { get; init; } internal Dictionary? ValueScales { get; init; } - internal bool IndexIsCategory { get; init; } - /// Pixel position along the index axis for a category index. - public double XForIndex(int index, bool centered = true) - => IndexScale?.PixelForIndex(index, centered) ?? 0; + /// Datasets the legend has toggled off at runtime. + internal IReadOnlySet? HiddenDatasets { get; init; } + + /// + /// Whether the dataset at the given index is actually drawn. A dataset is hidden either by its own + /// flag or by having been toggled off through the legend, and + /// anything drawn from a dataset's values has to honour both or it outlives the series it describes. + /// + public bool IsDatasetVisible(int datasetIndex) + { + var datasets = Config.Data.Datasets; + if (datasetIndex < 0 || datasetIndex >= datasets.Count) return false; + return !datasets[datasetIndex].Hidden && HiddenDatasets?.Contains(datasetIndex) != true; + } + + /// True when the index axis is a category axis, so indexes - not raw values - place things along it. + public bool IndexIsCategory { get; init; } + + /// + /// Whether categories sit in the middle of their band rather than on its edge. Bars force the + /// centered layout, so anything drawn over the data has to follow the same choice or it lands half + /// a band away from the points it is annotating. + /// + public bool IndexCentered { get; init; } + + /// Pixel position along the index axis for a category index. Follows the chart's own + /// band placement unless the caller overrides it. + public double XForIndex(int index, bool? centered = null) + => IndexScale?.PixelForIndex(index, centered ?? IndexCentered) ?? 0; /// Pixel position along the index axis for a raw value (linear/time axes). public double XForValue(double value) => IndexScale?.PixelFor(value) ?? 0; diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendline.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendline.cs new file mode 100644 index 0000000000..ac4c441a30 --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendline.cs @@ -0,0 +1,42 @@ +namespace Bit.BlazorUI; + +/// One fitted line drawn over a dataset by . +public sealed class BitChartTrendline +{ + /// Index of the dataset the line is fitted to. + public int DatasetIndex { get; set; } + + /// Which curve is fitted. + public BitChartTrendlineKind Kind { get; set; } = BitChartTrendlineKind.Linear; + + /// Window of the trailing moving average, used by . + public int Period { get; set; } = 5; + + /// Line color. When null it follows the dataset's own border color, dimmed. + public string? Color { get; set; } + + public double LineWidth { get; set; } = 2; + + /// Dash pattern; dashed by default so the fit never reads as another measured series. + public List? Dash { get; set; } = [6, 4]; + + /// + /// Projects a straight fit across the full width of the plot instead of stopping at the first and + /// last data point. Ignored by , which has no + /// meaning outside the data. + /// + public bool Extend { get; set; } + + /// Optional label drawn in a pill at the end of the line. + public string? Label { get; set; } + + public string LabelColor { get; set; } = "#fff"; + + /// Pill color behind the label. When null it follows the line color. + public string? LabelBackground { get; set; } + + public BitChartFont LabelFont { get; set; } = new() { Size = 11, Weight = "bold" }; + + /// Draw the line under the datasets rather than over them. + public bool DrawBehindDatasets { get; set; } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlineKind.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlineKind.cs new file mode 100644 index 0000000000..852e08501e --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlineKind.cs @@ -0,0 +1,15 @@ +namespace Bit.BlazorUI; + +/// The curve a fits through its dataset. +public enum BitChartTrendlineKind +{ + /// Least-squares straight line - the classic "is it going up or down" answer. + Linear, + + /// Trailing simple moving average over points, which + /// smooths a noisy series without straightening it. + MovingAverage, + + /// A flat line at the mean of the series, for reading each point against the average. + Average +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlinePlugin.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlinePlugin.cs new file mode 100644 index 0000000000..a87718935a --- /dev/null +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/Chart/Rendering/Plugins/BitChartTrendlinePlugin.cs @@ -0,0 +1,175 @@ +using System.Text; + +namespace Bit.BlazorUI; + +/// +/// Fits a trend line through a cartesian dataset - a least-squares regression, a trailing moving +/// average or the series mean - and draws it over the chart, mirroring what the charting world ships +/// as a trendline plugin. The fit is computed from the same values the chart plots, so a dataset +/// hidden through the legend takes its trend line with it. +/// +public sealed class BitChartTrendlinePlugin : IBitChartPlugin +{ + public string Id => "trendline"; + + public List Trendlines { get; } = new(); + + public BitChartTrendlinePlugin() { } + public BitChartTrendlinePlugin(params BitChartTrendline[] trendlines) => Trendlines.AddRange(trendlines); + + public void BeforeDatasetsDraw(BitChartPluginContext ctx) + { + foreach (var t in Trendlines.Where(t => t.DrawBehindDatasets)) Draw(ctx, t, behind: true); + } + + public void AfterDatasetsDraw(BitChartPluginContext ctx) + { + foreach (var t in Trendlines.Where(t => !t.DrawBehindDatasets)) Draw(ctx, t, behind: false); + } + + private static void Draw(BitChartPluginContext ctx, BitChartTrendline trend, bool behind) + { + if (!ctx.IsCartesian || ctx.Plot is not { } plot) return; + + var datasets = ctx.Config.Data.Datasets; + if (trend.DatasetIndex < 0 || trend.DatasetIndex >= datasets.Count) return; + var ds = datasets[trend.DatasetIndex]; + if (!ctx.IsDatasetVisible(trend.DatasetIndex)) return; + + // (x, y) in data coordinates: an index for a category axis, the point's own x otherwise. + var samples = new List<(double X, double Y)>(); + if (ds.Points is { } points) + { + foreach (var p in points.OrderBy(p => p.X)) samples.Add((p.X, p.Y)); + } + else + { + for (int i = 0; i < ds.Data.Count; i++) + if (ds.Data[i] is { } v) samples.Add((i, v)); + } + if (samples.Count < 2) return; + + bool byIndex = ds.Points is null && ctx.IndexIsCategory; + double Px(double x) => byIndex ? ctx.XForIndex((int)Math.Round(x)) : ctx.XForValue(x); + double Py(double y) => ctx.YForValue(y, ds.YAxisID); + + var fitted = trend.Kind switch + { + BitChartTrendlineKind.MovingAverage => MovingAverage(samples, trend.Period), + BitChartTrendlineKind.Average => Flat(samples), + _ => Regression(samples, trend.Extend, ctx, byIndex, plot) + }; + if (fitted.Count < 2) return; + + string color = trend.Color ?? BitChartColorUtil.WithAlpha( + ds.BorderColor ?? ds.BackgroundColor ?? BitChartColorUtil.Palette(trend.DatasetIndex), 0.85); + + var d = new StringBuilder(); + for (int i = 0; i < fitted.Count; i++) + { + double px = fitted[i].Fixed ? fitted[i].X : Px(fitted[i].X); + double py = Py(fitted[i].Y); + d.Append(i == 0 ? "M " : " L ").Append(BitChartSvg.N(px)).Append(' ').Append(BitChartSvg.N(py)); + } + + Action add = behind ? ctx.AddBehind : ctx.AddFront; + add(new BitChartSvgPath + { + D = d.ToString(), + Fill = "none", + Stroke = color, + StrokeWidth = trend.LineWidth, + Dash = BitChartSvg.Dash(trend.Dash), + LineCap = "round", + LineJoin = "round" + }); + + if (string.IsNullOrEmpty(trend.Label)) return; + + var end = fitted[^1]; + double lx = end.Fixed ? end.X : Px(end.X); + double ly = Py(end.Y); + double w = BitChartTextMeasure.Width(trend.Label, trend.LabelFont.Size, trend.LabelFont.Weight) + 12; + double h = trend.LabelFont.LineHeightPx + 6; + // Pinned inside the plot so a fit that ends at the right edge keeps its pill readable. + double left = Math.Clamp(lx - w, plot.Left, Math.Max(plot.Left, plot.Right - w)); + double top = Math.Clamp(ly - h / 2, plot.Top, Math.Max(plot.Top, plot.Bottom - h)); + + add(new BitChartSvgRect + { + X = left, Y = top, Width = w, Height = h, Rx = 4, + Fill = trend.LabelBackground ?? color + }); + add(new BitChartSvgText + { + X = left + w / 2, Y = top + h / 2, Text = trend.Label!, Fill = trend.LabelColor, + FontFamily = trend.LabelFont.Family, FontSize = trend.LabelFont.Size, FontWeight = trend.LabelFont.Weight, + Anchor = "middle", Baseline = "central" + }); + } + + /// + /// A fitted vertex. Fixed marks a point whose X is already a pixel - the regression uses it + /// to reach the plot edges, which have no data coordinate to convert from. + /// + private readonly record struct Vertex(double X, double Y, bool Fixed = false); + + /// Ordinary least-squares fit, evaluated at the two ends of the run it covers. + private static List Regression(List<(double X, double Y)> samples, bool extend, + BitChartPluginContext ctx, bool byIndex, BitChartArea plot) + { + int n = samples.Count; + double sx = 0, sy = 0, sxy = 0, sxx = 0; + foreach (var (x, y) in samples) { sx += x; sy += y; sxy += x * y; sxx += x * x; } + double denom = n * sxx - sx * sx; + // A vertical run of samples has no least-squares line; the mean is the honest answer for it. + if (Math.Abs(denom) < 1e-12) return Flat(samples); + + double slope = (n * sxy - sx * sy) / denom; + double intercept = (sy - slope * sx) / n; + + double x0 = samples[0].X, x1 = samples[^1].X; + if (!extend) + return [new Vertex(x0, intercept + slope * x0), new Vertex(x1, intercept + slope * x1)]; + + // Extending means walking the line out to the plot edges, so the endpoints are converted the + // other way round: from a pixel back to the data coordinate the fit is expressed in. + double px0 = byIndex ? ctx.XForIndex((int)Math.Round(x0)) : ctx.XForValue(x0); + double px1 = byIndex ? ctx.XForIndex((int)Math.Round(x1)) : ctx.XForValue(x1); + if (Math.Abs(px1 - px0) < 1e-9) + return [new Vertex(x0, intercept + slope * x0), new Vertex(x1, intercept + slope * x1)]; + + double perPixel = (x1 - x0) / (px1 - px0); + double left = px0 < px1 ? plot.Left : plot.Right; + double right = px0 < px1 ? plot.Right : plot.Left; + double dataAtLeft = x0 + (left - px0) * perPixel; + double dataAtRight = x0 + (right - px0) * perPixel; + return + [ + new Vertex(left, intercept + slope * dataAtLeft, Fixed: true), + new Vertex(right, intercept + slope * dataAtRight, Fixed: true) + ]; + } + + /// Trailing simple moving average; the first points average what there is so far. + private static List MovingAverage(List<(double X, double Y)> samples, int period) + { + int p = Math.Max(2, period); + var result = new List(samples.Count); + double sum = 0; + for (int i = 0; i < samples.Count; i++) + { + sum += samples[i].Y; + if (i >= p) sum -= samples[i - p].Y; + result.Add(new Vertex(samples[i].X, sum / Math.Min(i + 1, p))); + } + return result; + } + + /// A flat line at the mean of the series. + private static List Flat(List<(double X, double Y)> samples) + { + double mean = samples.Average(s => s.Y); + return [new Vertex(samples[0].X, mean), new Vertex(samples[^1].X, mean)]; + } +} diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss index db6c05810c..3ba68da4cf 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Styles/extra-components.scss @@ -1,6 +1,7 @@ @import "../Components/AccentColorSwitcher/BitAccentColorSwitcher.scss"; @import "../Components/AccordionList/BitAccordionList.scss"; @import "../Components/AppShell/BitAppShell.scss"; +@import "../Components/Chart/BitChart.scss"; @import "../Components/DataGrid/BitDataGrid.scss"; @import "../Components/ErrorBoundary/BitErrorBoundary.scss"; @import "../Components/Flag/BitFlag.scss"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupCustomDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupCustomDemo.razor index 8cf4168544..bbd7048d2b 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupCustomDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupCustomDemo.razor @@ -400,9 +400,9 @@
The Overflow decides what happens when the buttons do not fit: Wrap moves them onto - more lines, Scroll keeps one line and lets the group scroll sideways — by swiping, by + more lines, Scroll keeps one line and lets the group scroll sideways - by swiping, by shift+wheel, or with the arrow keys, since the scrollbar itself is hidden so that the border keeps hugging - the buttons — Scrollbar does the same but keeps the scrollbar visible as an affordance, + the buttons - Scrollbar does the same but keeps the scrollbar visible as an affordance, at the cost of the height it occupies inside the border, and Clip (the default) keeps one line and cuts the rest off. Drag the bottom-right corner of the dashed boxes below to resize them and watch the behavior change. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupItemDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupItemDemo.razor index b0f92af25a..158b34b1ac 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupItemDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupItemDemo.razor @@ -326,9 +326,9 @@
The Overflow decides what happens when the buttons do not fit: Wrap moves them onto - more lines, Scroll keeps one line and lets the group scroll sideways — by swiping, by + more lines, Scroll keeps one line and lets the group scroll sideways - by swiping, by shift+wheel, or with the arrow keys, since the scrollbar itself is hidden so that the border keeps hugging - the buttons — Scrollbar does the same but keeps the scrollbar visible as an affordance, + the buttons - Scrollbar does the same but keeps the scrollbar visible as an affordance, at the cost of the height it occupies inside the border, and Clip (the default) keeps one line and cuts the rest off. Drag the bottom-right corner of the dashed boxes below to resize them and watch the behavior change. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupOptionDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupOptionDemo.razor index 939d5e613f..9a51a092cb 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupOptionDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Buttons/ButtonGroup/_BitButtonGroupOptionDemo.razor @@ -541,9 +541,9 @@
The Overflow decides what happens when the buttons do not fit: Wrap moves them onto - more lines, Scroll keeps one line and lets the group scroll sideways — by swiping, by + more lines, Scroll keeps one line and lets the group scroll sideways - by swiping, by shift+wheel, or with the arrow keys, since the scrollbar itself is hidden so that the border keeps hugging - the buttons — Scrollbar does the same but keeps the scrollbar visible as an affordance, + the buttons - Scrollbar does the same but keeps the scrollbar visible as an affordance, at the cost of the height it occupies inside the border, and Clip (the default) keeps one line and cuts the rest off. Drag the bottom-right corner of the dashed boxes below to resize them and watch the behavior change. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor index cbd8fee9c5..55b0d81942 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor @@ -2,11 +2,12 @@ + Description="Native Blazor charting component rendered entirely with SVG - no JavaScript charting engine and no canvas - covering line, bar, area, pie, doughnut, polar area, radar, scatter, bubble, sparkline and mixed charts with scales, legends, tooltips, annotations, trendlines, animations, zoom, live updates, export and full keyboard and screen-reader support." /> @@ -22,60 +23,96 @@ nuget package, as described in the Optional steps of the Getting started page. + + The chart's styles ship in the Extras stylesheet + (_content/Bit.BlazorUI.Extras/styles/bit.blazorui.extras.css), so make sure it is linked + alongside the core stylesheet. + + + Like every other animated component, the chart honors the reduced motion preference of the OS/browser + (prefers-reduced-motion): its entry animations are left out and each chart is drawn straight in its final + state. If nothing on this page is animating, either turn the reduce motion setting off, use the + ForceAnimation parameter, or turn on the ForceAnimation toggle at the top of this page. + - - - <_BitChartLineDemo /> - - - <_BitChartBarDemo /> - - - <_BitChartAreaDemo /> - - - <_BitChartPieDemo /> - - - <_BitChartPolarDemo /> - - - <_BitChartRadarDemo /> - - - <_BitChartScatterDemo /> - - - <_BitChartMixedDemo /> - - - <_BitChartMultiAxisDemo /> - - - <_BitChartScalesDemo /> - - - <_BitChartTimeDemo /> - - - <_BitChartLegendDemo /> - - - <_BitChartTooltipsDemo /> - - - <_BitChartAnnotationsDemo /> - - - <_BitChartAnimationsDemo /> - - - <_BitChartScriptableDemo /> - - - <_BitChartZoomDemo /> - - + @* A plain element the scoped stylesheet can attach to: everything below is components, and a + ::deep rule needs an anchor carrying this page's scope attribute. *@ +
+ + + <_BitChartLineDemo /> + + + <_BitChartBarDemo /> + + + <_BitChartAreaDemo /> + + + <_BitChartPieDemo /> + + + <_BitChartPolarDemo /> + + + <_BitChartRadarDemo /> + + + <_BitChartScatterDemo /> + + + <_BitChartMixedDemo /> + + + <_BitChartMultiAxisDemo /> + + + <_BitChartScalesDemo /> + + + <_BitChartTimeDemo /> + + + <_BitChartTitlesDemo /> + + + <_BitChartLegendDemo /> + + + <_BitChartTooltipsDemo /> + + + <_BitChartInteractionDemo /> + + + <_BitChartDataLabelsDemo /> + + + <_BitChartAnnotationsDemo /> + + + <_BitChartTrendlinesDemo /> + + + <_BitChartAnimationsDemo /> + + + <_BitChartScriptableDemo /> + + + <_BitChartZoomDemo /> + + + <_BitChartLiveDemo /> + + + <_BitChartLocalizationDemo /> + + + <_BitChartExportDemo /> + + +
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.cs index 14955ec02d..6f3ac8c648 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.cs @@ -6,6 +6,20 @@ public partial class BitChartDemo private readonly List componentParameters = [ + new() + { + Name = "AriaLabel", + Type = "string?", + DefaultValue = "null", + Description = "Accessible label for the chart. When null it falls back to the chart title, then to a generated summary." + }, + new() + { + Name = "Class", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS class applied to the root element of the chart." + }, new() { Name = "Config", @@ -16,13 +30,6 @@ public partial class BitChartDemo Href = "#chart-config" }, new() - { - Name = "Type", - Type = "BitChartType", - DefaultValue = "BitChartType.Line", - Description = "The chart type: Line, Bar, Radar, Pie, Doughnut, PolarArea, Bubble or Scatter." - }, - new() { Name = "Data", Type = "BitChartData?", @@ -33,17 +40,24 @@ public partial class BitChartDemo }, new() { - Name = "Options", - Type = "BitChartOptions?", + Name = "Dir", + Type = "BitDir?", DefaultValue = "null", - Description = "The chart options: scales, plugins (title, legend, tooltip), interaction, animation and zoom." + Description = "Text direction of the chrome around the plot (title, legend, tooltip and the screen-reader table). The plot keeps its own coordinates; mirror it by setting Reverse on the index scale." }, new() { - Name = "Width", - Type = "string", - DefaultValue = "100%", - Description = "CSS width of the chart container." + Name = "ForceAnimation", + Type = "bool", + DefaultValue = "false", + Description = "Plays the entry and update animations even when reduced motion is requested (prefers-reduced-motion: reduce). By default the chart honors that preference and draws itself straight in its final state." + }, + new() + { + Name = "GenerateTable", + Type = "bool", + DefaultValue = "true", + Description = "Renders a visually-hidden data table for screen readers and points the chart's aria-describedby at it." }, new() { @@ -54,38 +68,92 @@ public partial class BitChartDemo }, new() { - Name = "Class", - Type = "string?", - DefaultValue = "null", - Description = "Custom CSS class applied to the root element of the chart." + Name = "HtmlAttributes", + Type = "Dictionary", + DefaultValue = "new()", + Description = "Additional HTML attributes applied to the root element." }, new() { - Name = "Style", + Name = "Id", Type = "string?", DefaultValue = "null", - Description = "Custom CSS style applied to the root element of the chart." + Description = "The id of the root element of the chart." }, new() { - Name = "AriaLabel", + Name = "MaxTableColumns", + Type = "int", + DefaultValue = "100", + Description = "Upper bound on the columns the screen-reader table renders. A value series is one row with a cell per category, so a long one is wide rather than tall and the row cap alone would not contain it. Ignored for scatter and bubble data, whose table is three fixed columns." + }, + new() + { + Name = "MaxTableRows", + Type = "int", + DefaultValue = "500", + Description = "Upper bound on the rows the screen-reader table renders, so a long series does not put tens of thousands of hidden nodes in the DOM. Past the limit the caption says how many rows were left out." + }, + new() + { + Name = "NavigationHint", Type = "string?", + DefaultValue = "\"Interactive chart. Use the left and right arrow keys...\"", + Description = "A visually hidden sentence telling a screen-reader user how to walk the data, pointed at by aria-describedby alongside the data table. Only rendered when there is data to navigate; set it to null to leave it out." + }, + new() + { + Name = "NoDataTemplate", + Type = "RenderFragment?", DefaultValue = "null", - Description = "Accessible label for the chart. When null a summary is generated from the title and datasets." + Description = "Custom content shown in place of the plot when there is nothing to draw. Takes precedence over NoDataText." }, new() { - Name = "GenerateTable", - Type = "bool", - DefaultValue = "true", - Description = "Renders a visually-hidden data table for screen readers." + Name = "NoDataText", + Type = "string", + DefaultValue = "No data to display", + Description = "Message shown in place of the plot when the configuration produces nothing to draw." }, new() { - Name = "RespectReducedMotion", - Type = "bool", - DefaultValue = "true", - Description = "When true, animations are disabled for users who requested reduced motion (prefers-reduced-motion: reduce). Set to false to always animate regardless of the OS setting." + Name = "OnElementClick", + Type = "EventCallback<(int DatasetIndex, int DataIndex)>", + Description = "Callback raised when a data element (point, bar, arc, ...) is clicked, by pointer or with Enter/Space while it is focused." + }, + new() + { + Name = "OnElementHover", + Type = "EventCallback", + Description = "Callback raised when the active (hovered or keyboard-focused) element set changes. The context is null once nothing is active." + }, + new() + { + Name = "OnLegendItemClick", + Type = "EventCallback", + Description = "Callback raised when a legend item is clicked, before the default visibility toggle runs." + }, + new() + { + Name = "OnZoomChange", + Type = "EventCallback", + Description = "Callback raised after zoom or pan changes the visible axis ranges." + }, + new() + { + Name = "Options", + Type = "BitChartOptions?", + DefaultValue = "null", + Description = "The chart options: scales, plugins (title, legend, tooltip, data labels), interaction, animation, culture and zoom.", + LinkType = LinkType.Link, + Href = "#chart-options" + }, + new() + { + Name = "Style", + Type = "string?", + DefaultValue = "null", + Description = "Custom CSS style applied to the root element of the chart." }, new() { @@ -96,9 +164,123 @@ public partial class BitChartDemo }, new() { - Name = "OnElementClick", - Type = "EventCallback<(int DatasetIndex, int DataIndex)>", - Description = "Callback raised when a data element (point, bar, arc, ...) is clicked." + Name = "Type", + Type = "BitChartType", + DefaultValue = "BitChartType.Line", + Description = "The chart type: Line, Bar, Radar, Pie, Doughnut, PolarArea, Bubble or Scatter." + }, + new() + { + Name = "Width", + Type = "string", + DefaultValue = "100%", + Description = "CSS width of the chart container." + }, + ]; + + private readonly List componentPublicMembers = + [ + new() + { + Name = "Refresh", + Type = "void Refresh()", + Description = "Rebuilds and redraws the chart from its current data and options. Blazor only re-renders on a parameter change it can see, so mutating the same BitChartData in place - appending to a live series, editing a value - needs this call. The counterpart of Chart.js's chart.update()." + }, + new() + { + Name = "IsDatasetVisible", + Type = "bool IsDatasetVisible(int datasetIndex)", + Description = "Whether a dataset is currently drawn, i.e. hidden neither through the legend nor by BitChartDataset.Hidden." + }, + new() + { + Name = "SetDatasetVisible", + Type = "void SetDatasetVisible(int datasetIndex, bool visible)", + Description = "Shows or hides a dataset, exactly as clicking its legend entry would; the axes re-scale around what is left." + }, + new() + { + Name = "ToggleDataset", + Type = "void ToggleDataset(int datasetIndex)", + Description = "Flips a dataset between shown and hidden." + }, + new() + { + Name = "IsDataIndexVisible", + Type = "bool IsDataIndexVisible(int dataIndex)", + Description = "Whether a data index - a pie, doughnut or polar-area slice - is currently drawn." + }, + new() + { + Name = "SetDataIndexVisible", + Type = "void SetDataIndexVisible(int dataIndex, bool visible)", + Description = "Shows or hides one data index across the chart, the slice-level counterpart of SetDatasetVisible." + }, + new() + { + Name = "ToggleDataIndex", + Type = "void ToggleDataIndex(int dataIndex)", + Description = "Flips one data index between shown and hidden." + }, + new() + { + Name = "ResetVisibility", + Type = "void ResetVisibility()", + Description = "Brings back every dataset and data index hidden through the legend or the API." + }, + new() + { + Name = "ResetZoom", + Type = "void ResetZoom()", + Description = "Clears every zoom/pan override and returns the chart to the full data range." + }, + new() + { + Name = "ZoomTo", + Type = "void ZoomTo(string axisId, double? min, double? max)", + Description = "Zooms an axis to an explicit value range, honoring the configured zoom limits. Pass null bounds to clear that axis's override." + }, + new() + { + Name = "GetAxisRange", + Type = "(double Min, double Max)? GetAxisRange(string axisId)", + Description = "The currently visible range of an axis: its zoomed range when zoomed, otherwise the full data range." + }, + new() + { + Name = "ExportSvgAsync", + Type = "Task ExportSvgAsync(string? fileName = null, string? backgroundColor = null)", + Description = "Downloads the chart as a standalone .svg file, with the theme tokens it references resolved into the file." + }, + new() + { + Name = "ExportPngAsync", + Type = "Task ExportPngAsync(string? fileName = null, double scale = 2, string? backgroundColor = \"#ffffff\")", + Description = "Downloads the chart as a .png image rasterized from the live SVG at the given pixel ratio." + }, + new() + { + Name = "ExportCsvAsync", + Type = "Task ExportCsvAsync(string? fileName = null)", + Description = "Downloads the chart's data as a .csv file, formatted with the chart's culture." + }, + new() + { + Name = "ToCsv", + Type = "string ToCsv()", + Description = "Returns the chart's data as CSV text: one row per series for value datasets, one row per point for scatter and bubble datasets." + }, + new() + { + Name = "ToSvgStringAsync", + Type = "Task ToSvgStringAsync(string? backgroundColor = null)", + Description = "Returns the chart as standalone SVG markup instead of downloading it, with the theme tokens it references resolved into the markup. Null when the chart has not been rendered in a browser yet." + }, + new() + { + Name = "ToBase64ImageAsync", + Type = "Task ToBase64ImageAsync(string mimeType = \"image/png\", double scale = 2, string? backgroundColor = \"#ffffff\")", + Description = "Returns the rasterized chart as a data: URL - the same picture ExportPngAsync downloads - ready for an img src or a PDF. Mirrors Chart.js's toBase64Image." }, ]; @@ -163,7 +345,7 @@ public partial class BitChartDemo { Id = "chart-dataset", Title = "BitChartDataset", - Description = "A single dataset, mirroring Chart.js dataset configuration.", + Description = "A single dataset, mirroring Chart.js dataset configuration. Colors, radii and styles marked *Fn are scriptable: they receive a BitChartScriptableContext per element and take precedence over the constant beside them.", Parameters = [ new() @@ -178,14 +360,21 @@ public partial class BitChartDemo Name = "Data", Type = "List", DefaultValue = "new()", - Description = "Per-index values (line, bar, radar, pie, doughnut, polarArea)." + Description = "Per-index values (line, bar, radar, pie, doughnut, polarArea). A null is a gap, not a zero." }, new() { Name = "Points", Type = "List?", DefaultValue = "null", - Description = "Point data (x, y[, r]) for scatter/bubble charts. When set, takes precedence over Data." + Description = "Point data (x, y[, r]) for scatter, bubble and time-based line charts. When set, takes precedence over Data." + }, + new() + { + Name = "RangeData", + Type = "List<(double Low, double High)?>?", + DefaultValue = "null", + Description = "Floating-bar ranges per index. When set, bars span low to high instead of growing from the base." }, new() { @@ -199,14 +388,28 @@ public partial class BitChartDemo Name = "BackgroundColor", Type = "string?", DefaultValue = "null", - Description = "The fill color of the dataset (bars, arcs, points and area fills)." + Description = "The fill color of the dataset: bars, arcs, points and the area fill of a filled line." + }, + new() + { + Name = "BackgroundColors", + Type = "List?", + DefaultValue = "null", + Description = "One fill color per data index, cycled when shorter than the data." }, new() { Name = "BorderColor", Type = "string?", DefaultValue = "null", - Description = "The line/border color of the dataset." + Description = "The line/border color. Bars and arcs fall back to their own fill color rather than an unrelated palette entry." + }, + new() + { + Name = "BorderWidth", + Type = "double?", + DefaultValue = "null", + Description = "Border/line thickness. When null a per-type default applies: 3 for lines and radar, 2 for arcs, and 0 for bars unless a border color was given." }, new() { @@ -216,12 +419,317 @@ public partial class BitChartDemo Description = "Area fill mode for line/radar datasets (None, Origin, Start, End, Stack, Dataset, Value)." }, new() + { + Name = "FillGradient", + Type = "BitChartGradientBase?", + DefaultValue = "null", + Description = "A linear or radial gradient used for the area fill, taking precedence over FillColor and BackgroundColor." + }, + new() + { + Name = "BackgroundPattern", + Type = "BitChartFillPattern?", + DefaultValue = "null", + Description = "A repeating hatch/grid/dot texture used instead of a solid fill; keeps series distinguishable in print and greyscale." + }, + new() { Name = "Tension", Type = "double", DefaultValue = "0", Description = "Bezier curve tension for line datasets (0 = straight lines)." }, + new() + { + Name = "Stepped", + Type = "BitChartSteppedLine", + DefaultValue = "BitChartSteppedLine.False", + Description = "Draws the line as steps (Before, After or Middle) instead of interpolating." + }, + new() + { + Name = "SpanGaps", + Type = "bool", + DefaultValue = "false", + Description = "Bridges null values instead of breaking the line at them." + }, + new() + { + Name = "Segment", + Type = "BitChartLineSegmentStyle?", + DefaultValue = "null", + Description = "Per-segment color, width and dash callbacks, evaluated from the two endpoints of each segment." + }, + new() + { + Name = "PointRadius", + Type = "double", + DefaultValue = "3", + Description = "Marker radius. Zero hides the marker but keeps the point hoverable." + }, + new() + { + Name = "PointStyle", + Type = "BitChartPointStyle", + DefaultValue = "BitChartPointStyle.Circle", + Description = "Marker shape. BitChartPointStyle.None removes the markers - and their hit targets - entirely." + }, + new() + { + Name = "Stack", + Type = "string?", + DefaultValue = "null", + Description = "Stack group id. Datasets sharing an id accumulate together; each group gets its own column." + }, + new() + { + Name = "Grouped", + Type = "bool", + DefaultValue = "true", + Description = "When false the bar dataset leaves the side-by-side layout and keeps the whole category band, so it can sit behind the others." + }, + new() + { + Name = "SkipNull", + Type = "bool", + DefaultValue = "false", + Description = "Lets the remaining bars of a category widen over the datasets that have no value there, instead of leaving a hole." + }, + new() + { + Name = "MinBarLength", + Type = "double?", + DefaultValue = "null", + Description = "Minimum bar length in pixels, so near-zero values stay visible." + }, + new() + { + Name = "Base", + Type = "double?", + DefaultValue = "null", + Description = "The value bars grow from. Defaults to zero clamped into the axis range." + }, + new() + { + Name = "BorderRadius", + Type = "double", + DefaultValue = "0", + Description = "Corner radius. On a bar only the corners away from the skipped (baseline) edge are rounded, and BorderRadiusCorners overrides each corner; on a pie, doughnut or polar-area arc it rounds the arc's own corners, clamped to half the ring's thickness." + }, + new() + { + Name = "Offset / SpacingArc / HoverOffset", + Type = "double", + DefaultValue = "0 / 0 / 6", + Description = "Arc geometry: how far every slice sits from the center, the gap left between neighbouring slices, and the extra distance the hovered slice pops out." + }, + new() + { + Name = "Weight", + Type = "double", + DefaultValue = "1", + Description = "Relative thickness of this dataset's ring in a multi-dataset pie or doughnut. The available radius is shared out in proportion to the weights." + }, + new() + { + Name = "ErrorData", + Type = "List?", + DefaultValue = "null", + Description = "Per-index uncertainty, drawn as a capped whisker through the value and named in the tooltip. A BitChartErrorBar comes from one number (symmetric) or two (asymmetric); a null entry leaves that value bare. Cartesian charts only." + }, + new() + { + Name = "ErrorBarColor / ErrorBarWidth / ErrorBarCapWidth", + Type = "string? / double / double", + DefaultValue = "null / 1.5 / 8", + Description = "Error-bar styling. A null color follows the primary foreground token; a zero cap width draws a bare whisker." + }, + new() + { + Name = "HoverBackgroundColor / HoverBorderColor / HoverBorderWidth", + Type = "string? / string? / double?", + DefaultValue = "null", + Description = "Styling used while a bar or arc is hovered or keyboard-focused." + }, + new() + { + Name = "XAxisID / YAxisID / RAxisID", + Type = "string", + DefaultValue = "x / y / r", + Description = "The scales this dataset is bound to. Naming a scale that does not exist yet creates a linear one." + }, + new() + { + Name = "Order", + Type = "int", + DefaultValue = "0", + Description = "Draw order across datasets; lower draws first. Bars are always drawn before lines and points." + }, + new() + { + Name = "Hidden", + Type = "bool", + DefaultValue = "false", + Description = "Hides the dataset without removing it, and marks its legend entry as toggled off." + }, + ] + }, + new() + { + Id = "chart-options", + Title = "BitChartOptions", + Description = "Top-level chart options, mirroring Chart.js options. The same instance can safely be shared between charts: the renderer completes the missing scales locally instead of writing them back.", + Parameters = + [ + new() + { + Name = "Responsive", + Type = "bool", + DefaultValue = "true", + Description = "Observes the container and renders at real device pixels, which keeps font sizes constant at any width." + }, + new() + { + Name = "MaintainAspectRatio / AspectRatio", + Type = "bool / double?", + DefaultValue = "true / null", + Description = "Whether the height follows the width, and the ratio to use. Defaults to 2 for cartesian charts and 1 for circular and radar ones." + }, + new() + { + Name = "IndexAxis", + Type = "BitChartIndexAxis", + DefaultValue = "BitChartIndexAxis.X", + Description = "The axis the data index runs along: X for vertical bars, Y for horizontal ones." + }, + new() + { + Name = "Sparkline", + Type = "bool", + DefaultValue = "false", + Description = "Draws the chart as a sparkline: axes, grid, tick labels, legend, title and subtitle are all dropped so the series fills the box. A presentation switch only - tooltips, keyboard navigation and the screen-reader table still describe the full series." + }, + new() + { + Name = "Scales", + Type = "Dictionary", + DefaultValue = "new()", + Description = "Named scales keyed by id (x, y, r, y2, ...): type, min/max, grid, ticks, title, stacking, time unit and radial options." + }, + new() + { + Name = "Interaction", + Type = "BitChartInteractionOptions", + DefaultValue = "new()", + Description = "Mode (Nearest, Index, Dataset, ...) and Intersect. With Intersect false - the default - hit bands make the whole plot hoverable, marked by a crosshair and an axis chip (Crosshair / CrosshairLabel / CrosshairColor). The tooltip inherits Mode and Intersect unless it overrides them." + }, + new() + { + Name = "Plugins", + Type = "BitChartPluginOptions", + DefaultValue = "new()", + Description = "Title, Subtitle, Legend, Tooltip, DataLabels and Decimation options, plus Custom for your own IBitChartPlugin drawing plugins." + }, + new() + { + Name = "Animation", + Type = "BitChartAnimationOptions", + DefaultValue = "new()", + Description = "Duration, easing, per-element stagger (DelayBetween) and the progressive draw-on for line charts." + }, + new() + { + Name = "Elements", + Type = "BitChartElementOptions", + DefaultValue = "new()", + Description = "Per-type defaults used whenever a dataset leaves the matching property unset." + }, + new() + { + Name = "Layout", + Type = "BitChartLayoutOptions", + DefaultValue = "new()", + Description = "Padding around the whole chart." + }, + new() + { + Name = "Zoom", + Type = "BitChartZoomOptions", + DefaultValue = "new()", + Description = "Wheel zoom, drag pan, drag-to-zoom box, axis mode, speed, and the limits that keep the view inside the data." + }, + new() + { + Name = "Culture", + Type = "CultureInfo?", + DefaultValue = "null", + Description = "Culture used for every number and date the chart prints - ticks, tooltips, data labels and the CSV export. Null means the invariant culture." + }, + new() + { + Name = "CutoutPercentage / CircumferenceDegrees / RotationDegrees", + Type = "double", + DefaultValue = "50 / 360 / -90", + Description = "Doughnut hole size, sweep and starting angle. A 180 degree sweep turns a doughnut into a gauge." + }, + ] + }, + new() + { + Id = "chart-trendline", + Title = "BitChartTrendline", + Description = "One fitted line drawn over a dataset by BitChartTrendlinePlugin, which is registered through Options.Plugins.Custom. Cartesian charts only.", + Parameters = + [ + new() + { + Name = "DatasetIndex", + Type = "int", + DefaultValue = "0", + Description = "Index of the dataset the line is fitted to. A dataset hidden through the legend takes its trend line with it." + }, + new() + { + Name = "Kind", + Type = "BitChartTrendlineKind", + DefaultValue = "BitChartTrendlineKind.Linear", + Description = "Linear for a least-squares regression, MovingAverage for a trailing average over Period points, or Average for a flat line at the series mean." + }, + new() + { + Name = "Period", + Type = "int", + DefaultValue = "5", + Description = "Window of the trailing moving average. Ignored by the other kinds." + }, + new() + { + Name = "Extend", + Type = "bool", + DefaultValue = "false", + Description = "Projects a straight fit out to both edges of the plot instead of stopping at the first and last data point. Ignored by MovingAverage, which has no meaning outside the data." + }, + new() + { + Name = "Color / LineWidth / Dash", + Type = "string? / double / List?", + DefaultValue = "null / 2 / [6, 4]", + Description = "Line styling. A null color follows the dataset's own border color; the dash is what keeps the fit from reading as another measured series - set it to null for a solid line." + }, + new() + { + Name = "Label / LabelColor / LabelBackground / LabelFont", + Type = "string? / string / string? / BitChartFont", + DefaultValue = "null / #fff / null / 11px bold", + Description = "An optional pill drawn at the end of the line, pinned inside the plot so it stays readable at the edge." + }, + new() + { + Name = "DrawBehindDatasets", + Type = "bool", + DefaultValue = "false", + Description = "Draws the line under the datasets rather than over them." + }, ] }, ]; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.scss b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.scss index fe7fc6e172..30392ab6bc 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.scss +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/BitChartDemo.razor.scss @@ -22,4 +22,81 @@ margin-top: 2rem; justify-content: center; } + + // The live controls above a sample (selects, checkboxes, sliders) share one row and wrap. + .controls { + gap: 1rem; + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 1rem; + + > label { + gap: 0.35rem; + display: inline-flex; + align-items: center; + } + } + + // A row of KPI tiles, each holding one chrome-free sparkline. + .sparkline-tiles { + gap: 1rem; + display: grid; + margin-top: 1rem; + grid-template-columns: repeat(auto-fit, minmax(rem2(160px), 1fr)); + } + + .sparkline-tile { + padding: 0.75rem; + border-radius: 6px; + background: var(--bit-clr-bg-sec); + border: 1px solid var(--bit-clr-brd-sec); + } + + .sparkline-caption { + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--bit-clr-fg-sec); + } + + .sparkline-value { + font-size: 1.5rem; + font-weight: 600; + line-height: 1.2; + margin-bottom: 0.25rem; + } + + .linked-readout { + padding: 0.5rem; + margin: 1rem 0; + text-align: center; + border-radius: 4px; + background: var(--bit-clr-bg-sec); + border: 1px solid var(--bit-clr-brd-sec); + } + + .brush-readout { + margin: 0.5rem 0 0.25rem; + text-align: center; + font-size: 0.85rem; + color: var(--bit-clr-fg-sec); + } + + .chart-snapshot { + max-width: 100%; + border-radius: 4px; + border: 1px solid var(--bit-clr-brd-sec); + } + + .csv-preview { + margin: 1rem 0; + padding: 0.75rem; + overflow: auto; + max-height: rem2(180px); + font-size: 0.8rem; + border-radius: 4px; + background: var(--bit-clr-bg-sec); + border: 1px solid var(--bit-clr-brd-sec); + } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnimationsDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnimationsDemo.razor index 5b8dbc2125..9d9ca88cff 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnimationsDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnimationsDemo.razor @@ -1,7 +1,12 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
Entry and update animations are driven by CSS in the browser: a staggered rise plus smooth value transitions. Tweak the controls below.
+
+ Entry animations are pure CSS, so they cost no JavaScript and replay whenever the data changes - the + renderer keys the scene on the values, so resizing, zooming and panning stay still. Duration and easing + map straight to animation-duration and animation-timing-function, and a positive + DelayBetween staggers the bars so they grow out of the axis one after another. +
Randomize data
-
+
-
The line path animates in (draw-on) and the points follow. These controls are independent from the Bars sample above.
+
+ With Progressive the stroke draws itself on from left to right and each marker pops in as the + line reaches it, which reads like the series being gathered over time. Dashed lines fade in instead, since + the dash pattern is already using the stroke dash array. +
Randomize data
-
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor index a8b2b0bf6c..2f4d32311b 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor @@ -1,11 +1,28 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
horizontal target + warning band
-
+
+ BitChartAnnotationPlugin draws reference lines, boxes, points and labels in data coordinates, + so they stay glued to the values as the chart resizes. A target line plus a shaded warning band turns raw + numbers into a verdict. +
+
-
highlight a region and an event
-
+
+ A box highlights a span of categories and a vertical line marks a single event. + DrawBehindDatasets decides whether an annotation sits under the data or over it. +
+
+
+ + +
+ Beyond lines and boxes, an annotation can be an Ellipse inscribed in the same XMin/XMax and + YMin/YMax bounds - for circling a cluster rather than boxing it - a regular Polygon of + any Sides, Radius and Rotation, or a Point. All three are + placed in data coordinates and take a Label, so they follow the values as the chart resizes. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor.cs index 1ae65c8e51..6b0f9a4445 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAnnotationsDemo.razor.cs @@ -4,6 +4,7 @@ public partial class _BitChartAnnotationsDemo { private BitChartOptions _lines = default!; private BitChartOptions _box = default!; + private BitChartOptions _shapes = default!; protected override void OnInitialized() { @@ -36,6 +37,36 @@ protected override void OnInitialized() } } }; + + _shapes = new BitChartOptions + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Custom = + { + new BitChartAnnotationPlugin( + new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Ellipse, XIsIndex = true, + XMin = 4, XMax = 6, YMin = 65, YMax = 100, + Color = "#2ecc71", FillColor = "rgba(46,204,113,0.12)", Label = "Best run" + }, + new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Polygon, XIsIndex = true, + XMin = 3, Value = 35, Sides = 3, Radius = 12, + Color = "#ff6384", Label = "Dip" + }, + new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Point, XIsIndex = true, + XMin = 1, Value = 45, Radius = 7, Color = "#9966ff" + } + ) + } + } + }; } private BitChartData Series() => new() @@ -110,4 +141,38 @@ protected override void OnInitialized() Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" }, Datasets = { new BitChartDataset { Label = ""Sales"", Data = new() { 30, 42, 55, 70, 64, 48, 52 }, BackgroundColor = ""#36a2eb"" } } };"; + + private readonly string shapesRazorCode = @""; + private readonly string shapesCsharpCode = @" +_shapes = new BitChartOptions +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Custom = + { + new BitChartAnnotationPlugin( + // An ellipse takes the same bounds a box does, and is inscribed in them. + new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Ellipse, XIsIndex = true, + XMin = 4, XMax = 6, YMin = 65, YMax = 100, + Color = ""#2ecc71"", FillColor = ""rgba(46,204,113,0.12)"", Label = ""Best run"" + }, + // A polygon is centered on one coordinate, sized in pixels, and points up by default. + new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Polygon, XIsIndex = true, + XMin = 3, Value = 35, Sides = 3, Radius = 12, + Color = ""#ff6384"", Label = ""Dip"" + }, + new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Point, XIsIndex = true, + XMin = 1, Value = 45, Radius = 7, Color = ""#9966ff"" + } + ) + } + } +};"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAreaDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAreaDemo.razor index aeb8c01b5f..ba68274fe9 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAreaDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartAreaDemo.razor @@ -1,26 +1,41 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
vertical gradient under a smooth line
-
+
+ An area is a line dataset with a Fill mode. FillGradient paints it with a linear + gradient - fading to transparent at the bottom keeps the fill from competing with the gridlines. +
+
-
scales.y.stacked, three cumulative series
-
+
+ With Stacked on the value axis the line datasets accumulate: each band is drawn between the + running total below it and its own, so the top edge is the overall total. +
+
-
upper fills down to lower (BitChartFillMode.Dataset)
-
+
+ BitChartFillMode.Dataset fills to another dataset's line instead of to an axis, with + FillTargetIndex naming it. That is the band shape for confidence intervals and min/max ranges. +
+
-
origin, start (top) and end (bottom) baselines
-
+
+ The remaining fill modes pick a different baseline: Origin fills to zero, Start + to the bottom of the axis, End to the top, and Value to any number you name. +
+
-
center→edge radial fill under the line
-
+
+ BitChartRadialGradient fills from the center of the shape outwards. Its center and radius are + fractions of the filled area's bounding box, so the gradient follows the data as the chart resizes. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor index a652bcc8ea..a8ec1235b7 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor @@ -1,51 +1,126 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
three datasets side by side
-
+
+ The default bar layout: every dataset gets its own slot inside each category, so the series stand side by + side and can be compared at a glance. CategoryPercentage controls how much of the band the + whole group takes and BarPercentage how much of its slot each bar fills. +
+
-
scales.y.stacked = true
-
+
+ Setting Stacked on the value scale makes the datasets accumulate instead of sitting side by + side, so each bar reads as a total broken into parts. Positive and negative values stack away from zero + independently. +
+
-
indexAxis = y
-
+
+ IndexAxis = BitChartIndexAxis.Y turns the chart on its side. Horizontal bars give long + category names room to breathe, so they suit rankings and survey results. +
+
-
borderRadius, palette per index
-
+
+ BorderRadius rounds the corners away from the baseline, so a bar keeps a flat foot on the + axis. A list in BackgroundColors gives one color per category instead of per series. +
+
-
each bar spans a [low, high] range
-
+
+ With RangeData each bar spans a [low, high] pair rather than growing from zero + - the shape for temperature ranges, price bands and Gantt-style schedules. +
+
-
scales.y.stacked100 normalizes each category
-
+
+ Adding Stacked100 normalizes every category to 100%, which shifts the story from totals to + composition. The tooltip still shows the original values. +
+
-
hatch & dot patterns (print-friendly)
-
+
+ BackgroundPattern fills the bars with a repeating hatch, grid or dot texture. Patterns keep + the series distinguishable when the chart is printed in black and white or read by someone with a color + vision deficiency. +
+
-
per-corner borderRadius
-
+
+ BorderRadiusCorners sets each corner separately when the automatic choice is not what you + want - here only the two top corners, with BitChartBorderRadiusCorners.Top. +
+
-
long category labels tilt to fit
-
+
+ Category labels that do not fit their band tilt automatically, just far enough to fit and never past + MaxRotation. The axis then reserves the extra height the rotated text needs. +
+
-
two independent stacks side by side (Stack id)
-
+
+ Datasets sharing a Stack id accumulate together, and each id gets its own column. Two stacks + side by side compare composition across scenarios - and the value axis fits the tallest stack, not + the sum of them all. +
+
+
+ + +
+ MinBarLength keeps near-zero values visible instead of collapsing them into the axis line, + and Base moves the value bars grow from - here a target of 100, so the chart reads as + distance from target rather than absolute size. +
+
+
+ + +
+ A dataset with Grouped = false leaves the grouping layout and keeps the full category band, + which is how a target or budget series is drawn behind the actuals. SkipNull then lets the + remaining bars widen over the categories where a series has no value, instead of leaving a hole. +
+
+
+ + +
+ ErrorData hangs an uncertainty interval off each value and draws it as a capped whisker through + the bar's tip - a confidence band, a measurement tolerance, a spread. A BitChartErrorBar is + built from one number for a symmetric interval or two for an asymmetric one, a null entry leaves + that value bare, and the interval is named in the tooltip so it is read as well as seen. + ErrorBarColor, ErrorBarWidth and ErrorBarCapWidth style it; lines and + scatter points take the same property. +
+
+
+ + +
+ A waterfall shows how a starting figure becomes an ending one, and it needs nothing the chart does not + already have: RangeData floats each bar between the running total before and after the step, + and BackgroundColorFn colors it by direction - green for a gain, red for a loss, neutral + for the totals that sit on the axis. A Callbacks.Label then reports the step rather than the + two ends of the bar. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor.cs index 3fe72d9147..a309f0da49 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartBarDemo.razor.cs @@ -335,4 +335,300 @@ public partial class _BitChartBarDemo new BitChartDataset { Label = ""2026 · Renew"", Stack = ""2026"", Data = new() { 15, 18, 17, 22 }, BackgroundColor = ""#ffb1c1"" } } };"; + + private readonly BitChartOptions _target = new() + { + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Display = false } }, + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Title = new BitChartScaleTitleOptions { Display = true, Text = "Units" } } } + }; + + private BitChartData AgainstTarget() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset + { + Label = "Units vs target", + Data = BitChartSampleData.V(118, 96, 100.2, 131, 88, 104, 112), + Base = 100, + MinBarLength = 6, + BorderRadius = 3, + BackgroundColorFn = ctx => ctx.Value >= 100 ? "#2ecc71" : "#ff6384" + } + } + }; + + private BitChartData Overlay() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset + { + Label = "Budget", Grouped = false, + Data = BitChartSampleData.V(20, 22, 24, 26, 28, 30, 32), + BackgroundColor = "rgba(120,130,145,0.25)" + }, + new BitChartDataset + { + Label = "Team A", SkipNull = true, + Data = BitChartSampleData.V(12, 19, 14, 22, 18, 25, 20), + BackgroundColor = "#36a2eb", BorderRadius = 3 + }, + new BitChartDataset + { + Label = "Team B", SkipNull = true, + Data = BitChartSampleData.V(8, null, 17, null, 14, 12, 19), + BackgroundColor = "#ff9f40", BorderRadius = 3 + } + } + }; + + private readonly string minLengthRazorCode = @""; + private readonly string minLengthCsharpCode = @" +private readonly BitChartOptions _target = new() +{ + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Display = false } }, + Scales = { [""y""] = new BitChartScaleOptions { Id = ""y"", Title = new BitChartScaleTitleOptions { Display = true, Text = ""Units"" } } } +}; + +private BitChartData AgainstTarget() => new() +{ + Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" }, + Datasets = + { + new BitChartDataset + { + Label = ""Units vs target"", + Data = new() { 118, 96, 100.2, 131, 88, 104, 112 }, + Base = 100, // bars grow from the target instead of zero + MinBarLength = 6, // a value that is almost on target still shows + BorderRadius = 3, + BackgroundColorFn = ctx => ctx.Value >= 100 ? ""#2ecc71"" : ""#ff6384"" + } + } +};"; + + private readonly string overlayRazorCode = @""; + private readonly string overlayCsharpCode = @" +private BitChartData Overlay() => new() +{ + Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" }, + Datasets = + { + // Grouped = false steps out of the side-by-side layout and keeps the whole band. + new BitChartDataset + { + Label = ""Budget"", Grouped = false, + Data = new() { 20, 22, 24, 26, 28, 30, 32 }, + BackgroundColor = ""rgba(120,130,145,0.25)"" + }, + new BitChartDataset + { + Label = ""Team A"", SkipNull = true, + Data = new() { 12, 19, 14, 22, 18, 25, 20 }, + BackgroundColor = ""#36a2eb"", BorderRadius = 3 + }, + // The nulls leave no gap: Team A widens over those categories. + new BitChartDataset + { + Label = ""Team B"", SkipNull = true, + Data = new() { 8, null, 17, null, 14, 12, 19 }, + BackgroundColor = ""#ff9f40"", BorderRadius = 3 + } + } +};"; + + private BitChartData Measured() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset + { + Label = "Response time (ms)", + Data = BitChartSampleData.V(120, 138, 131, 156, 149, 162, 158), + BackgroundColor = "rgba(54,162,235,0.55)", + BorderColor = "#36a2eb", + BorderRadius = 4, + ErrorData = [12, 9, 15, new BitChartErrorBar(6, 24), 11, null, 8], + ErrorBarColor = "#1f2733" + } + } + }; + + private readonly string errorBarsRazorCode = @""; + private readonly string errorBarsCsharpCode = @" +private BitChartData Measured() => new() +{ + Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" }, + Datasets = + { + new BitChartDataset + { + Label = ""Response time (ms)"", + Data = new() { 120, 138, 131, 156, 149, 162, 158 }, + BackgroundColor = ""rgba(54,162,235,0.55)"", + BorderColor = ""#36a2eb"", + BorderRadius = 4, + // A number is a symmetric interval; BitChartErrorBar(minus, plus) is asymmetric; + // null leaves that value without a whisker. + ErrorData = [12, 9, 15, new BitChartErrorBar(6, 24), 11, null, 8], + ErrorBarColor = ""#1f2733"" + } + } +};"; + + // Each step is a signed change, except the two totals which are drawn from the axis. + private static readonly (string Label, double Step, bool IsTotal)[] WaterfallSteps = + [ + ("Opening", 120, true), + ("New", 46, false), + ("Upsell", 18, false), + ("Churn", -32, false), + ("Discounts", -11, false), + ("Expansion", 24, false), + ("Closing", 0, true) + ]; + + private readonly BitChartOptions _waterfall = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Tooltip = new BitChartTooltipOptions + { + Callbacks = new BitChartTooltipCallbacks + { + Label = item => + { + var (label, step, isTotal) = WaterfallSteps[item.DataIndex]; + return isTotal ? $"{label}: {WaterfallTotalAt(item.DataIndex):N0}" + : $"{label}: {step:+#,##0;-#,##0;0}"; + } + } + } + } + }; + + /// The running total once the given step has been applied. + private static double WaterfallTotalAt(int index) + { + double total = 0; + for (int i = 0; i <= index; i++) total += WaterfallSteps[i].Step; + return total; + } + + private BitChartData Waterfall() + { + var ranges = new List<(double Low, double High)?>(); + double running = 0; + foreach (var (_, step, isTotal) in WaterfallSteps) + { + if (isTotal) + { + running += step; + ranges.Add((0, running)); + continue; + } + double next = running + step; + ranges.Add((Math.Min(running, next), Math.Max(running, next))); + running = next; + } + + return new BitChartData + { + Labels = WaterfallSteps.Select(s => s.Label).ToList(), + Datasets = + { + new BitChartDataset + { + Label = "MRR", + RangeData = ranges, + BorderRadius = 3, + BackgroundColorFn = ctx => + { + var (_, step, isTotal) = WaterfallSteps[ctx.DataIndex]; + return isTotal ? "#6b7785" : step >= 0 ? "#2ecc71" : "#ff6384"; + } + } + } + }; + } + + private readonly string waterfallRazorCode = @""; + private readonly string waterfallCsharpCode = @" +// Each step is a signed change, except the two totals which are drawn from the axis. +private static readonly (string Label, double Step, bool IsTotal)[] Steps = +[ + (""Opening"", 120, true), + (""New"", 46, false), + (""Upsell"", 18, false), + (""Churn"", -32, false), + (""Discounts"", -11, false), + (""Expansion"", 24, false), + (""Closing"", 0, true) +]; + +private readonly BitChartOptions _waterfall = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Tooltip = new BitChartTooltipOptions + { + Callbacks = new BitChartTooltipCallbacks + { + Label = item => + { + var (label, step, isTotal) = Steps[item.DataIndex]; + return isTotal ? $""{label}: {WaterfallTotalAt(item.DataIndex):N0}"" + : $""{label}: {step:+#,##0;-#,##0;0}""; + } + } + } + } +}; + +// The running total once the given step has been applied. +private static double WaterfallTotalAt(int index) +{ + double total = 0; + for (int i = 0; i <= index; i++) total += Steps[i].Step; + return total; +} + +private BitChartData Waterfall() +{ + // RangeData floats each bar between the running total before and after its step. + var ranges = new List<(double Low, double High)?>(); + double running = 0; + foreach (var (_, step, isTotal) in Steps) + { + if (isTotal) { running += step; ranges.Add((0, running)); continue; } + double next = running + step; + ranges.Add((Math.Min(running, next), Math.Max(running, next))); + running = next; + } + + return new BitChartData + { + Labels = Steps.Select(s => s.Label).ToList(), + Datasets = + { + new BitChartDataset + { + Label = ""MRR"", + RangeData = ranges, + BorderRadius = 3, + BackgroundColorFn = ctx => + { + var (_, step, isTotal) = Steps[ctx.DataIndex]; + return isTotal ? ""#6b7785"" : step >= 0 ? ""#2ecc71"" : ""#ff6384""; + } + } + } + }; +}"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor new file mode 100644 index 0000000000..8e8123d9ec --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor @@ -0,0 +1,35 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart + + +
+ Turning on Plugins.DataLabels prints each value on the chart, which lets a small chart drop + its value axis entirely. Anchor picks the point on the element the label hangs off (tip, + middle or baseline) and Align decides whether it sits outside, on, or inside that point. +
+
+
+ + +
+ With Anchor = End and Align = Start the label moves inside the bar, and a + BackgroundColor turns it into a rounded pill that stays readable over any fill. +
+
+
+ + +
+ Line, scatter and radar point markers get labels too. Formatter shapes the text and + DisplayFn filters which elements get one - here only the values above 250, so the + chart stays legible. +
+
+
+ + +
+ On arcs the anchor moves the label between the inner and outer edge of the ring. FormatterCtx + receives the dataset and data index as well as the value, which is what makes a share-of-total label possible. +
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor.cs new file mode 100644 index 0000000000..f1e00967dc --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartDataLabelsDemo.razor.cs @@ -0,0 +1,185 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart; + +public partial class _BitChartDataLabelsDemo +{ + private readonly BitChartOptions _outside = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Anchor = BitChartAlign.End, + Align = BitChartAlign.End, + Font = new BitChartFont { Weight = "bold" } + } + }, + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Display = false, Grace = 0.15 } } + }; + + private readonly BitChartOptions _inside = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Anchor = BitChartAlign.End, + Align = BitChartAlign.Start, + Color = "#fff", + BackgroundColor = "rgba(0,0,0,0.35)", + Padding = 3, + Font = new BitChartFont { Weight = "bold" } + } + } + }; + + private readonly BitChartOptions _line = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Offset = 6, + Formatter = v => $"{v:N0} k", + DisplayFn = (v, _, _) => v > 250 + } + } + }; + + private readonly BitChartOptions _doughnut = new() + { + CutoutPercentage = 55, + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Right }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Anchor = BitChartAlign.Center, + Color = "#fff", + Font = new BitChartFont { Weight = "bold" }, + FormatterCtx = (v, _, _) => $"{v / 1340d * 100:N0}%" + } + } + }; + + private BitChartData Sales() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset { Label = "Units", Data = BitChartSampleData.V(12, 19, 14, 22, 18, 25, 20), + BackgroundColor = "#36a2eb", BorderRadius = 6 } + } + }; + + private BitChartData Traffic() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset { Label = "Visits", Data = BitChartSampleData.V(120, 190, 260, 250, 320, 300, 280), + BorderColor = "#4bc0c0", Tension = 0.35, PointRadius = 4 } + } + }; + + + private readonly string barsRazorCode = @""; + private readonly string barsCsharpCode = @" +private readonly BitChartOptions _outside = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Anchor = BitChartAlign.End, + Align = BitChartAlign.End, + Font = new BitChartFont { Weight = ""bold"" } + } + }, + Scales = { [""y""] = new BitChartScaleOptions { Id = ""y"", Display = false, Grace = 0.15 } } +}; + +private BitChartData Sales() => new() +{ + Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" }, + Datasets = + { + new BitChartDataset { Label = ""Units"", Data = new() { 12, 19, 14, 22, 18, 25, 20 }, + BackgroundColor = ""#36a2eb"", BorderRadius = 6 } + } +};"; + + private readonly string insideRazorCode = @""; + private readonly string insideCsharpCode = @" +private readonly BitChartOptions _inside = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Anchor = BitChartAlign.End, + Align = BitChartAlign.Start, + Color = ""#fff"", + BackgroundColor = ""rgba(0,0,0,0.35)"", + Padding = 3, + Font = new BitChartFont { Weight = ""bold"" } + } + } +};"; + + private readonly string lineRazorCode = @""; + private readonly string lineCsharpCode = @" +private readonly BitChartOptions _line = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Offset = 6, + Formatter = v => $""{v:N0} k"", + DisplayFn = (v, _, _) => v > 250 + } + } +}; + +private BitChartData Traffic() => new() +{ + Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" }, + Datasets = + { + new BitChartDataset { Label = ""Visits"", Data = new() { 120, 190, 260, 250, 320, 300, 280 }, + BorderColor = ""#4bc0c0"", Tension = 0.35, PointRadius = 4 } + } +};"; + + private readonly string doughnutRazorCode = @""; + private readonly string doughnutCsharpCode = @" +private readonly BitChartOptions _doughnut = new() +{ + CutoutPercentage = 55, + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Right }, + DataLabels = new BitChartDataLabelOptions + { + Display = true, + Anchor = BitChartAlign.Center, + Color = ""#fff"", + Font = new BitChartFont { Weight = ""bold"" }, + FormatterCtx = (v, _, _) => $""{v / 1340d * 100:N0}%"" + } + } +};"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor new file mode 100644 index 0000000000..86cb60341d --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor @@ -0,0 +1,55 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart + + +
+ Because the chart is a live SVG element, it can hand itself over as a file. Capture the component with + @@ref and call ExportSvgAsync for a vector file (theme tokens are resolved into + it so it looks right outside the app), ExportPngAsync for a raster image at the pixel ratio + you pass, or ExportCsvAsync for the underlying values - formatted with the chart's culture + and written with a byte order mark, so a file of non-Latin labels opens correctly in a spreadsheet. +
+
+ Download SVG + Download PNG + Download CSV +
+
+
+ + +
+ ToCsv() returns the same text the CSV export writes, so you can post it to a server, put it on + the clipboard, or show it - as here. Value datasets become one row per series with a column per label; + scatter and bubble datasets become one row per point. +
+
+ Show the CSV +
+ @if (_csv is not null) + { +
@_csv
+ } +
+
+ + +
+ ToSvgStringAsync() hands back the same standalone markup the SVG download writes, and + ToBase64ImageAsync() the same raster picture as a data: URL - the counterpart + of Chart.js's toBase64Image. Neither touches the file system, so the chart can go straight into a + report, an email body or an img tag, as the preview below does. +
+
+ Render to a data URL + Show the SVG markup +
+ @if (_dataUrl is not null) + { +
A snapshot of the chart
+ } + @if (_markup is not null) + { +
@_markup
+ } +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor.cs new file mode 100644 index 0000000000..d716122ed6 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartExportDemo.razor.cs @@ -0,0 +1,114 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart; + +public partial class _BitChartExportDemo +{ + private BitChart? _chart; + private BitChart? _csvChart; + private string? _csv; + + private readonly BitChartOptions _options = new() + { + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = "Revenue by product" }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + } + }; + + private Task ExportSvg() => _chart?.ExportSvgAsync("revenue.svg") ?? Task.FromResult(false); + + private Task ExportPng() => _chart?.ExportPngAsync("revenue.png", scale: 2) ?? Task.FromResult(false); + + private Task ExportCsv() => _chart?.ExportCsvAsync("revenue.csv") ?? Task.FromResult(false); + + private void ShowCsv() => _csv = _csvChart?.ToCsv(); + + private BitChart? _imageChart; + private string? _dataUrl; + private string? _markup; + + private readonly BitChartOptions _imageOptions = new() + { + CutoutPercentage = 55, + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = "Traffic by source" }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Right } + } + }; + + private async Task ShowImage() + { + if (_imageChart is null) return; + _dataUrl = await _imageChart.ToBase64ImageAsync(scale: 1); + } + + private async Task ShowMarkup() + { + if (_imageChart is null) return; + _markup = await _imageChart.ToSvgStringAsync(); + } + + + private readonly string exportRazorCode = @"Download SVG +Download PNG +Download CSV + +"; + private readonly string exportCsharpCode = @" +private BitChart? _chart; + +private readonly BitChartOptions _options = new() +{ + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = ""Revenue by product"" }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + } +}; + +private Task ExportSvg() => _chart!.ExportSvgAsync(""revenue.svg""); + +// scale 2 renders at twice the on-screen size, which stays crisp on high-density displays. +private Task ExportPng() => _chart!.ExportPngAsync(""revenue.png"", scale: 2); + +private Task ExportCsv() => _chart!.ExportCsvAsync(""revenue.csv"");"; + + private readonly string csvRazorCode = @"Show the CSV + +@if (_csv is not null) +{ +
@_csv
+} + +"; + private readonly string csvCsharpCode = @" +private BitChart? _csvChart; +private string? _csv; + +private void ShowCsv() => _csv = _csvChart!.ToCsv();"; + + private readonly string imageRazorCode = @"Render to a data URL +Show the SVG markup + +@if (_dataUrl is not null) +{ + +} +@if (_markup is not null) +{ +
@_markup
+} + +"; + private readonly string imageCsharpCode = @" +private BitChart? _imageChart; +private string? _dataUrl; +private string? _markup; + +// scale 1 matches the on-screen size; pass 2 for a high-density snapshot. +private async Task ShowImage() => _dataUrl = await _imageChart!.ToBase64ImageAsync(scale: 1); + +// The theme tokens the chart references are resolved into the markup, so it stands alone. +private async Task ShowMarkup() => _markup = await _imageChart!.ToSvgStringAsync();"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor new file mode 100644 index 0000000000..09659ff054 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor @@ -0,0 +1,63 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart + + +
+ By default Interaction.Intersect is false: an invisible hit band per category + covers the plot, so moving anywhere over the chart activates that index. A crosshair marks it and a chip + on the axis names it. That is what keeps a line drawn without markers (PointRadius = 0) + hoverable at all. Both the line and the chip can be turned off with Interaction.Crosshair + and Interaction.CrosshairLabel. +
+
+
+ + +
+ Set Interaction.Intersect = true for the strict Chart.js behaviour: only the element directly + under the pointer reacts. The same series is shown here with visible markers, since with this setting a + marker-less dataset would have nothing left to hover. +
+
+
+ + +
+ Interaction.Mode decides which elements join the hovered one: Nearest takes just + that element, Index takes every series at the same label, and Dataset takes the + whole series. The tooltip inherits the mode unless Plugins.Tooltip.Mode overrides it. +
+
+ +
+
+
+ + +
+ When the configuration produces nothing to draw the chart says so instead of showing bare axes. Set + NoDataText for a message, or NoDataTemplate to render your own content. +
+
+ @(_hasData ? "Clear the data" : "Restore the data") +
+
+
+ + +
+ OnElementHover reports the active items whenever they change, and null once nothing is active, + which is all it takes to tie several charts to one reading: hover either chart below and both name the same + month. The same callback drives a shared readout, a linked table row, or a map highlight - the chart + reports what is active and leaves the coordination to you. +
+
@(_linked ?? "Hover either chart")
+
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor.cs new file mode 100644 index 0000000000..9d3899d2bc --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartInteractionDemo.razor.cs @@ -0,0 +1,140 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart; + +public partial class _BitChartInteractionDemo +{ + private BitChartInteractionMode _mode = BitChartInteractionMode.Index; + private bool _hasData = true; + + private readonly BitChartOptions _default = new() + { + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } + }; + + private readonly BitChartOptions _intersect = new() + { + Interaction = new BitChartInteractionOptions { Intersect = true }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } + }; + + private BitChartOptions ModeOptions() => new() + { + Interaction = new BitChartInteractionOptions { Mode = _mode }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } + }; + + private BitChartData Series() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset { Label = "Requests", Data = BitChartSampleData.V(120, 190, 160, 250, 220, 300, 280), + BorderColor = "#36a2eb", PointRadius = 0, Tension = 0.3 }, + new BitChartDataset { Label = "Errors", Data = BitChartSampleData.V(12, 9, 20, 14, 18, 11, 8), + BorderColor = "#ff6384", PointRadius = 0, Tension = 0.3 } + } + }; + + private BitChartData Markers() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset { Label = "Requests", Data = BitChartSampleData.V(120, 190, 160, 250, 220, 300, 280), + BorderColor = "#36a2eb", PointRadius = 5, Tension = 0.3 }, + new BitChartDataset { Label = "Errors", Data = BitChartSampleData.V(12, 9, 20, 14, 18, 11, 8), + BorderColor = "#ff6384", PointRadius = 5, Tension = 0.3 } + } + }; + + private void ToggleEmpty() => _hasData = !_hasData; + + private BitChartData EmptyDemoData() => _hasData ? BitChartSampleData.Revenue() : new BitChartData(); + + + private readonly string anywhereRazorCode = @""; + private readonly string anywhereCsharpCode = @" +// Intersect is false by default, so no extra configuration is needed. +private readonly BitChartOptions _default = new() +{ + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } +}; + +private BitChartData Series() => new() +{ + Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" }, + Datasets = + { + new BitChartDataset { Label = ""Requests"", Data = new() { 120, 190, 160, 250, 220, 300, 280 }, + BorderColor = ""#36a2eb"", PointRadius = 0, Tension = 0.3 }, + new BitChartDataset { Label = ""Errors"", Data = new() { 12, 9, 20, 14, 18, 11, 8 }, + BorderColor = ""#ff6384"", PointRadius = 0, Tension = 0.3 } + } +};"; + + private readonly string intersectRazorCode = @""; + private readonly string intersectCsharpCode = @" +private readonly BitChartOptions _intersect = new() +{ + Interaction = new BitChartInteractionOptions { Intersect = true }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } +};"; + + private readonly string modesRazorCode = @" + +"; + private readonly string modesCsharpCode = @" +private BitChartInteractionMode _mode = BitChartInteractionMode.Index; + +private BitChartOptions ModeOptions() => new() +{ + Interaction = new BitChartInteractionOptions { Mode = _mode }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } +};"; + + private readonly string emptyRazorCode = @"Clear the data + +"; + private readonly string emptyCsharpCode = @" +private bool _hasData = true; + +private void ToggleEmpty() => _hasData = !_hasData; + +private BitChartData EmptyDemoData() => _hasData ? Revenue() : new BitChartData();"; + + private string? _linked; + + private readonly BitChartOptions _linkedOptions = new() + { + Interaction = new BitChartInteractionOptions { Mode = BitChartInteractionMode.Index }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } + }; + + /// Both charts report through here; a null context means the pointer has left. + private void Link(BitChartTooltipContext? context) + => _linked = context is null + ? null + : $"{context.Title}: " + string.Join(", ", context.Points.Select(p => $"{p.Label} {p.FormattedValue}")); + + private readonly string linkedRazorCode = @"
@(_linked ?? ""Hover either chart"")
+ + +"; + private readonly string linkedCsharpCode = @" +private string? _linked; + +private readonly BitChartOptions _linkedOptions = new() +{ + Interaction = new BitChartInteractionOptions { Mode = BitChartInteractionMode.Index }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } +}; + +// Both charts report through here; a null context means the pointer has left. +private void Link(BitChartTooltipContext? context) + => _linked = context is null + ? null + : $""{context.Title}: "" + string.Join("", "", context.Points.Select(p => $""{p.Label} {p.FormattedValue}""));"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor index 6ec3b27b23..8ff1fb0059 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor @@ -1,7 +1,12 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
Position, alignment, a legend title, point-style markers and reversed order. Click any item to toggle its dataset — the chart recomputes instantly.
+
+ The legend lives on any of the four sides and aligns to either end or the middle of that side; + Reverse flips the order to match a stack read top-down. Every entry is a toggle button: + clicking one hides its dataset and the axes re-scale around what is left. Change the controls to see each + option, and set OnClickToggle to false for a legend that only labels. +
-
+
-
a heading above the items
-
+
+ A legend Title names what the entries stand for, which matters once a page carries several + charts. +
+
-
usePointStyle uses each dataset's marker
-
+
+ UsePointStyle draws each entry with its dataset's own marker instead of a color box, so the + legend matches what is actually on the plot. Every entry is a real button: reachable by Tab, toggled with + Enter or Space, and reporting its state through aria-pressed. +
+
+
+ + +
+ With enough series the legend starts eating the plot. MaxHeight caps it in pixels and lets the + rest scroll, so the chart keeps its space and no entry is pushed off it. The entries stay real buttons, so + Tab still reaches the ones below the fold. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor.cs index 014b792206..ed4cb429db 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLegendDemo.razor.cs @@ -125,4 +125,49 @@ public partial class _BitChartLegendDemo PointBackgroundColor = ""#9966ff"", PointStyle = BitChartPointStyle.Triangle, PointRadius = 6, Tension = 0.3 } } };"; + + private readonly BitChartOptions _capped = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Right, MaxHeight = 120 } + } + }; + + private BitChartData ManySeries() + { + var data = new BitChartData { Labels = BitChartSampleData.Months.ToList() }; + for (int i = 0; i < 12; i++) + { + var values = BitChartSampleData.Months + .Select((_, x) => (double?)Math.Round(30 + i * 4 + Math.Sin((x + i) / 1.4) * 12, 1)) + .ToList(); + data.Datasets.Add(new BitChartDataset { Label = $"Region {i + 1}", Data = values, PointRadius = 0 }); + } + return data; + } + + private readonly string maxHeightRazorCode = @""; + private readonly string maxHeightCsharpCode = @" +private readonly BitChartOptions _capped = new() +{ + Plugins = new BitChartPluginOptions + { + // Past 120px the legend scrolls instead of pushing the plot out of the way. + Legend = new BitChartLegendOptions { Position = BitChartPosition.Right, MaxHeight = 120 } + } +}; + +private BitChartData ManySeries() +{ + var data = new BitChartData { Labels = { ""Jan"", ""Feb"", ""Mar"", ""Apr"", ""May"", ""Jun"", ""Jul"" } }; + for (int i = 0; i < 12; i++) + { + var values = Enumerable.Range(0, 7) + .Select(x => (double?)Math.Round(30 + i * 4 + Math.Sin((x + i) / 1.4) * 12, 1)) + .ToList(); + data.Datasets.Add(new BitChartDataset { Label = $""Region {i + 1}"", Data = values, PointRadius = 0 }); + } + return data; +}"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor index 4afae31dbe..183479be80 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor @@ -2,35 +2,78 @@
A line chart plots data points on a line, often to show a trend. Here it is smoothed (tension 0.4) and filled to the origin.
-
+
-
Two series without smoothing (tension 0).
-
+
+ Straight segments between the points (Tension = 0). Without smoothing the line states exactly + what was measured, which is the honest default for sparse or irregular data. +
+
-
A stepped line interpolates between points as steps (Stepped = BitChartSteppedLine.Before).
-
+
+ Stepped holds each value until the next one instead of sloping between them, which is how a + quantity that changes at a moment rather than continuously - a price tier, a headcount, a plan - should be + drawn. Before steps at the new point, After at the old one, and + Middle halfway between. +
+
-
A dashed border (BorderDash), star point markers and gap handling (SpanGaps) for a null value.
-
+
+ BorderDash takes the dash pattern - the conventional way to mark a forecast apart from a + measurement - and PointStyle picks the marker from ten shapes. A null value means + "not measured": by default the line breaks there, and SpanGaps bridges it instead, as here. +
+
-
A logarithmic value axis handles data that spans several orders of magnitude.
-
+
+ A logarithmic value axis handles data spanning several orders of magnitude: growth that would be a flat + line at the bottom of a linear axis becomes a readable slope. +
+
-
Each line segment is styled from its own context: up = green, down = red, projection dashed.
-
+
+ Segment styles each span between two points from its own endpoints rather than the dataset as a + whole, so one line can carry more than one meaning: rising green and falling red here, with the last stretch + dashed to mark it as a projection. The callbacks return null to fall back to the dataset's own styling. +
+
-
Monotone cubic interpolation smooths the line without overshooting the data points.
-
+
+ A cardinal spline (plain Tension) can bulge past the values it connects, inventing a peak that + was never measured - visible in the grey line here. CubicInterpolationMode.Monotone smooths + the same points without ever overshooting them, which is the honest choice whenever the curve will be read + for its extremes. +
+
+
+ + +
+ Sparkline drops every piece of chrome - axes, grid, tick labels, legend, title - + so the series alone fills the box, which is what makes a trend readable at the size of a KPI tile or a table + cell. It is a presentation switch only: the data is untouched, so hovering, keyboard navigation and the + screen-reader table still describe the whole series. Any chart type honours it, bars included. +
+
+ @foreach (var tile in _tiles) + { +
+
@tile.Caption
+
@tile.Value
+ +
+ } +
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor.cs index 4ffaeabc0b..705e9ffabd 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLineDemo.razor.cs @@ -7,6 +7,48 @@ public partial class _BitChartLineDemo Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } }; + /// + /// A sparkline drops the chrome, and turning off MaintainAspectRatio lets it take the tile's own + /// height instead of a width-derived one. + /// + private readonly BitChartOptions _sparkline = new() + { + Sparkline = true, + MaintainAspectRatio = false, + Layout = new BitChartLayoutOptions { Padding = 2 } + }; + + private sealed record SparklineTile(string Caption, string Value, BitChartType Type, BitChartData Data); + + private readonly List _tiles = + [ + new("Sessions", "12,480", BitChartType.Line, Spark("#36a2eb", true, 30, 34, 31, 40, 44, 41, 52, 58, 55, 64)), + new("Signups", "934", BitChartType.Bar, Spark("#4bc0c0", false, 12, 18, 15, 22, 19, 26, 24, 31, 28, 35)), + new("Errors", "17", BitChartType.Line, Spark("#ff6384", true, 22, 19, 24, 16, 14, 18, 11, 9, 12, 7)) + ]; + + private static BitChartData Spark(string color, bool line, params double?[] values) => new() + { + // A category axis spans the labels, so a sparkline still needs one per value - blank, since the + // tile shows no axis - or every point lands on the same x. + Labels = [.. values.Select(_ => "")], + Datasets = + { + new BitChartDataset + { + Data = [.. values], + BorderColor = color, + BackgroundColor = color, + PointRadius = 0, + BorderWidth = 2, + Tension = 0.35, + Fill = line ? BitChartFillMode.Origin : BitChartFillMode.None, + FillColor = line ? BitChartColorUtil.WithAlpha(color, 0.18) : null, + BorderRadius = 2 + } + } + }; + private readonly BitChartOptions _logOptions = new() { Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Logarithmic } } @@ -221,4 +263,53 @@ public partial class _BitChartLineDemo PointRadius = 3, PointBackgroundColor = ""#36a2eb"" } } };"; + + private readonly string sparklineRazorCode = @"@foreach (var tile in _tiles) +{ +
+
@tile.Caption
+
@tile.Value
+ +
+}"; + private readonly string sparklineCsharpCode = @" +// Sparkline hides the axes, grid, legend and title; turning off MaintainAspectRatio lets the +// chart take the tile's own height instead of one derived from its width. +private readonly BitChartOptions _sparkline = new() +{ + Sparkline = true, + MaintainAspectRatio = false, + Layout = new BitChartLayoutOptions { Padding = 2 } +}; + +private sealed record SparklineTile(string Caption, string Value, BitChartType Type, BitChartData Data); + +private readonly List _tiles = +[ + new(""Sessions"", ""12,480"", BitChartType.Line, Spark(""#36a2eb"", true, 30, 34, 31, 40, 44, 41, 52, 58, 55, 64)), + new(""Signups"", ""934"", BitChartType.Bar, Spark(""#4bc0c0"", false, 12, 18, 15, 22, 19, 26, 24, 31, 28, 35)), + new(""Errors"", ""17"", BitChartType.Line, Spark(""#ff6384"", true, 22, 19, 24, 16, 14, 18, 11, 9, 12, 7)) +]; + +private static BitChartData Spark(string color, bool line, params double?[] values) => new() +{ + // A category axis spans the labels, so a sparkline still needs one per value - blank, since the + // tile shows no axis - or every point lands on the same x. + Labels = [.. values.Select(_ => """")], + Datasets = + { + new BitChartDataset + { + Data = [.. values], + BorderColor = color, + BackgroundColor = color, + PointRadius = 0, + BorderWidth = 2, + Tension = 0.35, + Fill = line ? BitChartFillMode.Origin : BitChartFillMode.None, + FillColor = line ? BitChartColorUtil.WithAlpha(color, 0.18) : null, + BorderRadius = 2 + } + } +};"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor new file mode 100644 index 0000000000..2e2aef24b6 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor @@ -0,0 +1,39 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart +@implements IDisposable + + +
+ Blazor only re-renders when a parameter it can compare changes, so appending a reading to the same + BitChartData object leaves the chart showing the old scene. Refresh() is what tells + it to rebuild from the data as it now stands - the counterpart of Chart.js's chart.update() + - which is all a live feed needs. An open tooltip keeps pointing at the reading it was on rather than + blinking out on every tick. +
+
+ @(_timer is null ? "Start" : "Stop") + Add one reading +
+
+
+ + +
+ The legend's toggle is also an API: ToggleDataset, SetDatasetVisible and + IsDatasetVisible drive the same state from your own controls, ToggleDataIndex does it + per slice for pie, doughnut and polar-area charts, and ResetVisibility brings everything back. + Hiding a series re-scales the axes around what is left, exactly as clicking its legend entry would. +
+
+ @for (int i = 0; i < _revenue.Datasets.Count; i++) + { + var index = i; + + } + Show all +
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor.cs new file mode 100644 index 0000000000..8da160e7fd --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLiveDemo.razor.cs @@ -0,0 +1,169 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart; + +public partial class _BitChartLiveDemo +{ + private const int WindowSize = 40; + + private BitChart? _live; + private BitChart? _visibility; + private System.Timers.Timer? _timer; + private readonly Random _random = new(11); + private double _value = 50; + + /// The one data object the feed appends to; the chart is told to re-read it by Refresh(). + private readonly BitChartData _stream = new() + { + Datasets = + { + new BitChartDataset + { + Label = "Throughput", + BorderColor = "#36a2eb", + FillColor = "rgba(54,162,235,0.15)", + Fill = BitChartFillMode.Origin, + PointRadius = 0, + BorderWidth = 2, + Tension = 0.3 + } + } + }; + + private readonly BitChartOptions _streamOptions = new() + { + Animation = new BitChartAnimationOptions { Animate = false }, + Scales = + { + ["y"] = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Linear, BeginAtZero = true, SuggestedMax = 100 } + }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Display = false } } + }; + + private readonly BitChartData _revenue = BitChartSampleData.Revenue(); + + private readonly BitChartOptions _visibilityOptions = new() + { + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } + }; + + protected override void OnInitialized() + { + for (int i = 0; i < WindowSize; i++) Append(); + } + + private void Toggle() + { + if (_timer is not null) { Stop(); return; } + _timer = new System.Timers.Timer(700) { AutoReset = true }; + _timer.Elapsed += async (_, _) => await InvokeAsync(AddReading); + _timer.Start(); + } + + private void Stop() + { + _timer?.Stop(); + _timer?.Dispose(); + _timer = null; + } + + /// Appends one reading, drops the oldest, then asks the chart to redraw itself. + private void AddReading() + { + Append(); + _live?.Refresh(); + StateHasChanged(); + } + + private void Append() + { + _value = Math.Clamp(_value + _random.NextDouble() * 18 - 9, 5, 95); + var ds = _stream.Datasets[0]; + ds.Data.Add(Math.Round(_value, 1)); + _stream.Labels.Add(DateTime.Now.ToString("HH:mm:ss")); + if (ds.Data.Count <= WindowSize) return; + ds.Data.RemoveAt(0); + _stream.Labels.RemoveAt(0); + } + + public void Dispose() => Stop(); + + + private readonly string streamRazorCode = @"@(_timer is null ? ""Start"" : ""Stop"") +Add one reading + +"; + private readonly string streamCsharpCode = @" +private const int WindowSize = 40; + +private BitChart? _live; +private System.Timers.Timer? _timer; + +// One data object, appended to in place - Refresh() is what makes the chart re-read it. +private readonly BitChartData _stream = new() +{ + Datasets = + { + new BitChartDataset + { + Label = ""Throughput"", + BorderColor = ""#36a2eb"", + FillColor = ""rgba(54,162,235,0.15)"", + Fill = BitChartFillMode.Origin, + PointRadius = 0, + Tension = 0.3 + } + } +}; + +// A live feed redraws several times a second, so the entry animation is turned off. +private readonly BitChartOptions _streamOptions = new() +{ + Animation = new BitChartAnimationOptions { Animate = false }, + Scales = + { + [""y""] = new BitChartScaleOptions { Id = ""y"", Type = BitChartScaleType.Linear, BeginAtZero = true, SuggestedMax = 100 } + }, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Display = false } } +}; + +private void Toggle() +{ + if (_timer is not null) { Stop(); return; } + _timer = new System.Timers.Timer(700) { AutoReset = true }; + _timer.Elapsed += async (_, _) => await InvokeAsync(AddReading); + _timer.Start(); +} + +private void AddReading() +{ + Append(); // append one value, drop the oldest + _live?.Refresh(); // rebuild the scene from the data as it now stands + StateHasChanged(); +}"; + + private readonly string visibilityRazorCode = @"@for (int i = 0; i < _revenue.Datasets.Count; i++) +{ + var index = i; + +} + _visibility?.ResetVisibility()"">Show all + +"; + private readonly string visibilityCsharpCode = @" +private BitChart? _visibility; + +private readonly BitChartData _revenue = Revenue(); + +private readonly BitChartOptions _visibilityOptions = new() +{ + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } +}; + +// The same state the legend drives: +// chart.ToggleDataset(i) / SetDatasetVisible(i, visible) / IsDatasetVisible(i) +// chart.ToggleDataIndex(i) for one pie/doughnut/polar-area slice +// chart.ResetVisibility() to bring everything back"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor new file mode 100644 index 0000000000..fc42090bde --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor @@ -0,0 +1,69 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart + + +
+ Every number the chart prints - tick labels, tooltips, data labels and the CSV export - is + formatted with Options.Culture. It defaults to the invariant culture so output is stable + regardless of the thread culture; set it to CultureInfo.CurrentCulture to follow the user. +
+
+ +
+
+
+ + +
+ Ticks.Format takes any .NET numeric format string and is applied with the chart's culture, + so "C0" gives properly placed currency symbols. Prefix and Suffix + are the simpler alternative when you just need to wrap the number. +
+
+
+ + +
+ A time axis names its own months and days, and it names them in Options.Culture too - + printing French numbers beside English months would be worse than either. TimeFormat still + overrides the text entirely when a specific wording is wanted. +
+
+ +
+
+
+ + +
+ Layout is measured, not guessed: the chart works out how much room each tick label, legend entry and axis + title needs before it decides where the plot goes. That measurement knows a CJK, Kana or Hangul glyph fills + a full em where a Latin letter fills about half, so a Japanese or Korean axis reserves the space it actually + needs instead of letting its labels collide. +
+
+
+ + +
+ Dir="BitDir.Rtl" flips everything around the plot: the title, the legend, the tooltip and the + screen-reader table. The plot itself keeps its own coordinates - mirror it as well by setting + Reverse on the index scale, as this sample does. +
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor.cs new file mode 100644 index 0000000000..f2092bb301 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartLocalizationDemo.razor.cs @@ -0,0 +1,174 @@ +using System.Globalization; + +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart; + +public partial class _BitChartLocalizationDemo +{ + private string _culture = "de-DE"; + + private BitChartOptions CultureOptions() => new() + { + Culture = CultureInfo.GetCultureInfo(_culture), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } + }; + + private readonly BitChartOptions _currency = new() + { + Culture = CultureInfo.GetCultureInfo("en-US"), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Ticks = new BitChartTickOptions { Format = "C0" } } } + }; + + private string _dateCulture = "fr-FR"; + + private BitChartOptions DateOptions() => new() + { + Culture = CultureInfo.GetCultureInfo(_dateCulture), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = { ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Time } } + }; + + private readonly BitChartOptions _wide = new() + { + Culture = CultureInfo.GetCultureInfo("ja-JP"), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = + { + ["y"] = new BitChartScaleOptions + { + Id = "y", + Title = new BitChartScaleTitleOptions { Display = true, Text = "売上高(百万円)" } + } + } + }; + + private BitChartData Japanese() => new() + { + Labels = { "東京都", "大阪府", "愛知県", "福岡県", "北海道", "京都府" }, + Datasets = + { + new BitChartDataset + { + Label = "今年度", + Data = BitChartSampleData.V(320, 245, 198, 162, 140, 121), + BackgroundColor = "#36a2eb", + BorderRadius = 4 + } + } + }; + + private readonly BitChartOptions _rtl = new() + { + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = "فروش فصلی" }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + }, + Scales = { ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Category, Reverse = true } } + }; + + private BitChartData Revenue() => new() + { + Labels = BitChartSampleData.Months.ToList(), + Datasets = + { + new BitChartDataset { Label = "Revenue", Data = BitChartSampleData.V(12500.5, 19300.25, 14100, 22800.75, 18250, 25400, 20900), + BackgroundColor = "#36a2eb", BorderColor = "#36a2eb", Tension = 0.3 } + } + }; + + private BitChartData Persian() => new() + { + Labels = { "بهار", "تابستان", "پاییز", "زمستان" }, + Datasets = + { + new BitChartDataset { Label = "درآمد", Data = BitChartSampleData.V(120, 190, 160, 250), BackgroundColor = "#4bc0c0", BorderRadius = 4 }, + new BitChartDataset { Label = "هزینه", Data = BitChartSampleData.V(80, 120, 110, 150), BackgroundColor = "#ff9f40", BorderRadius = 4 } + } + }; + + + private readonly string cultureRazorCode = @""; + private readonly string cultureCsharpCode = @" +private string _culture = ""de-DE""; + +private BitChartOptions CultureOptions() => new() +{ + Culture = CultureInfo.GetCultureInfo(_culture), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } } +};"; + + private readonly string currencyRazorCode = @""; + private readonly string currencyCsharpCode = @" +private readonly BitChartOptions _currency = new() +{ + Culture = CultureInfo.GetCultureInfo(""en-US""), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = { [""y""] = new BitChartScaleOptions { Id = ""y"", Ticks = new BitChartTickOptions { Format = ""C0"" } } } +};"; + + private readonly string rtlRazorCode = @""; + private readonly string rtlCsharpCode = @" +private readonly BitChartOptions _rtl = new() +{ + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = ""فروش فصلی"" }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + }, + Scales = { [""x""] = new BitChartScaleOptions { Id = ""x"", Type = BitChartScaleType.Category, Reverse = true } } +}; + +private BitChartData Persian() => new() +{ + Labels = { ""بهار"", ""تابستان"", ""پاییز"", ""زمستان"" }, + Datasets = + { + new BitChartDataset { Label = ""درآمد"", Data = new() { 120, 190, 160, 250 }, BackgroundColor = ""#4bc0c0"", BorderRadius = 4 }, + new BitChartDataset { Label = ""هزینه"", Data = new() { 80, 120, 110, 150 }, BackgroundColor = ""#ff9f40"", BorderRadius = 4 } + } +};"; + + private readonly string datesRazorCode = @""; + private readonly string datesCsharpCode = @" +private string _dateCulture = ""fr-FR""; + +// The culture drives the month and day names on a time axis as well as the numbers. +private BitChartOptions DateOptions() => new() +{ + Culture = CultureInfo.GetCultureInfo(_dateCulture), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = { [""x""] = new BitChartScaleOptions { Id = ""x"", Type = BitChartScaleType.Time } } +};"; + + private readonly string wideRazorCode = @""; + private readonly string wideCsharpCode = @" +private readonly BitChartOptions _wide = new() +{ + Culture = CultureInfo.GetCultureInfo(""ja-JP""), + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = + { + [""y""] = new BitChartScaleOptions + { + Id = ""y"", + Title = new BitChartScaleTitleOptions { Display = true, Text = ""売上高(百万円)"" } + } + } +}; + +private BitChartData Japanese() => new() +{ + Labels = { ""東京都"", ""大阪府"", ""愛知県"", ""福岡県"", ""北海道"", ""京都府"" }, + Datasets = + { + new BitChartDataset + { + Label = ""今年度"", + Data = new() { 320, 245, 198, 162, 140, 121 }, + BackgroundColor = ""#36a2eb"", + BorderRadius = 4 + } + } +};"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMixedDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMixedDemo.razor index 6b3c9bf18d..0de4c3a951 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMixedDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMixedDemo.razor @@ -1,11 +1,17 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
line drawn on top of bars
-
+
+ A per-dataset Type mixes chart types in one plot. Bars are always drawn first so lines and + points land on top of them; Order decides the rest. +
+
-
revenue (left) vs. margin % (right)
-
+
+ Two measures with unrelated units get their own value axes: YAxisID binds a dataset to a + scale, and a scale with Position = BitChartPosition.Right is drawn on the other side. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMultiAxisDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMultiAxisDemo.razor index 339d1ee6d5..2b455d6ba3 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMultiAxisDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartMultiAxisDemo.razor @@ -1,16 +1,26 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
bars on left axis, line on right axis
-
+
+ The classic combo: rainfall as bars on the left axis, temperature as a line on the right. Each axis scales + to its own datasets, so neither measure flattens the other. +
+
-
two metrics with very different ranges
-
+
+ Two lines whose ranges differ by orders of magnitude. On one axis the smaller series would be a flat line + along the bottom. +
+
-
a second x-axis (top) with its own scale
-
+
+ Secondary x axes work the same way, through XAxisID. They stack outside the plot - + Position = BitChartPosition.Top puts this one above it - and only the primary axis + draws the chart-area grid. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor index 036d16ad28..8f16c662db 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor @@ -1,26 +1,62 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
Proportional slices. Click a legend label to hide a slice; hover a slice to see its share.
-
+
+ A pie divides one dataset into proportional slices, so it answers "what share of the whole". Each slice + takes its color from the palette by data index; hovering one shows its value and percentage, and clicking + a legend label removes it from the total and redraws the rest. +
+
-
A doughnut is a pie with a cutout in the middle (CutoutPercentage = 60).
-
+
+ A doughnut is a pie with the middle cut out (CutoutPercentage). The ring is easier to compare + than wedges, and the hole leaves room for a headline number. +
+
-
A half doughnut acts as a gauge: circumference 180°, rotation -90°.
-
+
+ Narrowing the sweep with CircumferenceDegrees and rotating the start with + RotationDegrees turns a doughnut into a gauge - 180° starting at the left here. +
+
-
Two datasets are drawn as concentric rings.
-
+
+ Several datasets become concentric rings sharing one set of labels, which compares the same breakdown + across two periods without two separate charts. +
+
-
The BitChartCenterTextPlugin draws a total inside the doughnut cutout.
-
+
+ BitChartCenterTextPlugin writes one or two lines into the cutout and shrinks them to fit, + turning the doughnut into a KPI tile. It is an ordinary IBitChartPlugin, so your own + overlays hook in the same way. +
+
+
+ + +
+ SpacingArc leaves a gap between neighbouring slices, Offset pushes every slice + out from the center, and HoverOffset pushes just the hovered one further - with + HoverBackgroundColor repainting it. All of it is precomputed, so hovering costs no re-layout. +
+
+
+ + +
+ BorderRadius rounds an arc's own corners - the same property that rounds a bar - + clamped to half the ring's thickness so a wedge can never fold in on itself. Weight shares the + available radius out between the rings of a multi-dataset doughnut, so the series that matters can be given + the thicker band instead of every ring getting an equal slice of the radius. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor.cs index 48b3dc27fb..b55505adab 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPieDemo.razor.cs @@ -124,4 +124,87 @@ public partial class _BitChartPieDemo } }; // Traffic(): Direct/Organic/Referral/Social/Email = 300/500/180/240/120"; + + private readonly BitChartOptions _exploded = new() + { + CutoutPercentage = 45, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Right } } + }; + + private BitChartData Exploded() => new() + { + Labels = { "Direct", "Organic", "Referral", "Social", "Email" }, + Datasets = + { + new BitChartDataset + { + Label = "Sessions", + Data = BitChartSampleData.V(300, 500, 180, 240, 120), + SpacingArc = 6, + Offset = 4, + HoverOffset = 14, + HoverBackgroundColor = "#1f2733" + } + } + }; + + private readonly string spacingRazorCode = @""; + private readonly string spacingCsharpCode = @" +private readonly BitChartOptions _exploded = new() +{ + CutoutPercentage = 45, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Right } } +}; + +private BitChartData Exploded() => new() +{ + Labels = { ""Direct"", ""Organic"", ""Referral"", ""Social"", ""Email"" }, + Datasets = + { + new BitChartDataset + { + Label = ""Sessions"", + Data = new() { 300, 500, 180, 240, 120 }, + SpacingArc = 6, // gap between neighbouring slices + Offset = 4, // every slice sits slightly off center + HoverOffset = 14, // the hovered one pops out further + HoverBackgroundColor = ""#1f2733"" + } + } +};"; + + private readonly BitChartOptions _rounded = new() + { + CutoutPercentage = 35, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Right } } + }; + + private BitChartData Weighted() => new() + { + Labels = { "Mobile", "Desktop", "Tablet" }, + Datasets = + { + new BitChartDataset { Label = "2026", Data = BitChartSampleData.V(62, 28, 10), BorderRadius = 8, SpacingArc = 4, Weight = 2 }, + new BitChartDataset { Label = "2025", Data = BitChartSampleData.V(55, 35, 10), BorderRadius = 8, SpacingArc = 4, Weight = 1 } + } + }; + + private readonly string roundedRingsRazorCode = @""; + private readonly string roundedRingsCsharpCode = @" +private readonly BitChartOptions _rounded = new() +{ + CutoutPercentage = 35, + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Right } } +}; + +private BitChartData Weighted() => new() +{ + Labels = { ""Mobile"", ""Desktop"", ""Tablet"" }, + Datasets = + { + // Weight 2 vs 1: the outer ring gets two thirds of the available radius. + new BitChartDataset { Label = ""2026"", Data = new() { 62, 28, 10 }, BorderRadius = 8, SpacingArc = 4, Weight = 2 }, + new BitChartDataset { Label = ""2025"", Data = new() { 55, 35, 10 }, BorderRadius = 8, SpacingArc = 4, Weight = 1 } + } +};"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPolarDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPolarDemo.razor index 72b981f6de..63e51de406 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPolarDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartPolarDemo.razor @@ -1,11 +1,17 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
click legend to toggle slices
-
+
+ A polar area chart gives every category the same angle and lets the radius carry the value, so it compares + magnitudes without the misleading wedge areas of a pie. Click a legend label to drop a slice. +
+
-
category labels around the perimeter
-
+
+ PointLabels on the radial scale writes the category names around the rim, which can replace + the legend entirely. A Callback shortens or rewords each one. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartRadarDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartRadarDemo.razor index 4a90f12862..3e4763c2ef 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartRadarDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartRadarDemo.razor @@ -1,21 +1,33 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
filled areas
-
+
+ A radar chart plots several measures on one shared radial scale, so two profiles can be compared by shape. + Filling the webs (Fill = BitChartFillMode.Origin) makes the overlap easy to read. +
+
-
no fill, thicker borders
-
+
+ Outlines only. Past two or three series the fills start hiding each other, so a thicker border and no fill + keeps every web readable. +
+
-
grid.circular + styled point labels & backdrop
-
+
+ Grid.Circular draws the rings as circles rather than polygons, and the radial tick labels get + a backdrop so they stay legible where they cross the web. +
+
-
null values are skipped, not zeroed
-
+
+ A null means "not measured", not zero: the vertex is skipped and the web closes over it + instead of collapsing to the center and inventing a value. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor index be09f38123..97a56b44a1 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor @@ -1,31 +1,57 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
y locked to 0–100
-
+
+ Min and Max pin the axis exactly, which keeps a series comparable across + refreshes instead of rescaling every time the data moves. +
+
-
hints that expand (not clamp) the range
-
+
+ SuggestedMin and SuggestedMax only ever widen the range: the axis still grows + past them when the data does, so an outlier is never clipped out of view. +
+
-
ticks every 25 units
-
+
+ StepSize fixes the spacing between ticks, and MaxTicksLimit caps how many are + drawn. On top of that the axis drops labels the available space cannot fit, so they never collide. +
+
-
decade ticks + minor gridlines
-
+
+ A logarithmic axis gives every decade the same amount of space, so data spanning several orders of + magnitude stays readable. Faint minor gridlines mark the steps in between. +
+
-
rankings: lower is better
-
+
+ Reverse flips the axis. For rankings that puts first place at the top, where a reader + expects it. +
+
+
+ + +
+ Position = BitChartPosition.Center draws an axis where the other one reads zero instead of + along the edge of the plot, which is what turns a scatter of signed values into readable quadrants. A + centered axis takes no layout space, because it lives inside the plot. +
+
-
10% headroom above & below the data
-
+
+ Grace pads the range by a fraction of itself, so the extremes do not sit right on the frame. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor.cs index 8c48e6ac00..5a515f8a94 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScalesDemo.razor.cs @@ -130,4 +130,50 @@ public partial class _BitChartScalesDemo Scales = { [""y""] = new BitChartScaleOptions { Id = ""y"", Type = BitChartScaleType.Linear, Grace = 0.1 } } }; // Series(): Jan..Jul = 35/52/48/70/60/78/66"; + + private readonly BitChartOptions _center = new() + { + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Display = false } }, + Scales = + { + ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Linear, Position = BitChartPosition.Center }, + ["y"] = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Linear, Position = BitChartPosition.Center } + } + }; + + private BitChartData Quadrants() + { + var rnd = new Random(11); + var pts = new List(); + for (int i = 0; i < 40; i++) + pts.Add(new BitChartDataPoint(Math.Round(rnd.NextDouble() * 20 - 10, 2), Math.Round(rnd.NextDouble() * 20 - 10, 2))); + return new BitChartData + { + Datasets = { new BitChartDataset { Label = "Samples", Points = pts, BackgroundColor = "#9966ff", PointRadius = 5 } } + }; + } + + private readonly string centerRazorCode = @""; + private readonly string centerCsharpCode = @" +private readonly BitChartOptions _center = new() +{ + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Display = false } }, + Scales = + { + [""x""] = new BitChartScaleOptions { Id = ""x"", Type = BitChartScaleType.Linear, Position = BitChartPosition.Center }, + [""y""] = new BitChartScaleOptions { Id = ""y"", Type = BitChartScaleType.Linear, Position = BitChartPosition.Center } + } +}; + +private BitChartData Quadrants() +{ + var rnd = new Random(11); + var pts = new List(); + for (int i = 0; i < 40; i++) + pts.Add(new BitChartDataPoint(Math.Round(rnd.NextDouble() * 20 - 10, 2), Math.Round(rnd.NextDouble() * 20 - 10, 2))); + return new BitChartData + { + Datasets = { new BitChartDataset { Label = ""Samples"", Points = pts, BackgroundColor = ""#9966ff"", PointRadius = 5 } } + }; +}"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScatterDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScatterDemo.razor index 7c5df90e73..10b48f794f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScatterDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScatterDemo.razor @@ -1,11 +1,17 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
two point clouds on a linear x axis
-
+
+ A scatter chart places each point at its own (x, y), so both axes are linear and the data + needs no shared labels. It is the shape for showing correlation between two measures. +
+
-
radius = magnitude
-
+
+ A bubble chart adds a third dimension: the R of each point becomes its radius, so position + carries two measures and size a third. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScriptableDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScriptableDemo.razor index 83409373b7..8a09f210f7 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScriptableDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartScriptableDemo.razor @@ -1,21 +1,26 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
Colors, point radius and point styles can be computed per data element from a context. Here bars turn red when the value is negative.
-
+
+ A scriptable option is a function of a BitChartScriptableContext evaluated per element, so + color, radius and marker can follow the data rather than the series - here bars turn red below zero. + The context also carries Active, which is set while the renderer builds each element's hover + appearance, so the hovered state can be styled from the same function. +
+
Points above 75 are highlighted with a different color and a larger radius.
-
+
The point radius scales with the value via PointRadiusFn.
-
+
Alternating point styles chosen per index via PointStyleFn.
-
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTimeDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTimeDemo.razor index 4a330307bd..19bd5a2098 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTimeDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTimeDemo.razor @@ -1,11 +1,18 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -
60 days → weekly ticks
-
+
+ A time scale reads each point's x value as a date (an OLE Automation date, from + DateTime.ToOADate) and picks the tick unit from the span - weekly ticks across these two + months. Unevenly spaced samples land at their true position, which a category axis cannot do. +
+
-
BitChartTimeUnit.Month, custom formatter
-
+
+ TimeUnit forces a unit when the automatic choice is too fine or too coarse, and + TimeFormat takes over the label text entirely. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor new file mode 100644 index 0000000000..d1728cacda --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor @@ -0,0 +1,46 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart + + +
+ Plugins.Title and Plugins.Subtitle render above the plot with their own font, + color and alignment. The title also becomes the chart's accessible name when no + AriaLabel is given, so a titled chart is described correctly for free. +
+
+
+ + +
+ A title can sit on any of the four sides: top and bottom run across the chart, left and right run down + beside the plot. Align pins it to the start, center or end of that edge, and a newline in + the text breaks it over two lines. +
+
+ + +
+
+
+ + +
+ Each scale carries its own Title, drawn beside the axis and rotated to run along it. The + axis reserves the room the title needs, so it never overlaps the tick labels. +
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor.cs new file mode 100644 index 0000000000..a88d3e388b --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTitlesDemo.razor.cs @@ -0,0 +1,103 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart; + +public partial class _BitChartTitlesDemo +{ + private BitChartPosition _position = BitChartPosition.Left; + private BitChartAlign _align = BitChartAlign.Center; + + private readonly BitChartOptions _titled = new() + { + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = "Revenue by product" }, + Subtitle = new BitChartTitleOptions + { + Display = true, + Text = "Thousands of euro, first seven months", + Font = new BitChartFont { Size = 12 }, + Color = "var(--bit-clr-fg-sec)" + }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + } + }; + + private BitChartOptions Placement() => new() + { + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = "Monthly sales", Position = _position, Align = _align }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + } + }; + + private readonly BitChartOptions _axisTitles = new() + { + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = + { + ["x"] = new BitChartScaleOptions + { + Id = "x", Type = BitChartScaleType.Category, + Title = new BitChartScaleTitleOptions { Display = true, Text = "Month" } + }, + ["y"] = new BitChartScaleOptions + { + Id = "y", Type = BitChartScaleType.Linear, BeginAtZero = true, + Title = new BitChartScaleTitleOptions { Display = true, Text = "Units sold" } + } + } + }; + + + private readonly string basicRazorCode = @""; + private readonly string basicCsharpCode = @" +private readonly BitChartOptions _titled = new() +{ + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = ""Revenue by product"" }, + Subtitle = new BitChartTitleOptions + { + Display = true, + Text = ""Thousands of euro, first seven months"", + Font = new BitChartFont { Size = 12 }, + Color = ""var(--bit-clr-fg-sec)"" + }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + } +};"; + + private readonly string placementRazorCode = @""; + private readonly string placementCsharpCode = @" +private BitChartPosition _position = BitChartPosition.Left; +private BitChartAlign _align = BitChartAlign.Center; + +private BitChartOptions Placement() => new() +{ + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = ""Monthly sales"", Position = _position, Align = _align }, + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } + } +};"; + + private readonly string axisRazorCode = @""; + private readonly string axisCsharpCode = @" +private readonly BitChartOptions _axisTitles = new() +{ + Plugins = new BitChartPluginOptions { Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom } }, + Scales = + { + [""x""] = new BitChartScaleOptions + { + Id = ""x"", Type = BitChartScaleType.Category, + Title = new BitChartScaleTitleOptions { Display = true, Text = ""Month"" } + }, + [""y""] = new BitChartScaleOptions + { + Id = ""y"", Type = BitChartScaleType.Linear, BeginAtZero = true, + Title = new BitChartScaleTitleOptions { Display = true, Text = ""Units sold"" } + } + } +};"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor index 6eec30448a..9263f682ce 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor @@ -1,26 +1,51 @@ @namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart -

hover a group: custom title + summed footer

-
+
+ The tooltip callbacks mirror Chart.js: Title builds the heading from the active items and + Footer adds a summary line - here the total of the series at the hovered category. +
+
-

label callback formats each row as currency

-
+
+ Callbacks.Label replaces the body row of a single item, which is where value formatting + belongs. LabelFormatter is the shorthand for when all you need is the number. +
+
-

extra context appended below the values

-
+
+ BeforeBody and AfterBody add free lines around the values - a note, a + delta, a data-quality warning. Text containing newlines becomes several lines. +
+
-

light theme, border, centered text

-
+
+ Colors, padding, corner radius, border, caret and per-part alignment are all options. The tooltip keeps + itself inside the chart box and flips below the anchor when there is no room above it, so it is never + clipped at an edge. +
+
-

usePointStyle draws the marker in the tooltip

-
+
+ UsePointStyle swaps the color square for each dataset's own marker, which is what ties a + tooltip row to its series when several share a color family. +
+
+
+ + +
+ A tooltip keeps each row on one line, which is right for a value and wrong for a sentence. + MaxWidth caps the box in pixels and lets the text wrap inside it, so a callback that returns + prose - an explanation, a caveat, a definition - stays readable instead of running off the chart. +
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor.cs index 1df4f46170..21bcfda6e9 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTooltipsDemo.razor.cs @@ -228,4 +228,47 @@ public partial class _BitChartTooltipsDemo BorderColor = ""#ff9f40"", PointBackgroundColor = ""#ff9f40"", PointStyle = BitChartPointStyle.RectRot, PointRadius = 5, Tension = 0.3 } } };"; + + private readonly BitChartOptions _wrapped = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Tooltip = new BitChartTooltipOptions + { + MaxWidth = 240, + Callbacks = new BitChartTooltipCallbacks + { + AfterBody = items => items.Count == 0 + ? null + : $"The index blends affordability, transit coverage and air quality, " + + $"rebased so the median country reads 70. {items[0].Label} is " + + $"{(items[0].Value >= 70 ? "above" : "below")} that median." + } + } + } + }; + + private readonly string wrappedRazorCode = @""; + private readonly string wrappedCsharpCode = @" +private readonly BitChartOptions _wrapped = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Tooltip = new BitChartTooltipOptions + { + // A width is what lets the box wrap; without one every row stays on a single line. + MaxWidth = 240, + Callbacks = new BitChartTooltipCallbacks + { + AfterBody = items => items.Count == 0 + ? null + : $""The index blends affordability, transit coverage and air quality, "" + + $""rebased so the median country reads 70. {items[0].Label} is "" + + $""{(items[0].Value >= 70 ? ""above"" : ""below"")} that median."" + } + } + } +};"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor new file mode 100644 index 0000000000..bc80346a33 --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor @@ -0,0 +1,30 @@ +@namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart + + +
+ BitChartTrendlinePlugin fits a line through a dataset and draws it over the chart. The default + Linear kind is an ordinary least-squares regression, which is the shortest answer to "is this + going up or down" when the series itself is too noisy to say. It is dashed by default so it never reads as + another measured series, and it takes the dataset's own color unless you give it one. +
+
+
+ + +
+ Extend continues the fitted line to both edges of the plot instead of stopping at the first and + last point, which is how a regression is read as a projection. A Label puts a pill at the end of + the line naming what it is. +
+
+
+ + +
+ MovingAverage smooths the series over a trailing Period window - it follows the + shape rather than straightening it, which is what makes a seasonal series readable. Average is a + flat line at the series mean, for reading each point against the norm. Several trend lines can sit on one + chart, each bound to its own dataset by DatasetIndex. +
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor.cs new file mode 100644 index 0000000000..04b8e2fb2d --- /dev/null +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartTrendlinesDemo.razor.cs @@ -0,0 +1,189 @@ +namespace Bit.BlazorUI.Demo.Client.Core.Pages.Components.Extras.Chart; + +public partial class _BitChartTrendlinesDemo +{ + private static readonly string[] Weeks = + ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8", "W9", "W10", "W11", "W12"]; + + private readonly BitChartOptions _linear = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom }, + Custom = { new BitChartTrendlinePlugin(new BitChartTrendline { DatasetIndex = 0 }) } + } + }; + + private readonly BitChartOptions _extended = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom }, + Custom = + { + new BitChartTrendlinePlugin(new BitChartTrendline + { + DatasetIndex = 0, + Extend = true, + Color = "#ff6384", + Label = "trend" + }) + } + } + }; + + private readonly BitChartOptions _averages = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom }, + Custom = + { + new BitChartTrendlinePlugin( + new BitChartTrendline + { + DatasetIndex = 0, + Kind = BitChartTrendlineKind.MovingAverage, + Period = 4, + Color = "#9966ff", + Dash = null, + Label = "4-week average" + }, + new BitChartTrendline + { + DatasetIndex = 0, + Kind = BitChartTrendlineKind.Average, + Color = "#c9cbcf", + LineWidth = 1.5 + }) + } + } + }; + + private BitChartData Noisy() => new() + { + Labels = [.. Weeks], + Datasets = + { + new BitChartDataset + { + Label = "Tickets closed", + Data = BitChartSampleData.V(18, 26, 21, 33, 28, 39, 31, 44, 38, 51, 46, 57), + BackgroundColor = "rgba(54,162,235,0.55)", + BorderColor = "#36a2eb", + BorderRadius = 4, + Tension = 0.3 + } + } + }; + + private BitChartData Seasonal() => new() + { + Labels = [.. Weeks], + Datasets = + { + new BitChartDataset + { + Label = "Orders", + Data = BitChartSampleData.V(42, 61, 38, 70, 45, 78, 52, 84, 49, 90, 58, 96), + BorderColor = "#4bc0c0", + BackgroundColor = "#4bc0c0", + PointRadius = 3 + } + } + }; + + + private readonly string linearRazorCode = @""; + private readonly string linearCsharpCode = @" +private readonly BitChartOptions _linear = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom }, + Custom = { new BitChartTrendlinePlugin(new BitChartTrendline { DatasetIndex = 0 }) } + } +}; + +private BitChartData Noisy() => new() +{ + Labels = { ""W1"", ""W2"", ""W3"", ""W4"", ""W5"", ""W6"", ""W7"", ""W8"", ""W9"", ""W10"", ""W11"", ""W12"" }, + Datasets = + { + new BitChartDataset + { + Label = ""Tickets closed"", + Data = new() { 18, 26, 21, 33, 28, 39, 31, 44, 38, 51, 46, 57 }, + BackgroundColor = ""rgba(54,162,235,0.55)"", + BorderColor = ""#36a2eb"", + BorderRadius = 4 + } + } +};"; + + private readonly string extendRazorCode = @""; + private readonly string extendCsharpCode = @" +private readonly BitChartOptions _extended = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom }, + Custom = + { + new BitChartTrendlinePlugin(new BitChartTrendline + { + DatasetIndex = 0, + Extend = true, // run the fit out to both edges of the plot + Color = ""#ff6384"", + Label = ""trend"" + }) + } + } +};"; + + private readonly string averagesRazorCode = @""; + private readonly string averagesCsharpCode = @" +private readonly BitChartOptions _averages = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Position = BitChartPosition.Bottom }, + Custom = + { + new BitChartTrendlinePlugin( + new BitChartTrendline + { + DatasetIndex = 0, + Kind = BitChartTrendlineKind.MovingAverage, + Period = 4, + Color = ""#9966ff"", + Dash = null, // solid, so it reads as the smoothed series + Label = ""4-week average"" + }, + new BitChartTrendline + { + DatasetIndex = 0, + Kind = BitChartTrendlineKind.Average, + Color = ""#c9cbcf"", + LineWidth = 1.5 + }) + } + } +}; + +private BitChartData Seasonal() => new() +{ + Labels = { ""W1"", ""W2"", ""W3"", ""W4"", ""W5"", ""W6"", ""W7"", ""W8"", ""W9"", ""W10"", ""W11"", ""W12"" }, + Datasets = + { + new BitChartDataset + { + Label = ""Orders"", + Data = new() { 42, 61, 38, 70, 45, 78, 52, 84, 49, 90, 58, 96 }, + BorderColor = ""#4bc0c0"", + BackgroundColor = ""#4bc0c0"", + PointRadius = 3 + } + } +};"; +} diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor index 0b6f926c35..6817140efb 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor @@ -2,23 +2,50 @@
- Scroll to zoom X, drag to pan, double-click to reset. A @(_count.ToString("N0"))-point series is - downsampled to @_samples points with the LTTB algorithm so it stays smooth. Pointer math uses a tiny JS helper; all drawing remains pure SVG. + Scroll to zoom X, drag to pan, double-click to reset - and on a touch screen, drag with one finger and + pinch with two, which the chart claims from the browser's own gestures so they reach the plot. + A @(_count.ToString("N0"))-point series is downsampled to @_samples points with the LTTB algorithm, which + keeps the peaks that define the shape rather than sampling blindly, so it stays smooth at any zoom. Only the + pointer math is JavaScript; all drawing remains pure SVG.
-
+
-
Mode = XY on a scatter plot: scroll to zoom both axes, drag to pan.
-
+
+ Mode = XY zooms and pans both axes at once, which is what a scatter plot needs. The mode names a + direction on screen rather than an axis id, so X always means the axis running across the plot + - on a horizontal-bar chart, the values. LimitToData keeps the view inside the series so + it can never be dragged off into empty space, and MinRangeFraction stops it zooming in forever. +
+
-
Drag a rectangle to zoom into it; double-click to reset (DragZoom = true, Wheel = false).
-
+
With DragZoom the drag gesture selects a rectangle to zoom into instead of panning. Double-click resets it; from code, ResetZoom() and ZoomTo(axisId, min, max) do the same, and OnZoomChange reports every change.
+
-
Scroll to zoom a 40-category bar chart; drag to pan.
-
+
Category axes zoom too: the visible window is a range of indexes, so 40 categories can be explored a handful at a time without touching the data.
+
+
+ + +
+ The zoom API is enough to build the overview-and-detail pattern that dense time series want: a full-height + chart shows the window, a short chrome-free Sparkline underneath shows the whole series, and + ZoomTo moves the window while GetAxisRange and OnZoomChange report + where it is. Drag or scroll the top chart, or use the buttons; the readout below always names the visible + span. +
+
+ First quarter + Middle + Last quarter + Whole series +
+
+
Showing @_window
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor.cs index 3b047eb9d5..84451fd6af 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/Chart/_BitChartZoomDemo.razor.cs @@ -36,7 +36,11 @@ public partial class _BitChartZoomDemo Zoom = new BitChartZoomOptions { Enabled = true, Mode = BitChartZoomMode.X } }; - protected override void OnInitialized() => _data = BitChartSampleData.LargeSeries(_count); + protected override void OnInitialized() + { + _data = BitChartSampleData.LargeSeries(_count); + _brushData = BitChartSampleData.LargeSeries(2000); + } private BitChartData Scatter() { @@ -127,4 +131,116 @@ private BitChartData ManyBars() Datasets = { new BitChartDataset { Label = ""Daily"", Data = data, BackgroundColor = ""#4bc0c0"" } } }; }"; + + // ---- overview and detail ---- + + private BitChart? _brush; + private BitChartData _brushData = default!; + private string _window = "the whole series"; + + private readonly BitChartOptions _brushOptions = new() + { + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Decimation = new BitChartDecimationOptions { Enabled = true, Samples = 250, Threshold = 400 } + }, + Zoom = new BitChartZoomOptions { Enabled = true, Mode = BitChartZoomMode.X }, + Scales = { ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Time } } + }; + + /// The strip under the detail chart: the same series with nothing but the line. + private readonly BitChartOptions _overview = new() + { + Sparkline = true, + MaintainAspectRatio = false, + Plugins = new BitChartPluginOptions + { + Decimation = new BitChartDecimationOptions { Enabled = true, Samples = 250, Threshold = 400 }, + Tooltip = new BitChartTooltipOptions { Enabled = false } + }, + Scales = { ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Time } } + }; + + /// Zooms the detail chart to a fraction of the full series. + private void ShowWindow(double from, double to) + { + if (_brush is null) return; + var full = FullRange(); + double span = full.Max - full.Min; + _brush.ZoomTo("x", full.Min + span * from, full.Min + span * to); + } + + private (double Min, double Max) FullRange() + { + var points = _brushData.Datasets[0].Points!; + return (points[0].X, points[^1].X); + } + + private void ReadWindow() + { + // No range means nothing is zoomed - a reset leaves the readout claiming the old window otherwise. + if (_brush?.GetAxisRange("x") is not { } range) + { + _window = "the whole series"; + return; + } + _window = $"{DateTime.FromOADate(range.Min):MMM d, HH:mm} to {DateTime.FromOADate(range.Max):MMM d, HH:mm}"; + } + + private readonly string brushRazorCode = @" ShowWindow(0, 0.25)"">First quarter + _brush?.ResetZoom()"">Whole series + + +
Showing @_window
+ +@* The overview strip: the same series with nothing but the line. *@ +"; + private readonly string brushCsharpCode = @" +private BitChart? _brush; +private BitChartData _brushData = default!; +private string _window = ""the whole series""; + +private readonly BitChartOptions _brushOptions = new() +{ + Plugins = new BitChartPluginOptions + { + Legend = new BitChartLegendOptions { Display = false }, + Decimation = new BitChartDecimationOptions { Enabled = true, Samples = 250, Threshold = 400 } + }, + Zoom = new BitChartZoomOptions { Enabled = true, Mode = BitChartZoomMode.X }, + Scales = { [""x""] = new BitChartScaleOptions { Id = ""x"", Type = BitChartScaleType.Time } } +}; + +private readonly BitChartOptions _overview = new() +{ + Sparkline = true, + MaintainAspectRatio = false, + Plugins = new BitChartPluginOptions + { + Decimation = new BitChartDecimationOptions { Enabled = true, Samples = 250, Threshold = 400 }, + Tooltip = new BitChartTooltipOptions { Enabled = false } + }, + Scales = { [""x""] = new BitChartScaleOptions { Id = ""x"", Type = BitChartScaleType.Time } } +}; + +// Zooms the detail chart to a fraction of the full series. +private void ShowWindow(double from, double to) +{ + var points = _brushData.Datasets[0].Points!; + double min = points[0].X, span = points[^1].X - min; + _brush!.ZoomTo(""x"", min + span * from, min + span * to); +} + +// OnZoomChange fires after every wheel, drag and ZoomTo, so the readout always matches the view. +private void ReadWindow() +{ + // No range means nothing is zoomed - a reset leaves the readout claiming the old window otherwise. + if (_brush?.GetAxisRange(""x"") is not { } range) + { + _window = ""the whole series""; + return; + } + _window = $""{DateTime.FromOADate(range.Min):MMM d, HH:mm} to {DateTime.FromOADate(range.Max):MMM d, HH:mm}""; +}"; } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor index a73ee67b63..c4f3bf43ea 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor @@ -4,7 +4,7 @@ reports itself as required to assistive technologies), one preserving its original callout width instead of matching the width of the dropdown, and a custom delimiter for joining the selected values in multi select mode. A disabled dropdown cannot be focused or opened at all, while a read-only one stays focusable and - its callout can still be browsed — only changing the selection is blocked, and every control that would + its callout can still be browsed - only changing the selection is blocked, and every control that would change it (the clear button, the chip remove buttons) is hidden accordingly. The Title parameter adds the tooltip shown while the pointer rests on the dropdown. Clicking the Label moves the focus to the dropdown, and the Name parameter names the @@ -79,7 +79,7 @@ scrolled past, so a long grouped list never leaves the user looking at items whose group has scrolled away.

The grouping survives a search: the headers of the groups that still have a match stay above them, and only - the ones left naming nothing — along with a divider that lost the items on one of its sides — go + the ones left naming nothing - along with a divider that lost the items on one of its sides - go with the items they framed. A result that arrived as a flat list would take the grouping away exactly when the list is hardest to read, so the third example below keeps it while filtering.
@@ -115,8 +115,8 @@
- The Prefix and Suffix parameters put fixed text before and after the selected value — a - unit, a currency, a category — and PrefixTemplate and SuffixTemplate replace them with any + The Prefix and Suffix parameters put fixed text before and after the selected value - a + unit, a currency, a category - and PrefixTemplate and SuffixTemplate replace them with any content, an icon for instance. They are decoration only: they are not part of the value and are not read out with it, so keep a descriptive Label for screen reader users.
@@ -187,8 +187,8 @@
Three parameters change how much furniture the dropdown draws around itself. NoBorder removes its border - entirely, Underlined replaces the box with a single bottom border — the variant that suits a dense - form where a box per field would be too much — and Transparent removes the background, so the + entirely, Underlined replaces the box with a single bottom border - the variant that suits a dense + form where a box per field would be too much - and Transparent removes the background, so the control can sit on a colored or image surface without cutting a rectangle out of it. They are independent and combine freely; the callout keeps its own surface either way, so the items stay readable. Since the two borderless variants have no box to draw a focus ring around, the keyboard focus (and the invalid state) is @@ -257,7 +257,7 @@

MaxHeight caps the scrollable item list at a height of your own, in pixels, so a long list stays a modest panel instead of stretching to whatever the viewport allows. It is applied on top of that available - space rather than in place of it, so it can only ever make the list shorter — a dropdown near the bottom + space rather than in place of it, so it can only ever make the list shorter - a dropdown near the bottom of the window is still limited by the room it actually has. Note that this is the one size a stylesheet cannot reach: the height is measured and written as an inline style every time the callout opens, so a CSS rule would always lose to it. @@ -297,7 +297,7 @@ finding a button with the mouse. Escape stays a dismiss key first: it closes the callout, and in the ComboBox mode it drops the text that was typed into it, and only a press with nothing left to dismiss reaches the selection. It goes through the very same clear as the button, so it raises OnClear and - is refused wherever the button would be — and it needs no ShowClearButton, so a dropdown that + is refused wherever the button would be - and it needs no ShowClearButton, so a dropdown that shows no clear button can still be cleared this way.

@@ -402,7 +402,7 @@
A handful of parameters tune the default search without replacing it. SearchMode picks how the text of an item - is matched against the typed term — Contains (the default), StartsWith, EndsWith or + is matched against the typed term - Contains (the default), StartsWith, EndsWith or ExactMatch, always ignoring case. MinSearchLength holds the filtering back until the term reaches the given number of characters, which keeps a one-letter term from being treated as a real query (and, with an ItemsProvider, from turning every first keystroke into a request). While the term is still @@ -411,7 +411,7 @@ MinSearchLengthText is the composite format of that hint, which receives the number of characters that are still needed and is announced to screen readers along with it. SearchIgnoreDiacritics matches the term against the item texts with the diacritics of both folded away, so Jose finds José and - Muller finds Müller — which is what makes the search usable to anyone typing on a keyboard + Muller finds Müller - which is what makes the search usable to anyone typing on a keyboard that has no accented keys. HighlightSearch emphasizes the matched part of each item text so the reason an item is in the result is visible at a glance, and it keeps lining up with the accented text because the folding replaces each character with exactly one character. Whenever a search is active the number of results is also @@ -572,7 +572,7 @@ the selected value shown on the closed dropdown and PlaceholderTemplate what is shown while nothing is selected, LabelTemplate replaces the label, CaretDownIconName and CaretDownTemplate the chevron, and CalloutHeaderTemplate and CalloutFooterTemplate add fixed content above and below the - scrollable item list — the footer being the usual home of an "add a new item" action. The templates that + scrollable item list - the footer being the usual home of an "add a new item" action. The templates that render an item receive the item itself, so the extra state carried in its Data property is available to them.
@@ -666,12 +666,12 @@ Every piece of the dropdown's state can be bound or observed. The selection is two-way bound over Value in single select mode and Values in multi select mode, or left uncontrolled with DefaultValue and DefaultValues and observed through the OnChange and OnValuesChange events; - OnSelectItem hands over the clicked item itself rather than its value — in multi select mode for + OnSelectItem hands over the clicked item itself rather than its value - in multi select mode for every pick, including the one that unselects an already selected item, which OnDeselectItem reports on its own so that an addition and a removal can be told apart. Reselectable makes the select events fire even when the already selected item is picked again, which is otherwise treated as a no-op. The open state of the callout is two-way bound via IsOpen, so it can be opened and closed from code, and - the OnOpen and OnClose events report every change to it — a natural place to start fetching + the OnOpen and OnClose events report every change to it - a natural place to start fetching the items on first open.

ValueComparer decides whether two values stand for the same selection, in place of the default equality @@ -679,8 +679,8 @@ value compares by reference by default, so a value arriving from a form, a query string or a fresh fetch would never match the item it names, however equal the two look. The example below uses a case-insensitive comparer, so the value F-APP selects the item whose value is f-app. The comparer governs every value - comparison the component makes — which item a value selects, which chip a removal takes away, whether a - typed term is already selected — so two values it calls equal are one and the same selection throughout. Finally, OnFocusIn and OnFocusOut follow the focus of the + comparison the component makes - which item a value selects, which chip a removal takes away, whether a + typed term is already selected - so two values it calls equal are one and the same selection throughout. Finally, OnFocusIn and OnFocusOut follow the focus of the dropdown as a whole: they sit on the trigger and focusin/focusout bubble, so moving between the trigger and the ComboBox input inside it does not report a round trip through the outside.
@@ -817,7 +817,7 @@ The Combo parameter turns the dropdown into a ComboBox: an editable input renders in place of the selected text and filters the items as you type, so the trigger doubles as the search box. Enter selects the item whose text matches what was typed, Backspace on an empty input removes the last selected item, the arrow - keys — and typing itself, which reveals the list it filters — open the callout, Escape abandons the typed term, and a term that was typed but never turned into a + keys - and typing itself, which reveals the list it filters - open the callout, Escape abandons the typed term, and a term that was typed but never turned into a selection is discarded when the callout closes, so the input goes back to showing the current selection.

Requiring the typed text to match an item exactly makes Enter a dead key for anyone who only typed the beginning @@ -825,14 +825,14 @@ ban and pressing Enter then selects Banana. It takes precedence over the Dynamic mode below, so a term that names an item the list already has selects that item instead of creating a second one beside it.

- Whichever item a commit would take — the one the typed text names exactly, or the first one it still - matches under AutoSelectFirstMatch — is marked in the list as you type, so what Enter is + Whichever item a commit would take - the one the typed text names exactly, or the first one it still + matches under AutoSelectFirstMatch - is marked in the list as you type, so what Enter is about to select is visible before it is pressed rather than only afterwards. The same item is named to a screen reader through the aria-activedescendant of the input, so the cue is not a visual-only one. When nothing is marked, Enter either creates a new item (with Dynamic) or does nothing at all.

SelectTextOnFocus selects whatever is already in the input when it takes the focus, so coming back to a - combo box that holds a term and typing replaces that term instead of appending to it — which is what a + combo box that holds a term and typing replaces that term instead of appending to it - which is what a field the user returns to in order to look for something else needs. An empty input has nothing to select, and neither has a read-only one, where the selection would only be a highlight over text that cannot be changed.
@@ -939,7 +939,7 @@ how many of them the closed dropdown shows: with Chips the extra ones collapse into an overflow chip whose text comes from OverflowTextFormat ("+{0}" by default), and without chips the joined list is replaced by the summary of SelectedItemsTextFormat ("{0} items selected" by default) as soon - as the limit is passed. Nothing is removed from the selection — only the way it is displayed changes. + as the limit is passed. Nothing is removed from the selection - only the way it is displayed changes.

AutoClearSearch covers the other half of a multi select session: the callout stays open after a pick, so by default the next item has to be found through the filter left over from the previous one. Enabling it clears @@ -1008,7 +1008,7 @@ When the Dynamic parameter is true, a text typed into the ComboBox that matches no existing item can be added as a new one, which is what makes free-form values (tags, e-mail recipients, ad-hoc categories) possible. DynamicValueGenerator produces the value of the new item from its text and OnDynamicAdd notifies - about the addition so the item can be persisted into the source collection — and only about an addition + about the addition so the item can be persisted into the source collection - and only about an addition that stands, so a term refused by the selection limit or by a one-way binding is never reported as one. Before creating anything the component first looks for an existing match; FindItemFunction and ExistsSelectedItemFunction replace the default case-insensitive text comparison used for those two @@ -1090,32 +1090,32 @@ The dropdown is fully operable with the keyboard, following the ARIA authoring practices for a combobox: Enter, Space and the arrow keys open the callout and put the focus on the selected item (or the first one), ArrowUp and ArrowDown move between the items, Home and End jump to the - first and the last one — from the closed dropdown too, so reaching the end of a long list never takes an - opening key followed by a second one — PageUp and PageDown jump several items at a time, Enter and Space + first and the last one - from the closed dropdown too, so reaching the end of a long list never takes an + opening key followed by a second one - PageUp and PageDown jump several items at a time, Enter and Space select the focused item, Escape and Alt+ArrowUp close the callout and return the focus to the - dropdown, and Tab closes it and moves on — from the trigger just as much as from inside the callout, so a + dropdown, and Tab closes it and moves on - from the trigger just as much as from inside the callout, so a popup revealed without the focus is never left behind when the focus leaves the dropdown. Alt+ArrowDown is the exception among the openers: it reveals the list without moving the focus into it, so the trigger keeps it and the plain arrows can walk the list afterwards. Opening the callout with a mouse click focuses the selected item as well, so the keyboard can take over at any point. In multi select mode Ctrl+A (or Cmd+A) - selects every item the current search shows — or clears them when they are all selected already — + selects every item the current search shows - or clears them when they are all selected already - while inside the search and ComboBox inputs the shortcut keeps its native select-the-text behavior. Typing printable characters runs a typeahead: the accumulated characters jump to the item starting with them, repeating one character cycles through the items starting with it, and the buffer resets after a short pause. The arrow keys wrap around by default, so ArrowDown on the last item comes back to the first one; NoWrapNavigation stops them at the ends instead, which suits a long list where the jump from one end to the other is more likely to read as the focus having been lost than as a move that - was asked for — the typeahead keeps wrapping either way, since it looks for the item that matches rather + was asked for - the typeahead keeps wrapping either way, since it looks for the item that matches rather than for the one that comes next. Disabled and hidden items are skipped throughout. In virtualize mode only the rendered items take part in the typeahead, since the ones that have not been rendered yet have no text to match against. The options themselves stay out of the tab order, as the options of a listbox should: they are reached with the arrow keys, and Tab leaves the whole dropdown rather than walking through the list. - In ComboBox mode the keys that belong to the typed text — the printable characters, Backspace, - Delete, ArrowLeft and ArrowRight — return the focus to the input and act on it + In ComboBox mode the keys that belong to the typed text - the printable characters, Backspace, + Delete, ArrowLeft and ArrowRight - return the focus to the input and act on it there, so arrowing into the list never strands the user away from the term they are typing. - However the callout is dismissed — a key, a click outside it, the close button of the responsive panel or - a swipe — the focus comes back to the dropdown (to the ComboBox input when there is one) instead of + However the callout is dismissed - a key, a click outside it, the close button of the responsive panel or + a swipe - the focus comes back to the dropdown (to the ComboBox input when there is one) instead of being dropped at the top of the page along with the element that was holding it. ClearOnEscape gives Escape one more job once it has nothing left to dismiss: a press with the callout already closed (and, in the ComboBox mode, with nothing typed) clears the selection, which is what a @@ -1153,8 +1153,8 @@ result untouched. The SelectAllText parameter customizes its text. It is not available when the items come from an ItemsProvider, since the items that are not loaded yet cannot be selected. It also honors MaxSelectedItems: it stops at the limit, and once there is no room left it clears the selection instead - of doing nothing. It also goes away when there is nothing left for it to select — an empty list, or a - search that matched nothing — instead of topping the empty state with a control that cannot do anything. + of doing nothing. It also goes away when there is nothing left for it to select - an empty list, or a + search that matched nothing - instead of topping the empty state with a control that cannot do anything. While the callout is open, the Ctrl+A (or Cmd+A) shortcut toggles the same select all behavior from the keyboard, even when the select all item itself is not shown. @@ -1188,10 +1188,10 @@ The MaxSelectedItems parameter limits how many items can be selected in the multi select dropdown. Once the limit is reached the unselected items are disabled rather than silently refusing the click, so the boundary is visible before it is hit, and they become available again as soon as an item is unselected. The select all - item honors the same limit and stops adding items once it is reached — and since it can then never reach + item honors the same limit and stops adding items once it is reached - and since it can then never reach "all selected", the next click on it clears the selection instead of leaving the user with a control that does nothing. The items turning unavailable is a change only a sighted user notices, so reaching the limit is also - announced to screen readers, with a message you can localize through MaxSelectedItemsText — and the + announced to screen readers, with a message you can localize through MaxSelectedItemsText - and the announcement goes quiet again as soon as unselecting an item makes room.
@@ -1213,7 +1213,7 @@ When there is no item to show, the callout renders a message instead of an empty list. There are two distinct cases and each gets its own text: EmptyText and EmptyTemplate cover a list that has nothing in it ("No items found" by default), while NoResultsText and NoResultsTemplate cover a search that - matched nothing ("No results found" by default) — telling the user that their term found nothing is a + matched nothing ("No results found" by default) - telling the user that their term found nothing is a different message from telling them the list is empty. When the no-results pair is not set the empty pair is used for both cases. @@ -1343,8 +1343,8 @@ rows down and has never been rendered: the list is scrolled to where its index says it is and the selection is centred in the window, the way a native select behaves. That needs an index to scroll to, so it applies to a local Items collection; with an ItemsProvider the loaded window is all the component has. - Each option still reports where it sits in the whole set — the provider says how many items there are - and which window it is handing over — so a screen reader announces "Item 4210 of 10,000" in a list that + Each option still reports where it sits in the whole set - the provider says how many items there are + and which window it is handing over - so a screen reader announces "Item 4210 of 10,000" in a list that has never been loaded in full.


@@ -1483,7 +1483,7 @@ ChipsRemoveIconName for the remove button of a chip, and ResponsiveCloseIconName and ComboBoxAddButtonIconName for the close and add buttons that only the responsive panel shows. Each of them has an ...Icon counterpart that takes a BitIconInfo and wins when both are set, which - is how an icon from outside the Fluent set gets in — see the External Icons section below. + is how an icon from outside the Fluent set gets in - see the External Icons section below. None of them is ever read out on its own: the icons are hidden from assistive technologies and the button around them carries the accessible name, which is why those names are parameters of their own. @@ -1551,7 +1551,7 @@ without reopening the list. CloseOnSelect overrides that decision in both directions. Set it to false on a single select dropdown to keep the list open while the user tries one option after another against the page behind it, or to true on a multi select one to turn every pick into a complete - interaction of its own — useful when each selection triggers work that the user should see before + interaction of its own - useful when each selection triggers work that the user should see before choosing again.

Whichever way it goes, the focus follows: a callout that closes hands the focus back to the dropdown (or to its @@ -1586,7 +1586,7 @@ The TokenSeparators parameter turns the listed characters into term endings for the multi select ComboBox input: typing one commits the term before it exactly as pressing Enter would, and pasting a whole delimited list commits every term it contains in one go. A term that names an existing item selects that item, - and — with Dynamic enabled — a term that names none becomes a new item, so a list copied out + and - with Dynamic enabled - a term that names none becomes a new item, so a list copied out of a spreadsheet or an e-mail turns into a selection without being retyped item by item. A term the selection already covers is refused, so committing the same list twice does not duplicate anything. @@ -1611,7 +1611,7 @@
The OpenOnFocus parameter opens the callout the moment the dropdown receives the focus, so tabbing into - it (or clicking any part of it) already shows the items without a further click or key press — one + it (or clicking any part of it) already shows the items without a further click or key press - one interaction fewer in a form that is filled top to bottom. The component tells a focus move made by the user apart from one made by its own focus management: a dismissal that returns the focus to the dropdown (Escape, a pick that closes the callout) does not reopen what was just closed. @@ -1631,13 +1631,13 @@
- The Description parameter renders a line of helper text under the dropdown — the place for the rule + The Description parameter renders a line of helper text under the dropdown - the place for the rule that the label has no room for ("only the categories you have access to", "leave empty for all regions"). It is not decoration: the dropdown points at it with aria-describedby, so a screen reader reads it along with the control instead of leaving it as text that merely happens to sit underneath. In the ComboBox mode the editable input is described by it as well, since that is the element the user is actually typing into. - DescriptionTemplate replaces it with any content — a link, an icon, a warning that changes with the - selection — and is tied to the dropdown in exactly the same way. + DescriptionTemplate replaces it with any content - a link, an icon, a warning that changes with the + selection - and is tied to the dropdown in exactly the same way.

@@ -1664,7 +1664,7 @@
- Nothing here is final — you can change it later. + Nothing here is final - you can change it later.
@@ -1673,8 +1673,8 @@
- The Color parameter paints the accents of the dropdown — the focus border, the search box - underline and icon, the group headers and the check boxes of the multi select items — in one of the theme + The Color parameter paints the accents of the dropdown - the focus border, the search box + underline and icon, the group headers and the check boxes of the multi select items - in one of the theme colors, Primary being the default. It changes the accents only, not the surface of the control, so the dropdown keeps fitting its surroundings.
@@ -1751,8 +1751,8 @@ Every icon of the dropdown can come from an external library instead of the built-in Fluent UI set. The CaretDownIcon, ClearButtonIcon, ChipsRemoveIcon, SearchBoxIcon, SearchBoxClearIcon, ResponsiveCloseIcon and ItemCheckIcon parameters take a - BitIconInfo — built with BitIconInfo.Fa, BitIconInfo.Bi or the generic - BitIconInfo.Css — and each takes precedence over its ...IconName counterpart when both are + BitIconInfo - built with BitIconInfo.Fa, BitIconInfo.Bi or the generic + BitIconInfo.Css - and each takes precedence over its ...IconName counterpart when both are set. The items themselves follow the same rule through their Icon and IconName members. Remember to reference the stylesheet of the icon library you use.
@@ -1870,7 +1870,7 @@
The dropdown can be styled at every level: Style and Class apply to the root element, each item carries its own style and class, and the Styles and Classes parameters reach every internal part - of the component individually — the label, the container, the callout, the search box, the scroll + of the component individually - the label, the container, the callout, the search box, the scroll container, the items and everything else listed in the class-styles reference below. Because the callout is rendered outside the root element, styling it through Styles.Callout or Classes.Callout is the way to reach it rather than a descendant selector on the root. @@ -1922,7 +1922,7 @@ callout aligns to the right edge of the dropdown, and the responsive panel slides in from the left and is swiped away in the opposite direction. The callout is rendered outside the dropdown so that it can escape any clipping ancestor around it, which also puts it out of the reach of the direction the dropdown declares, so it - carries that direction itself — the list of an RTL dropdown reads right-to-left even on a page that does not. + carries that direction itself - the list of an RTL dropdown reads right-to-left even on a page that does not.

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor.samples.cs index 102e636829..61e409994a 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownCustomDemo.razor.samples.cs @@ -2713,7 +2713,7 @@ public class Product
- Nothing here is final — you can change it later. + Nothing here is final - you can change it later.
"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor index 4d375f353d..767c31d578 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor @@ -4,7 +4,7 @@ reports itself as required to assistive technologies), one preserving its original callout width instead of matching the width of the dropdown, and a custom delimiter for joining the selected values in multi select mode. A disabled dropdown cannot be focused or opened at all, while a read-only one stays focusable and - its callout can still be browsed — only changing the selection is blocked, and every control that would + its callout can still be browsed - only changing the selection is blocked, and every control that would change it (the clear button, the chip remove buttons) is hidden accordingly. The Title parameter adds the tooltip shown while the pointer rests on the dropdown. Clicking the Label moves the focus to the dropdown, and the Name parameter names the @@ -75,7 +75,7 @@ scrolled past, so a long grouped list never leaves the user looking at items whose group has scrolled away.

The grouping survives a search: the headers of the groups that still have a match stay above them, and only - the ones left naming nothing — along with a divider that lost the items on one of its sides — go + the ones left naming nothing - along with a divider that lost the items on one of its sides - go with the items they framed. A result that arrived as a flat list would take the grouping away exactly when the list is hardest to read, so the third example below keeps it while filtering.
@@ -108,8 +108,8 @@
- The Prefix and Suffix parameters put fixed text before and after the selected value — a - unit, a currency, a category — and PrefixTemplate and SuffixTemplate replace them with any + The Prefix and Suffix parameters put fixed text before and after the selected value - a + unit, a currency, a category - and PrefixTemplate and SuffixTemplate replace them with any content, an icon for instance. They are decoration only: they are not part of the value and are not read out with it, so keep a descriptive Label for screen reader users.
@@ -185,8 +185,8 @@
Three parameters change how much furniture the dropdown draws around itself. NoBorder removes its border - entirely, Underlined replaces the box with a single bottom border — the variant that suits a dense - form where a box per field would be too much — and Transparent removes the background, so the + entirely, Underlined replaces the box with a single bottom border - the variant that suits a dense + form where a box per field would be too much - and Transparent removes the background, so the control can sit on a colored or image surface without cutting a rectangle out of it. They are independent and combine freely; the callout keeps its own surface either way, so the items stay readable. Since the two borderless variants have no box to draw a focus ring around, the keyboard focus (and the invalid state) is @@ -255,7 +255,7 @@

MaxHeight caps the scrollable item list at a height of your own, in pixels, so a long list stays a modest panel instead of stretching to whatever the viewport allows. It is applied on top of that available - space rather than in place of it, so it can only ever make the list shorter — a dropdown near the bottom + space rather than in place of it, so it can only ever make the list shorter - a dropdown near the bottom of the window is still limited by the room it actually has. Note that this is the one size a stylesheet cannot reach: the height is measured and written as an inline style every time the callout opens, so a CSS rule would always lose to it. @@ -294,7 +294,7 @@ finding a button with the mouse. Escape stays a dismiss key first: it closes the callout, and in the ComboBox mode it drops the text that was typed into it, and only a press with nothing left to dismiss reaches the selection. It goes through the very same clear as the button, so it raises OnClear and - is refused wherever the button would be — and it needs no ShowClearButton, so a dropdown that + is refused wherever the button would be - and it needs no ShowClearButton, so a dropdown that shows no clear button can still be cleared this way.

@@ -395,7 +395,7 @@
A handful of parameters tune the default search without replacing it. SearchMode picks how the text of an item - is matched against the typed term — Contains (the default), StartsWith, EndsWith or + is matched against the typed term - Contains (the default), StartsWith, EndsWith or ExactMatch, always ignoring case. MinSearchLength holds the filtering back until the term reaches the given number of characters, which keeps a one-letter term from being treated as a real query (and, with an ItemsProvider, from turning every first keystroke into a request). While the term is still @@ -404,7 +404,7 @@ MinSearchLengthText is the composite format of that hint, which receives the number of characters that are still needed and is announced to screen readers along with it. SearchIgnoreDiacritics matches the term against the item texts with the diacritics of both folded away, so Jose finds José and - Muller finds Müller — which is what makes the search usable to anyone typing on a keyboard + Muller finds Müller - which is what makes the search usable to anyone typing on a keyboard that has no accented keys. HighlightSearch emphasizes the matched part of each item text so the reason an item is in the result is visible at a glance, and it keeps lining up with the accented text because the folding replaces each character with exactly one character. Whenever a search is active the number of results is also @@ -558,7 +558,7 @@ the selected value shown on the closed dropdown and PlaceholderTemplate what is shown while nothing is selected, LabelTemplate replaces the label, CaretDownIconName and CaretDownTemplate the chevron, and CalloutHeaderTemplate and CalloutFooterTemplate add fixed content above and below the - scrollable item list — the footer being the usual home of an "add a new item" action. The templates that + scrollable item list - the footer being the usual home of an "add a new item" action. The templates that render an item receive the item itself, so the extra state carried in its Data property is available to them.
@@ -652,12 +652,12 @@ Every piece of the dropdown's state can be bound or observed. The selection is two-way bound over Value in single select mode and Values in multi select mode, or left uncontrolled with DefaultValue and DefaultValues and observed through the OnChange and OnValuesChange events; - OnSelectItem hands over the clicked item itself rather than its value — in multi select mode for + OnSelectItem hands over the clicked item itself rather than its value - in multi select mode for every pick, including the one that unselects an already selected item, which OnDeselectItem reports on its own so that an addition and a removal can be told apart. Reselectable makes the select events fire even when the already selected item is picked again, which is otherwise treated as a no-op. The open state of the callout is two-way bound via IsOpen, so it can be opened and closed from code, and - the OnOpen and OnClose events report every change to it — a natural place to start fetching + the OnOpen and OnClose events report every change to it - a natural place to start fetching the items on first open.

ValueComparer decides whether two values stand for the same selection, in place of the default equality @@ -665,8 +665,8 @@ value compares by reference by default, so a value arriving from a form, a query string or a fresh fetch would never match the item it names, however equal the two look. The example below uses a case-insensitive comparer, so the value F-APP selects the item whose value is f-app. The comparer governs every value - comparison the component makes — which item a value selects, which chip a removal takes away, whether a - typed term is already selected — so two values it calls equal are one and the same selection throughout. Finally, OnFocusIn and OnFocusOut follow the focus of the + comparison the component makes - which item a value selects, which chip a removal takes away, whether a + typed term is already selected - so two values it calls equal are one and the same selection throughout. Finally, OnFocusIn and OnFocusOut follow the focus of the dropdown as a whole: they sit on the trigger and focusin/focusout bubble, so moving between the trigger and the ComboBox input inside it does not report a round trip through the outside. @@ -791,7 +791,7 @@ The Combo parameter turns the dropdown into a ComboBox: an editable input renders in place of the selected text and filters the items as you type, so the trigger doubles as the search box. Enter selects the item whose text matches what was typed, Backspace on an empty input removes the last selected item, the arrow - keys — and typing itself, which reveals the list it filters — open the callout, Escape abandons the typed term, and a term that was typed but never turned into a + keys - and typing itself, which reveals the list it filters - open the callout, Escape abandons the typed term, and a term that was typed but never turned into a selection is discarded when the callout closes, so the input goes back to showing the current selection.

Requiring the typed text to match an item exactly makes Enter a dead key for anyone who only typed the beginning @@ -799,14 +799,14 @@ ban and pressing Enter then selects Banana. It takes precedence over the Dynamic mode below, so a term that names an item the list already has selects that item instead of creating a second one beside it.

- Whichever item a commit would take — the one the typed text names exactly, or the first one it still - matches under AutoSelectFirstMatch — is marked in the list as you type, so what Enter is + Whichever item a commit would take - the one the typed text names exactly, or the first one it still + matches under AutoSelectFirstMatch - is marked in the list as you type, so what Enter is about to select is visible before it is pressed rather than only afterwards. The same item is named to a screen reader through the aria-activedescendant of the input, so the cue is not a visual-only one. When nothing is marked, Enter either creates a new item (with Dynamic) or does nothing at all.

SelectTextOnFocus selects whatever is already in the input when it takes the focus, so coming back to a - combo box that holds a term and typing replaces that term instead of appending to it — which is what a + combo box that holds a term and typing replaces that term instead of appending to it - which is what a field the user returns to in order to look for something else needs. An empty input has nothing to select, and neither has a read-only one, where the selection would only be a highlight over text that cannot be changed. @@ -906,7 +906,7 @@ how many of them the closed dropdown shows: with Chips the extra ones collapse into an overflow chip whose text comes from OverflowTextFormat ("+{0}" by default), and without chips the joined list is replaced by the summary of SelectedItemsTextFormat ("{0} items selected" by default) as soon - as the limit is passed. Nothing is removed from the selection — only the way it is displayed changes. + as the limit is passed. Nothing is removed from the selection - only the way it is displayed changes.

AutoClearSearch covers the other half of a multi select session: the callout stays open after a pick, so by default the next item has to be found through the filter left over from the previous one. Enabling it clears @@ -970,7 +970,7 @@ When the Dynamic parameter is true, a text typed into the ComboBox that matches no existing item can be added as a new one, which is what makes free-form values (tags, e-mail recipients, ad-hoc categories) possible. DynamicValueGenerator produces the value of the new item from its text and OnDynamicAdd notifies - about the addition so the item can be persisted into the source collection — and only about an addition + about the addition so the item can be persisted into the source collection - and only about an addition that stands, so a term refused by the selection limit or by a one-way binding is never reported as one. Before creating anything the component first looks for an existing match; FindItemFunction and ExistsSelectedItemFunction replace the default case-insensitive text comparison used for those two @@ -1049,32 +1049,32 @@ The dropdown is fully operable with the keyboard, following the ARIA authoring practices for a combobox: Enter, Space and the arrow keys open the callout and put the focus on the selected item (or the first one), ArrowUp and ArrowDown move between the items, Home and End jump to the - first and the last one — from the closed dropdown too, so reaching the end of a long list never takes an - opening key followed by a second one — PageUp and PageDown jump several items at a time, Enter and Space + first and the last one - from the closed dropdown too, so reaching the end of a long list never takes an + opening key followed by a second one - PageUp and PageDown jump several items at a time, Enter and Space select the focused item, Escape and Alt+ArrowUp close the callout and return the focus to the - dropdown, and Tab closes it and moves on — from the trigger just as much as from inside the callout, so a + dropdown, and Tab closes it and moves on - from the trigger just as much as from inside the callout, so a popup revealed without the focus is never left behind when the focus leaves the dropdown. Alt+ArrowDown is the exception among the openers: it reveals the list without moving the focus into it, so the trigger keeps it and the plain arrows can walk the list afterwards. Opening the callout with a mouse click focuses the selected item as well, so the keyboard can take over at any point. In multi select mode Ctrl+A (or Cmd+A) - selects every item the current search shows — or clears them when they are all selected already — + selects every item the current search shows - or clears them when they are all selected already - while inside the search and ComboBox inputs the shortcut keeps its native select-the-text behavior. Typing printable characters runs a typeahead: the accumulated characters jump to the item starting with them, repeating one character cycles through the items starting with it, and the buffer resets after a short pause. The arrow keys wrap around by default, so ArrowDown on the last item comes back to the first one; NoWrapNavigation stops them at the ends instead, which suits a long list where the jump from one end to the other is more likely to read as the focus having been lost than as a move that - was asked for — the typeahead keeps wrapping either way, since it looks for the item that matches rather + was asked for - the typeahead keeps wrapping either way, since it looks for the item that matches rather than for the one that comes next. Disabled and hidden items are skipped throughout. In virtualize mode only the rendered items take part in the typeahead, since the ones that have not been rendered yet have no text to match against. The options themselves stay out of the tab order, as the options of a listbox should: they are reached with the arrow keys, and Tab leaves the whole dropdown rather than walking through the list. - In ComboBox mode the keys that belong to the typed text — the printable characters, Backspace, - Delete, ArrowLeft and ArrowRight — return the focus to the input and act on it + In ComboBox mode the keys that belong to the typed text - the printable characters, Backspace, + Delete, ArrowLeft and ArrowRight - return the focus to the input and act on it there, so arrowing into the list never strands the user away from the term they are typing. - However the callout is dismissed — a key, a click outside it, the close button of the responsive panel or - a swipe — the focus comes back to the dropdown (to the ComboBox input when there is one) instead of + However the callout is dismissed - a key, a click outside it, the close button of the responsive panel or + a swipe - the focus comes back to the dropdown (to the ComboBox input when there is one) instead of being dropped at the top of the page along with the element that was holding it. ClearOnEscape gives Escape one more job once it has nothing left to dismiss: a press with the callout already closed (and, in the ComboBox mode, with nothing typed) clears the selection, which is what a @@ -1112,8 +1112,8 @@ result untouched. The SelectAllText parameter customizes its text. It is not available when the items come from an ItemsProvider, since the items that are not loaded yet cannot be selected. It also honors MaxSelectedItems: it stops at the limit, and once there is no room left it clears the selection instead - of doing nothing. It also goes away when there is nothing left for it to select — an empty list, or a - search that matched nothing — instead of topping the empty state with a control that cannot do anything. + of doing nothing. It also goes away when there is nothing left for it to select - an empty list, or a + search that matched nothing - instead of topping the empty state with a control that cannot do anything. While the callout is open, the Ctrl+A (or Cmd+A) shortcut toggles the same select all behavior from the keyboard, even when the select all item itself is not shown. @@ -1145,10 +1145,10 @@ The MaxSelectedItems parameter limits how many items can be selected in the multi select dropdown. Once the limit is reached the unselected items are disabled rather than silently refusing the click, so the boundary is visible before it is hit, and they become available again as soon as an item is unselected. The select all - item honors the same limit and stops adding items once it is reached — and since it can then never reach + item honors the same limit and stops adding items once it is reached - and since it can then never reach "all selected", the next click on it clears the selection instead of leaving the user with a control that does nothing. The items turning unavailable is a change only a sighted user notices, so reaching the limit is also - announced to screen readers, with a message you can localize through MaxSelectedItemsText — and the + announced to screen readers, with a message you can localize through MaxSelectedItemsText - and the announcement goes quiet again as soon as unselecting an item makes room.
@@ -1169,7 +1169,7 @@ When there is no item to show, the callout renders a message instead of an empty list. There are two distinct cases and each gets its own text: EmptyText and EmptyTemplate cover a list that has nothing in it ("No items found" by default), while NoResultsText and NoResultsTemplate cover a search that - matched nothing ("No results found" by default) — telling the user that their term found nothing is a + matched nothing ("No results found" by default) - telling the user that their term found nothing is a different message from telling them the list is empty. When the no-results pair is not set the empty pair is used for both cases. @@ -1292,8 +1292,8 @@ rows down and has never been rendered: the list is scrolled to where its index says it is and the selection is centred in the window, the way a native select behaves. That needs an index to scroll to, so it applies to a local Items collection; with an ItemsProvider the loaded window is all the component has. - Each option still reports where it sits in the whole set — the provider says how many items there are - and which window it is handing over — so a screen reader announces "Item 4210 of 10,000" in a list that + Each option still reports where it sits in the whole set - the provider says how many items there are + and which window it is handing over - so a screen reader announces "Item 4210 of 10,000" in a list that has never been loaded in full.


@@ -1427,7 +1427,7 @@ ChipsRemoveIconName for the remove button of a chip, and ResponsiveCloseIconName and ComboBoxAddButtonIconName for the close and add buttons that only the responsive panel shows. Each of them has an ...Icon counterpart that takes a BitIconInfo and wins when both are set, which - is how an icon from outside the Fluent set gets in — see the External Icons section below. + is how an icon from outside the Fluent set gets in - see the External Icons section below. None of them is ever read out on its own: the icons are hidden from assistive technologies and the button around them carries the accessible name, which is why those names are parameters of their own. @@ -1489,7 +1489,7 @@ without reopening the list. CloseOnSelect overrides that decision in both directions. Set it to false on a single select dropdown to keep the list open while the user tries one option after another against the page behind it, or to true on a multi select one to turn every pick into a complete - interaction of its own — useful when each selection triggers work that the user should see before + interaction of its own - useful when each selection triggers work that the user should see before choosing again.

Whichever way it goes, the focus follows: a callout that closes hands the focus back to the dropdown (or to its @@ -1522,7 +1522,7 @@ The TokenSeparators parameter turns the listed characters into term endings for the multi select ComboBox input: typing one commits the term before it exactly as pressing Enter would, and pasting a whole delimited list commits every term it contains in one go. A term that names an existing item selects that item, - and — with Dynamic enabled — a term that names none becomes a new item, so a list copied out + and - with Dynamic enabled - a term that names none becomes a new item, so a list copied out of a spreadsheet or an e-mail turns into a selection without being retyped item by item. A term the selection already covers is refused, so committing the same list twice does not duplicate anything. @@ -1546,7 +1546,7 @@
The OpenOnFocus parameter opens the callout the moment the dropdown receives the focus, so tabbing into - it (or clicking any part of it) already shows the items without a further click or key press — one + it (or clicking any part of it) already shows the items without a further click or key press - one interaction fewer in a form that is filled top to bottom. The component tells a focus move made by the user apart from one made by its own focus management: a dismissal that returns the focus to the dropdown (Escape, a pick that closes the callout) does not reopen what was just closed. @@ -1565,13 +1565,13 @@
- The Description parameter renders a line of helper text under the dropdown — the place for the rule + The Description parameter renders a line of helper text under the dropdown - the place for the rule that the label has no room for ("only the categories you have access to", "leave empty for all regions"). It is not decoration: the dropdown points at it with aria-describedby, so a screen reader reads it along with the control instead of leaving it as text that merely happens to sit underneath. In the ComboBox mode the editable input is described by it as well, since that is the element the user is actually typing into. - DescriptionTemplate replaces it with any content — a link, an icon, a warning that changes with the - selection — and is tied to the dropdown in exactly the same way. + DescriptionTemplate replaces it with any content - a link, an icon, a warning that changes with the + selection - and is tied to the dropdown in exactly the same way.

@@ -1595,7 +1595,7 @@
- Nothing here is final — you can change it later. + Nothing here is final - you can change it later.
@@ -1604,8 +1604,8 @@
- The Color parameter paints the accents of the dropdown — the focus border, the search box - underline and icon, the group headers and the check boxes of the multi select items — in one of the theme + The Color parameter paints the accents of the dropdown - the focus border, the search box + underline and icon, the group headers and the check boxes of the multi select items - in one of the theme colors, Primary being the default. It changes the accents only, not the surface of the control, so the dropdown keeps fitting its surroundings.
@@ -1682,8 +1682,8 @@ Every icon of the dropdown can come from an external library instead of the built-in Fluent UI set. The CaretDownIcon, ClearButtonIcon, ChipsRemoveIcon, SearchBoxIcon, SearchBoxClearIcon, ResponsiveCloseIcon and ItemCheckIcon parameters take a - BitIconInfo — built with BitIconInfo.Fa, BitIconInfo.Bi or the generic - BitIconInfo.Css — and each takes precedence over its ...IconName counterpart when both are + BitIconInfo - built with BitIconInfo.Fa, BitIconInfo.Bi or the generic + BitIconInfo.Css - and each takes precedence over its ...IconName counterpart when both are set. The items themselves follow the same rule through their Icon and IconName members. Remember to reference the stylesheet of the icon library you use.
@@ -1796,7 +1796,7 @@
The dropdown can be styled at every level: Style and Class apply to the root element, each item carries its own style and class, and the Styles and Classes parameters reach every internal part - of the component individually — the label, the container, the callout, the search box, the scroll + of the component individually - the label, the container, the callout, the search box, the scroll container, the items and everything else listed in the class-styles reference below. Because the callout is rendered outside the root element, styling it through Styles.Callout or Classes.Callout is the way to reach it rather than a descendant selector on the root. @@ -1848,7 +1848,7 @@ callout aligns to the right edge of the dropdown, and the responsive panel slides in from the left and is swiped away in the opposite direction. The callout is rendered outside the dropdown so that it can escape any clipping ancestor around it, which also puts it out of the reach of the direction the dropdown declares, so it - carries that direction itself — the list of an RTL dropdown reads right-to-left even on a page that does not. + carries that direction itself - the list of an RTL dropdown reads right-to-left even on a page that does not.

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor.samples.cs index cc4ff70dd2..3b2b33f43f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownItemDemo.razor.samples.cs @@ -1833,7 +1833,7 @@ private void HandleOnDynamicAdd(BitDropdownItem item)
- Nothing here is final — you can change it later. + Nothing here is final - you can change it later.
"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor index 95e20fc90b..b839a64857 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor @@ -4,7 +4,7 @@ reports itself as required to assistive technologies), one preserving its original callout width instead of matching the width of the dropdown, and a custom delimiter for joining the selected values in multi select mode. A disabled dropdown cannot be focused or opened at all, while a read-only one stays focusable and - its callout can still be browsed — only changing the selection is blocked, and every control that would + its callout can still be browsed - only changing the selection is blocked, and every control that would change it (the clear button, the chip remove buttons) is hidden accordingly. The Title parameter adds the tooltip shown while the pointer rests on the dropdown. Clicking the Label moves the focus to the dropdown, and the Name parameter names the @@ -117,7 +117,7 @@ scrolled past, so a long grouped list never leaves the user looking at options whose group has scrolled away.

The grouping survives a search: the headers of the groups that still have a match stay above them, and only - the ones left naming nothing — along with a divider that lost the options on one of its sides — go + the ones left naming nothing - along with a divider that lost the options on one of its sides - go with the options they framed. A result that arrived as a flat list would take the grouping away exactly when the list is hardest to read, so the third example below keeps it while filtering.
@@ -171,8 +171,8 @@
- The Prefix and Suffix parameters put fixed text before and after the selected value — a - unit, a currency, a category — and PrefixTemplate and SuffixTemplate replace them with any + The Prefix and Suffix parameters put fixed text before and after the selected value - a + unit, a currency, a category - and PrefixTemplate and SuffixTemplate replace them with any content, an icon for instance. They are decoration only: they are not part of the value and are not read out with it, so keep a descriptive Label for screen reader users.
@@ -276,8 +276,8 @@
Three parameters change how much furniture the dropdown draws around itself. NoBorder removes its border - entirely, Underlined replaces the box with a single bottom border — the variant that suits a dense - form where a box per field would be too much — and Transparent removes the background, so the + entirely, Underlined replaces the box with a single bottom border - the variant that suits a dense + form where a box per field would be too much - and Transparent removes the background, so the control can sit on a colored or image surface without cutting a rectangle out of it. They are independent and combine freely; the callout keeps its own surface either way, so the options stay readable. Since the two borderless variants have no box to draw a focus ring around, the keyboard focus (and the invalid state) is @@ -376,7 +376,7 @@

MaxHeight caps the scrollable item list at a height of your own, in pixels, so a long list stays a modest panel instead of stretching to whatever the viewport allows. It is applied on top of that available - space rather than in place of it, so it can only ever make the list shorter — a dropdown near the bottom + space rather than in place of it, so it can only ever make the list shorter - a dropdown near the bottom of the window is still limited by the room it actually has. Note that this is the one size a stylesheet cannot reach: the height is measured and written as an inline style every time the callout opens, so a CSS rule would always lose to it. @@ -427,7 +427,7 @@ finding a button with the mouse. Escape stays a dismiss key first: it closes the callout, and in the ComboBox mode it drops the text that was typed into it, and only a press with nothing left to dismiss reaches the selection. It goes through the very same clear as the button, so it raises OnClear and - is refused wherever the button would be — and it needs no ShowClearButton, so a dropdown that + is refused wherever the button would be - and it needs no ShowClearButton, so a dropdown that shows no clear button can still be cleared this way.

@@ -564,7 +564,7 @@
A handful of parameters tune the default search without replacing it. SearchMode picks how the text of an item - is matched against the typed term — Contains (the default), StartsWith, EndsWith or + is matched against the typed term - Contains (the default), StartsWith, EndsWith or ExactMatch, always ignoring case. MinSearchLength holds the filtering back until the term reaches the given number of characters, which keeps a one-letter term from being treated as a real query (and, with an ItemsProvider, from turning every first keystroke into a request). While the term is still @@ -573,7 +573,7 @@ MinSearchLengthText is the composite format of that hint, which receives the number of characters that are still needed and is announced to screen readers along with it. SearchIgnoreDiacritics matches the term against the item texts with the diacritics of both folded away, so Jose finds José and - Muller finds Müller — which is what makes the search usable to anyone typing on a keyboard + Muller finds Müller - which is what makes the search usable to anyone typing on a keyboard that has no accented keys. HighlightSearch emphasizes the matched part of each item text so the reason an item is in the result is visible at a glance, and it keeps lining up with the accented text because the folding replaces each character with exactly one character. Whenever a search is active the number of results is also @@ -774,7 +774,7 @@ the selected value shown on the closed dropdown and PlaceholderTemplate what is shown while nothing is selected, LabelTemplate replaces the label, CaretDownIconName and CaretDownTemplate the chevron, and CalloutHeaderTemplate and CalloutFooterTemplate add fixed content above and below the - scrollable item list — the footer being the usual home of an "add a new item" action. The templates that + scrollable item list - the footer being the usual home of an "add a new item" action. The templates that render an item receive the item itself, so the extra state carried in its Data property is available to them.
@@ -902,12 +902,12 @@ Every piece of the dropdown's state can be bound or observed. The selection is two-way bound over Value in single select mode and Values in multi select mode, or left uncontrolled with DefaultValue and DefaultValues and observed through the OnChange and OnValuesChange events; - OnSelectItem hands over the clicked item itself rather than its value — in multi select mode for + OnSelectItem hands over the clicked item itself rather than its value - in multi select mode for every pick, including the one that unselects an already selected item, which OnDeselectItem reports on its own so that an addition and a removal can be told apart. Reselectable makes the select events fire even when the already selected item is picked again, which is otherwise treated as a no-op. The open state of the callout is two-way bound via IsOpen, so it can be opened and closed from code, and - the OnOpen and OnClose events report every change to it — a natural place to start fetching + the OnOpen and OnClose events report every change to it - a natural place to start fetching the items on first open.

ValueComparer decides whether two values stand for the same selection, in place of the default equality @@ -915,8 +915,8 @@ value compares by reference by default, so a value arriving from a form, a query string or a fresh fetch would never match the item it names, however equal the two look. The example below uses a case-insensitive comparer, so the value F-APP selects the item whose value is f-app. The comparer governs every value - comparison the component makes — which item a value selects, which chip a removal takes away, whether a - typed term is already selected — so two values it calls equal are one and the same selection throughout. Finally, OnFocusIn and OnFocusOut follow the focus of the + comparison the component makes - which item a value selects, which chip a removal takes away, whether a + typed term is already selected - so two values it calls equal are one and the same selection throughout. Finally, OnFocusIn and OnFocusOut follow the focus of the dropdown as a whole: they sit on the trigger and focusin/focusout bubble, so moving between the trigger and the ComboBox input inside it does not report a round trip through the outside. @@ -1105,7 +1105,7 @@ The Combo parameter turns the dropdown into a ComboBox: an editable input renders in place of the selected text and filters the items as you type, so the trigger doubles as the search box. Enter selects the item whose text matches what was typed, Backspace on an empty input removes the last selected item, the arrow - keys — and typing itself, which reveals the list it filters — open the callout, Escape abandons the typed term, and a term that was typed but never turned into a + keys - and typing itself, which reveals the list it filters - open the callout, Escape abandons the typed term, and a term that was typed but never turned into a selection is discarded when the callout closes, so the input goes back to showing the current selection.

Requiring the typed text to match an item exactly makes Enter a dead key for anyone who only typed the beginning @@ -1113,14 +1113,14 @@ ban and pressing Enter then selects Banana. It takes precedence over the Dynamic mode below, so a term that names an item the list already has selects that item instead of creating a second one beside it.

- Whichever item a commit would take — the one the typed text names exactly, or the first one it still - matches under AutoSelectFirstMatch — is marked in the list as you type, so what Enter is + Whichever item a commit would take - the one the typed text names exactly, or the first one it still + matches under AutoSelectFirstMatch - is marked in the list as you type, so what Enter is about to select is visible before it is pressed rather than only afterwards. The same item is named to a screen reader through the aria-activedescendant of the input, so the cue is not a visual-only one. When nothing is marked, Enter either creates a new item (with Dynamic) or does nothing at all.

SelectTextOnFocus selects whatever is already in the input when it takes the focus, so coming back to a - combo box that holds a term and typing replaces that term instead of appending to it — which is what a + combo box that holds a term and typing replaces that term instead of appending to it - which is what a field the user returns to in order to look for something else needs. An empty input has nothing to select, and neither has a read-only one, where the selection would only be a highlight over text that cannot be changed. @@ -1252,7 +1252,7 @@ how many of them the closed dropdown shows: with Chips the extra ones collapse into an overflow chip whose text comes from OverflowTextFormat ("+{0}" by default), and without chips the joined list is replaced by the summary of SelectedItemsTextFormat ("{0} items selected" by default) as soon - as the limit is passed. Nothing is removed from the selection — only the way it is displayed changes. + as the limit is passed. Nothing is removed from the selection - only the way it is displayed changes.

AutoClearSearch covers the other half of a multi select session: the callout stays open after a pick, so by default the next item has to be found through the filter left over from the previous one. Enabling it clears @@ -1341,7 +1341,7 @@ When the Dynamic parameter is true, a text typed into the ComboBox that matches no existing item can be added as a new one, which is what makes free-form values (tags, e-mail recipients, ad-hoc categories) possible. DynamicValueGenerator produces the value of the new item from its text and OnDynamicAdd notifies - about the addition so the item can be persisted into the source collection — and only about an addition + about the addition so the item can be persisted into the source collection - and only about an addition that stands, so a term refused by the selection limit or by a one-way binding is never reported as one. Before creating anything the component first looks for an existing match; FindItemFunction and ExistsSelectedItemFunction replace the default case-insensitive text comparison used for those two @@ -1442,29 +1442,29 @@ first one), ArrowUp and ArrowDown move between the items, Home and End jump to the first and the last one, PageUp and PageDown jump several items at a time, Enter and Space select the focused item, Escape and Alt+ArrowUp close the callout and return the focus to the - dropdown, and Tab closes it and moves on — from the trigger just as much as from inside the callout, so a + dropdown, and Tab closes it and moves on - from the trigger just as much as from inside the callout, so a popup revealed without the focus is never left behind when the focus leaves the dropdown. Alt+ArrowDown is the exception among the openers: it reveals the list without moving the focus into it, so the trigger keeps it and the plain arrows can walk the list afterwards. Opening the callout with a mouse click focuses the selected item as well, so the keyboard can take over at any point. In multi select mode Ctrl+A (or Cmd+A) - selects every item the current search shows — or clears them when they are all selected already — + selects every item the current search shows - or clears them when they are all selected already - while inside the search and ComboBox inputs the shortcut keeps its native select-the-text behavior. Typing printable characters runs a typeahead: the accumulated characters jump to the item starting with them, repeating one character cycles through the items starting with it, and the buffer resets after a short pause. The arrow keys wrap around by default, so ArrowDown on the last item comes back to the first one; NoWrapNavigation stops them at the ends instead, which suits a long list where the jump from one end to the other is more likely to read as the focus having been lost than as a move that - was asked for — the typeahead keeps wrapping either way, since it looks for the item that matches rather + was asked for - the typeahead keeps wrapping either way, since it looks for the item that matches rather than for the one that comes next. Disabled and hidden items are skipped throughout. In virtualize mode only the rendered items take part in the typeahead, since the ones that have not been rendered yet have no text to match against. The options themselves stay out of the tab order, as the options of a listbox should: they are reached with the arrow keys, and Tab leaves the whole dropdown rather than walking through the list. - In ComboBox mode the keys that belong to the typed text — the printable characters, Backspace, - Delete, ArrowLeft and ArrowRight — return the focus to the input and act on it + In ComboBox mode the keys that belong to the typed text - the printable characters, Backspace, + Delete, ArrowLeft and ArrowRight - return the focus to the input and act on it there, so arrowing into the list never strands the user away from the term they are typing. - However the callout is dismissed — a key, a click outside it, the close button of the responsive panel or - a swipe — the focus comes back to the dropdown (to the ComboBox input when there is one) instead of + However the callout is dismissed - a key, a click outside it, the close button of the responsive panel or + a swipe - the focus comes back to the dropdown (to the ComboBox input when there is one) instead of being dropped at the top of the page along with the element that was holding it. ClearOnEscape gives Escape one more job once it has nothing left to dismiss: a press with the callout already closed (and, in the ComboBox mode, with nothing typed) clears the selection, which is what a @@ -1517,8 +1517,8 @@ result untouched. The SelectAllText parameter customizes its text. It is not available when the items come from an ItemsProvider, since the items that are not loaded yet cannot be selected. It also honors MaxSelectedItems: it stops at the limit, and once there is no room left it clears the selection instead - of doing nothing. It also goes away when there is nothing left for it to select — an empty list, or a - search that matched nothing — instead of topping the empty state with a control that cannot do anything. + of doing nothing. It also goes away when there is nothing left for it to select - an empty list, or a + search that matched nothing - instead of topping the empty state with a control that cannot do anything. While the callout is open, the Ctrl+A (or Cmd+A) shortcut toggles the same select all behavior from the keyboard, even when the select all item itself is not shown. @@ -1560,10 +1560,10 @@ The MaxSelectedItems parameter limits how many items can be selected in the multi select dropdown. Once the limit is reached the unselected items are disabled rather than silently refusing the click, so the boundary is visible before it is hit, and they become available again as soon as an item is unselected. The select all - item honors the same limit and stops adding items once it is reached — and since it can then never reach + item honors the same limit and stops adding items once it is reached - and since it can then never reach "all selected", the next click on it clears the selection instead of leaving the user with a control that does nothing. The items turning unavailable is a change only a sighted user notices, so reaching the limit is also - announced to screen readers, with a message you can localize through MaxSelectedItemsText — and the + announced to screen readers, with a message you can localize through MaxSelectedItemsText - and the announcement goes quiet again as soon as unselecting an item makes room.
@@ -1589,7 +1589,7 @@ When there is no item to show, the callout renders a message instead of an empty list. There are two distinct cases and each gets its own text: EmptyText and EmptyTemplate cover a list that has nothing in it ("No items found" by default), while NoResultsText and NoResultsTemplate cover a search that - matched nothing ("No results found" by default) — telling the user that their term found nothing is a + matched nothing ("No results found" by default) - telling the user that their term found nothing is a different message from telling them the list is empty. When the no-results pair is not set the empty pair is used for both cases. @@ -1803,7 +1803,7 @@ ChipsRemoveIconName for the remove button of a chip, and ResponsiveCloseIconName and ComboBoxAddButtonIconName for the close and add buttons that only the responsive panel shows. Each of them has an ...Icon counterpart that takes a BitIconInfo and wins when both are set, which - is how an icon from outside the Fluent set gets in — see the External Icons section below. + is how an icon from outside the Fluent set gets in - see the External Icons section below. None of them is ever read out on its own: the icons are hidden from assistive technologies and the button around them carries the accessible name, which is why those names are parameters of their own. @@ -1895,7 +1895,7 @@ without reopening the list. CloseOnSelect overrides that decision in both directions. Set it to false on a single select dropdown to keep the list open while the user tries one option after another against the page behind it, or to true on a multi select one to turn every pick into a complete - interaction of its own — useful when each selection triggers work that the user should see before + interaction of its own - useful when each selection triggers work that the user should see before choosing again.

Whichever way it goes, the focus follows: a callout that closes hands the focus back to the dropdown (or to its @@ -1938,7 +1938,7 @@ The TokenSeparators parameter turns the listed characters into term endings for the multi select ComboBox input: typing one commits the term before it exactly as pressing Enter would, and pasting a whole delimited list commits every term it contains in one go. A term that names an existing item selects that item, - and — with Dynamic enabled — a term that names none becomes a new item, so a list copied out + and - with Dynamic enabled - a term that names none becomes a new item, so a list copied out of a spreadsheet or an e-mail turns into a selection without being retyped item by item. A term the selection already covers is refused, so committing the same list twice does not duplicate anything. @@ -1967,7 +1967,7 @@
The OpenOnFocus parameter opens the callout the moment the dropdown receives the focus, so tabbing into - it (or clicking any part of it) already shows the items without a further click or key press — one + it (or clicking any part of it) already shows the items without a further click or key press - one interaction fewer in a form that is filled top to bottom. The component still tells the focus a user moved apart from the focus it moves itself: a dismissal that returns the focus to the dropdown (Escape, a pick that closes the callout) does not reopen what was just closed. @@ -1991,13 +1991,13 @@
- The Description parameter renders a line of helper text under the dropdown — the place for the rule + The Description parameter renders a line of helper text under the dropdown - the place for the rule that the label has no room for ("only the categories you have access to", "leave empty for all regions"). It is not decoration: the dropdown points at it with aria-describedby, so a screen reader reads it along with the control instead of leaving it as text that merely happens to sit underneath. In the ComboBox mode the editable input is described by it as well, since that is the element the user is actually typing into. - DescriptionTemplate replaces it with any content — a link, an icon, a warning that changes with the - selection — and is tied to the dropdown in exactly the same way. + DescriptionTemplate replaces it with any content - a link, an icon, a warning that changes with the + selection - and is tied to the dropdown in exactly the same way.

@@ -2031,7 +2031,7 @@
- Nothing here is final — you can change it later. + Nothing here is final - you can change it later.
@@ -2046,8 +2046,8 @@
- The Color parameter paints the accents of the dropdown — the focus border, the search box - underline and icon, the group headers and the check boxes of the multi select items — in one of the theme + The Color parameter paints the accents of the dropdown - the focus border, the search box + underline and icon, the group headers and the check boxes of the multi select items - in one of the theme colors, Primary being the default. It changes the accents only, not the surface of the control, so the dropdown keeps fitting its surroundings.
@@ -2156,8 +2156,8 @@ Every icon of the dropdown can come from an external library instead of the built-in Fluent UI set. The CaretDownIcon, ClearButtonIcon, ChipsRemoveIcon, SearchBoxIcon, SearchBoxClearIcon, ResponsiveCloseIcon and ItemCheckIcon parameters take a - BitIconInfo — built with BitIconInfo.Fa, BitIconInfo.Bi or the generic - BitIconInfo.Css — and each takes precedence over its ...IconName counterpart when both are + BitIconInfo - built with BitIconInfo.Fa, BitIconInfo.Bi or the generic + BitIconInfo.Css - and each takes precedence over its ...IconName counterpart when both are set. The items themselves follow the same rule through their Icon and IconName members. Remember to reference the stylesheet of the icon library you use.
@@ -2337,7 +2337,7 @@
The dropdown can be styled at every level: Style and Class apply to the root element, each item carries its own style and class, and the Styles and Classes parameters reach every internal part - of the component individually — the label, the container, the callout, the search box, the scroll + of the component individually - the label, the container, the callout, the search box, the scroll container, the items and everything else listed in the class-styles reference below. Because the callout is rendered outside the root element, styling it through Styles.Callout or Classes.Callout is the way to reach it rather than a descendant selector on the root. @@ -2409,7 +2409,7 @@ callout aligns to the right edge of the dropdown, and the responsive panel slides in from the left and is swiped away in the opposite direction. The callout is rendered outside the dropdown so that it can escape any clipping ancestor around it, which also puts it out of the reach of the direction the dropdown declares, so it - carries that direction itself — the list of an RTL dropdown reads right-to-left even on a page that does not. + carries that direction itself - the list of an RTL dropdown reads right-to-left even on a page that does not.

diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor.samples.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor.samples.cs index 6e1d93a1a8..02a6a407c2 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor.samples.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Inputs/Dropdown/_BitDropdownOptionDemo.razor.samples.cs @@ -2166,7 +2166,7 @@ private void HandleOnDynamicAdd(BitDropdownOption item)
- Nothing here is final — you can change it later. + Nothing here is final - you can change it later.
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/BitNavBarDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/BitNavBarDemo.razor index 5b809ddb51..5abca3903e 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/BitNavBarDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/BitNavBarDemo.razor @@ -19,14 +19,14 @@ Each item renders an icon over its text and becomes a link when it carries a URL. In the default Automatic mode the navbar follows the browser - and selects the item whose URL points at the current page — matched exactly, by prefix, by a - wildcard or by a regular expression — while the + and selects the item whose URL points at the current page - matched exactly, by prefix, by a + wildcard or by a regular expression - while the Manual mode leaves the selection to clicks and to the two-way SelectedItem binding. The selected item can swap to a filled SelectedIconName and take an - Indicator of its own — a line along its - edge or a pill behind its icon — and the bar reshapes itself for the space it has: labels + Indicator of its own - a line along its + edge or a pill behind its icon - and the bar reshapes itself for the space it has: labels beside the icons, only on the selected item or dropped altogether, a column that turns it into a navigation rail, a chosen Alignment of the items along it, a Scrollable list for more diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarCustomDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarCustomDemo.razor index 07b018a220..ab855563c0 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarCustomDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarCustomDemo.razor @@ -241,8 +241,8 @@
Indicator gives the selection a mark of its own beside the color of the item: a Line - along the edge of the selected item — its bottom edge across a bar and its leading edge down a - Vertical rail, the way a tab strip marks its current tab — or the Pill a Material + along the edge of the selected item - its bottom edge across a bar and its leading edge down a + Vertical rail, the way a tab strip marks its current tab - or the Pill a Material navigation bar draws behind the icon of its current destination. The pill is the filled part in that mode, so it takes the fill of the item over, and both follow the Color while Filled is enabled. @@ -285,7 +285,7 @@ A navbar squeezes its items into the room it has, which runs out once it holds more destinations than it fits. Scrollable leaves every item at the size of its own content and scrolls the list instead, with the scrollbar itself hidden, and it brings the selected item into view whenever the - selection moves — from a click, from the URL in the Automatic mode, or from the binding, + selection moves - from a click, from the URL in the Automatic mode, or from the binding, as the button below does. A Vertical rail scrolls down its own length in exactly the same way.


@@ -415,7 +415,7 @@ Badge puts a short count or status on the icon of an item, and Dot marks it as needing attention without a number. Both are drawn on the icon and hidden from assistive technology, and the badge is folded into the name of the item instead, so it is announced once rather than twice - — "Inbox (12)". BadgeAriaLabel replaces that with a description a screen reader can read + - "Inbox (12)". BadgeAriaLabel replaces that with a description a screen reader can read out on its own, and is the only way a Dot is announced at all, since a dot carries no text.


@@ -433,7 +433,7 @@
Filled fills the hovered and the selected item, which gives the selection a mark of its own instead of leaving it to the text color alone. The fill takes the Color of the navbar, and the - content of the item — along with the Indicator, when the navbar carries one — + content of the item - along with the Indicator, when the navbar carries one - moves onto the on-color of that color so that it stays legible over the fill.


@@ -452,7 +452,7 @@ the anchor (or the button) the item is; the TemplateRenderMode selector of an item (and ItemTemplateRenderMode of the navbar, for its own template) set to Replace has the template render that element itself instead, which is what an item that is a control of its own - — the center action of a mobile bar — needs, since an interactive element cannot be + - the center action of a mobile bar - needs, since an interactive element cannot be nested in another one. A replaced item owns its clicks, its focus and its accessible name, and is left out of the keyboard navigation of the bar. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarItemDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarItemDemo.razor index 03f964e211..14e192855f 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarItemDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarItemDemo.razor @@ -183,8 +183,8 @@
Indicator gives the selection a mark of its own beside the color of the item: a Line - along the edge of the selected item — its bottom edge across a bar and its leading edge down a - Vertical rail, the way a tab strip marks its current tab — or the Pill a Material + along the edge of the selected item - its bottom edge across a bar and its leading edge down a + Vertical rail, the way a tab strip marks its current tab - or the Pill a Material navigation bar draws behind the icon of its current destination. The pill is the filled part in that mode, so it takes the fill of the item over, and both follow the Color while Filled is enabled. @@ -221,7 +221,7 @@ A navbar squeezes its items into the room it has, which runs out once it holds more destinations than it fits. Scrollable leaves every item at the size of its own content and scrolls the list instead, with the scrollbar itself hidden, and it brings the selected item into view whenever the - selection moves — from a click, from the URL in the Automatic mode, or from the binding, + selection moves - from a click, from the URL in the Automatic mode, or from the binding, as the button below does. A Vertical rail scrolls down its own length in exactly the same way.


@@ -327,7 +327,7 @@ Badge puts a short count or status on the icon of an item, and Dot marks it as needing attention without a number. Both are drawn on the icon and hidden from assistive technology, and the badge is folded into the name of the item instead, so it is announced once rather than twice - — "Inbox (12)". BadgeAriaLabel replaces that with a description a screen reader can read + - "Inbox (12)". BadgeAriaLabel replaces that with a description a screen reader can read out on its own, and is the only way a Dot is announced at all, since a dot carries no text.

@@ -340,7 +340,7 @@
Filled fills the hovered and the selected item, which gives the selection a mark of its own instead of leaving it to the text color alone. The fill takes the Color of the navbar, and the - content of the item — along with the Indicator, when the navbar carries one — + content of the item - along with the Indicator, when the navbar carries one - moves onto the on-color of that color so that it stays legible over the fill.


@@ -356,7 +356,7 @@ inside the anchor (or the button) the item is; TemplateRenderMode of an item (and ItemTemplateRenderMode of the navbar, for its own template) set to Replace has the template render that element itself instead, which is what an item that is a control of its own - — the center action of a mobile bar — needs, since an interactive element cannot be + - the center action of a mobile bar - needs, since an interactive element cannot be nested in another one. A replaced item owns its clicks, its focus and its accessible name, and is left out of the keyboard navigation of the bar. diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarOptionDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarOptionDemo.razor index 5e8a962cd4..7446bc3f59 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarOptionDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Navs/NavBar/_BitNavBarOptionDemo.razor @@ -265,8 +265,8 @@
Indicator gives the selection a mark of its own beside the color of the option: a Line - along the edge of the selected option — its bottom edge across a bar and its leading edge down - a Vertical rail, the way a tab strip marks its current tab — or the Pill a + along the edge of the selected option - its bottom edge across a bar and its leading edge down + a Vertical rail, the way a tab strip marks its current tab - or the Pill a Material navigation bar draws behind the icon of its current destination. The pill is the filled part in that mode, so it takes the fill of the option over, and both follow the Color while Filled is enabled. @@ -308,7 +308,7 @@ A navbar squeezes its options into the room it has, which runs out once it holds more destinations than it fits. Scrollable leaves every option at the size of its own content and scrolls the list instead, with the scrollbar itself hidden, and it brings the selected option into view whenever - the selection moves — from a click, from the URL in the Automatic mode, or from the + the selection moves - from a click, from the URL in the Automatic mode, or from the binding, as the button below does. A Vertical rail scrolls down its own length the same way.


@@ -461,7 +461,7 @@ Badge puts a short count or status on the icon of an option, and Dot marks it as needing attention without a number. Both are drawn on the icon and hidden from assistive technology, and the badge is folded into the name of the option instead, so it is announced once rather than twice - — "Inbox (12)". BadgeAriaLabel replaces that with a description a screen reader can read + - "Inbox (12)". BadgeAriaLabel replaces that with a description a screen reader can read out on its own, and is the only way a Dot is announced at all, since a dot carries no text.

@@ -479,8 +479,8 @@
Filled fills the hovered and the selected option, which gives the selection a mark of its own instead of leaving it to the text color alone. The fill takes the Color of the navbar, - and the content of the option — along with the Indicator, when the navbar carries one - — moves onto the on-color of that color so that it stays legible over the fill. + and the content of the option - along with the Indicator, when the navbar carries one + - moves onto the on-color of that color so that it stays legible over the fill.


diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Iconography/IconographyPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Iconography/IconographyPage.razor index 986fedcc35..4749510add 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Iconography/IconographyPage.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Iconography/IconographyPage.razor @@ -222,7 +222,7 @@ MDL2 names things in its own words. Try a shorter term, or the word Microsoft - would have used — Ringer for a bell, Contact for a user, + would have used - Ringer for a bell, Contact for a user, Cancel for a close. @@ -247,7 +247,7 @@ Description="The icons ship separately from the components, so an app that does not use them does not download them."> - Add the Bit.BlazorUI.Icons package and link its stylesheet — both halves, + Add the Bit.BlazorUI.Icons package and link its stylesheet - both halves, or the glyphs come out as empty boxes. It is one of the optional steps of the getting started page. @@ -256,7 +256,7 @@ <link href="_content/Bit.BlazorUI.Icons/styles/bit.blazorui.icons.css" rel="stylesheet" /> - The core package embeds a @IconCatalog.CoreItems.Count-glyph subset of the same font — about 3 KB — + The core package embeds a @IconCatalog.CoreItems.Count-glyph subset of the same font - about 3 KB - because its own components draw with it. These render in an app that never installed anything above, which is why a date picker has arrows and a message has a dismiss cross before the icon pack is anywhere in the project: @@ -273,7 +273,7 @@
- Anything else — every other name on this page — needs the package. + Anything else - every other name on this page - needs the package. @@ -285,7 +285,7 @@ BitIcon renders one glyph. IconName takes a name from - BitIconName, which is a class of string constants — so the compiler + BitIconName, which is a class of string constants - so the compiler catches a typo that a raw string would not. @@ -303,7 +303,7 @@ - The full parameter list — and every state of it — is on the + The full parameter list - and every state of it - is on the BitIcon page. @@ -313,7 +313,7 @@ Anything in the library that can show an icon takes the same name. The parameter is IconName on the component itself, or a property of the same name on the - item type of a list-shaped one — a nav item, a menu item, a pivot header. + item type of a list-shaped one - a nav item, a menu item, a pivot header.
@@ -335,7 +335,7 @@ The stylesheet is the whole API: a glyph is a ::before on a class, so any element can carry one without a component around it. This is also what the browser above - renders — two thousand components would cost more than two thousand glyphs are worth. + renders - two thousand components would cost more than two thousand glyphs are worth.
@@ -455,7 +455,7 @@ BitIconInfo.Bit("Home") // back to the built-in set
- The set's own stylesheet has to be referenced by the app — the library never fetches + The set's own stylesheet has to be referenced by the app - the library never fetches one for you. Nothing else changes: sizing, coloring and disabled states are the component's, not the icon set's. @@ -466,7 +466,7 @@ BitIconInfo.Bit("Home") // back to the built-in set Description="An icon is either decoration or information, and the two are marked up differently. Deciding which is the whole job."> - Decorative — the icon repeats a label that is already there, and must add + Decorative - the icon repeats a label that is already there, and must add nothing to what is announced. The library's components draw the glyph as an empty element with no text of its own, so a BitButton with a word beside its icon announces the word and nothing else. In markup you write yourself, mark the glyph @@ -474,7 +474,7 @@ BitIconInfo.Bit("Home") // back to the built-in set - Informative — the icon is the only thing saying what the control does. It needs + Informative - the icon is the only thing saying what the control does. It needs a text alternative, which on BitIcon and on every icon-only control is AriaLabel. An icon-only button without one is a button screen readers announce as nothing at all. @@ -497,7 +497,7 @@ BitIconInfo.Bit("Home") // back to the built-in set
  • Never let color carry the meaning on its own. A red glyph and a green glyph are - the same glyph to a reader who cannot tell them apart — pair the color with a + the same glyph to a reader who cannot tell them apart - pair the color with a shape that differs, or with a word.
  • @@ -525,7 +525,7 @@ BitIconInfo.Bit("Home") // back to the built-in set

    • Stay inside one set. Fabric next to Font Awesome reads as two designs sharing a screen.
    • -
    • Use the same icon for the same action everywhere — one delete glyph, not three.
    • +
    • Use the same icon for the same action everywhere - one delete glyph, not three.
    • Let the icon sit beside a label wherever there is room for one.
    • Size the glyph to the text it sits with, and let it take currentColor.
    • Prefer the plainest glyph that says it. MDL2 offers a dozen mails; the one called Mail is almost always right.
    • @@ -539,10 +539,10 @@ BitIconInfo.Bit("Home") // back to the built-in set

      • Don't use an icon for a concept it does not already carry. If it needs explaining, use a word.
      • -
      • Don't rotate or mirror a glyph to mean something else — the set has a name for that direction.
      • +
      • Don't rotate or mirror a glyph to mean something else - the set has a name for that direction.
      • Don't put an icon on every row of a list. When everything is emphasised, nothing is.
      • Don't scale a glyph past about 32 px as an illustration; it is a UI icon and its strokes thin out.
      • -
      • Don't ship the whole icon pack for the handful of glyphs a marketing page uses — the core subset may already have them.
      • +
      • Don't ship the whole icon pack for the handful of glyphs a marketing page uses - the core subset may already have them.
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor index a14e16b397..451905adf8 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Theming/ThemingPage.razor @@ -44,18 +44,18 @@
  1. - HTML attributes on <html> — declarative bootstrap. Pick a preset, follow OS appearance, persist the user's choice, or rename the dark/light keys. No code required. + HTML attributes on <html> - declarative bootstrap. Pick a preset, follow OS appearance, persist the user's choice, or rename the dark/light keys. No code required.
  2. - C# APIsBitThemeManager for runtime preset switching and token overrides, BitThemeProvider for scoped overrides on a subtree, BitThemeNotifications for change events. + C# APIs - BitThemeManager for runtime preset switching and token overrides, BitThemeProvider for scoped overrides on a subtree, BitThemeNotifications for change events.
  3. - JavaScript APIBitBlazorUI.Theme mirrors the C# manager for non-Blazor code (host pages, custom scripts, third-party widgets). + JavaScript API - BitBlazorUI.Theme mirrors the C# manager for non-Blazor code (host pages, custom scripts, third-party widgets).
- Underneath, every component reads --bit-* CSS custom properties. Presets set them via stylesheet rules selected by the bit-theme attribute — on :root for the whole document, or on any element to re-theme just that subtree (scoped presets, see Scoped presets below); BitThemeProvider and ApplyBitThemeAsync override them via inline style. Inline wins, so brand tweaks apply on top of any preset. + Underneath, every component reads --bit-* CSS custom properties. Presets set them via stylesheet rules selected by the bit-theme attribute - on :root for the whole document, or on any element to re-theme just that subtree (scoped presets, see Scoped presets below); BitThemeProvider and ApplyBitThemeAsync override them via inline style. Inline wins, so brand tweaks apply on top of any preset. @@ -118,7 +118,7 @@ - bit-theme-view-transition is purely presentational: when present, every theme swap (from SetThemeAsync, ToggleDarkLightAsync, the JS API, or an OS appearance change while following the system) runs inside document.startViewTransition, so the page cross-fades to the new palette instead of hard-swapping. It is checked live on every change — add or remove it at runtime to turn the effect on or off — and it is skipped automatically (falling back to an instant swap) in browsers without the View Transitions API and for users with prefers-reduced-motion: reduce. Style the animation itself in CSS via the standard ::view-transition-old(root) / ::view-transition-new(root) pseudo-elements (e.g. a circular reveal instead of the default cross-fade). + bit-theme-view-transition is purely presentational: when present, every theme swap (from SetThemeAsync, ToggleDarkLightAsync, the JS API, or an OS appearance change while following the system) runs inside document.startViewTransition, so the page cross-fades to the new palette instead of hard-swapping. It is checked live on every change - add or remove it at runtime to turn the effect on or off - and it is skipped automatically (falling back to an instant swap) in browsers without the View Transitions API and for users with prefers-reduced-motion: reduce. Style the animation itself in CSS via the standard ::view-transition-old(root) / ::view-transition-new(root) pseudo-elements (e.g. a circular reveal instead of the default cross-fade). @@ -152,19 +152,19 @@
    -
  • Color--bit-clr-*, the eight semantic roles and the tones of each
  • -
  • Background, foreground, border--bit-clr-bg-*, --bit-clr-fg-*, --bit-clr-brd-*, the neutral surface tiers
  • -
  • Shadow--bit-shd-*, elevation by size and by surface family
  • -
  • Shape--bit-shp-*, corners, strokes and the focus ring
  • -
  • Size--bit-siz-*, the geometry of controls, glyphs and components
  • -
  • Spacing--bit-spa-*, the spacing unit and the dialog inset
  • -
  • Z-index--bit-zin-*, stacking order
  • -
  • Typography--bit-tpg-*, the type ramp, the weights and the BitText variants
  • -
  • Motion--bit-mot-*, durations and easing curves
  • -
  • Opacity--bit-opa-*, the disabled alpha
  • -
  • Layout--bit-layout-* and --bit-bp-*, density, dialog footers and breakpoints
  • -
  • Semantic aliases--bit-sem-*, intent-named pointers to the primitives (see Semantic aliases below)
  • -
  • Per-component--bit-<cmp>-*, each component's own knobs (see Per-component variables below)
  • +
  • Color - --bit-clr-*, the eight semantic roles and the tones of each
  • +
  • Background, foreground, border - --bit-clr-bg-*, --bit-clr-fg-*, --bit-clr-brd-*, the neutral surface tiers
  • +
  • Shadow - --bit-shd-*, elevation by size and by surface family
  • +
  • Shape - --bit-shp-*, corners, strokes and the focus ring
  • +
  • Size - --bit-siz-*, the geometry of controls, glyphs and components
  • +
  • Spacing - --bit-spa-*, the spacing unit and the dialog inset
  • +
  • Z-index - --bit-zin-*, stacking order
  • +
  • Typography - --bit-tpg-*, the type ramp, the weights and the BitText variants
  • +
  • Motion - --bit-mot-*, durations and easing curves
  • +
  • Opacity - --bit-opa-*, the disabled alpha
  • +
  • Layout - --bit-layout-* and --bit-bp-*, density, dialog footers and breakpoints
  • +
  • Semantic aliases - --bit-sem-*, intent-named pointers to the primitives (see Semantic aliases below)
  • +
  • Per-component - --bit-<cmp>-*, each component's own knobs (see Per-component variables below)
@@ -176,35 +176,35 @@ - Color--bit-clr-*. Eight semantic roles (pri, sec, ter, inf, suc, wrn, swr, err), each with the same slots: + Color - --bit-clr-*. Eight semantic roles (pri, sec, ter, inf, suc, wrn, swr, err), each with the same slots:
    -
  • -dark* / base + -hover / -active / -light* — the nine-tone ramp
  • -
  • -text — the on-color
  • -
  • -dis / -dis-text — the disabled pair
  • -
  • -focus — the focus indicator
  • +
  • -dark* / base + -hover / -active / -light* - the nine-tone ramp
  • +
  • -text - the on-color
  • +
  • -dis / -dis-text - the disabled pair
  • +
  • -focus - the focus indicator
- Background, foreground, border — the neutral surfaces: + Background, foreground, border - the neutral surfaces:
    -
  • --bit-clr-bg-*, --bit-clr-fg-*, --bit-clr-brd-* — the same nine-tone shape, in primary/secondary/tertiary tiers
  • -
  • --bit-clr-bg-overlay — the modal scrim
  • -
  • --bit-clr-req — the required-field marker
  • +
  • --bit-clr-bg-*, --bit-clr-fg-*, --bit-clr-brd-* - the same nine-tone shape, in primary/secondary/tertiary tiers
  • +
  • --bit-clr-bg-overlay - the modal scrim
  • +
  • --bit-clr-req - the required-field marker
- Shadow--bit-shd-*: + Shadow - --bit-shd-*:
    -
  • --bit-shd-sm--bit-shd-2xl — the callout shadows, by size
  • -
  • --bit-shd-focus-ring — the focus ring
  • -
  • --bit-shd-1--bit-shd-24 — the elevation steps
  • -
  • --bit-shd-{card,popup,dialog,sheet,tooltip,snackbar,appbar-top,appbar-bottom} — the per-surface elevations that components actually consume (see Family tokens below)
  • +
  • --bit-shd-sm--bit-shd-2xl - the callout shadows, by size
  • +
  • --bit-shd-focus-ring - the focus ring
  • +
  • --bit-shd-1--bit-shd-24 - the elevation steps
  • +
  • --bit-shd-{card,popup,dialog,sheet,tooltip,snackbar,appbar-top,appbar-bottom} - the per-surface elevations that components actually consume (see Family tokens below)
@@ -236,49 +236,49 @@ - Shape--bit-shp-*: + Shape - --bit-shp-*:
    -
  • --bit-shp-brd-radius, --bit-shp-brd-width, --bit-shp-brd-width-thick, --bit-shp-brd-style — the base corner and stroke
  • -
  • --bit-shp-focus-ring-width, --bit-shp-focus-ring-offset — the focus ring
  • -
  • --bit-shp-radius-{none,xs,sm,md,lg,xl,2xl,full} — the radius scale
  • -
  • --bit-shp-radius-{control,surface,popup,dialog} — the per-family radii
  • -
  • --bit-shp-radius-{button,chip,selection} — the three control sub-families (buttons, tags/chips, the checkbox box), each falling back to control
  • +
  • --bit-shp-brd-radius, --bit-shp-brd-width, --bit-shp-brd-width-thick, --bit-shp-brd-style - the base corner and stroke
  • +
  • --bit-shp-focus-ring-width, --bit-shp-focus-ring-offset - the focus ring
  • +
  • --bit-shp-radius-{none,xs,sm,md,lg,xl,2xl,full} - the radius scale
  • +
  • --bit-shp-radius-{control,surface,popup,dialog} - the per-family radii
  • +
  • --bit-shp-radius-{button,chip,selection} - the three control sub-families (buttons, tags/chips, the checkbox box), each falling back to control
- Size--bit-siz-*: + Size - --bit-siz-*:
    -
  • --bit-siz-ctrl-{sm,md,lg} — control heights: 26/32/40px
  • -
  • --bit-siz-ctrl-pad-x-{sm,md,lg} / --bit-siz-ctrl-pad-y-{sm,md,lg} — control padding: 12/16/20px horizontal, 4/6/8px vertical
  • -
  • --bit-siz-ctrl-min-width — the floor under a labeled button: auto under Fluent and Cupertino; Material sets 64px, Fluent 2 96px
  • -
  • --bit-siz-icon-{sm,md,lg} — glyphs inside controls: 12/16/20px
  • -
  • --bit-siz-sel-{sm,md,lg} — checkbox box / radio ring: 14/20/26px
  • -
  • --bit-siz-item-{sm,md,lg} — popup list row heights: 30/36/44px
  • -
  • --bit-siz-tab / --bit-siz-tab-indicator — pivot header height and selection-indicator stroke: 44px / 2px
  • -
  • --bit-siz-divider — separator thickness; follows --bit-shp-brd-width until a preset thins it — Cupertino's 0.5px hairline
  • -
  • --bit-siz-track-{sm,md,lg} — linear progress track thickness: 2/4/8px
  • -
  • --bit-siz-switch-{w,h,thumb}-{sm,md,lg} — BitToggle's track and knob: 40x20 with a 12px knob at md; Material 52x32/24, Cupertino 51x31/27
  • -
  • --bit-siz-slider-thumb-{sm,md,lg} — BitSlider's handle: 12/16/24px
  • -
  • --bit-siz-spinner-stroke — the stroke of an inline spinner
  • -
  • --bit-siz-popup-max-height — the height a scrolling popup list stops at
  • -
  • --bit-siz-dialog-max-width — the width a dialog stops growing at on its own
  • +
  • --bit-siz-ctrl-{sm,md,lg} - control heights: 26/32/40px
  • +
  • --bit-siz-ctrl-pad-x-{sm,md,lg} / --bit-siz-ctrl-pad-y-{sm,md,lg} - control padding: 12/16/20px horizontal, 4/6/8px vertical
  • +
  • --bit-siz-ctrl-min-width - the floor under a labeled button: auto under Fluent and Cupertino; Material sets 64px, Fluent 2 96px
  • +
  • --bit-siz-icon-{sm,md,lg} - glyphs inside controls: 12/16/20px
  • +
  • --bit-siz-sel-{sm,md,lg} - checkbox box / radio ring: 14/20/26px
  • +
  • --bit-siz-item-{sm,md,lg} - popup list row heights: 30/36/44px
  • +
  • --bit-siz-tab / --bit-siz-tab-indicator - pivot header height and selection-indicator stroke: 44px / 2px
  • +
  • --bit-siz-divider - separator thickness; follows --bit-shp-brd-width until a preset thins it - Cupertino's 0.5px hairline
  • +
  • --bit-siz-track-{sm,md,lg} - linear progress track thickness: 2/4/8px
  • +
  • --bit-siz-switch-{w,h,thumb}-{sm,md,lg} - BitToggle's track and knob: 40x20 with a 12px knob at md; Material 52x32/24, Cupertino 51x31/27
  • +
  • --bit-siz-slider-thumb-{sm,md,lg} - BitSlider's handle: 12/16/24px
  • +
  • --bit-siz-spinner-stroke - the stroke of an inline spinner
  • +
  • --bit-siz-popup-max-height - the height a scrolling popup list stops at
  • +
  • --bit-siz-dialog-max-width - the width a dialog stops growing at on its own
- Spacing--bit-spa-*: + Spacing - --bit-spa-*:
    -
  • --bit-spa-scaling-factor — the spacing unit everything else is a multiple of
  • -
  • --bit-spa-dialog — the inset padding of dialogs and message boxes: 24px
  • +
  • --bit-spa-scaling-factor - the spacing unit everything else is a multiple of
  • +
  • --bit-spa-dialog - the inset padding of dialogs and message boxes: 24px
- Z-index--bit-zin-{snackbar,callout,overlay,modal,base}: stacking order, highest first — overlay is the click-away layer of callouts and intentionally sits above modal. + Z-index - --bit-zin-{snackbar,callout,overlay,modal,base}: stacking order, highest first - overlay is the click-away layer of callouts and intentionally sits above modal. @@ -286,40 +286,40 @@
    -
  • --bit-layout-density-scale — the density multiplier
  • -
  • --bit-layout-dialog-actions-direction / --bit-layout-dialog-actions-justify / --bit-layout-dialog-actions-align — how dialog and message-box footers lay out their action buttons: row / flex-end / center under Fluent; Cupertino stacks them full width with column / center / stretch
  • -
  • --bit-bp-{xs,sm,md,lg,xl,xxl} — the breakpoints
  • +
  • --bit-layout-density-scale - the density multiplier
  • +
  • --bit-layout-dialog-actions-direction / --bit-layout-dialog-actions-justify / --bit-layout-dialog-actions-align - how dialog and message-box footers lay out their action buttons: row / flex-end / center under Fluent; Cupertino stacks them full width with column / center / stretch
  • +
  • --bit-bp-{xs,sm,md,lg,xl,xxl} - the breakpoints
- Typography--bit-tpg-*: + Typography - --bit-tpg-*:
  • the base font family, weight and line-height
  • -
  • --bit-tpg-font-family-mono — the monospaced family, for the text whose characters have to line up in a column (BitText's Monospace)
  • -
  • --bit-tpg-fs-{2xs,xs,sm,md,lg,xl,2xl,3xl,4xl} — the font-size scale, 10…32px; component size classes read sm→xs, md→sm, lg→md
  • -
  • --bit-tpg-fw-{light,regular,medium,semibold,bold} — the weight scale
  • -
  • --bit-tpg-ctrl-letter-spacing / --bit-tpg-ctrl-text-transform — the control-label pair
  • +
  • --bit-tpg-font-family-mono - the monospaced family, for the text whose characters have to line up in a column (BitText's Monospace)
  • +
  • --bit-tpg-fs-{2xs,xs,sm,md,lg,xl,2xl,3xl,4xl} - the font-size scale, 10…32px; component size classes read sm→xs, md→sm, lg→md
  • +
  • --bit-tpg-fw-{light,regular,medium,semibold,bold} - the weight scale
  • +
  • --bit-tpg-ctrl-letter-spacing / --bit-tpg-ctrl-text-transform - the control-label pair
  • the BitText variants h1..h6, body1/2, subtitle1/2, caption1/2, button, overline, inherit
- Motion--bit-mot-*: + Motion - --bit-mot-*:
    -
  • --bit-mot-duration, --bit-mot-duration-short, --bit-mot-duration-long, --bit-mot-easing — the state-transition set
  • -
  • --bit-mot-easing-decelerate / --bit-mot-easing-accelerate — the entry / exit curves
  • -
  • --bit-mot-duration-spinner, --bit-mot-easing-spinner, --bit-mot-loop-factor — the looping set
  • +
  • --bit-mot-duration, --bit-mot-duration-short, --bit-mot-duration-long, --bit-mot-easing - the state-transition set
  • +
  • --bit-mot-easing-decelerate / --bit-mot-easing-accelerate - the entry / exit curves
  • +
  • --bit-mot-duration-spinner, --bit-mot-easing-spinner, --bit-mot-loop-factor - the looping set
  • the unreduced *-full sources of both (see Motion and accessibility)
- Opacity--bit-opa-dis: the alpha of a disabled element that keeps its own colors, e.g. an image. 0.5 under Fluent, Fluent 2 and Cupertino; Material sets the 0.38 that M3 names for the disabled state. A text-bearing control does not use it — it recolors through the --bit-clr-*-dis tokens instead. + Opacity - --bit-opa-dis: the alpha of a disabled element that keeps its own colors, e.g. an image. 0.5 under Fluent, Fluent 2 and Cupertino; Material sets the 0.38 that M3 names for the disabled state. A text-bearing control does not use it - it recolors through the --bit-clr-*-dis tokens instead.
@@ -347,11 +347,11 @@ - Every color family — semantic role or neutral surface — uses the same nine slots: three tiers (-dark, the base, and -light), each carrying the same two interaction steps (-hover, -active). Knowing the shape means you can predict any token name, and overriding a tier keeps its states in proportion. + Every color family - semantic role or neutral surface - uses the same nine slots: three tiers (-dark, the base, and -light), each carrying the same two interaction steps (-hover, -active). Knowing the shape means you can predict any token name, and overriding a tier keeps its states in proportion. - Two rules govern the tones. Interaction always moves toward more contrast with the surface it sits on, so states darken in the light palettes and lighten in the dark ones — the direction Fluent 2's own dark hover tokens take. And tones are placed on evenly spaced OKLCH lightness steps at a fixed hue per family, so the steps read as equal-sized to the eye instead of bunching up in the dark end the way sRGB percentages do, and no family drifts in hue across its ramp. + Two rules govern the tones. Interaction always moves toward more contrast with the surface it sits on, so states darken in the light palettes and lighten in the dark ones - the direction Fluent 2's own dark hover tokens take. And tones are placed on evenly spaced OKLCH lightness steps at a fixed hue per family, so the steps read as equal-sized to the eye instead of bunching up in the dark end the way sRGB percentages do, and no family drifts in hue across its ramp. @@ -360,7 +360,7 @@
  • - -text holds ≥ 4.5:1 over every fill it is painted on — that is the base tier and the -dark tier, which BitToggleButton's checked state and BitPagination's selected page fill with while still drawing the on-color as text. + -text holds ≥ 4.5:1 over every fill it is painted on - that is the base tier and the -dark tier, which BitToggleButton's checked state and BitPagination's selected page fill with while still drawing the on-color as text.
  • The -light tier is a standalone tint; the on-color is never painted over it, so it is free to be as pale as it likes. @@ -369,12 +369,12 @@ -dis-text holds ≥ 3:1 over -dis. Disabled controls are exempt from WCAG 1.4.3, but text nobody can read is still text nobody can read.
  • - --bit-clr-fg-pri and --bit-clr-fg-sec clear 4.5:1 over bg-pri, bg-sec and bg-ter (they sit at 14.6:1 and 6.6:1 even on the deepest of the three). --bit-clr-fg-ter, the subtle/placeholder tier, clears 4.5:1 on the page surface and stays above 4:1 on the deepest one. --bit-clr-brd-pri keeps ≥ 3:1 over all three (SC 1.4.11, non-text contrast). brd-sec and brd-ter are the decorative stroke tiers — spinner tracks, menu separators, calendar grid lines — and are tuned for definition rather than to a contrast floor. Reach for brd-pri when a border is a control boundary. + --bit-clr-fg-pri and --bit-clr-fg-sec clear 4.5:1 over bg-pri, bg-sec and bg-ter (they sit at 14.6:1 and 6.6:1 even on the deepest of the three). --bit-clr-fg-ter, the subtle/placeholder tier, clears 4.5:1 on the page surface and stays above 4:1 on the deepest one. --bit-clr-brd-pri keeps ≥ 3:1 over all three (SC 1.4.11, non-text contrast). brd-sec and brd-ter are the decorative stroke tiers - spinner tracks, menu separators, calendar grid lines - and are tuned for definition rather than to a contrast floor. Reach for brd-pri when a border is a control boundary.
- Two roles carry a documented exception: sec and wrn are a vivid orange and a warning amber, and no tone of those hues clears 4.5:1 as text on a white page while still reading as itself — the same tension Fluent resolves by shipping separate fill and foreground tokens. They are fully compliant as fills; when you need them as text or as a meaningful icon on a light surface, use their -dark tones, which clear the 3:1 non-text floor. + Two roles carry a documented exception: sec and wrn are a vivid orange and a warning amber, and no tone of those hues clears 4.5:1 as text on a white page while still reading as itself - the same tension Fluent resolves by shipping separate fill and foreground tokens. They are fully compliant as fills; when you need them as text or as a meaningful icon on a light surface, use their -dark tones, which clear the 3:1 non-text floor. @@ -400,11 +400,11 @@ --bit-sem-focus-color /* → var(--bit-clr-pri-focus) */ - The two tiers have deliberately different override semantics. Components consume primitives (and their per-role/per-component variables), never the aliases — so overriding a primitive retunes components and the aliases that point at it, while overriding a semantic token retunes only the app styling that opted into that intent. + The two tiers have deliberately different override semantics. Components consume primitives (and their per-role/per-component variables), never the aliases - so overriding a primitive retunes components and the aliases that point at it, while overriding a semantic token retunes only the app styling that opted into that intent. - This holds for every override path: CSS custom properties substitute var() references at the element that defines them, so for inline overrides (BitThemeProvider / ApplyBitThemeAsync) the theme system automatically re-declares any alias whose target the theme touches on the same element — the semantic tier here, and the family tier below (a Shape.Radius.Control override carries the button, chip and selection radii that fall back to it) — keeping the alias in lock-step with the override for that subtree, while untouched intents (and explicitly-set alias values) are left alone. Semantic tokens are themable from C# via BitTheme.Color.Semantic: + This holds for every override path: CSS custom properties substitute var() references at the element that defines them, so for inline overrides (BitThemeProvider / ApplyBitThemeAsync) the theme system automatically re-declares any alias whose target the theme touches on the same element - the semantic tier here, and the family tier below (a Shape.Radius.Control override carries the button, chip and selection radii that fall back to it) - keeping the alias in lock-step with the override for that subtree, while untouched intents (and explicitly-set alias values) are left alone. Semantic tokens are themable from C# via BitTheme.Color.Semantic: var theme = new BitTheme(); @@ -446,18 +446,18 @@ await BitThemeManager.ApplyBitThemeAsync(theme); --bit-shd-appbar-bottom /* footer, cast upwards */ - Under Fluent every family points at the same primitive, so overriding --bit-shp-brd-radius still re-rounds everything exactly as before. The families exist for the design systems that disagree: Material rounds a button (pill), a chip (8px), a card (12px), a menu (4px) and a dialog (28px) differently and lifts a menu, a dialog and a snackbar to different elevation levels; Cupertino capsules its buttons and chips over 8px fields, and separates hairlines from shadows. Those become one token per family instead of a fork of the component CSS — exactly what the packaged Material and Cupertino presets do; see Authoring your own preset below. Themable from C# via BitTheme.Shape.Radius.* and BitTheme.BoxShadow.{Card,Popup,Dialog,Sheet,Tooltip,Snackbar,AppBarTop,AppBarBottom}. + Under Fluent every family points at the same primitive, so overriding --bit-shp-brd-radius still re-rounds everything exactly as before. The families exist for the design systems that disagree: Material rounds a button (pill), a chip (8px), a card (12px), a menu (4px) and a dialog (28px) differently and lifts a menu, a dialog and a snackbar to different elevation levels; Cupertino capsules its buttons and chips over 8px fields, and separates hairlines from shadows. Those become one token per family instead of a fork of the component CSS - exactly what the packaged Material and Cupertino presets do; see Authoring your own preset below. Themable from C# via BitTheme.Shape.Radius.* and BitTheme.BoxShadow.{Card,Popup,Dialog,Sheet,Tooltip,Snackbar,AppBarTop,AppBarBottom}.
- Below the theme tokens sits a third tier: every component exposes its own themable knobs as --bit-<cmp>-* custom properties (for example --bit-btn-clr, --bit-btn-padding, --bit-chb-box-size), assigned on the component root by its role/variant classes and consumed by its internal selectors. These names are stable public API — you can find them by inspecting a component in the browser dev tools or in its SCSS sources. + Below the theme tokens sits a third tier: every component exposes its own themable knobs as --bit-<cmp>-* custom properties (for example --bit-btn-clr, --bit-btn-padding, --bit-chb-box-size), assigned on the component root by its role/variant classes and consumed by its internal selectors. These names are stable public API - you can find them by inspecting a component in the browser dev tools or in its SCSS sources. - Override them app-wide from your CSS, or per instance via the component's Style / Class parameters — a surgical alternative to a full BitTheme when you only need to retune one component: + Override them app-wide from your CSS, or per instance via the component's Style / Class parameters - a surgical alternative to a full BitTheme when you only need to retune one component: /* app-wide: every primary button gets a custom fill and padding */ @@ -509,7 +509,7 @@ public static class BitExtraThemePresets - The Fluent presets are self-contained in bit.blazorui.css. The Fluent 2, Material and Cupertino presets ship with the Bit.BlazorUI.Extras package as separate override-only bundles that re-value the global tokens under :root[bit-theme="…"] — their palettes come out of the same seed-derivation pipeline as the packaged Fluent colors (so they clear the same WCAG contrast gates), and their shape, size, typography, and motion values follow the published Fluent 2 design tokens, the Material 3 spec and the Apple HIG respectively. Link the bundle you use after the core stylesheet and set the bit-theme attribute; nothing else is involved: + The Fluent presets are self-contained in bit.blazorui.css. The Fluent 2, Material and Cupertino presets ship with the Bit.BlazorUI.Extras package as separate override-only bundles that re-value the global tokens under :root[bit-theme="…"] - their palettes come out of the same seed-derivation pipeline as the packaged Fluent colors (so they clear the same WCAG contrast gates), and their shape, size, typography, and motion values follow the published Fluent 2 design tokens, the Material 3 spec and the Apple HIG respectively. Link the bundle you use after the core stylesheet and set the bit-theme attribute; nothing else is involved: @@ -543,7 +543,7 @@ public static class BitExtraThemePresets Letting the visitor pick among these is ready-made as the BitThemeSwitcher component in - the same Bit.BlazorUI.Extras package — the design system picker and the light/dark + the same Bit.BlazorUI.Extras package - the design system picker and the light/dark toggle in this site's own header are that component. See the ThemeSwitcher demo page for the full API. @@ -570,7 +570,7 @@ await _bitThemeManager.SetThemeAsync(corporate); - The bit-theme attribute is not limited to <html>: put it on any element and the named packaged palette (colors, shadows, color-scheme, and the semantic aliases) re-themes just that subtree via CSS custom-property inheritance — no C# involved. Use it for an always-dark hero section, a print-preview that stays light, or side-by-side theme comparisons: + The bit-theme attribute is not limited to <html>: put it on any element and the named packaged palette (colors, shadows, color-scheme, and the semantic aliases) re-themes just that subtree via CSS custom-property inheritance - no C# involved. Use it for an always-dark hero section, a print-preview that stays light, or side-by-side theme comparisons: <!-- The page follows the user's theme; this section is always dark --> @@ -582,11 +582,11 @@ await _bitThemeManager.SetThemeAsync(corporate); </section> - Scoped presets and BitThemeProvider are complementary: the attribute swaps a subtree to a named palette from CSS, the provider overlays individual token values from a C# BitTheme object — and they can nest. One caveat applies to both: components that render at the document level (modals, callouts with AbsolutePosition disabled, snackbars) escape the subtree in the DOM, so they inherit the document palette rather than the scoped one. + Scoped presets and BitThemeProvider are complementary: the attribute swaps a subtree to a named palette from CSS, the provider overlays individual token values from a C# BitTheme object - and they can nest. One caveat applies to both: components that render at the document level (modals, callouts with AbsolutePosition disabled, snackbars) escape the subtree in the DOM, so they inherit the document palette rather than the scoped one. - Why a preset declares a token twice. A custom property's var() references are substituted at the element that declares it, and descendants inherit the already-substituted value. So a token whose value is an alias — --bit-shp-radius-control, --bit-shd-popup, --bit-sem-*, --bit-mot-duration, the composed --bit-shd-focus-ring — would freeze the document's value inside a scoped region if it were declared on :root alone. The core stylesheet therefore re-declares every alias tier on :root [bit-theme] as well, which re-runs the substitution against that region's own primitives. Two rules follow for a preset of your own: pair every :root[bit-theme="x"] selector with a :root [bit-theme="x"] twin, and re-declare a token you rely on even when its value is unchanged from the one you inherit — if that value is an alias, inheriting it is not the same as re-resolving it. The packaged presets are pinned to both rules by a contract test. + Why a preset declares a token twice. A custom property's var() references are substituted at the element that declares it, and descendants inherit the already-substituted value. So a token whose value is an alias - --bit-shp-radius-control, --bit-shd-popup, --bit-sem-*, --bit-mot-duration, the composed --bit-shd-focus-ring - would freeze the document's value inside a scoped region if it were declared on :root alone. The core stylesheet therefore re-declares every alias tier on :root [bit-theme] as well, which re-runs the substitution against that region's own primitives. Two rules follow for a preset of your own: pair every :root[bit-theme="x"] selector with a :root [bit-theme="x"] twin, and re-declare a token you rely on even when its value is unchanged from the one you inherit - if that value is an alias, inheriting it is not the same as re-resolving it. The packaged presets are pinned to both rules by a contract test. @@ -612,7 +612,7 @@ BitAccentColorPresets.Rose; // #C239B3 - Every value that a design system decides once — the type ramp and weights, the corner radius of each component family, the elevation of each surface family, control heights, icon and checkbox sizes, the spinner stroke, entry/exit motion curves, the disabled alpha — is a global token that the component CSS reads and never hard-codes. That is what makes a Material or a Cupertino look a stylesheet rather than a fork: a preset is one :root[bit-theme="…"] block that re-values the tokens, and every component follows in step. The packaged Fluent 2, Material and Cupertino presets (see The packaged design systems above) are built exactly this way; the anatomy below is for when you want the same treatment for another design system — or a house variant of those three. + Every value that a design system decides once - the type ramp and weights, the corner radius of each component family, the elevation of each surface family, control heights, icon and checkbox sizes, the spinner stroke, entry/exit motion curves, the disabled alpha - is a global token that the component CSS reads and never hard-codes. That is what makes a Material or a Cupertino look a stylesheet rather than a fork: a preset is one :root[bit-theme="…"] block that re-values the tokens, and every component follows in step. The packaged Fluent 2, Material and Cupertino presets (see The packaged design systems above) are built exactly this way; the anatomy below is for when you want the same treatment for another design system - or a house variant of those three. @@ -666,7 +666,7 @@ BitAccentColorPresets.Rose; // #C239B3 } - A Cupertino-flavoured preset is the same file with different numbers — the numbers the packaged cupertino-* presets carry: + A Cupertino-flavoured preset is the same file with different numbers - the numbers the packaged cupertino-* presets carry:
    @@ -714,7 +714,7 @@ BitAccentColorPresets.Rose; // #C239B3 await BitThemeManager.ApplyBitThemeAsync(BitThemeUtilities.Merge(material, brandTheme)); - What stays per-component. A preset re-values tokens and nothing else: it never selects a component class, because anything a design system decides differently has to be a token the components already read — the packaged Fluent 2, Material and Cupertino bundles are pure :root[bit-theme="…"] token blocks, down to their pill button corners, their switch geometry and their slider handles. What stays per-component is geometry no design system has an opinion about (a clock dial, an avatar coin ladder, the loader shapes, the length of a slider): it is exposed as that component's own --bit-<cmp>-* variables (see Per-component variables) for an application to retune on the instances it wants, not for a theme to fork the library from. + What stays per-component. A preset re-values tokens and nothing else: it never selects a component class, because anything a design system decides differently has to be a token the components already read - the packaged Fluent 2, Material and Cupertino bundles are pure :root[bit-theme="…"] token blocks, down to their pill button corners, their switch geometry and their slider handles. What stays per-component is geometry no design system has an opinion about (a clock dial, an avatar coin ladder, the loader shapes, the length of a slider): it is exposed as that component's own --bit-<cmp>-* variables (see Per-component variables) for an application to retune on the instances it wants, not for a theme to fork the library from. @@ -747,7 +747,7 @@ var themed = BitThemeFactory.CreateLightTheme(new BitThemeAccentColors - CreateLightTheme recolors the accent and leaves everything else to the packaged stylesheet. CreateLightThemeFromSeed goes the rest of the way: one hex in, and you get the second accent, the status roles, every surface / text / stroke tier, and the gray ramp — a complete palette rather than an overlay. + CreateLightTheme recolors the accent and leaves everything else to the packaged stylesheet. CreateLightThemeFromSeed goes the rest of the way: one hex in, and you get the second accent, the status roles, every surface / text / stroke tier, and the gray ramp - a complete palette rather than an overlay. // One brand color → an entire palette (~200 tokens), not just the accent. @@ -766,34 +766,34 @@ var tuned = BitThemeFactory.CreateLightThemeFromSeed("#7C3AED", new BitThemeSeed }); - The palette is not calculated from step constants — it is this palette, the packaged Fluent one, with a hue rotation applied. That matters because the packaged values are hand-solved against the contrast rules their own stylesheets document, and hand-solved values have deliberate irregularities a fitted curve smooths away: a dark tier that stops early where the on-color is black, a light tier that runs into white. Transforming them keeps those decisions instead of re-deriving past them. + The palette is not calculated from step constants - it is this palette, the packaged Fluent one, with a hue rotation applied. That matters because the packaged values are hand-solved against the contrast rules their own stylesheets document, and hand-solved values have deliberate irregularities a fitted curve smooths away: a dark tier that stops early where the on-color is black, a light tier that runs into white. Transforming them keeps those decisions instead of re-deriving past them. - So the accents rotate onto the seed's hue, with primary also shifted in lightness and chroma so its main slot lands exactly on the brand color you passed. Secondary, tertiary and info rotate by the same angle, which preserves Fluent's own spacing between them — the ~157° from primary to secondary, info sitting on primary's hue — without those numbers being written down anywhere. The neutrals rotate too, so the dark palette's already-tinted surfaces land on the new hue; the light palette's are pure gray, and rotation alone leaves them that way until NeutralTintChroma adds a tint. The status roles keep their conventional green/amber/red. Rotating them onto the seed is never offered — a blue "error" is a bug report waiting to happen — but SemanticHarmonizationDegrees will lean them a few degrees toward the brand if you want that. + So the accents rotate onto the seed's hue, with primary also shifted in lightness and chroma so its main slot lands exactly on the brand color you passed. Secondary, tertiary and info rotate by the same angle, which preserves Fluent's own spacing between them - the ~157° from primary to secondary, info sitting on primary's hue - without those numbers being written down anywhere. The neutrals rotate too, so the dark palette's already-tinted surfaces land on the new hue; the light palette's are pure gray, and rotation alone leaves them that way until NeutralTintChroma adds a tint. The status roles keep their conventional green/amber/red. Rotating them onto the seed is never offered - a blue "error" is a bug report waiting to happen - but SemanticHarmonizationDegrees will lean them a few degrees toward the brand if you want that. - Harmonization is off by default for two reasons. It is the only setting that can make the result disagree with the packaged palettes when the seed is their own primary. And Material's 15° cap is measured in CAM16 hue rather than OKLCH — ported across unchanged it over-rotates here, pulling the packaged green to a teal and the red to a magenta. Around 6-8° is the useful range in this color space. + Harmonization is off by default for two reasons. It is the only setting that can make the result disagree with the packaged palettes when the seed is their own primary. And Material's 15° cap is measured in CAM16 hue rather than OKLCH - ported across unchanged it over-rotates here, pulling the packaged green to a teal and the red to a magenta. Around 6-8° is the useful range in this color space. - Contrast survives the transform. Rotating hue happens at constant OKLCH lightness, which is perceptual rather than photometric, so WCAG ratios do move a little; a repair pass afterwards pulls back any ramp step whose on-color would no longer clear its floor, ending up at the same shallower ramp the packaged palettes give those roles by hand. The floors are the ones those palettes document about themselves — on-color ≥ 4.5:1 over every fill it is painted on, foregrounds ≥ 4.5:1 over all three surfaces, brd-pri ≥ 3:1 — and tests grid them over ~200 seeds in both schemes. + Contrast survives the transform. Rotating hue happens at constant OKLCH lightness, which is perceptual rather than photometric, so WCAG ratios do move a little; a repair pass afterwards pulls back any ramp step whose on-color would no longer clear its floor, ending up at the same shallower ramp the packaged palettes give those roles by hand. The floors are the ones those palettes document about themselves - on-color ≥ 4.5:1 over every fill it is painted on, foregrounds ≥ 4.5:1 over all three surfaces, brd-pri ≥ 3:1 - and tests grid them over ~200 seeds in both schemes. - Seeding BitAccentColorPresets.Blue — the packaged palettes' own primary — reproduces them exactly: all 199 tokens, byte for byte, in both schemes, which a test pins. Two tiers are deliberately left unset so they keep tracking rather than freezing: the --bit-sem-* aliases and the --bit-clr-*-focus indicators are var() references onto the primitives this fills, so they follow the generated palette on their own. + Seeding BitAccentColorPresets.Blue - the packaged palettes' own primary - reproduces them exactly: all 199 tokens, byte for byte, in both schemes, which a test pins. Two tiers are deliberately left unset so they keep tracking rather than freezing: the --bit-sem-* aliases and the --bit-clr-*-focus indicators are var() references onto the primitives this fills, so they follow the generated palette on their own. - Try it. Each swatch below feeds one BitAccentColorPresets hex through CreateLightThemeFromSeed / CreateDarkThemeFromSeed and applies the result to this page — watch the surfaces, borders and body text move with the accent, not just the buttons. Toggle dark/light in the header afterwards to watch the same brand color get re-derived for the other scheme, and note that the choice survives a refresh (it is the same switcher as the one on the home page). + Try it. Each swatch below feeds one BitAccentColorPresets hex through CreateLightThemeFromSeed / CreateDarkThemeFromSeed and applies the result to this page - watch the surfaces, borders and body text move with the accent, not just the buttons. Toggle dark/light in the header afterwards to watch the same brand color get re-derived for the other scheme, and note that the choice survives a refresh (it is the same switcher as the one on the home page). This switcher ships ready-made as the BitAccentColorSwitcher component in the - Bit.BlazorUI.Extras package — swatches, persistence, dark/light re-derivation + Bit.BlazorUI.Extras package - swatches, persistence, dark/light re-derivation and the first-paint (SSR / CDN cache) setup included. See the AccentColorSwitcher demo page for the full API. @@ -802,11 +802,11 @@ var tuned = BitThemeFactory.CreateLightThemeFromSeed("#7C3AED", new BitThemeSeed - Under the hood, BitThemeColorDerivation fills the role steps (Main, MainHover, MainActive, Dark, Light, Disabled, Focus, Text, …) from a single main color in the perceptually uniform OKLCH space, at constant hue, with step sizes calibrated to the packaged Fluent palettes — so a derived role behaves like a packaged one, including the interaction direction: in the light scheme the states step down in lightness, in the dark scheme they step up (toward the light source), matching the packaged dark palette. Derived interactive fills keep ≥3:1 against the auto-selected Text for any seed. Caller-set values are preserved, so you can pin any slot and derive the rest. + Under the hood, BitThemeColorDerivation fills the role steps (Main, MainHover, MainActive, Dark, Light, Disabled, Focus, Text, …) from a single main color in the perceptually uniform OKLCH space, at constant hue, with step sizes calibrated to the packaged Fluent palettes - so a derived role behaves like a packaged one, including the interaction direction: in the light scheme the states step down in lightness, in the dark scheme they step up (toward the light source), matching the packaged dark palette. Derived interactive fills keep ≥3:1 against the auto-selected Text for any seed. Caller-set values are preserved, so you can pin any slot and derive the rest. - The Disabled/DisabledText pair is the one family that is not a relative step off main: it targets an absolute lightness with the chroma capped to a faint trace of the hue. That is deliberate — a relative mix would inherit main's lightness, so a bright role and a dark one would land on different disabled weights and the state would read as a per-role variant instead of one recognizable state. Those targets are the packaged palettes' own, so deriving from a packaged role reproduces its disabled pair exactly (a test pins it). + The Disabled/DisabledText pair is the one family that is not a relative step off main: it targets an absolute lightness with the chroma capped to a faint trace of the hue. That is deliberate - a relative mix would inherit main's lightness, so a bright role and a dark one would land on different disabled weights and the state would read as a per-role variant instead of one recognizable state. Those targets are the packaged palettes' own, so deriving from a packaged role reproduces its disabled pair exactly (a test pins it). var theme = new BitTheme(); @@ -847,7 +847,7 @@ bool passesLarge = BitThemeColorContrast.MeetsWcagAaLargeText(ratio); // true< - The same class also exposes APCA (Advanced Perceptual Contrast Algorithm) — the perceptually-tuned, polarity-aware method that is the candidate for the in-progress WCAG 3. WCAG 2.x overstates contrast for dark color pairs, so it is a poor guide when tuning dark themes; APCA scores dark-text-on-light differently from light-text-on-dark and tracks perceived readability across the whole lightness range. Treat it as advisory — WCAG 2.x remains the formal conformance bar (WCAG 3 is still a Working Draft), which is why the built-in palettes and BitThemeColorDerivation are tuned to WCAG. + The same class also exposes APCA (Advanced Perceptual Contrast Algorithm) - the perceptually-tuned, polarity-aware method that is the candidate for the in-progress WCAG 3. WCAG 2.x overstates contrast for dark color pairs, so it is a poor guide when tuning dark themes; APCA scores dark-text-on-light differently from light-text-on-dark and tracks perceived readability across the whole lightness range. Treat it as advisory - WCAG 2.x remains the formal conformance bar (WCAG 3 is still a Working Draft), which is why the built-in palettes and BitThemeColorDerivation are tuned to WCAG. // APCA is directional: pass text first, background second. @@ -865,7 +865,7 @@ double large = BitThemeColorContrast.ApcaLargeTextLc; // 60.0 double nonText = BitThemeColorContrast.ApcaNonTextLc; // 45.0 - Because the two methods measure differently, they can disagree — deliberately. A mid-tone accent that WCAG pushes toward dark on-text to clear 4.5:1 may actually read better with white text under APCA. Use APCA to sanity-check dark-theme readability; keep WCAG as the pass/fail gate you report against. + Because the two methods measure differently, they can disagree - deliberately. A mid-tone accent that WCAG pushes toward dark on-text to clear 4.5:1 may actually read better with white text under APCA. Use APCA to sanity-check dark-theme readability; keep WCAG as the pass/fail gate you report against. @@ -902,11 +902,11 @@ await BitThemeManager.ApplyBitThemeAsync(theme); - Density scales rhythm, not type: paddings, gaps, control heights (--bit-siz-ctrl-*) and selection sizes (--bit-siz-sel-*) follow the spacing unit times the density multiplier, while font sizes (--bit-tpg-fs-*) and glyph sizes (--bit-siz-icon-*) are rem values that follow the root font size only — the same split Material and Fluent make, so a compact UI keeps legible text. + Density scales rhythm, not type: paddings, gaps, control heights (--bit-siz-ctrl-*) and selection sizes (--bit-siz-sel-*) follow the spacing unit times the density multiplier, while font sizes (--bit-tpg-fs-*) and glyph sizes (--bit-siz-icon-*) are rem values that follow the root font size only - the same split Material and Fluent make, so a compact UI keeps legible text. - This holds under every packaged preset, not only Fluent. Fluent 2, Material and Cupertino all fix their control geometry on an 8px grid — 40dp Material buttons, the 44pt iOS tap target, Fluent 2's 32px medium control — so each preset writes those measurements as spacing multiples rather than as absolute lengths, and the density multiplier reaches them exactly as it does under Fluent. A preset of your own should do the same wherever its geometry lands on the grid; where it does not, an absolute length is fine and simply opts that one measurement out of density. + This holds under every packaged preset, not only Fluent. Fluent 2, Material and Cupertino all fix their control geometry on an 8px grid - 40dp Material buttons, the 44pt iOS tap target, Fluent 2's 32px medium control - so each preset writes those measurements as spacing multiples rather than as absolute lengths, and the density multiplier reaches them exactly as it does under Fluent. A preset of your own should do the same wherever its geometry lands on the grid; where it does not, an absolute length is fine and simply opts that one measurement out of density. var theme = new BitTheme @@ -980,7 +980,7 @@ await BitThemeManager.ApplyBitThemeAsync(dense); }; - One caveat when theming durations. BitTheme.Motion.* writes the effective tokens (--bit-mot-duration…), and those are exactly the tokens the reduced-motion media query collapses to 0.01ms — but it does so on :root, while ApplyBitThemeAsync / BitThemeProvider write inline on the body or a subtree, and inline wins. A duration set through the C# theme therefore also applies for users who asked for reduced motion. To theme durations and keep the opt-out working, set the --bit-mot-duration*-full sources in CSS instead — they feed the effective tokens in both states: + One caveat when theming durations. BitTheme.Motion.* writes the effective tokens (--bit-mot-duration…), and those are exactly the tokens the reduced-motion media query collapses to 0.01ms - but it does so on :root, while ApplyBitThemeAsync / BitThemeProvider write inline on the body or a subtree, and inline wins. A duration set through the C# theme therefore also applies for users who asked for reduced motion. To theme durations and keep the opt-out working, set the --bit-mot-duration*-full sources in CSS instead - they feed the effective tokens in both states: :root { @@ -1003,7 +1003,7 @@ await BitThemeManager.ApplyBitThemeAsync(dense); A single component can opt out with the ForceAnimation parameter (it renders the bit-fam class, which restores the full durations for that element and its content).
  • - Looping animations are slowed, not stopped. A spinner or a loader is the only thing telling the user that work is still in progress, so it keeps running under reduced motion — and collapsing it to 0.01ms would render as flicker rather than as stillness. Instead --bit-mot-duration-spinner goes to 4s and --bit-mot-easing-spinner to linear, while --bit-mot-loop-factor rises to 3, stretching every other looping animation (the indeterminate BitProgress bar, the staggered BitLoading variants) as a whole so the phase offsets between their parts survive. All three have -full sources and are restored by ForceAnimation, exactly like the durations above. The exception is a shimmer or skeleton — a placeholder for content that has not arrived rather than a progress signal — which stops outright. + Looping animations are slowed, not stopped. A spinner or a loader is the only thing telling the user that work is still in progress, so it keeps running under reduced motion - and collapsing it to 0.01ms would render as flicker rather than as stillness. Instead --bit-mot-duration-spinner goes to 4s and --bit-mot-easing-spinner to linear, while --bit-mot-loop-factor rises to 3, stretching every other looping animation (the indeterminate BitProgress bar, the staggered BitLoading variants) as a whole so the phase offsets between their parts survive. All three have -full sources and are restored by ForceAnimation, exactly like the durations above. The exception is a shimmer or skeleton - a placeholder for content that has not arrived rather than a progress signal - which stops outright.
  • Forced colors / high contrast. Under forced-colors: active (Windows High Contrast), the palette switches to system colors (CanvasText, Canvas, Highlight, LinkText), shadows drop to none, and the focus ring uses the system Highlight color so it stays visible. @@ -1013,7 +1013,7 @@ await BitThemeManager.ApplyBitThemeAsync(dense);
    • borders thicken (--bit-shp-brd-width 1px → 2px)
    • the focus indicator strengthens (--bit-shp-focus-ring-width 2px → 3px with a matching offset bump, flowing through --bit-shd-focus-ring to every focused control)
    • -
    • the two subtlest tiers are promoted a step — --bit-clr-brd-ter and --bit-clr-fg-ter are re-pointed at their -sec counterparts, which lifts the weakest pairs without touching the palette itself
    • +
    • the two subtlest tiers are promoted a step - --bit-clr-brd-ter and --bit-clr-fg-ter are re-pointed at their -sec counterparts, which lifts the weakest pairs without touching the palette itself
    Disabled and muted colors are deliberately not promoted: their meaning depends on staying subdued.
  • @@ -1166,7 +1166,7 @@ await BitThemeManager.ApplyBitThemeAsync(new BitTheme - Performance: the Frozen parameter. By default the provider rebuilds its merged theme and CSS-variable string on every parameters update, so mutating a BitTheme instance in place is picked up automatically. When you hand the provider a stable instance and never mutate it, set Frozen="true" to skip that per-render rebuild entirely while the Theme / parent-theme references are unchanged. The trade-off: in-place mutations of a frozen theme are no longer detected — assign a new BitTheme instance to apply changes. + Performance: the Frozen parameter. By default the provider rebuilds its merged theme and CSS-variable string on every parameters update, so mutating a BitTheme instance in place is picked up automatically. When you hand the provider a stable instance and never mutate it, set Frozen="true" to skip that per-render rebuild entirely while the Theme / parent-theme references are unchanged. The trade-off: in-place mutations of a frozen theme are no longer detected - assign a new BitTheme instance to apply changes. <BitThemeProvider Theme="_stableTheme" Frozen="true"> @@ -1177,7 +1177,7 @@ await BitThemeManager.ApplyBitThemeAsync(new BitTheme - BitThemeNotifications is a scoped DI service that fires ThemeChanged whenever bit-theme changes — whether the change came from SetThemeAsync, ToggleDarkLightAsync, the JS API, or the OS while following prefers-color-scheme. + BitThemeNotifications is a scoped DI service that fires ThemeChanged whenever bit-theme changes - whether the change came from SetThemeAsync, ToggleDarkLightAsync, the JS API, or the OS while following prefers-color-scheme. @@inject BitThemeNotifications ThemeNotifications @@ -1239,7 +1239,7 @@ BitTheme restored = BitThemeSerialization.Deserialize(json); BitThemeDtcg round-trips a theme through the W3C Design Tokens Community Group - format, so a BitTheme can flow to and from design tooling that speaks DTCG — Tokens Studio (Figma), Style Dictionary, and the wider token pipeline. It uses bit's own group structure as the token namespace (color.primary.main, boxShadow.md, …) and the string-value token profile the ecosystem consumes: + format, so a BitTheme can flow to and from design tooling that speaks DTCG - Tokens Studio (Figma), Style Dictionary, and the wider token pipeline. It uses bit's own group structure as the token namespace (color.primary.main, boxShadow.md, …) and the string-value token profile the ecosystem consumes: // Export a theme as a DTCG token document for a designer / Style Dictionary @@ -1314,7 +1314,7 @@ await ThemeLoader.AttachStylesheetAsync("tenant-theme", "/themes/acme.css"); await ThemeLoader.DetachStylesheetAsync("tenant-theme"); - Only same-origin URLs are accepted — relative paths or http(s) URLs on the app's own origin; cross-origin URLs (e.g. CDNs) are rejected. Unsafe schemes such as javascript:, data:, and vbscript: are rejected as well. Always pass URLs you control or have explicitly trusted. + Only same-origin URLs are accepted - relative paths or http(s) URLs on the app's own origin; cross-origin URLs (e.g. CDNs) are rejected. Unsafe schemes such as javascript:, data:, and vbscript: are rejected as well. Always pass URLs you control or have explicitly trusted.
    @@ -1326,7 +1326,7 @@ await ThemeLoader.DetachStylesheetAsync("tenant-theme"); The runtime BitBlazorUI.Theme object mirrors BitThemeManager. Use it from host pages, custom scripts, or component libraries that aren't Blazor. - get / set / toggle / useSystem — preset and OS-following helpers. + get / set / toggle / useSystem - preset and OS-following helpers. // Read the active theme name const current = BitBlazorUI.Theme.get(); @@ -1340,7 +1340,7 @@ BitBlazorUI.Theme.toggleDarkLight(); // Re-follow the OS even after the user picked an explicit preset BitBlazorUI.Theme.useSystem(); - applyTheme / clearAppliedTheme — inline CSS variable overrides. + applyTheme / clearAppliedTheme - inline CSS variable overrides. // Set inline CSS variables on document.body (or a target element) BitBlazorUI.Theme.applyTheme( @@ -1355,7 +1355,7 @@ BitBlazorUI.Theme.applyTheme( // Remove only the variables we set above BitBlazorUI.Theme.clearAppliedTheme(document.body); - onChange / isSystemDark / getPersisted — observation helpers. + onChange / isSystemDark / getPersisted - observation helpers. BitBlazorUI.Theme.onChange((newTheme, oldTheme) => { const meta = document.querySelector('meta[name=theme-color]'); @@ -1366,14 +1366,14 @@ const dark = BitBlazorUI.Theme.isSystemDark(); // boolean const stored = BitBlazorUI.Theme.getPersisted(); // null when persistence is off - onChange holds a single callback (a later call, or an init with an onChange option, replaces the previous one). When several independent consumers need to observe theme changes, listen to the bit-theme-change CustomEvent instead — it is dispatched on document after every change (including OS-driven ones), supports any number of listeners, and works from third-party scripts that know nothing about bit: + onChange holds a single callback (a later call, or an init with an onChange option, replaces the previous one). When several independent consumers need to observe theme changes, listen to the bit-theme-change CustomEvent instead - it is dispatched on document after every change (including OS-driven ones), supports any number of listeners, and works from third-party scripts that know nothing about bit: document.addEventListener('bit-theme-change', (e) => { console.log(`theme: ${e.detail.oldTheme} -> ${e.detail.newTheme}`); }); - init — manual bootstrap (the script self-initializes from <html> attributes; call init only when you bypass them). + init - manual bootstrap (the script self-initializes from <html> attributes; call init only when you bypass them). BitBlazorUI.Theme.init({ system: true, @@ -1456,13 +1456,13 @@ BitThemeSsr.BuildRootThemeAttributes(preference, defaultTheme: BitThemePresets.F - In a Blazor Web App the root document is App.razor, and a MarkupString cannot open a tag that the same file has to close. Use BuildRootThemeAttributeMap there and splat it — it resolves identically, marker attributes included: + In a Blazor Web App the root document is App.razor, and a MarkupString cannot open a tag that the same file has to close. Use BuildRootThemeAttributeMap there and splat it - it resolves identically, marker attributes included: <html lang="en" @@attributes="BitThemeSsr.BuildRootThemeAttributeMap(preference)" bit-theme-persist bit-theme-persist-cookie> - With bit-theme-persist-cookie on <html>, the runtime script mirrors every theme change into the bit-theme-preference cookie automatically, keeping the client (localStorage) and server (cookie) stores in sync — no manual BitThemeNotifications.ThemeChanged subscription is needed for that. + With bit-theme-persist-cookie on <html>, the runtime script mirrors every theme change into the bit-theme-preference cookie automatically, keeping the client (localStorage) and server (cookie) stores in sync - no manual BitThemeNotifications.ThemeChanged subscription is needed for that.
    @@ -1474,7 +1474,7 @@ BitThemeSsr.BuildRootThemeAttributes(preference, defaultTheme: BitThemePresets.F A common production setup combines a few of the pieces above. Bootstrap declaratively for first paint, switch presets via BitThemeManager, override brand tokens via BitThemeProvider, and react to changes through BitThemeNotifications. - 1. Host document — opt into system tracking, persistence, and reduce flash: + 1. Host document - opt into system tracking, persistence, and reduce flash: <html bit-theme-system bit-theme-persist bit-theme-default="fluent-light"> <head> @@ -1522,7 +1522,7 @@ BitThemeSsr.BuildRootThemeAttributes(preference, defaultTheme: BitThemePresets.F public void Dispose() => ThemeNotifications.ThemeChanged -= OnThemeChanged; } - 4. Tenant brand region — derive a palette from a single hex and scope it: + 4. Tenant brand region - derive a palette from a single hex and scope it: <BitThemeProvider Theme="_tenantTheme"> <TenantDashboard /> @@ -1549,32 +1549,32 @@ BitThemeSsr.BuildRootThemeAttributes(preference, defaultTheme: BitThemePresets.F
    1. - IsSystemInDarkMode() is now IsSystemInDarkModeAsync(). A pure rename to follow the async naming convention — update call sites. + IsSystemInDarkMode() is now IsSystemInDarkModeAsync(). A pure rename to follow the async naming convention - update call sites.
    2. Manager methods return ValueTask<string?> instead of ValueTask<string>. - GetCurrentThemeAsync, SetThemeAsync, ToggleDarkLightAsync, and GetCurrentPersistedThemeAsync return null when JS interop is unavailable (prerendering or a disconnected circuit) — the previous signatures already produced null at runtime in those states but claimed non-null, so the annotation now tells the truth. With nullable reference types enabled you may get new warnings at call sites; treat null as "not resolvable yet". + GetCurrentThemeAsync, SetThemeAsync, ToggleDarkLightAsync, and GetCurrentPersistedThemeAsync return null when JS interop is unavailable (prerendering or a disconnected circuit) - the previous signatures already produced null at runtime in those states but claimed non-null, so the annotation now tells the truth. With nullable reference types enabled you may get new warnings at call sites; treat null as "not resolvable yet".
    3. BitThemeManager is now IAsyncDisposable. DI-resolved instances are disposed by the container automatically; if you construct one manually, dispose it. Calling any method after disposal throws ObjectDisposedException.
    4. Typography variants were restructured. - Per-variant FontFamily was removed — the font family is global (Typography.FontFamily), with Typography.Inherit.FontFamily as the per-element escape hatch. TextTransform and Display now exist only on the Button, Overline, and Inherit variants, which are typed BitThemeLabelTypographyVariants (or a derivative) — so Typography.Button = new BitThemeTypographyVariants() no longer compiles; assign a BitThemeLabelTypographyVariants or set properties on the existing instance instead. + Per-variant FontFamily was removed - the font family is global (Typography.FontFamily), with Typography.Inherit.FontFamily as the per-element escape hatch. TextTransform and Display now exist only on the Button, Overline, and Inherit variants, which are typed BitThemeLabelTypographyVariants (or a derivative) - so Typography.Button = new BitThemeTypographyVariants() no longer compiles; assign a BitThemeLabelTypographyVariants or set properties on the existing instance instead.
    5. Theme names are normalized. SetThemeAsync("Dark") now writes the canonical lowercase dark onto the bit-theme attribute (matching the packaged :root[bit-theme=…] selectors and BitThemeName). Compare returned theme names case-insensitively or against the canonical lowercase form.
    6. - The packaged palettes were re-solved (visual, not source-breaking). Token names are unchanged, so nothing to update in code — but the values moved, and the differences are visible. The on-color now clears 4.5:1 on every fill it is painted on (it previously dipped to 2.3:1 on some dark-tier fills), disabled text clears 3:1, and the tertiary foreground is readable on all three surfaces. The dark canvas moved off near-black, severe-warning moved onto Fluent 2's own severe-warning hue, and the dark modal scrim is now a dark veil rather than a white one. If you pinned brand tokens on top of a packaged palette, re-check the pairs you pinned; if you hardcoded a packaged hex anywhere, take the value from the Design tokens section above. + The packaged palettes were re-solved (visual, not source-breaking). Token names are unchanged, so nothing to update in code - but the values moved, and the differences are visible. The on-color now clears 4.5:1 on every fill it is painted on (it previously dipped to 2.3:1 on some dark-tier fills), disabled text clears 3:1, and the tertiary foreground is readable on all three surfaces. The dark canvas moved off near-black, severe-warning moved onto Fluent 2's own severe-warning hue, and the dark modal scrim is now a dark veil rather than a white one. If you pinned brand tokens on top of a packaged palette, re-check the pairs you pinned; if you hardcoded a packaged hex anywhere, take the value from the Design tokens section above.
    7. - Dark-scheme derivation now lightens on interaction. BitThemeColorDerivation.FillColorRoleFromMain(…, BitThemeColorScheme.Dark) (and therefore BitThemeFactory.CreateDarkTheme) used to darken hover/active on a dark surface; it now steps them up in lightness, matching the packaged dark palette and Fluent 2's dark hover tokens. Themes you derive from a brand color will hover the opposite way from before — which is the point: previously a derived dark role and a packaged one reacted in opposite directions in the same UI. Slots you set explicitly are still never overwritten. + Dark-scheme derivation now lightens on interaction. BitThemeColorDerivation.FillColorRoleFromMain(…, BitThemeColorScheme.Dark) (and therefore BitThemeFactory.CreateDarkTheme) used to darken hover/active on a dark surface; it now steps them up in lightness, matching the packaged dark palette and Fluent 2's dark hover tokens. Themes you derive from a brand color will hover the opposite way from before - which is the point: previously a derived dark role and a packaged one reacted in opposite directions in the same UI. Slots you set explicitly are still never overwritten.
    - Everything else is additive: BitThemeName (typed theme names), BitThemeNotifications (theme-change events; subscribing alone wires them up), BitThemeSsr and the bit-theme-preference cookie (flash-free first paint), BitThemeColorDerivation/BitThemeColorContrast (palette generation and WCAG + advisory APCA checks), the density/accent presets, and BitExternalThemeLoader — each covered in its own section above. + Everything else is additive: BitThemeName (typed theme names), BitThemeNotifications (theme-change events; subscribing alone wires them up), BitThemeSsr and the bit-theme-preference cookie (flash-free first paint), BitThemeColorDerivation/BitThemeColorContrast (palette generation and WCAG + advisory APCA checks), the density/accent presets, and BitExternalThemeLoader - each covered in its own section above. @@ -1630,8 +1630,8 @@ BitThemeSsr.BuildRootThemeAttributes(preference, defaultTheme: BitThemePresets.F Authoring helpers in C#
      -
    • BitCss.Classbit-css-* utility class names (colors, shadows incl. per-surface, z-index, shape incl. the radius scale bit-css-shp-radius-*, focus ring)
    • -
    • BitCss.Var--bit-* custom property names
    • +
    • BitCss.Class - bit-css-* utility class names (colors, shadows incl. per-surface, z-index, shape incl. the radius scale bit-css-shp-radius-*, focus ring)
    • +
    • BitCss.Var - --bit-* custom property names
    JavaScript diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Scripts/app.ts b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Scripts/app.ts index be83dbca5f..d6de3b5270 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Scripts/app.ts +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Scripts/app.ts @@ -179,13 +179,26 @@ function registerSideRailScrollSpy(id: string, dotnetObj: any, activeItemMethodN listener(); }; + // A scroll is not what swaps the sections, though: clicking a pivot tab replaces them while the + // page stays exactly where it was, so a check that only ran on scroll would leave the rail listing + // the previous tab until the reader next moved. Watching the document for removals closes that + // gap. The callback only asks whether a measured section has left - no layout is read - so the + // mutations a live chart makes every second cost next to nothing, and the rAF gate is shared. + const observer = new MutationObserver(() => { + if (sections.some(section => section.element.isConnected === false)) { + listener(); + } + }); + sideRailScrollSpies[id] = () => { window.removeEventListener('scroll', listener, true); window.removeEventListener('resize', resizeListener); + observer.disconnect(); if (frame !== 0) cancelAnimationFrame(frame); }; window.addEventListener('scroll', listener, true); window.addEventListener('resize', resizeListener); + observer.observe(document.body, { childList: true, subtree: true }); measure(); listener(); diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartAxisScaleTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartAxisScaleTests.cs new file mode 100644 index 0000000000..3593643f9e --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartAxisScaleTests.cs @@ -0,0 +1,296 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Extras.Chart; + +/// Value-to-pixel mapping and tick generation, the arithmetic every chart type sits on. +[TestClass] +public class BitChartAxisScaleTests +{ + private static BitChartAxisScale Vertical(BitChartScaleOptions options, double dataMin, double dataMax, + double bottom = 300, double top = 0) + { + var scale = new BitChartAxisScale(options, horizontal: false); + scale.SetDataRange(dataMin, dataMax); + scale.SetPixelRange(bottom, top); + return scale; + } + + private static BitChartScaleOptions Linear(string id = "y") => new() { Id = id, Type = BitChartScaleType.Linear }; + + [TestMethod] + public void ALinearScaleShouldMapItsEndsToThePixelEnds() + { + var scale = Vertical(Linear(), 0, 100); + + Assert.AreEqual(300, scale.PixelFor(scale.Min), 0.001); + Assert.AreEqual(0, scale.PixelFor(scale.Max), 0.001); + } + + [TestMethod] + public void ReverseShouldFlipThePixelMapping() + { + var options = Linear(); + options.Reverse = true; + var scale = Vertical(options, 0, 100); + + Assert.IsTrue(scale.PixelFor(scale.Min) < scale.PixelFor(scale.Max)); + } + + [TestMethod] + public void ExplicitMinMaxShouldWinOverTheData() + { + var options = Linear(); + options.Min = -10; + options.Max = 10; + var scale = Vertical(options, 0, 100); + + Assert.AreEqual(-10, scale.Min, 1e-9); + Assert.AreEqual(10, scale.Max, 1e-9); + } + + [TestMethod] + public void SuggestedBoundsShouldOnlyEverWidenTheRange() + { + var options = Linear(); + options.SuggestedMin = -50; + options.SuggestedMax = 10; + var scale = Vertical(options, 0, 100); + + Assert.IsTrue(scale.Min <= -50); + Assert.IsTrue(scale.Max >= 100, "a suggestion below the data must not clamp it"); + } + + [TestMethod] + public void BeginAtZeroShouldPullTheRangeToTheOrigin() + { + var options = Linear(); + options.BeginAtZero = true; + var scale = Vertical(options, 40, 100); + + Assert.IsTrue(scale.Min <= 0); + } + + [TestMethod] + public void GraceShouldPadBothEndsOfTheRange() + { + var plain = Vertical(Linear(), 0, 100); + var options = Linear(); + options.Grace = 0.1; + var graced = Vertical(options, 0, 100); + + Assert.IsTrue(graced.Min < plain.Min || graced.Max > plain.Max); + } + + [TestMethod] + public void AFlatSeriesShouldStillGetAUsableRange() + { + var scale = Vertical(Linear(), 5, 5); + Assert.IsTrue(scale.Max > scale.Min); + + var zero = Vertical(Linear(), 0, 0); + Assert.IsTrue(zero.Max > zero.Min); + } + + [TestMethod] + public void StepSizeShouldSpaceTheTicksExactly() + { + var options = Linear(); + options.Min = 0; + options.Max = 100; + options.Ticks.StepSize = 25; + var scale = Vertical(options, 0, 100, bottom: 600); + + var values = scale.Ticks.Select(t => t.Value).ToList(); + CollectionAssert.AreEqual(new[] { 0d, 25, 50, 75, 100 }, values); + } + + [TestMethod] + public void MaxTicksLimitShouldCapTheTickCount() + { + var options = Linear(); + options.Ticks.MaxTicksLimit = 3; + var scale = Vertical(options, 0, 1000, bottom: 900); + + Assert.IsTrue(scale.Ticks.Count <= 4, $"got {scale.Ticks.Count}"); + } + + [TestMethod] + public void TheTickCountShouldFollowTheSpaceAvailable() + { + int Count(double height) + { + var scale = Vertical(Linear(), 0, 100, bottom: height); + return scale.Ticks.Count; + } + + Assert.IsTrue(Count(60) < Count(600), "a short axis cannot carry as many labels as a tall one"); + } + + [TestMethod] + public void TurningOffAutoSkipTicksShouldRestoreTheRequestedCount() + { + var options = Linear(); + options.AutoSkipTicks = false; + var scale = Vertical(options, 0, 100, bottom: 40); + + Assert.IsTrue(scale.Ticks.Count > 5, $"got {scale.Ticks.Count}"); + } + + [TestMethod] + public void TickCallbackShouldReplaceTheLabel() + { + var options = Linear(); + options.Ticks.Callback = (v, _) => $"[{v}]"; + var scale = Vertical(options, 0, 100); + + Assert.IsTrue(scale.Ticks.All(t => t.Label.StartsWith("["))); + } + + [TestMethod] + public void PrefixAndSuffixShouldWrapTheLabel() + { + var options = Linear(); + options.Ticks.Prefix = "$"; + options.Ticks.Suffix = "k"; + var scale = Vertical(options, 0, 100); + + Assert.IsTrue(scale.Ticks.All(t => t.Label.StartsWith("$") && t.Label.EndsWith("k")), + string.Join("|", scale.Ticks.Select(t => t.Label))); + } + + [TestMethod] + public void PrecisionShouldFixTheDecimalPlaces() + { + var options = Linear(); + options.Ticks.Precision = 2; + var scale = Vertical(options, 0, 1); + + Assert.IsTrue(scale.Ticks.All(t => t.Label.Split('.').Last().Length == 2), + string.Join("|", scale.Ticks.Select(t => t.Label))); + } + + [TestMethod] + public void CultureShouldDecideTheDecimalAndGroupSeparators() + { + var scale = new BitChartAxisScale(Linear(), horizontal: false) { Culture = new CultureInfo("de-DE") }; + scale.SetDataRange(0, 5000); + scale.SetPixelRange(600, 0); + + Assert.IsTrue(scale.Ticks.Any(t => t.Label.Contains('.')), string.Join("|", scale.Ticks.Select(t => t.Label))); + } + + // ---- logarithmic ---- + + [TestMethod] + public void ALogScaleShouldPlaceDecadesEvenly() + { + var options = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Logarithmic }; + var scale = Vertical(options, 1, 1000); + + double p1 = scale.PixelFor(1), p10 = scale.PixelFor(10), p100 = scale.PixelFor(100); + Assert.AreEqual(p1 - p10, p10 - p100, 0.5, "each decade must take the same amount of space"); + } + + [TestMethod] + public void ALogScaleShouldEmitMajorAndMinorTicks() + { + var options = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Logarithmic }; + var scale = Vertical(options, 1, 1000); + + Assert.IsTrue(scale.Ticks.Any(t => !t.Minor && t.Label.Length > 0)); + Assert.IsTrue(scale.Ticks.Any(t => t.Minor), "the in-between gridlines are what make a log axis readable"); + } + + [TestMethod] + public void ALogScaleShouldNotTakeANonPositiveMinimum() + { + var options = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Logarithmic }; + var scale = Vertical(options, 0, 100); + + Assert.IsTrue(scale.Min > 0); + } + + // ---- category ---- + + [TestMethod] + public void ACategoryScaleShouldCenterIndexesInTheirBand() + { + var options = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Category }; + var categories = new List { "A", "B", "C", "D" }; + var scale = new BitChartAxisScale(options, horizontal: true, categories); + scale.SetDataRange(0, 3); + scale.SetPixelRange(0, 400); + + Assert.AreEqual(50, scale.PixelForIndex(0, centered: true), 0.001); + Assert.AreEqual(350, scale.PixelForIndex(3, centered: true), 0.001); + Assert.AreEqual(100, scale.BandWidth(), 0.001); + } + + [TestMethod] + public void ACategoryScaleWithoutOffsetShouldPinTheEndsToTheAxis() + { + var options = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Category }; + var categories = new List { "A", "B", "C" }; + var scale = new BitChartAxisScale(options, horizontal: true, categories); + scale.SetDataRange(0, 2); + scale.SetPixelRange(0, 400); + + Assert.AreEqual(0, scale.PixelForIndex(0, centered: false), 0.001); + Assert.AreEqual(400, scale.PixelForIndex(2, centered: false), 0.001); + } + + [TestMethod] + public void ACategoryScaleShouldSkipLabelsItCannotFit() + { + var options = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Category }; + options.Ticks.MaxTicksLimit = 5; + var categories = Enumerable.Range(0, 40).Select(i => $"C{i}").ToList(); + var scale = new BitChartAxisScale(options, horizontal: true, categories); + scale.SetDataRange(0, 39); + scale.SetPixelRange(0, 800); + + Assert.IsTrue(scale.Ticks.Count <= 6, $"got {scale.Ticks.Count}"); + } + + // ---- zoom ---- + + [TestMethod] + public void AForcedRangeShouldOverrideTheData() + { + var scale = new BitChartAxisScale(Linear(), horizontal: false) { Forced = (25, 75) }; + scale.SetDataRange(0, 100); + scale.SetPixelRange(300, 0); + + Assert.AreEqual(25, scale.Min, 1e-9); + Assert.AreEqual(75, scale.Max, 1e-9); + Assert.AreEqual(300, scale.PixelFor(25), 0.001); + } + + [TestMethod] + public void AForcedRangeShouldNotBeRoundedToNiceNumbers() + { + var scale = new BitChartAxisScale(Linear(), horizontal: false) { Forced = (13.7, 41.3) }; + scale.SetDataRange(0, 100); + scale.SetPixelRange(300, 0); + + Assert.AreEqual(13.7, scale.Min, 1e-9); + Assert.AreEqual(41.3, scale.Max, 1e-9); + } + + // ---- nice numbers ---- + + [TestMethod] + [DataRow(0.0, 1.0)] + [DataRow(-5.0, 1.0)] + [DataRow(1.1, 1.0)] + [DataRow(2.4, 2.0)] + [DataRow(6.0, 5.0)] + [DataRow(9.0, 10.0)] + public void NiceNumberShouldRoundToAFriendlyStep(double value, double expected) + { + Assert.AreEqual(expected, BitChartAxisScale.NiceNumber(value, round: true), 1e-9); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartRendererTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartRendererTests.cs new file mode 100644 index 0000000000..9874f456ab --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartRendererTests.cs @@ -0,0 +1,2116 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Extras.Chart; + +/// +/// Scene-level tests: they drive directly, which is where every layout, +/// scale and styling decision is made, so they pin the behavior without depending on the DOM. +/// +[TestClass] +public class BitChartRendererTests +{ + private static BitChartScene Render(BitChartConfig config, double w = 600, double h = 300, string uid = "u1", + BitChartRenderState? state = null) + => new BitChartRenderer(config, state ?? new BitChartRenderState(), w, h, uid).Render(); + + private static BitChartData Bars(params double?[] values) => new() + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Label = "S", Data = values.ToList() } } + }; + + // ---- scales are resolved without touching the caller's options ---- + + [TestMethod] + public void RendererShouldNotMutateTheCallersScaleDictionary() + { + var options = new BitChartOptions(); + Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.AreEqual(0, options.Scales.Count, + "the renderer must complete the scales locally so one options instance can be shared between charts"); + } + + [TestMethod] + public void RendererShouldNotWriteDefaultPositionsBackOntoUserScales() + { + var scale = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Linear }; + var options = new BitChartOptions { Scales = { ["y"] = scale } }; + Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)); + + Assert.IsNull(scale.Position, "an unset position must stay unset; the default is applied per render"); + } + + [TestMethod] + public void SharedOptionsShouldRenderDifferentChartTypesIndependently() + { + var options = new BitChartOptions(); + var cartesian = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)); + var pie = Render(new BitChartConfig(BitChartType.Pie, Bars(1, 2, 3), options)); + + Assert.IsNotNull(cartesian.PlotArea); + Assert.IsTrue(pie.IsRadialOrCircular); + Assert.IsNull(pie.PlotArea); + } + + // ---- def ids are namespaced per chart ---- + + [TestMethod] + public void GradientAndPatternIdsShouldBeNamespacedPerChartInstance() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset + { + Data = { 1, 2 }, + Fill = BitChartFillMode.Origin, + FillGradient = BitChartLinearGradient.Vertical2("#fff", "#000") + }, + new BitChartDataset { Data = { 1, 2 }, Type = BitChartType.Bar, BackgroundPattern = new BitChartFillPattern() } + } + }; + + var a = Render(new BitChartConfig(BitChartType.Line, data), uid: "chartA"); + var b = Render(new BitChartConfig(BitChartType.Line, data), uid: "chartB"); + + Assert.IsTrue(a.Defs[0].Id.StartsWith("chartA"), a.Defs[0].Id); + Assert.IsTrue(b.Defs[0].Id.StartsWith("chartB"), b.Defs[0].Id); + Assert.AreNotEqual(a.Defs[0].Id, b.Defs[0].Id, "two charts on one page must not share a defs id"); + Assert.IsTrue(a.Patterns[0].Id.StartsWith("chartA"), a.Patterns[0].Id); + } + + // ---- bar styling ---- + + [TestMethod] + public void BarWithoutBorderColorShouldNotBeOutlined() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Data = { 10 }, BackgroundColor = "#ff0000" } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + var rect = (BitChartSvgRect)scene.Elements[0].Shape; + Assert.AreEqual(0, rect.StrokeWidth, "bars default to no border, like Chart.js"); + Assert.IsNull(scene.Elements[0].BorderShape); + } + + [TestMethod] + public void BarBorderShouldFallBackToTheFillColorInsteadOfThePalette() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = + { + new BitChartDataset { Data = { 1 }, BackgroundColor = "#111111" }, + new BitChartDataset { Data = { 1 }, BackgroundColor = "#222222", BorderWidth = 2 } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + var border = (BitChartSvgPath)scene.Elements[1].BorderShape!; + Assert.AreEqual("#222222", border.Stroke, + "with no explicit border color a bar takes its own fill, not an unrelated palette entry"); + } + + [TestMethod] + public void ExplicitBorderColorShouldGiveABarAOnePixelBorderByDefault() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Data = { 1 }, BackgroundColor = "#111", BorderColor = "#999" } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + var border = (BitChartSvgPath)scene.Elements[0].BorderShape!; + Assert.AreEqual("#999", border.Stroke); + Assert.AreEqual(1, border.StrokeWidth); + } + + [TestMethod] + public void UniformBorderRadiusShouldOnlyRoundTheCornersAwayFromTheBaseline() + { + BitChartScene RenderWithSkip(BitChartBorderSkipped skip) => Render(new BitChartConfig(BitChartType.Bar, new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Data = { 10 }, BorderRadius = 8, BorderSkipped = skip } } + })); + + var skipped = (BitChartSvgPath)RenderWithSkip(BitChartBorderSkipped.Start).Elements[0].Shape; + var all = (BitChartSvgPath)RenderWithSkip(BitChartBorderSkipped.None).Elements[0].Shape; + + Assert.AreNotEqual(skipped.D, all.D, + "the default skips the baseline edge, so only the tip corners round; BorderSkipped.None rounds all four"); + } + + [TestMethod] + public void MinBarLengthShouldKeepTinyValuesVisible() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 100, 0.0001 }, MinBarLength = 12 } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + var tiny = (BitChartSvgRect)scene.Elements[1].Shape; + Assert.IsTrue(tiny.Height >= 12, $"expected at least 12px, got {tiny.Height}"); + } + + [TestMethod] + public void BarBaseShouldMoveWhereBarsStartFrom() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Data = { 100 }, Base = 50 } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data, new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Min = 0, Max = 100 } } + })); + + var rect = (BitChartSvgRect)scene.Elements[0].Shape; + var plot = scene.PlotArea!.Value; + // Half the axis: the bar covers 50..100, i.e. the upper half of the plot. + Assert.AreEqual(plot.Height / 2, rect.Height, plot.Height * 0.02); + } + + [TestMethod] + public void UngroupedBarDatasetShouldSpanTheWholeCategory() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 5, 6 } }, + new BitChartDataset { Data = { 7, 8 } }, + new BitChartDataset { Data = { 3, 4 }, Grouped = false } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + // The two grouped datasets share the category; the ungrouped one sits behind them at full width. + var grouped = (BitChartSvgRect)scene.Elements.First(e => e.DatasetIndex == 0).Shape; + var ungrouped = (BitChartSvgRect)scene.Elements.First(e => e.DatasetIndex == 2).Shape; + Assert.AreEqual(grouped.Width * 2, ungrouped.Width, 0.01, + $"an ungrouped bar keeps the whole band ({ungrouped.Width} vs {grouped.Width})"); + } + + [TestMethod] + public void SkipNullShouldWidenTheRemainingBarsOfACategory() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 5, null }, SkipNull = true }, + new BitChartDataset { Data = { 3, 4 }, SkipNull = true } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + var shared = (BitChartSvgRect)scene.Elements.First(e => e.DatasetIndex == 1 && e.DataIndex == 0).Shape; + var alone = (BitChartSvgRect)scene.Elements.First(e => e.DatasetIndex == 1 && e.DataIndex == 1).Shape; + Assert.IsTrue(alone.Width > shared.Width * 1.8, + $"the surviving bar fills the category ({alone.Width} vs {shared.Width})"); + } + + [TestMethod] + public void BarBaselineShouldBeTheAxisZeroLineNotTheLastBarDrawn() + { + // Two independent stacks: the last bar drawn sits well above zero, so a baseline taken from it + // would make the entry animation scale out of the wrong place. + var data = new BitChartData + { + Labels = { "A" }, + Datasets = + { + new BitChartDataset { Data = { 10 }, Stack = "s" }, + new BitChartDataset { Data = { 10 }, Stack = "s" } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data, new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Stacked = true } } + })); + + Assert.AreEqual(scene.PlotArea!.Value.Bottom, scene.BarBaseline, 0.5); + } + + // ---- stacking ---- + + [TestMethod] + public void IndependentStacksShouldNotAddUpIntoOneAxisRange() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = + { + new BitChartDataset { Data = { 10 }, Stack = "left" }, + new BitChartDataset { Data = { 10 }, Stack = "left" }, + new BitChartDataset { Data = { 10 }, Stack = "right" }, + new BitChartDataset { Data = { 10 }, Stack = "right" } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data, new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Stacked = true } } + })); + + Assert.AreEqual(20, scene.DataRanges["y"].Max, 1e-6, + "the axis must fit the tallest stack (20), not the sum of every dataset (40)"); + } + + [TestMethod] + public void StackedLineAreasShouldNormalizeWhenStacked100IsSet() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 30, 10 }, Fill = BitChartFillMode.Origin }, + new BitChartDataset { Data = { 10, 30 }, Fill = BitChartFillMode.Origin } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data, new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Stacked = true, Stacked100 = true } } + })); + + Assert.AreEqual(0, scene.DataRanges["y"].Min, 1e-6); + Assert.AreEqual(100, scene.DataRanges["y"].Max, 1e-6); + Assert.IsTrue(scene.Series.Count > 0); + } + + // ---- line / area ---- + + [TestMethod] + public void AreaFillShouldUseTheDatasetBackgroundColor() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 1, 2 }, Fill = BitChartFillMode.Origin, BackgroundColor = "#00ff00", BorderColor = "#f00" } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data)); + + var fill = (BitChartSvgPath)scene.Series[0]; + Assert.AreEqual("#00ff00", fill.Fill); + } + + [TestMethod] + public void ExplicitFillColorShouldWinOverBackgroundColor() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 1, 2 }, Fill = BitChartFillMode.Origin, BackgroundColor = "#00ff00", FillColor = "#0000ff" } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data)); + + Assert.AreEqual("#0000ff", ((BitChartSvgPath)scene.Series[0]).Fill); + } + + [TestMethod] + public void LineBorderWidthShouldBeHonoredExactlyWhenSet() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1, 2 }, BorderWidth = 1 } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data)); + + var line = scene.Series.OfType().First(p => p.Stroke is not null); + Assert.AreEqual(1, line.StrokeWidth, "a dataset asking for a 1px line must get a 1px line"); + } + + [TestMethod] + public void LineBorderWidthShouldFallBackToTheElementDefault() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1, 2 } } } + }; + var options = new BitChartOptions { Elements = { LineBorderWidth = 5 } }; + var scene = Render(new BitChartConfig(BitChartType.Line, data, options)); + + var line = scene.Series.OfType().First(p => p.Stroke is not null); + Assert.AreEqual(5, line.StrokeWidth); + } + + [TestMethod] + public void ABrokenSeriesShouldRegisterItsFillPaintOnlyOnce() + { + var data = new BitChartData + { + Labels = { "A", "B", "C", "D", "E" }, + Datasets = + { + new BitChartDataset + { + Data = { 1, 2, null, 4, 5 }, + Fill = BitChartFillMode.Origin, + FillGradient = BitChartLinearGradient.Vertical2("#fff", "#000") + } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data)); + + Assert.AreEqual(1, scene.Defs.Count, "a gap must not add a duplicate gradient definition"); + } + + [TestMethod] + public void MarkerlessLineShouldStillProduceHoverableElements() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 1, 2, 3 }, PointRadius = 0 } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data)); + + Assert.AreEqual(3, scene.Elements.Count, + "a line drawn without markers still needs hit targets, otherwise it can never show a tooltip"); + } + + [TestMethod] + public void PointStyleNoneShouldSuppressMarkersEntirely() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1, 2 }, PointStyle = BitChartPointStyle.None } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data)); + + Assert.AreEqual(0, scene.Elements.Count); + } + + // ---- hit bands ---- + + [TestMethod] + public void HitBandsShouldCoverEveryCategoryByDefault() + { + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3))); + + Assert.AreEqual(3, scene.HitBands.Count); + var plot = scene.PlotArea!.Value; + Assert.AreEqual(plot.Left, scene.HitBands.First().X, 0.5); + Assert.AreEqual(plot.Right, scene.HitBands.Last().X + scene.HitBands.Last().Width, 0.5); + } + + [TestMethod] + public void HitBandsShouldBeSkippedWhenTheInteractionRequiresAnIntersection() + { + var options = new BitChartOptions { Interaction = { Intersect = true } }; + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.AreEqual(0, scene.HitBands.Count); + } + + [TestMethod] + public void HitBandsShouldBeSkippedForScatterCharts() + { + var data = new BitChartData + { + Datasets = + { + new BitChartDataset { Points = [new(1, 1), new(2, 4), new(3, 9)] } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Scatter, data)); + + Assert.AreEqual(0, scene.HitBands.Count, "scatter points are placed by value, so index bands make no sense"); + Assert.AreEqual(3, scene.Elements.Count); + } + + [TestMethod] + public void HorizontalBarHitBandsShouldRunAcrossThePlot() + { + var options = new BitChartOptions { IndexAxis = BitChartIndexAxis.Y }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)); + + Assert.AreEqual(3, scene.HitBands.Count); + var plot = scene.PlotArea!.Value; + Assert.AreEqual(plot.Width, scene.HitBands[0].Width, 0.5); + } + + // ---- hover shapes ---- + + [TestMethod] + [DataRow(BitChartType.Bar)] + [DataRow(BitChartType.Line)] + [DataRow(BitChartType.Pie)] + [DataRow(BitChartType.Doughnut)] + [DataRow(BitChartType.PolarArea)] + [DataRow(BitChartType.Radar)] + public void EveryElementShouldCarryAPrecomputedHoverShape(BitChartType type) + { + var scene = Render(new BitChartConfig(type, Bars(1, 2, 3))); + + Assert.IsTrue(scene.Elements.Count > 0); + foreach (var el in scene.Elements) + Assert.IsNotNull(el.HoverShape, $"{type} elements must know how they look when hovered"); + } + + [TestMethod] + public void HoveredArcShouldBePushedOutFromTheCenter() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1, 1 }, HoverOffset = 20 } } + }; + var scene = Render(new BitChartConfig(BitChartType.Pie, data)); + + var normal = (BitChartSvgPath)scene.Elements[0].Shape; + var hover = (BitChartSvgPath)scene.Elements[0].HoverShape!; + Assert.AreNotEqual(normal.D, hover.D); + } + + [TestMethod] + public void HoverBackgroundColorShouldBeUsedByTheHoverShape() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Data = { 5 }, BackgroundColor = "#111", HoverBackgroundColor = "#abcdef" } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + Assert.AreEqual("#abcdef", ((BitChartSvgRect)scene.Elements[0].HoverShape!).Fill); + } + + [TestMethod] + public void ScriptableColorsShouldSeeActiveTrueWhileBuildingTheHoverShape() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = + { + new BitChartDataset + { + Data = { 5 }, + BackgroundColorFn = ctx => ctx.Active ? "#00ff00" : "#ff0000" + } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + Assert.AreEqual("#ff0000", ((BitChartSvgRect)scene.Elements[0].Shape).Fill); + Assert.AreEqual("#00ff00", ((BitChartSvgRect)scene.Elements[0].HoverShape!).Fill); + } + + // ---- data labels ---- + + [TestMethod] + public void HorizontalBarDataLabelsShouldSitBesideTheBarNotOnTheDiagonal() + { + var options = new BitChartOptions + { + IndexAxis = BitChartIndexAxis.Y, + Plugins = { DataLabels = { Display = true } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(10, 20, 30), options)); + + var labels = scene.Foreground.OfType().ToList(); + Assert.AreEqual(3, labels.Count); + for (int i = 0; i < labels.Count; i++) + Assert.AreEqual(scene.Elements[i].CenterY, labels[i].Y, 0.01, + "a horizontal bar's label must be vertically centered on its bar"); + } + + [TestMethod] + public void DataLabelsShouldBeDrawnForLinePoints() + { + var options = new BitChartOptions { Plugins = { DataLabels = { Display = true } } }; + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.AreEqual(3, scene.Foreground.OfType().Count()); + } + + [TestMethod] + public void ShowOnPointsFalseShouldKeepLinePointsClean() + { + var options = new BitChartOptions { Plugins = { DataLabels = { Display = true, ShowOnPoints = false } } }; + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.AreEqual(0, scene.Foreground.OfType().Count()); + } + + [TestMethod] + public void DataLabelAnchorShouldMoveTheLabelBetweenTipAndBaseline() + { + double LabelY(BitChartAlign anchor) + { + var options = new BitChartOptions { Plugins = { DataLabels = { Display = true, Anchor = anchor, Align = BitChartAlign.Center } } }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(10, 20, 30), options)); + return scene.Foreground.OfType().First().Y; + } + + double tip = LabelY(BitChartAlign.End); + double middle = LabelY(BitChartAlign.Center); + double baseline = LabelY(BitChartAlign.Start); + + Assert.IsTrue(tip < middle && middle < baseline, $"tip {tip}, middle {middle}, baseline {baseline}"); + } + + // ---- culture ---- + + [TestMethod] + public void TickLabelsShouldFollowTheConfiguredCulture() + { + var options = new BitChartOptions { Culture = new CultureInfo("de-DE") }; + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1000, 5000 } } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data, options)); + + var labels = scene.Background.OfType().Select(t => t.Text).ToList(); + Assert.IsTrue(labels.Any(l => l.Contains('.')), string.Join("|", labels)); + Assert.IsFalse(labels.Any(l => l.Contains(',')), string.Join("|", labels)); + } + + [TestMethod] + public void TicksShouldUseAnExplicitNumericFormatWhenGiven() + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Ticks = { Format = "0.0" } } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + var labels = scene.Background.OfType().Select(t => t.Text).ToList(); + Assert.IsTrue(labels.Any(l => l.Contains('.')), string.Join("|", labels)); + } + + // ---- axes ---- + + [TestMethod] + public void AShortChartShouldNotCrowdItsValueAxisWithLabels() + { + int TickCount(double height) + { + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(0, 50, 100)), h: height); + return scene.Background.OfType().Count(); + } + + Assert.IsTrue(TickCount(120) < TickCount(700), + "the tick count has to follow the space actually available along the axis"); + } + + [TestMethod] + public void MirroredTicksShouldNotReserveSpaceOutsideThePlot() + { + BitChartArea Plot(bool mirror) + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Ticks = { Mirror = mirror } } } + }; + return Render(new BitChartConfig(BitChartType.Line, Bars(1000, 2000, 3000), options)).PlotArea!.Value; + } + + Assert.IsTrue(Plot(true).Left < Plot(false).Left); + } + + [TestMethod] + public void HiddenAxisShouldReserveNoSpace() + { + BitChartArea Plot(bool display) + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Display = display } } + }; + return Render(new BitChartConfig(BitChartType.Line, Bars(1000, 2000, 3000), options)).PlotArea!.Value; + } + + Assert.IsTrue(Plot(false).Left < Plot(true).Left); + } + + [TestMethod] + public void DataRangesShouldRecordTheUnzoomedExtentForEveryAxis() + { + var state = new BitChartRenderState(); + state.AxisRanges["y"] = (0, 5); + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(0, 50, 100)), state: state); + + Assert.AreEqual(100, scene.DataRanges["y"].Max, 1e-6, "the full range must survive a zoom"); + Assert.AreEqual(5, scene.AxisRanges["y"].Max, 1e-6, "the visible range is the zoomed one"); + } + + // ---- legend ---- + + [TestMethod] + public void LegendFilterShouldRemoveItems() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = + { + new BitChartDataset { Label = "keep", Data = { 1 } }, + new BitChartDataset { Label = "drop", Data = { 2 } } + } + }; + var options = new BitChartOptions { Plugins = { Legend = { Filter = i => i.Text != "drop" } } }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data, options)); + + Assert.AreEqual(1, scene.Legend!.Items.Count); + Assert.AreEqual("keep", scene.Legend.Items[0].Text); + } + + [TestMethod] + public void CircularLegendShouldListTheLabelsNotTheDatasets() + { + var scene = Render(new BitChartConfig(BitChartType.Doughnut, Bars(1, 2, 3))); + + Assert.AreEqual(3, scene.Legend!.Items.Count); + Assert.IsTrue(scene.Legend.Items.All(i => i.IsDataIndex)); + } + + // ---- empty ---- + + [TestMethod] + public void ASceneWithNothingToDrawShouldReportItself() + { + Assert.IsTrue(Render(new BitChartConfig(BitChartType.Line, new BitChartData())).IsEmpty); + Assert.IsTrue(Render(new BitChartConfig(BitChartType.Pie, new BitChartData())).IsEmpty); + Assert.IsFalse(Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3))).IsEmpty); + } + + [TestMethod] + public void HidingEveryDatasetShouldProduceAnEmptyScene() + { + var state = new BitChartRenderState(); + state.HiddenDatasets.Add(0); + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3)), state: state); + + Assert.IsTrue(scene.IsEmpty); + } + + // ---- arcs ---- + + [TestMethod] + public void ArcSpacingShouldLeaveAGapBetweenSlices() + { + BitChartSvgPath First(double spacing) => + (BitChartSvgPath)Render(new BitChartConfig(BitChartType.Pie, new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 1, 1, 1 }, SpacingArc = spacing } } + })).Elements[0].Shape; + + Assert.AreNotEqual(First(0).D, First(10).D); + } + + [TestMethod] + public void DoughnutCutoutShouldProduceARingPath() + { + var options = new BitChartOptions { CutoutPercentage = 60 }; + var scene = Render(new BitChartConfig(BitChartType.Doughnut, Bars(1, 2, 3), options)); + + // A ring path traces two arcs; a solid pie wedge traces one. + var d = ((BitChartSvgPath)scene.Elements[0].Shape).D; + Assert.AreEqual(2, d.Split('A').Length - 1, d); + } + + // ---- mixed / multi axis ---- + + [TestMethod] + public void PerDatasetTypeShouldProduceAMixedChart() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 1, 2 }, Type = BitChartType.Bar }, + new BitChartDataset { Data = { 3, 4 }, Type = BitChartType.Line } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + Assert.IsTrue(scene.HasBars); + Assert.IsTrue(scene.Series.Count > 0, "the line dataset must contribute a series path"); + Assert.IsTrue(scene.Elements.Any(e => e.Shape is BitChartSvgRect)); + Assert.IsTrue(scene.Elements.Any(e => e.Shape is BitChartSvgCircle)); + } + + [TestMethod] + public void ASecondValueAxisShouldGetItsOwnRange() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 1, 2 }, YAxisID = "y" }, + new BitChartDataset { Data = { 1000, 2000 }, YAxisID = "y2" } + } + }; + var options = new BitChartOptions + { + Scales = { ["y2"] = new BitChartScaleOptions { Id = "y2", Position = BitChartPosition.Right } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data, options)); + + Assert.IsTrue(scene.AxisRanges["y"].Max < 100); + Assert.IsTrue(scene.AxisRanges["y2"].Max >= 2000); + } + + // ---- plugins ---- + + [TestMethod] + public void AnnotationPluginShouldDrawInFrontOrBehindAsAsked() + { + var options = new BitChartOptions(); + options.Plugins.Custom.Add(new BitChartAnnotationPlugin( + new BitChartAnnotation { Value = 2, Label = "target" }, + new BitChartAnnotation { Value = 1, DrawBehindDatasets = true })); + + var before = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3))).Foreground.Count; + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.IsTrue(scene.Foreground.Count > before); + Assert.IsTrue(scene.Background.OfType().Any()); + } + + [TestMethod] + public void AnnotationLabelWidthShouldFollowTheMeasuredText() + { + double Width(string label) + { + var options = new BitChartOptions(); + options.Plugins.Custom.Add(new BitChartAnnotationPlugin(new BitChartAnnotation { Value = 2, Label = label })); + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + return scene.Foreground.OfType().First().Width; + } + + Assert.IsTrue(Width("iiii") < Width("WWWW"), "the label pill is measured, not counted"); + } + + [TestMethod] + public void CenterTextPluginShouldDrawInsideTheDoughnut() + { + var options = new BitChartOptions { CutoutPercentage = 70 }; + options.Plugins.Custom.Add(new BitChartCenterTextPlugin("120", "total")); + var scene = Render(new BitChartConfig(BitChartType.Doughnut, Bars(1, 2, 3), options)); + + var texts = scene.Foreground.OfType().Select(t => t.Text).ToList(); + CollectionAssert.Contains(texts, "120"); + CollectionAssert.Contains(texts, "total"); + } + + // ---- tooltips ---- + + [TestMethod] + public void TooltipLabelCallbackShouldReplaceTheDefaultRow() + { + var options = new BitChartOptions + { + Plugins = { Tooltip = { Callbacks = { Label = i => $"<{i.Value}>" } } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(7), options)); + + Assert.AreEqual("<7>", scene.Elements[0].Tooltip.Items[0].Text); + } + + [TestMethod] + public void TooltipValuesShouldBeFormattedWithTheConfiguredCulture() + { + var options = new BitChartOptions { Culture = new CultureInfo("de-DE") }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1.5), options)); + + StringAssert.Contains(scene.Elements[0].Tooltip.Items[0].Text, "1,5"); + } + + // ---- horizontal (index axis = y) layout ---- + + [TestMethod] + public void HorizontalChartShouldReserveTheLeftEdgeForItsCategoryLabels() + { + BitChartArea Plot(params string[] labels) + { + var data = new BitChartData + { + Labels = labels.ToList(), + Datasets = { new BitChartDataset { Data = labels.Select(_ => (double?)10).ToList() } } + }; + var options = new BitChartOptions { IndexAxis = BitChartIndexAxis.Y }; + return Render(new BitChartConfig(BitChartType.Bar, data, options)).PlotArea!.Value; + } + + double narrow = Plot("A", "B", "C").Left; + double wide = Plot("A very long category name", "B", "C").Left; + + Assert.IsTrue(wide > narrow + 40, + $"the categories run down the left edge, so their width is what must be reserved ({wide} vs {narrow})"); + } + + [TestMethod] + public void HorizontalChartShouldReserveTheBottomForItsValueLabels() + { + var options = new BitChartOptions { IndexAxis = BitChartIndexAxis.Y }; + var plot = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)).PlotArea!.Value; + + Assert.IsTrue(plot.Bottom < 300 - 10, $"the value axis under the plot needs room: bottom was {plot.Bottom}"); + Assert.IsTrue(plot.Right > 580, $"nothing is drawn on the right, so nothing should be reserved: right was {plot.Right}"); + } + + [TestMethod] + public void EveryHorizontalBarLabelShouldStayInsideTheChart() + { + var data = new BitChartData + { + Labels = { "Netherlands", "Switzerland", "New Zealand" }, + Datasets = { new BitChartDataset { Data = { 10, 20, 30 } } } + }; + var options = new BitChartOptions { IndexAxis = BitChartIndexAxis.Y }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data, options)); + + foreach (var text in scene.Background.OfType().Where(t => t.Anchor == "end")) + { + double left = text.X - BitChartTextMeasure.Width(text.Text, text.FontSize); + Assert.IsTrue(left >= -0.5, $"'{text.Text}' starts at {left}, outside the chart"); + } + } + + // ---- stack grouping ---- + + [TestMethod] + public void BarsAndLinesOnOneStackedAxisShouldNotShareAStack() + { + var data = new BitChartData + { + Labels = { "A" }, + Datasets = + { + new BitChartDataset { Data = { 10 }, Type = BitChartType.Bar }, + new BitChartDataset { Data = { 10 }, Type = BitChartType.Bar }, + new BitChartDataset { Data = { 10 }, Type = BitChartType.Line } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data, new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Stacked = true } } + })); + + // The two bars stack to 20; the line stacks on its own. The axis must fit 20, not 30. + Assert.AreEqual(20, scene.DataRanges["y"].Max, 1e-6); + } + + // ---- element defaults ---- + + [TestMethod] + public void PointRadiusShouldFallBackToTheElementDefault() + { + var options = new BitChartOptions { Elements = { PointRadius = 9 } }; + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.AreEqual(9, ((BitChartSvgCircle)scene.Elements[0].Shape).R, 1e-9); + } + + [TestMethod] + public void AnExplicitZeroPointRadiusShouldStillWinOverTheElementDefault() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1, 2 }, PointRadius = 0 } } + }; + var options = new BitChartOptions { Elements = { PointRadius = 9 } }; + var scene = Render(new BitChartConfig(BitChartType.Line, data, options)); + + // The marker is gone, but an invisible hit target keeps the point hoverable. + var shape = (BitChartSvgCircle)scene.Elements[0].Shape; + Assert.AreEqual("transparent", shape.Fill); + } + + [TestMethod] + public void TensionShouldFallBackToTheElementDefault() + { + string Path(BitChartOptions options) + => ((BitChartSvgPath)Render(new BitChartConfig(BitChartType.Line, Bars(1, 5, 2), options)) + .Series.First(n => n is BitChartSvgPath { Stroke: not null })).D; + + string straight = Path(new BitChartOptions()); + string curved = Path(new BitChartOptions { Elements = { LineTension = 0.5 } }); + + Assert.IsFalse(straight.Contains('C'), straight); + Assert.IsTrue(curved.Contains('C'), curved); + } + + // ---- legend position ---- + + [TestMethod] + public void ALegendPositionWithNowhereToGoShouldFallBackToTheTop() + { + var options = new BitChartOptions { Plugins = { Legend = { Position = BitChartPosition.Center } } }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)); + + Assert.AreEqual(BitChartPosition.Top, scene.Legend!.Position, + "the legend renders on one of four sides; anything else would silently disappear"); + } + + [TestMethod] + public void ReversedAxesShouldBeReportedOnTheScene() + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Reverse = true } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.IsTrue(scene.ReversedAxes.Contains("y")); + Assert.IsFalse(scene.ReversedAxes.Contains("x")); + } + + // ---- radial label space ---- + + [TestMethod] + public void RadarShouldShrinkItsWebToFitLongPointLabels() + { + double Radius(params string[] labels) + { + var data = new BitChartData + { + Labels = labels.ToList(), + Datasets = { new BitChartDataset { Data = labels.Select(_ => (double?)10).ToList() } } + }; + var scene = Render(new BitChartConfig(BitChartType.Radar, data)); + // The outermost grid ring is the web's radius. + var poly = scene.Background.OfType().Last(); + return poly.Points.Max(pt => Math.Abs(pt.X - 300)); + } + + // Index 1 of a four-spoke web points straight to the right, where a long label reaches out + // sideways by its whole width - the case that actually costs radius. + double shortLabels = Radius("A", "B", "C", "D"); + double longLabels = Radius("A", "Operational excellence", "C", "D"); + + Assert.IsTrue(longLabels < shortLabels - 20, + $"a long category name has to be measured, not assumed ({longLabels} vs {shortLabels})"); + } + + [TestMethod] + public void RadarPointLabelsShouldStayInsideTheChart() + { + var data = new BitChartData + { + Labels = { "Operational excellence", "Speed", "Cost", "Reliability" }, + Datasets = { new BitChartDataset { Data = { 10, 20, 30, 40 } } } + }; + var scene = Render(new BitChartConfig(BitChartType.Radar, data)); + + foreach (var text in scene.Background.OfType()) + { + double w = BitChartTextMeasure.Width(text.Text, text.FontSize, text.FontWeight); + double left = text.Anchor switch { "end" => text.X - w, "middle" => text.X - w / 2, _ => text.X }; + Assert.IsTrue(left >= -1, $"'{text.Text}' starts at {left}"); + Assert.IsTrue(left + w <= 601, $"'{text.Text}' ends at {left + w}"); + } + } + + [TestMethod] + public void PolarAreaShouldAlsoMeasureItsPerimeterLabels() + { + double Radius(params string[] labels) + { + var data = new BitChartData + { + Labels = labels.ToList(), + Datasets = { new BitChartDataset { Data = labels.Select(_ => (double?)10).ToList() } } + }; + var options = new BitChartOptions + { + Scales = { ["r"] = new BitChartScaleOptions { Id = "r", Type = BitChartScaleType.RadialLinear } } + }; + var scene = Render(new BitChartConfig(BitChartType.PolarArea, data, options)); + return scene.Background.OfType().Max(c => c.R); + } + + Assert.IsTrue(Radius("A distinctly long slice name", "B", "C") < Radius("A", "B", "C") - 10); + } + + // ---- axes at the zero line ---- + + [TestMethod] + public void ACenteredAxisShouldBeDrawnAtTheOtherAxisZero() + { + var data = new BitChartData + { + Datasets = { new BitChartDataset { Points = [new(-10, -10), new(10, 10)] } } + }; + var options = new BitChartOptions + { + Scales = + { + ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Linear, Position = BitChartPosition.Center }, + ["y"] = new BitChartScaleOptions { Id = "y", Type = BitChartScaleType.Linear, Position = BitChartPosition.Center } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Scatter, data, options)); + var plot = scene.PlotArea!.Value; + + // Both axis lines run through the middle of a symmetric plot rather than along its edges. + var lines = scene.Background.OfType().ToList(); + Assert.IsTrue(lines.Any(l => Math.Abs(l.X1 - l.X2) < 0.01 && Math.Abs(l.X1 - plot.CenterX) < plot.Width * 0.1), + "expected a vertical axis line near the horizontal center"); + Assert.IsTrue(lines.Any(l => Math.Abs(l.Y1 - l.Y2) < 0.01 && Math.Abs(l.Y1 - plot.CenterY) < plot.Height * 0.1), + "expected a horizontal axis line near the vertical center"); + } + + [TestMethod] + public void ACenteredAxisShouldReserveNoLayoutSpace() + { + BitChartArea Plot(BitChartPosition position) + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Position = position } } + }; + return Render(new BitChartConfig(BitChartType.Line, Bars(1000, 2000, 3000), options)).PlotArea!.Value; + } + + Assert.IsTrue(Plot(BitChartPosition.Center).Left < Plot(BitChartPosition.Left).Left, + "a centered axis lives inside the plot, so it must not push the plot inwards"); + } + + // ---- reversed value axis ---- + + [TestMethod] + public void BarsOnAReversedAxisShouldStillGrowAwayFromTheirBaseline() + { + BitChartSvgRect Bar(bool reverse) + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Reverse = reverse, Min = 0, Max = 100 } } + }; + var data = new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Data = { 60 } } } + }; + return (BitChartSvgRect)Render(new BitChartConfig(BitChartType.Bar, data, options)).Elements[0].Shape; + } + + var normal = Bar(false); + var reversed = Bar(true); + + // Normally the bar hangs down to the axis at the bottom; reversed, zero is at the top and the + // bar hangs down from it. Either way it starts at the baseline and is the same length. + Assert.AreEqual(normal.Height, reversed.Height, 0.5); + Assert.IsTrue(reversed.Y < normal.Y, $"reversed bar top {reversed.Y}, normal {normal.Y}"); + } + + [TestMethod] + public void ABarOnAReversedAxisShouldSkipTheEdgeTouchingItsBaseline() + { + string Corners(bool reverse) + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Reverse = reverse, Min = 0, Max = 100 } } + }; + var data = new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Data = { 60 }, BorderRadius = 8 } } + }; + return ((BitChartSvgPath)Render(new BitChartConfig(BitChartType.Bar, data, options)).Elements[0].Shape).D; + } + + Assert.AreNotEqual(Corners(false), Corners(true), + "the rounded end follows the tip of the bar, which a reversed axis moves to the other side"); + } + + [TestMethod] + public void NegativeBarsShouldPointTheirTooltipAtTheirOwnTip() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 10, -10 } } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + var positive = (BitChartSvgRect)scene.Elements[0].Shape; + var negative = (BitChartSvgRect)scene.Elements[1].Shape; + Assert.AreEqual(positive.Y, scene.Elements[0].Tooltip.AnchorY, 0.01); + Assert.AreEqual(negative.Y + negative.Height, scene.Elements[1].Tooltip.AnchorY, 0.01); + } + + [TestMethod] + public void ADoughnutCutoutOfOneHundredPercentShouldNotInvertItsArcs() + { + var options = new BitChartOptions { CutoutPercentage = 100 }; + var scene = Render(new BitChartConfig(BitChartType.Doughnut, Bars(1, 2, 3), options)); + + Assert.AreEqual(3, scene.Elements.Count); + foreach (var el in scene.Elements) + Assert.IsFalse(((BitChartSvgPath)el.Shape).D.Contains("NaN")); + } + + [TestMethod] + public void AChartOfMalformedColorsShouldStillRender() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 1, 2 }, BackgroundColor = "#nothex", BorderColor = "not a color", Fill = BitChartFillMode.Origin } + } + }; + + var scene = Render(new BitChartConfig(BitChartType.Line, data)); + + Assert.AreEqual(2, scene.Elements.Count); + } + + [TestMethod] + public void DataLabelsShouldStayInsideTheChartBox() + { + var options = new BitChartOptions + { + Plugins = { DataLabels = { Display = true, Anchor = BitChartAlign.End, Align = BitChartAlign.End, Offset = 40 } }, + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Display = false } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(100, 100, 100), options)); + + foreach (var t in scene.Foreground.OfType()) + Assert.IsTrue(t.Y >= 0 && t.Y <= 300, $"label at y={t.Y} is outside the 300px chart"); + } + + [TestMethod] + public void ARadarChartShouldReadTheRadialScaleItsDatasetsName() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 1, 2, 3 }, RAxisID = "radial" } } + }; + var options = new BitChartOptions + { + Scales = + { + ["radial"] = new BitChartScaleOptions + { + Id = "radial", Type = BitChartScaleType.RadialLinear, Grid = { Circular = true } + } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Radar, data, options)); + + // Circular rings only appear when the named scale is the one actually being read. + Assert.IsTrue(scene.Background.OfType().Any(), + "the scale named by RAxisID must be the one the chart uses"); + } + + // ---- percentage stacking ---- + + private static BitChartConfig PercentStack(params double?[][] series) + { + var data = new BitChartData { Labels = { "A", "B", "C" } }; + foreach (var s in series) + data.Datasets.Add(new BitChartDataset { Data = s.ToList(), Stack = "s" }); + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Stacked = true, Stacked100 = true } } + }; + return new BitChartConfig(BitChartType.Bar, data, options); + } + + [TestMethod] + public void APercentageStackOfPositivesShouldSpanZeroToAHundred() + { + var scene = Render(PercentStack([1, 2, 3], [3, 2, 1])); + + var range = scene.AxisRanges["y"]; + Assert.AreEqual(0, range.Min, 1e-6); + Assert.AreEqual(100, range.Max, 1e-6); + } + + [TestMethod] + public void APercentageStackWithNegativesShouldMakeRoomBelowTheBaseline() + { + // Every category is 50% up and 50% down, so the axis has to reach -50 for the bars to be drawn. + var scene = Render(PercentStack([10, 10, 10], [-10, -10, -10])); + + var range = scene.AxisRanges["y"]; + Assert.IsTrue(range.Min <= -50, $"a negative share must stay inside the axis, but it stops at {range.Min}"); + Assert.IsTrue(range.Max >= 50, $"the positive share must stay inside the axis, but it stops at {range.Max}"); + } + + [TestMethod] + public void EveryPercentageStackedBarShouldBeDrawnInsideThePlot() + { + var scene = Render(PercentStack([10, 10, 10], [-10, -10, -10])); + var plot = scene.PlotArea!.Value; + + Assert.AreEqual(6, scene.Elements.Count); + foreach (var el in scene.Elements) + { + var r = (BitChartSvgRect)el.Shape; + Assert.IsTrue(r.Y >= plot.Top - 0.5 && r.Y + r.Height <= plot.Bottom + 0.5, + $"a bar spanning {r.Y}..{r.Y + r.Height} is clipped out of the plot {plot.Top}..{plot.Bottom}"); + } + } + + // ---- tick label rotation ---- + + [TestMethod] + public void MinRotationShouldSlantLabelsThatWouldHaveFitAnyway() + { + var options = new BitChartOptions(); + options.Scales["x"] = new BitChartScaleOptions + { + Id = "x", Type = BitChartScaleType.Category, Ticks = { MinRotation = 30 } + }; + + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)); + + // Short labels over a wide axis fit horizontally, but MinRotation is an instruction, not a fallback. + Assert.IsTrue(scene.Background.OfType().Any(t => Math.Abs(t.Rotation - 30) < 1e-6), + "MinRotation must be applied even when the labels would have fitted unrotated"); + } + + [TestMethod] + public void LabelsThatFitShouldStayHorizontalWithoutAMinRotation() + { + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3))); + + Assert.IsTrue(scene.Background.OfType().All(t => Math.Abs(t.Rotation) < 1e-6)); + } + + // ---- polar area ---- + + [TestMethod] + public void HidingThePolarAreaDatasetShouldEmptyTheChart() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 1, 2, 3 }, Hidden = true } } + }; + + var scene = Render(new BitChartConfig(BitChartType.PolarArea, data)); + + Assert.AreEqual(0, scene.Elements.Count, "a hidden dataset must not still be drawn"); + Assert.IsTrue(scene.IsEmpty); + } + + [TestMethod] + public void PolarAreaShouldDrawTheFirstVisibleDataset() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = + { + new BitChartDataset { Data = { 1, 2, 3 }, Hidden = true }, + new BitChartDataset { Data = { 4, 5, 6 } } + } + }; + + var scene = Render(new BitChartConfig(BitChartType.PolarArea, data)); + + Assert.AreEqual(3, scene.Elements.Count); + Assert.IsTrue(scene.Elements.All(e => e.DatasetIndex == 1), + "the visible dataset is the one that should be drawn, not the hidden first one"); + } + + [TestMethod] + public void APolarWedgeBelowTheScaleMinimumShouldNotInvertThroughTheCenter() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 5, 10 } } } + }; + var options = new BitChartOptions + { + Scales = { ["r"] = new BitChartScaleOptions { Id = "r", Type = BitChartScaleType.RadialLinear, Min = 8 } } + }; + + var scene = Render(new BitChartConfig(BitChartType.PolarArea, data, options)); + + // The 5 sits below the axis minimum: it collapses to nothing rather than drawing a wedge + // through the far side of the center. + Assert.AreEqual(2, scene.Elements.Count); + foreach (var el in scene.Elements) + Assert.IsFalse(((BitChartSvgPath)el.Shape).D.Contains('-'), + "no arc coordinate should come out negative from a clamped radius"); + } + + // ---- plugins on radial charts ---- + + private sealed class ProbePlugin : IBitChartPlugin + { + public string Id => "probe"; + public int Before, After; + public bool SawCartesian; + public double OuterRadius; + public void BeforeDatasetsDraw(BitChartPluginContext ctx) { Before++; SawCartesian = ctx.IsCartesian; OuterRadius = ctx.OuterRadius; } + public void AfterDatasetsDraw(BitChartPluginContext ctx) => After++; + } + + [TestMethod] + [DataRow(BitChartType.Radar)] + [DataRow(BitChartType.PolarArea)] + [DataRow(BitChartType.Doughnut)] + public void PluginsShouldRunOnEveryRadialChartType(BitChartType type) + { + var probe = new ProbePlugin(); + var options = new BitChartOptions(); + options.Plugins.Custom.Add(probe); + + Render(new BitChartConfig(type, Bars(1, 2, 3), options)); + + Assert.AreEqual(1, probe.Before, $"{type} must give plugins their before-draw hook"); + Assert.AreEqual(1, probe.After, $"{type} must give plugins their after-draw hook"); + Assert.IsFalse(probe.SawCartesian); + Assert.IsTrue(probe.OuterRadius > 0, "a radial context has to carry the geometry plugins draw against"); + } + + [TestMethod] + public void TheCenterTextPluginShouldAlsoWorkOnARadarChart() + { + var options = new BitChartOptions(); + options.Plugins.Custom.Add(new BitChartCenterTextPlugin("87", "score")); + + var scene = Render(new BitChartConfig(BitChartType.Radar, Bars(1, 2, 3), options)); + + var texts = scene.Foreground.OfType().Select(t => t.Text).ToList(); + CollectionAssert.Contains(texts, "87"); + CollectionAssert.Contains(texts, "score"); + } + + // ---- arcs: corner radius and ring weight ---- + + [TestMethod] + public void ArcBorderRadiusShouldRoundTheArcInsteadOfLeavingItSquare() + { + var square = Render(new BitChartConfig(BitChartType.Doughnut, Bars(1, 2, 3))); + var data = Bars(1, 2, 3); + data.Datasets[0].BorderRadius = 6; + var rounded = Render(new BitChartConfig(BitChartType.Doughnut, data)); + + string plain = ((BitChartSvgPath)square.Elements[0].Shape).D; + string curved = ((BitChartSvgPath)rounded.Elements[0].Shape).D; + + Assert.IsFalse(plain.Contains('Q'), "a plain arc is made of straight edges and circular arcs"); + Assert.IsTrue(curved.Contains('Q'), "a rounded arc fillets its corners with quadratic curves"); + } + + [TestMethod] + public void ARoundedPieWedgeShouldStillStartAtTheCenter() + { + var data = Bars(1, 2, 3); + data.Datasets[0].BorderRadius = 40; // deliberately larger than the wedge can take + var scene = Render(new BitChartConfig(BitChartType.Pie, data)); + + foreach (var el in scene.Elements) + StringAssert.StartsWith(((BitChartSvgPath)el.Shape).D, "M ", + "a pie wedge is still drawn from its point outwards"); + } + + [TestMethod] + public void AFullCircleShouldIgnoreArcRounding() + { + var data = new BitChartData { Labels = { "Only" }, Datasets = { new BitChartDataset { Data = { 1 } } } }; + data.Datasets[0].BorderRadius = 8; + var scene = Render(new BitChartConfig(BitChartType.Doughnut, data)); + + // A single slice sweeps the whole circle, so it has no corners to round. + Assert.IsFalse(((BitChartSvgPath)scene.Elements[0].Shape).D.Contains('Q')); + } + + [TestMethod] + public void RingWeightShouldShareTheRadiusOutInProportion() + { + // The radial midpoint of a ring is where its band sits, so growing one band moves its own + // midpoint and, with it, the boundary between the two. + static double MidRadius(int datasetIndex, double outerWeight, double innerWeight) + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 1, 1 }, Weight = outerWeight }, + new BitChartDataset { Data = { 1, 1 }, Weight = innerWeight } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Doughnut, data)); + var el = scene.Elements.First(e => e.DatasetIndex == datasetIndex); + double dx = el.CenterX - scene.Width / 2; + double dy = el.CenterY - scene.Height / 2; + return Math.Sqrt(dx * dx + dy * dy); + } + + Assert.IsTrue(MidRadius(0, 2, 1) < MidRadius(0, 1, 1), + "a heavier outer ring claims radius inwards, so its own midpoint moves towards the center"); + Assert.IsTrue(MidRadius(1, 1, 2) > MidRadius(1, 1, 1), + "a heavier inner ring claims radius outwards, so its own midpoint moves away from the center"); + } + + [TestMethod] + public void EqualRingWeightsShouldSplitTheRadiusEvenly() + { + static double MidRadius(BitChartScene scene, int datasetIndex) + { + var el = scene.Elements.First(e => e.DatasetIndex == datasetIndex); + double dx = el.CenterX - scene.Width / 2; + double dy = el.CenterY - scene.Height / 2; + return Math.Sqrt(dx * dx + dy * dy); + } + + var two = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1, 1 } }, new BitChartDataset { Data = { 1, 1 } } } + }; + var one = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 1, 1 } } } + }; + + var split = Render(new BitChartConfig(BitChartType.Doughnut, two)); + var whole = Render(new BitChartConfig(BitChartType.Doughnut, one)); + + // Default weight is 1 everywhere, so the two bands must straddle the single ring's midpoint. + double outer = MidRadius(split, 0), inner = MidRadius(split, 1); + Assert.AreEqual(MidRadius(whole, 0), (outer + inner) / 2, 0.01, + "equal weights have to keep the even split the chart had before weights existed"); + } + + // ---- sparkline ---- + + [TestMethod] + public void ASparklineShouldDropEveryPieceOfChrome() + { + var options = new BitChartOptions + { + Sparkline = true, + Plugins = new BitChartPluginOptions + { + Title = new BitChartTitleOptions { Display = true, Text = "Trend" }, + Legend = new BitChartLegendOptions { Display = true } + } + }; + + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + + Assert.IsNull(scene.Title, "a sparkline carries no title"); + Assert.IsNull(scene.Legend, "a sparkline carries no legend"); + Assert.AreEqual(0, scene.Background.Count, "a sparkline draws no axis, grid or tick label"); + } + + [TestMethod] + public void ASparklineShouldGiveTheWholeBoxToTheSeries() + { + var plain = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3))); + var spark = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), new BitChartOptions { Sparkline = true })); + + Assert.IsTrue(spark.PlotArea!.Value.Width > plain.PlotArea!.Value.Width); + Assert.IsTrue(spark.PlotArea!.Value.Height > plain.PlotArea!.Value.Height); + } + + [TestMethod] + public void ASparklineShouldStillBeInteractiveAndDescribable() + { + var scene = Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), new BitChartOptions { Sparkline = true })); + + Assert.AreEqual(3, scene.Elements.Count, "dropping the chrome must not drop the data"); + Assert.IsTrue(scene.HitBands.Count > 0, "the plot stays hoverable"); + } + + [TestMethod] + public void ASparklineOnARadarChartShouldDropItsPerimeterLabels() + { + var spark = Render(new BitChartConfig(BitChartType.Radar, Bars(1, 2, 3), new BitChartOptions { Sparkline = true })); + + Assert.IsFalse(spark.Background.OfType().Any(), + "a sparkline radar draws no point labels or radial ticks"); + } + + // ---- trendlines ---- + + private static BitChartScene Trend(BitChartTrendline trendline, BitChartType type = BitChartType.Line, + BitChartData? data = null) + { + var options = new BitChartOptions(); + options.Plugins.Custom.Add(new BitChartTrendlinePlugin(trendline)); + return Render(new BitChartConfig(type, data ?? Bars(1, 2, 3), options)); + } + + [TestMethod] + public void ALinearTrendlineShouldFollowTheSlopeOfItsDataset() + { + var rising = Trend(new BitChartTrendline { DatasetIndex = 0 }, data: Bars(1, 2, 3)); + var falling = Trend(new BitChartTrendline { DatasetIndex = 0 }, data: Bars(3, 2, 1)); + + // A rising series maps to a falling pixel path (y grows downwards), and vice versa. + Assert.IsTrue(EndY(rising) < StartY(rising), "a rising series must give a rising trend line"); + Assert.IsTrue(EndY(falling) > StartY(falling), "a falling series must give a falling trend line"); + } + + private static BitChartSvgPath TrendPath(BitChartScene scene) + => scene.Foreground.OfType().Last(); + + private static double StartY(BitChartScene scene) => PathPoints(TrendPath(scene))[0].Y; + private static double EndY(BitChartScene scene) => PathPoints(TrendPath(scene))[^1].Y; + + private static List<(double X, double Y)> PathPoints(BitChartSvgPath path) + { + var points = new List<(double, double)>(); + foreach (var token in path.D.Split([' '], StringSplitOptions.RemoveEmptyEntries) + .Where(t => t is not ("M" or "L")).Chunk(2)) + points.Add((double.Parse(token[0], CultureInfo.InvariantCulture), + double.Parse(token[1], CultureInfo.InvariantCulture))); + return points; + } + + [TestMethod] + public void AnExtendedTrendlineShouldReachBothEdgesOfThePlot() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 0, Extend = true }); + var plot = scene.PlotArea!.Value; + var points = PathPoints(TrendPath(scene)); + + Assert.AreEqual(plot.Left, points[0].X, 0.01); + Assert.AreEqual(plot.Right, points[^1].X, 0.01); + } + + [TestMethod] + public void AnUnextendedTrendlineShouldStopAtTheData() + { + // Bars center their categories, so the first and last of them sit inside the plot - which is + // exactly where an unextended fit has to begin and end. + var scene = Trend(new BitChartTrendline { DatasetIndex = 0 }, BitChartType.Bar); + var plot = scene.PlotArea!.Value; + var points = PathPoints(TrendPath(scene)); + + Assert.AreEqual(scene.Elements.First(e => e.DataIndex == 0).CenterX, points[0].X, 0.5); + Assert.AreEqual(scene.Elements.First(e => e.DataIndex == 2).CenterX, points[^1].X, 0.5); + Assert.IsTrue(points[0].X > plot.Left, "the fit starts at the first point, not the edge"); + Assert.IsTrue(points[^1].X < plot.Right, "the fit ends at the last point, not the edge"); + } + + [TestMethod] + public void AMovingAverageShouldHaveOneVertexPerDataPoint() + { + var data = new BitChartData + { + Labels = { "A", "B", "C", "D", "E" }, + Datasets = { new BitChartDataset { Data = { 10, 0, 10, 0, 10 } } } + }; + var scene = Trend(new BitChartTrendline { DatasetIndex = 0, Kind = BitChartTrendlineKind.MovingAverage, Period = 2 }, + data: data); + + Assert.AreEqual(5, PathPoints(TrendPath(scene)).Count); + } + + [TestMethod] + public void AnAverageTrendlineShouldBeFlat() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 0, Kind = BitChartTrendlineKind.Average }, + data: Bars(1, 5, 9)); + var points = PathPoints(TrendPath(scene)); + + Assert.AreEqual(points[0].Y, points[^1].Y, 0.01, "the mean does not slope"); + } + + [TestMethod] + public void ATrendlineShouldSitOnTheValuesItWasFittedTo() + { + // The mean of 1, 5, 9 is 5, which is exactly where the middle point of the series is drawn. + var scene = Trend(new BitChartTrendline { DatasetIndex = 0, Kind = BitChartTrendlineKind.Average }, + data: Bars(1, 5, 9)); + + double middle = scene.Elements.Single(e => e.DataIndex == 1).CenterY; + Assert.AreEqual(middle, PathPoints(TrendPath(scene))[0].Y, 0.5); + } + + [TestMethod] + public void ATrendlineOverBarsShouldLandOnTheBarCenters() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 0, Kind = BitChartTrendlineKind.MovingAverage, Period = 1 }, + BitChartType.Bar); + var points = PathPoints(TrendPath(scene)); + + // Bars center their category in its band, so the fit has to be placed the same way. + for (int i = 0; i < scene.Elements.Count; i++) + Assert.AreEqual(scene.Elements[i].CenterX, points[i].X, 0.5, + "a trend line over bars must follow the same band placement the bars use"); + } + + [TestMethod] + public void AHiddenDatasetShouldTakeItsTrendlineWithIt() + { + var data = Bars(1, 2, 3); + data.Datasets[0].Hidden = true; + var scene = Trend(new BitChartTrendline { DatasetIndex = 0 }, data: data); + + Assert.IsFalse(scene.Foreground.OfType().Any()); + } + + [TestMethod] + public void ATrendlineNamingADatasetThatIsNotThereShouldDrawNothing() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 7 }); + + Assert.IsFalse(scene.Foreground.OfType().Any()); + } + + [TestMethod] + public void ATrendlineLabelShouldBeDrawnInAPillInsideThePlot() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 0, Label = "trend" }); + var plot = scene.PlotArea!.Value; + + var pill = scene.Foreground.OfType().Last(); + Assert.IsTrue(pill.X >= plot.Left - 0.01 && pill.X + pill.Width <= plot.Right + 0.01); + Assert.IsTrue(pill.Y >= plot.Top - 0.01 && pill.Y + pill.Height <= plot.Bottom + 0.01); + CollectionAssert.Contains(scene.Foreground.OfType().Select(t => t.Text).ToList(), "trend"); + } + + [TestMethod] + public void ATrendlineShouldOnlyDrawOnCartesianCharts() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 0 }, BitChartType.Doughnut); + + Assert.IsFalse(scene.Foreground.OfType().Any(), + "there is no plot to fit a line across on a circular chart"); + } + + [TestMethod] + public void ATrendlineShouldBeAbleToDrawUnderTheDatasets() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 0, DrawBehindDatasets = true }); + + Assert.IsFalse(scene.Foreground.OfType().Any()); + Assert.IsTrue(scene.Background.OfType().Any()); + } + + [TestMethod] + public void ATrendlineOfOnePointShouldDrawNothingRatherThanThrow() + { + var data = new BitChartData { Labels = { "A" }, Datasets = { new BitChartDataset { Data = { 5 } } } }; + var scene = Trend(new BitChartTrendline { DatasetIndex = 0 }, data: data); + + Assert.IsFalse(scene.Foreground.OfType().Any()); + } + + [TestMethod] + public void ATrendlineOverAFlatSeriesShouldStillBeDrawn() + { + var scene = Trend(new BitChartTrendline { DatasetIndex = 0 }, data: Bars(4, 4, 4)); + var points = PathPoints(TrendPath(scene)); + + Assert.AreEqual(points[0].Y, points[^1].Y, 0.01); + } + + [TestMethod] + public void ATrendlineOverScatterPointsShouldUseTheirOwnXValues() + { + var data = new BitChartData + { + Datasets = + { + new BitChartDataset + { + Points = + [ + new BitChartDataPoint(0, 1), + new BitChartDataPoint(5, 3), + new BitChartDataPoint(10, 5) + ] + } + } + }; + var scene = Trend(new BitChartTrendline { DatasetIndex = 0 }, BitChartType.Scatter, data); + var points = PathPoints(TrendPath(scene)); + + // Perfectly collinear points: the fit has to pass through the first and last of them. + Assert.AreEqual(scene.Elements.First(e => e.DataIndex == 0).CenterY, points[0].Y, 0.5); + Assert.AreEqual(scene.Elements.First(e => e.DataIndex == 2).CenterY, points[^1].Y, 0.5); + } + + // ---- error bars ---- + + private static BitChartData WithErrors(BitChartType _, params BitChartErrorBar?[] errors) + { + var data = Bars(10, 20, 30); + data.Datasets[0].ErrorData = errors.ToList(); + return data; + } + + [TestMethod] + [DataRow(BitChartType.Bar)] + [DataRow(BitChartType.Line)] + public void AnErrorBarShouldSpanItsIntervalWithACapAtEachEnd(BitChartType type) + { + var scene = Render(new BitChartConfig(type, WithErrors(type, 5, 5, 5))); + + // One whisker plus two caps per point, on top of anything else the chart puts in the foreground. + var lines = scene.Foreground.OfType().ToList(); + Assert.AreEqual(9, lines.Count); + } + + [TestMethod] + public void AnErrorBarShouldReachTheValuesItWasGiven() + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Min = 0, Max = 40 } } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, WithErrors(BitChartType.Bar, null, 5, null), options)); + var plot = scene.PlotArea!.Value; + + // The whisker is the tall line; its ends must land where 15 and 25 do on the axis. + var whisker = scene.Foreground.OfType() + .OrderByDescending(l => Math.Abs(l.Y2 - l.Y1)).First(); + double Pixel(double v) => plot.Bottom - (v - 0) / 40 * plot.Height; + + Assert.AreEqual(Pixel(15), Math.Max(whisker.Y1, whisker.Y2), 0.5); + Assert.AreEqual(Pixel(25), Math.Min(whisker.Y1, whisker.Y2), 0.5); + } + + [TestMethod] + public void AnAsymmetricErrorBarShouldUseBothArms() + { + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Min = 0, Max = 40 } } + }; + var data = WithErrors(BitChartType.Bar, null, new BitChartErrorBar(2, 8), null); + var scene = Render(new BitChartConfig(BitChartType.Bar, data, options)); + var plot = scene.PlotArea!.Value; + + var whisker = scene.Foreground.OfType() + .OrderByDescending(l => Math.Abs(l.Y2 - l.Y1)).First(); + double Pixel(double v) => plot.Bottom - v / 40 * plot.Height; + + Assert.AreEqual(Pixel(18), Math.Max(whisker.Y1, whisker.Y2), 0.5); + Assert.AreEqual(Pixel(28), Math.Min(whisker.Y1, whisker.Y2), 0.5); + } + + [TestMethod] + public void ANullErrorEntryShouldLeaveThatPointWithoutAWhisker() + { + var scene = Render(new BitChartConfig(BitChartType.Bar, WithErrors(BitChartType.Bar, 3, null, 3))); + + Assert.AreEqual(6, scene.Foreground.OfType().Count()); + } + + [TestMethod] + public void AZeroCapWidthShouldDrawABareWhisker() + { + var data = WithErrors(BitChartType.Bar, 5, 5, 5); + data.Datasets[0].ErrorBarCapWidth = 0; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + Assert.AreEqual(3, scene.Foreground.OfType().Count()); + } + + [TestMethod] + public void HorizontalBarsShouldLayTheirErrorBarsAlongTheValueAxis() + { + var options = new BitChartOptions { IndexAxis = BitChartIndexAxis.Y }; + var scene = Render(new BitChartConfig(BitChartType.Bar, WithErrors(BitChartType.Bar, 5, 5, 5), options)); + + var whiskers = scene.Foreground.OfType() + .Where(l => Math.Abs(l.X2 - l.X1) > Math.Abs(l.Y2 - l.Y1)).ToList(); + Assert.AreEqual(3, whiskers.Count, "the whisker follows the value axis, which now runs across the plot"); + } + + [TestMethod] + public void AnErrorBarShouldBeNamedInTheTooltip() + { + var symmetric = Render(new BitChartConfig(BitChartType.Bar, WithErrors(BitChartType.Bar, 5, 5, 5))); + StringAssert.Contains(symmetric.Elements[0].Tooltip.Items[0].Text, "±5"); + + var data = WithErrors(BitChartType.Bar, new BitChartErrorBar(1, 3), null, null); + var asymmetric = Render(new BitChartConfig(BitChartType.Bar, data)); + StringAssert.Contains(asymmetric.Elements[0].Tooltip.Items[0].Text, "+3/-1"); + } + + [TestMethod] + public void ADatasetWithoutErrorDataShouldReadExactlyAsBefore() + { + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(10, 20, 30))); + + Assert.IsFalse(scene.Elements[0].Tooltip.Items[0].Text.Contains('±')); + Assert.AreEqual(0, scene.Foreground.OfType().Count()); + } + + [TestMethod] + public void ErrorBarsShouldFollowTheirOwnBarInAGroup() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 10, 10 }, ErrorData = [1, 1] }, + new BitChartDataset { Data = { 10, 10 }, ErrorData = [1, 1] } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Bar, data)); + + // Each whisker sits over the bar it belongs to, not over the shared category center. + var whiskerCenters = scene.Foreground.OfType() + .Where(l => Math.Abs(l.Y2 - l.Y1) > Math.Abs(l.X2 - l.X1)) + .Select(l => Math.Round(l.X1, 3)).OrderBy(x => x).ToList(); + var barCenters = scene.Elements.Select(e => Math.Round(e.CenterX, 3)).OrderBy(x => x).ToList(); + CollectionAssert.AreEqual(barCenters, whiskerCenters); + } + + // ---- ellipse and polygon annotations ---- + + private static BitChartScene Annotated(BitChartAnnotation annotation) + { + var options = new BitChartOptions(); + options.Plugins.Custom.Add(new BitChartAnnotationPlugin(annotation)); + return Render(new BitChartConfig(BitChartType.Line, Bars(1, 2, 3), options)); + } + + [TestMethod] + public void AnEllipseAnnotationShouldBeInscribedInItsBounds() + { + var scene = Annotated(new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Ellipse, + XMin = 0, XMax = 2, YMin = 1, YMax = 3, XIsIndex = true + }); + + var path = scene.Foreground.OfType().Single(); + StringAssert.Contains(path.D, "A ", "an ellipse is drawn from arcs"); + Assert.IsTrue(path.D.EndsWith("Z"), "and it closes"); + } + + [TestMethod] + public void AnEllipseWithNoExtentShouldDrawNothing() + { + var scene = Annotated(new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Ellipse, + XMin = 1, XMax = 1, YMin = 2, YMax = 2, XIsIndex = true + }); + + Assert.AreEqual(0, scene.Foreground.OfType().Count()); + } + + [TestMethod] + [DataRow(3)] + [DataRow(6)] + public void APolygonAnnotationShouldHaveTheSidesItWasAskedFor(int sides) + { + var scene = Annotated(new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Polygon, + Sides = sides, Radius = 15, XMin = 1, Value = 2, XIsIndex = true + }); + + var poly = scene.Foreground.OfType().Single(); + Assert.AreEqual(sides, poly.Points.Count); + Assert.IsTrue(poly.Closed); + } + + [TestMethod] + public void APolygonAnnotationShouldPointUpwardsByDefault() + { + var scene = Annotated(new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Polygon, + Sides = 3, Radius = 15, XMin = 1, Value = 2, XIsIndex = true + }); + + var poly = scene.Foreground.OfType().Single(); + double top = poly.Points.Min(p => p.Y); + Assert.AreEqual(1, poly.Points.Count(p => Math.Abs(p.Y - top) < 0.01), + "a triangle drawn from the top has exactly one vertex up there"); + } + + [TestMethod] + public void APolygonAnnotationShouldRotate() + { + BitChartSvgPolygon Poly(double rotation) => Annotated(new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Polygon, + Sides = 3, Radius = 15, XMin = 1, Value = 2, XIsIndex = true, Rotation = rotation + }).Foreground.OfType().Single(); + + Assert.AreNotEqual(Math.Round(Poly(0).Points[0].Y, 3), Math.Round(Poly(180).Points[0].Y, 3)); + } + + [TestMethod] + public void APointAnnotationShouldTakeAnExplicitRadius() + { + var scene = Annotated(new BitChartAnnotation + { + Kind = BitChartAnnotationKind.Point, XMin = 1, Value = 2, XIsIndex = true, Radius = 9 + }); + + Assert.AreEqual(9, scene.Foreground.OfType().Single().R, 1e-6); + } + + // ---- legend and tooltip sizing ---- + + [TestMethod] + public void TheLegendShouldCarryItsHeightCapThroughToTheScene() + { + var options = new BitChartOptions { Plugins = { Legend = { MaxHeight = 60 } } }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)); + + Assert.AreEqual(60, scene.Legend!.MaxHeight); + } + + // ---- pointer gestures map onto the axes the chart actually drew ---- + + [TestMethod] + public void AVerticalChartShouldReportItsAxesAsItDrewThem() + { + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3))); + + Assert.AreEqual((true, false), scene.AxisOrientations["x"], "the index axis runs across the plot"); + Assert.AreEqual((false, true), scene.AxisOrientations["y"], "the value axis runs up it, minimum at the bottom"); + } + + [TestMethod] + public void AHorizontalChartShouldReportTheSwappedAxes() + { + var options = new BitChartOptions { IndexAxis = BitChartIndexAxis.Y }; + var scene = Render(new BitChartConfig(BitChartType.Bar, Bars(1, 2, 3), options)); + + Assert.AreEqual((false, false), scene.AxisOrientations["x"], "the categories now run down the plot"); + Assert.AreEqual((true, false), scene.AxisOrientations["y"], "and the values run across it"); + } + + [TestMethod] + public void ASecondaryXAxisShouldBeReportedAsHorizontalToo() + { + var data = new BitChartData + { + Datasets = + { + new BitChartDataset { Points = [new(0, 1), new(1, 2)] }, + new BitChartDataset { Points = [new(0, 3), new(1, 4)], XAxisID = "x2" } + } + }; + var scene = Render(new BitChartConfig(BitChartType.Scatter, data)); + + Assert.AreEqual((true, false), scene.AxisOrientations["x2"]); + } + + [TestMethod] + public void ATimeAxisShouldPrintItsMonthsInTheChartsCulture() + { + var data = new BitChartData + { + Datasets = + { + new BitChartDataset + { + Points = + [ + new BitChartDataPoint(new DateTime(2026, 1, 15).ToOADate(), 1), + new BitChartDataPoint(new DateTime(2026, 6, 15).ToOADate(), 2) + ] + } + } + }; + BitChartOptions Options(CultureInfo? culture) => new() + { + Culture = culture, + Scales = { ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Time } } + }; + + var french = Render(new BitChartConfig(BitChartType.Line, data, Options(new CultureInfo("fr-FR")))); + var invariant = Render(new BitChartConfig(BitChartType.Line, data, Options(null))); + + static List Labels(BitChartScene scene) => + scene.Background.OfType().Select(t => t.Text).ToList(); + + // The chart already formats its numbers with the culture; its dates have to follow. The axis + // ticks on month boundaries, so March is the one both renders are certain to carry. + CollectionAssert.Contains(Labels(french), "mars 2026", + "a French chart must not print English month names, but drew: " + string.Join(" | ", Labels(french))); + CollectionAssert.Contains(Labels(invariant), "Mar 2026"); + } + + [TestMethod] + public void APercentageStackShouldNotDrawErrorBarsItCannotPlace() + { + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = + { + new BitChartDataset { Data = { 10, 20 }, ErrorData = [2, 2], Stack = "s" }, + new BitChartDataset { Data = { 30, 40 }, Stack = "s" } + } + }; + var options = new BitChartOptions + { + Scales = { ["y"] = new BitChartScaleOptions { Id = "y", Stacked = true, Stacked100 = true } } + }; + + var scene = Render(new BitChartConfig(BitChartType.Bar, data, options)); + + // The values are rescaled to percentages; an interval still in the original units would land + // somewhere meaningless, so it is left out rather than drawn wrong. + Assert.AreEqual(0, scene.Foreground.OfType().Count()); + } + + // ---- a numeric index axis reads value datasets too ---- + + [TestMethod] + public void ANumericIndexAxisShouldSpanAValueDatasetsIndexes() + { + var data = new BitChartData + { + Datasets = { new BitChartDataset { Data = { 5, 6, 7, 8, 9 } } } + }; + var options = new BitChartOptions + { + Scales = { ["x"] = new BitChartScaleOptions { Id = "x", Type = BitChartScaleType.Linear } } + }; + var scene = Render(new BitChartConfig(BitChartType.Line, data, options)); + + Assert.AreEqual(0, scene.AxisRanges["x"].Min, 1e-6); + Assert.AreEqual(4, scene.AxisRanges["x"].Max, 1e-6, "five values span indexes 0 to 4, not 0 to 1"); + } +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartTests.cs new file mode 100644 index 0000000000..a27e47c986 --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartTests.cs @@ -0,0 +1,1323 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Bunit; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Extras.Chart; + +/// Component-level tests: markup, accessibility, legend interaction and keyboard navigation. +[TestClass] +public class BitChartTests : BunitTestContext +{ + private static BitChartData TwoSeries() => new() + { + Labels = { "Jan", "Feb", "Mar" }, + Datasets = + { + new BitChartDataset { Label = "Alpha", Data = { 1, 2, 3 } }, + new BitChartDataset { Label = "Beta", Data = { 3, 2, 1 } } + } + }; + + private IRenderedComponent RenderChart(BitChartType type = BitChartType.Bar, BitChartData? data = null, + BitChartOptions? options = null) + => RenderComponent(p => + { + p.Add(c => c.Type, type); + p.Add(c => c.Data, data ?? TwoSeries()); + if (options is not null) p.Add(c => c.Options, options); + }); + + // ---- markup ---- + + [TestMethod] + public void BitChartShouldRenderTheRootAndAnSvg() + { + var component = RenderChart(); + + var root = component.Find(".bit-cht"); + Assert.IsNotNull(root); + Assert.IsNotNull(component.Find("svg.bit-cht-svg")); + } + + [TestMethod] + public void BitChartShouldAppendTheCustomClassAndStyle() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.Class, "my-chart"); + p.Add(c => c.Style, "opacity:0.5;"); + p.Add(c => c.Id, "chart-1"); + }); + + var root = component.Find(".bit-cht"); + Assert.IsTrue(root.ClassList.Contains("my-chart")); + Assert.AreEqual("chart-1", root.Id); + StringAssert.Contains(root.GetAttribute("style"), "opacity:0.5"); + } + + [TestMethod] + public void BitChartShouldSplatHtmlAttributes() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.HtmlAttributes, new Dictionary { ["data-test"] = "chart" }); + }); + + Assert.AreEqual("chart", component.Find(".bit-cht").GetAttribute("data-test")); + } + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void BitChartShouldRespectForceAnimation(bool forceAnimation) + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.ForceAnimation, forceAnimation); + }); + + Assert.AreEqual(forceAnimation, component.Find(".bit-cht").ClassList.Contains("bit-fam")); + } + + [TestMethod] + public void BitChartShouldRenderTheRequestedDirection() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.Dir, BitDir.Rtl); + }); + + Assert.AreEqual("rtl", component.Find(".bit-cht").GetAttribute("dir")); + } + + [TestMethod] + public void BitChartShouldNotEmitAPerInstanceStyleBlock() + { + var component = RenderChart(); + + Assert.AreEqual(0, component.FindAll("style").Count, + "the chart styles ship in the Extras stylesheet, not duplicated into every instance"); + } + + // ---- accessibility ---- + + [TestMethod] + public void BitChartShouldDescribeItselfWithTheGivenAriaLabel() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.AriaLabel, "Quarterly revenue"); + }); + + Assert.AreEqual("Quarterly revenue", component.Find("svg").GetAttribute("aria-label")); + } + + [TestMethod] + public void BitChartShouldFallBackToTheTitleForItsAccessibleName() + { + var options = new BitChartOptions { Plugins = { Title = { Display = true, Text = "Sales" } } }; + var component = RenderChart(options: options); + + Assert.AreEqual("Sales", component.Find("svg").GetAttribute("aria-label")); + } + + [TestMethod] + public void BitChartShouldRenderAScreenReaderTableAndPointAtIt() + { + var component = RenderChart(); + + var table = component.Find("table"); + var svg = component.Find("svg"); + // The description points at the how-to-navigate sentence and then the data itself. + var described = svg.GetAttribute("aria-describedby")!.Split(' '); + CollectionAssert.Contains(described, table.Id); + Assert.AreEqual(2, described.Length); + Assert.IsNotNull(component.Find($"#{described[0]}")); + Assert.AreEqual(3, table.QuerySelectorAll("thead th").Length - 1); + Assert.AreEqual(2, table.QuerySelectorAll("tbody tr").Length); + } + + [TestMethod] + public void BitChartShouldTellAScreenReaderHowToWalkTheData() + { + var component = RenderChart(); + + var hintId = component.Find("svg").GetAttribute("aria-describedby")!.Split(' ')[0]; + StringAssert.Contains(component.Find($"#{hintId}").TextContent, "arrow keys"); + Assert.AreEqual("chart", component.Find("svg").GetAttribute("aria-roledescription")); + } + + [TestMethod] + public void TheNavigationHintCanBeReplacedOrTurnedOff() + { + var custom = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.NavigationHint, "Arrow keys walk the bars."); + }); + StringAssert.Contains(custom.Find(".bit-cht").TextContent, "Arrow keys walk the bars."); + + var silent = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.NavigationHint, null); + }); + var table = silent.Find("table"); + Assert.AreEqual(table.Id, silent.Find("svg").GetAttribute("aria-describedby")); + } + + [TestMethod] + public void AnEmptyChartShouldNotPromiseKeyboardNavigation() + { + var component = RenderComponent(p => p.Add(c => c.Data, new BitChartData())); + + // Nothing to walk: the hint would be a lie, so only the (empty) table is described. + Assert.AreEqual(component.Find("table").Id, component.Find("svg").GetAttribute("aria-describedby")); + } + + [TestMethod] + public void BitChartShouldSkipTheDataTableWhenAsked() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.GenerateTable, false); + p.Add(c => c.NavigationHint, null); + }); + + Assert.AreEqual(0, component.FindAll("table").Count); + Assert.IsNull(component.Find("svg").GetAttribute("aria-describedby")); + } + + [TestMethod] + public void DroppingTheTableShouldStillLeaveTheNavigationHintDescribing() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.GenerateTable, false); + }); + + var described = component.Find("svg").GetAttribute("aria-describedby"); + Assert.IsNotNull(described); + Assert.IsFalse(described!.Contains(' '), "with no table there is only the hint to point at"); + StringAssert.Contains(component.Find($"#{described}").TextContent, "arrow keys"); + } + + [TestMethod] + public void BitChartTableShouldListPointsForScatterData() + { + var data = new BitChartData + { + Datasets = { new BitChartDataset { Label = "P", Points = [new(1, 2), new(3, 4)] } } + }; + var component = RenderChart(BitChartType.Scatter, data); + + var headers = component.FindAll("table thead th").Select(h => h.TextContent).ToList(); + CollectionAssert.AreEqual(new[] { "Series", "X", "Y" }, headers); + Assert.AreEqual(2, component.FindAll("table tbody tr").Count); + } + + // ---- legend ---- + + [TestMethod] + public void LegendItemsShouldBeFocusableButtonsWithAPressedState() + { + var component = RenderChart(); + + var items = component.FindAll(".bit-cht-lgd-itm"); + Assert.AreEqual(2, items.Count); + foreach (var item in items) + { + Assert.AreEqual("button", item.TagName.ToLowerInvariant(), + "legend entries are controls, so they have to be reachable by keyboard"); + Assert.AreEqual("true", item.GetAttribute("aria-pressed")); + Assert.IsNull(item.GetAttribute("role"), + "a role on the button would replace its native toggle-button semantics"); + } + Assert.AreEqual("group", component.Find(".bit-cht-lgd").GetAttribute("role")); + } + + [TestMethod] + public void ClickingALegendItemShouldHideItsDataset() + { + var component = RenderChart(); + + component.FindAll(".bit-cht-lgd-itm")[0].Click(); + + var item = component.FindAll(".bit-cht-lgd-itm")[0]; + Assert.AreEqual("false", item.GetAttribute("aria-pressed")); + Assert.IsTrue(item.ClassList.Contains("bit-cht-hdn")); + } + + [TestMethod] + public void ClickingALegendItemTwiceShouldBringTheDatasetBack() + { + var component = RenderChart(); + + component.FindAll(".bit-cht-lgd-itm")[0].Click(); + component.FindAll(".bit-cht-lgd-itm")[0].Click(); + + Assert.AreEqual("true", component.FindAll(".bit-cht-lgd-itm")[0].GetAttribute("aria-pressed")); + } + + [TestMethod] + public void LegendClickShouldRaiseTheCallback() + { + BitChartLegendItemModel? clicked = null; + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.OnLegendItemClick, (BitChartLegendItemModel i) => clicked = i); + }); + + component.FindAll(".bit-cht-lgd-itm")[1].Click(); + + Assert.IsNotNull(clicked); + Assert.AreEqual("Beta", clicked!.Text); + } + + [TestMethod] + public void LegendToggleCanBeTurnedOff() + { + var options = new BitChartOptions { Plugins = { Legend = { OnClickToggle = false } } }; + var component = RenderChart(options: options); + + component.FindAll(".bit-cht-lgd-itm")[0].Click(); + + Assert.AreEqual("true", component.FindAll(".bit-cht-lgd-itm")[0].GetAttribute("aria-pressed")); + } + + [TestMethod] + public void CircularLegendShouldToggleSlices() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 1, 2, 3 } } } + }; + var component = RenderChart(BitChartType.Pie, data); + + int before = component.FindAll(".bit-cht-data > g").Count; + component.FindAll(".bit-cht-lgd-itm")[0].Click(); + + Assert.AreEqual(before - 1, component.FindAll(".bit-cht-data > g").Count); + } + + // ---- interaction ---- + + [TestMethod] + public void HoveringAnElementShouldShowATooltip() + { + var component = RenderChart(); + + Assert.AreEqual(0, component.FindAll(".bit-cht-tt").Count); + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + + Assert.AreEqual(1, component.FindAll(".bit-cht-tt").Count); + component.FindAll(".bit-cht-data > g")[0].MouseLeave(); + Assert.AreEqual(0, component.FindAll(".bit-cht-tt").Count); + } + + [TestMethod] + public void HoveringAHitBandShouldShowEverySeriesAtThatIndex() + { + var component = RenderChart(BitChartType.Line); + + var bands = component.FindAll(".bit-cht-band"); + Assert.AreEqual(3, bands.Count); + bands[1].MouseEnter(); + + var rows = component.FindAll(".bit-cht-tt .bit-cht-tt-itm"); + Assert.AreEqual(2, rows.Count, "a band covers the whole index, so both series are listed"); + } + + [TestMethod] + public void TooltipCanBeDisabled() + { + var options = new BitChartOptions { Plugins = { Tooltip = { Enabled = false } } }; + var component = RenderChart(options: options); + + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + + Assert.AreEqual(0, component.FindAll(".bit-cht-tt").Count); + } + + [TestMethod] + public void ClickingAnElementShouldRaiseTheCallback() + { + (int ds, int di)? clicked = null; + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.Type, BitChartType.Bar); + p.Add(c => c.OnElementClick, (ValueTuple e) => clicked = e); + }); + + component.FindAll(".bit-cht-data > g")[0].Click(); + + Assert.IsNotNull(clicked); + Assert.AreEqual(0, clicked!.Value.ds); + Assert.AreEqual(0, clicked.Value.di); + } + + [TestMethod] + public void HoverShouldRaiseTheHoverCallbackWithAndWithoutAContext() + { + var seen = new List(); + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.OnElementHover, (BitChartTooltipContext? ctx) => seen.Add(ctx)); + }); + + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + component.FindAll(".bit-cht-data > g")[0].MouseLeave(); + + Assert.AreEqual(2, seen.Count); + Assert.IsNotNull(seen[0]); + Assert.IsNull(seen[1]); + } + + [TestMethod] + public void CustomTooltipTemplateShouldReplaceTheDefaultBody() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.TooltipTemplate, (BitChartTooltipContext ctx) => + (builder) => + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "my-tt"); + builder.AddContent(2, ctx.Points.Count.ToString(CultureInfo.InvariantCulture)); + builder.CloseElement(); + }); + }); + + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + + Assert.AreEqual(1, component.FindAll(".bit-cht-tt-custom .my-tt").Count); + } + + // ---- keyboard ---- + + [TestMethod] + public void ArrowKeysShouldWalkTheDataAndAnnounceIt() + { + var component = RenderChart(); + + component.Find("svg").KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "ArrowRight" }); + + var live = component.Find("[role=status]"); + StringAssert.Contains(live.TextContent, "Jan"); + // Counted within the series the arrow keys walk (3 points), not across the whole scene. + StringAssert.Contains(live.TextContent, "1 of 3"); + StringAssert.Contains(live.TextContent, "series 1 of 2"); + Assert.AreEqual(1, component.FindAll(".bit-cht-focus-ring").Count); + } + + [TestMethod] + public void EscapeShouldClearTheKeyboardSelection() + { + var component = RenderChart(); + var svg = component.Find("svg"); + + svg.KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "ArrowRight" }); + svg.KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Escape" }); + + Assert.AreEqual(0, component.FindAll(".bit-cht-focus-ring").Count); + Assert.AreEqual(string.Empty, component.Find("[role=status]").TextContent.Trim()); + } + + [TestMethod] + public void EndKeyShouldJumpToTheLastElement() + { + var component = RenderChart(); + + component.Find("svg").KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "End" }); + + StringAssert.Contains(component.Find("[role=status]").TextContent, "Mar"); + } + + [TestMethod] + public void EnterShouldActivateTheFocusedElement() + { + (int ds, int di)? clicked = null; + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.OnElementClick, (ValueTuple e) => clicked = e); + }); + var svg = component.Find("svg"); + + svg.KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Home" }); + svg.KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Enter" }); + + Assert.IsNotNull(clicked); + } + + // ---- empty state ---- + + [TestMethod] + public void AnEmptyChartShouldExplainItself() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, new BitChartData()); + p.Add(c => c.NoDataText, "Nothing here"); + }); + + Assert.AreEqual("Nothing here", component.Find(".bit-cht-nodata").TextContent.Trim()); + } + + [TestMethod] + public void AnEmptyChartShouldRenderTheCustomTemplate() + { + var component = RenderComponent(p => + { + p.Add(c => c.Data, new BitChartData()); + p.Add(c => c.NoDataTemplate, (builder) => + { + builder.OpenElement(0, "b"); + builder.AddAttribute(1, "class", "empty"); + builder.AddContent(2, "none"); + builder.CloseElement(); + }); + }); + + Assert.AreEqual("none", component.Find(".bit-cht-nodata .empty").TextContent); + } + + [TestMethod] + public void AChartWithDataShouldNotShowTheEmptyState() + { + Assert.AreEqual(0, RenderChart().FindAll(".bit-cht-nodata").Count); + } + + // ---- titles ---- + + [TestMethod] + public void TitleAndSubtitleShouldRenderAtTheRequestedPositions() + { + var options = new BitChartOptions + { + Plugins = + { + Title = { Display = true, Text = "Main" }, + Subtitle = { Display = true, Text = "Sub", Position = BitChartPosition.Bottom } + } + }; + var component = RenderChart(options: options); + + Assert.AreEqual("Main", component.Find(".bit-cht-ttl").TextContent.Trim()); + Assert.AreEqual("Sub", component.Find(".bit-cht-sub").TextContent.Trim()); + } + + [TestMethod] + public void AMultiLineTitleShouldBreakOnNewlines() + { + var options = new BitChartOptions { Plugins = { Title = { Display = true, Text = "One\nTwo" } } }; + var component = RenderChart(options: options); + + Assert.AreEqual(1, component.FindAll(".bit-cht-ttl br").Count); + } + + // ---- csv export ---- + + [TestMethod] + public void ToCsvShouldWriteOneRowPerSeries() + { + var csv = RenderChart().Instance.ToCsv().Replace("\r\n", "\n").TrimEnd('\n'); + + var lines = csv.Split('\n'); + Assert.AreEqual("Series,Jan,Feb,Mar", lines[0]); + Assert.AreEqual("Alpha,1,2,3", lines[1]); + Assert.AreEqual("Beta,3,2,1", lines[2]); + } + + [TestMethod] + public void ToCsvShouldQuoteFieldsThatContainSeparators() + { + var data = new BitChartData + { + Labels = { "a,b" }, + Datasets = { new BitChartDataset { Label = "say \"hi\"", Data = { 1 } } } + }; + var csv = RenderChart(BitChartType.Bar, data).Instance.ToCsv(); + + StringAssert.Contains(csv, "\"a,b\""); + StringAssert.Contains(csv, "\"say \"\"hi\"\"\""); + } + + [TestMethod] + public void ToCsvShouldWriteOneRowPerPointForScatterData() + { + var data = new BitChartData + { + Datasets = { new BitChartDataset { Label = "P", Points = [new(1, 2), new(3, 4, 5)] } } + }; + var csv = RenderChart(BitChartType.Bubble, data).Replace_ToCsv(); + + StringAssert.StartsWith(csv, "Series,X,Y,R"); + StringAssert.Contains(csv, "P,3,4,5"); + } + + [TestMethod] + public void ToCsvShouldFollowTheConfiguredCulture() + { + var options = new BitChartOptions { Culture = new CultureInfo("de-DE") }; + var data = new BitChartData + { + Labels = { "A" }, + Datasets = { new BitChartDataset { Label = "S", Data = { 1.5 } } } + }; + var csv = RenderChart(BitChartType.Bar, data, options).Instance.ToCsv(); + + StringAssert.Contains(csv, "\"1,5\""); + } + + // ---- zoom api ---- + + [TestMethod] + public async Task ZoomToShouldNarrowTheVisibleRangeAndResetShouldRestoreIt() + { + var options = new BitChartOptions { Zoom = { Enabled = true } }; + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 0, 50, 100 } } } + }; + var component = RenderChart(BitChartType.Line, data, options); + var chart = component.Instance; + + var full = chart.GetAxisRange("y")!.Value; + await component.InvokeAsync(() => chart.ZoomTo("y", 20, 40)); + var zoomed = chart.GetAxisRange("y")!.Value; + Assert.AreEqual(20, zoomed.Min, 1e-6); + Assert.AreEqual(40, zoomed.Max, 1e-6); + + await component.InvokeAsync(chart.ResetZoom); + Assert.AreEqual(full.Max, chart.GetAxisRange("y")!.Value.Max, 1e-6); + } + + [TestMethod] + public void ZoomShouldStayInsideTheDataRange() + { + var options = new BitChartOptions { Zoom = { Enabled = true, LimitToData = true } }; + var data = new BitChartData + { + Labels = { "A", "B" }, + Datasets = { new BitChartDataset { Data = { 0, 100 } } } + }; + var component = RenderChart(BitChartType.Line, data, options); + var chart = component.Instance; + var full = chart.GetAxisRange("y")!.Value; + + component.InvokeAsync(() => chart.ZoomTo("y", -500, 5000)); + var clamped = chart.GetAxisRange("y")!.Value; + + Assert.IsTrue(clamped.Min >= full.Min - 1e-6, $"{clamped.Min} < {full.Min}"); + Assert.IsTrue(clamped.Max <= full.Max + 1e-6, $"{clamped.Max} > {full.Max}"); + } + + [TestMethod] + public void WheelZoomInModeXShouldMoveTheAxisThatRunsAcrossThePlot() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 0, 50, 100 } } } + }; + var options = new BitChartOptions + { + IndexAxis = BitChartIndexAxis.Y, + Zoom = { Enabled = true, Mode = BitChartZoomMode.X } + }; + var component = RenderChart(BitChartType.Bar, data, options); + var chart = component.Instance; + + var before = chart.GetAxisRange("y")!.Value; + component.InvokeAsync(() => chart.OnWheelZoom(0.5, 0.5, -100)); + + // Horizontal bars put the values across the plot, so an "X" gesture is about the value axis. + var after = chart.GetAxisRange("y")!.Value; + Assert.IsTrue(after.Max - after.Min < before.Max - before.Min, + "the mode names a direction on screen, not the axis called x"); + } + + [TestMethod] + public void WheelZoomInModeXShouldLeaveTheAxisRunningDownThePlotAlone() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 0, 50, 100 } } } + }; + var options = new BitChartOptions + { + IndexAxis = BitChartIndexAxis.Y, + Zoom = { Enabled = true, Mode = BitChartZoomMode.X } + }; + var component = RenderChart(BitChartType.Bar, data, options); + var chart = component.Instance; + + var before = chart.GetAxisRange("x")!.Value; + component.InvokeAsync(() => chart.OnWheelZoom(0.5, 0.5, -100)); + + Assert.AreEqual(before, chart.GetAxisRange("x")!.Value); + } + + [TestMethod] + public void WheelZoomShouldCenterOnThePointerOnAVerticalChart() + { + var data = new BitChartData + { + Datasets = { new BitChartDataset { Points = [new(0, 0), new(100, 100)] } } + }; + var options = new BitChartOptions { Zoom = { Enabled = true, Mode = BitChartZoomMode.X } }; + var component = RenderChart(BitChartType.Scatter, data, options); + var chart = component.Instance; + + // Zooming at the left edge keeps the low end and pulls the high end in. + var before = chart.GetAxisRange("x")!.Value; + component.InvokeAsync(() => chart.OnWheelZoom(0.02, 0.5, -100)); + var after = chart.GetAxisRange("x")!.Value; + + Assert.IsTrue(after.Max < before.Max); + Assert.IsTrue(after.Min - before.Min < before.Max - after.Max, + "the edge nearest the pointer barely moves"); + } + + [TestMethod] + public async Task ZoomChangeShouldRaiseTheCallback() + { + int raised = 0; + var options = new BitChartOptions { Zoom = { Enabled = true } }; + var component = RenderComponent(p => + { + p.Add(c => c.Type, BitChartType.Line); + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.Options, options); + p.Add(c => c.OnZoomChange, () => raised++); + }); + + await component.InvokeAsync(() => component.Instance.ZoomTo("y", 1, 2)); + + Assert.AreEqual(1, raised); + } + + // ---- data table size ---- + + [TestMethod] + public void TheScreenReaderTableShouldNotRenderTensOfThousandsOfHiddenRows() + { + var points = Enumerable.Range(0, 3000).Select(i => new BitChartDataPoint(i, i)).ToList(); + var data = new BitChartData { Datasets = { new BitChartDataset { Label = "P", Points = points } } }; + + var component = RenderChart(BitChartType.Line, data); + + Assert.AreEqual(500, component.FindAll("table tbody tr").Count); + StringAssert.Contains(component.Find("table caption").TextContent, "3,000"); + } + + [TestMethod] + public void TheTableShouldRenderEveryRowWhenItFitsTheLimit() + { + var points = Enumerable.Range(0, 10).Select(i => new BitChartDataPoint(i, i)).ToList(); + var data = new BitChartData { Datasets = { new BitChartDataset { Label = "P", Points = points } } }; + + var component = RenderChart(BitChartType.Line, data); + + Assert.AreEqual(10, component.FindAll("table tbody tr").Count); + Assert.IsFalse(component.Find("table caption").TextContent.Contains("Showing the first")); + } + + [TestMethod] + public void TheTableLimitShouldBeConfigurable() + { + var points = Enumerable.Range(0, 40).Select(i => new BitChartDataPoint(i, i)).ToList(); + var data = new BitChartData { Datasets = { new BitChartDataset { Label = "P", Points = points } } }; + + var component = RenderComponent(p => + { + p.Add(c => c.Type, BitChartType.Line); + p.Add(c => c.Data, data); + p.Add(c => c.MaxTableRows, 5); + }); + + Assert.AreEqual(5, component.FindAll("table tbody tr").Count); + } + + // ---- title placement ---- + + [TestMethod] + public void ASideTitleShouldRenderBesideThePlot() + { + var options = new BitChartOptions + { + Plugins = { Title = { Display = true, Text = "Down the side", Position = BitChartPosition.Left } } + }; + var component = RenderChart(options: options); + + var title = component.Find(".bit-cht-mid > .bit-cht-ttl"); + Assert.IsTrue(title.ClassList.Contains("bit-cht-ttl-v")); + StringAssert.Contains(title.TextContent, "Down the side"); + } + + [TestMethod] + public void ATitleWithNowhereToGoShouldRenderAtTheTop() + { + var options = new BitChartOptions + { + Plugins = { Title = { Display = true, Text = "Fallback", Position = BitChartPosition.Chart } } + }; + var component = RenderChart(options: options); + + Assert.AreEqual(0, component.FindAll(".bit-cht-mid > .bit-cht-ttl").Count); + StringAssert.Contains(component.Find(".bit-cht > .bit-cht-ttl").TextContent, "Fallback"); + } + + // ---- crosshair ---- + + [TestMethod] + public void HoveringAHitBandShouldNameTheCategoryOnTheAxis() + { + var component = RenderChart(BitChartType.Line); + + component.FindAll(".bit-cht-band")[1].MouseEnter(); + + var chip = component.FindAll(".bit-cht-hover text").Select(t => t.TextContent).ToList(); + CollectionAssert.Contains(chip, "Feb", string.Join("|", chip)); + } + + [TestMethod] + public void TheCrosshairCanBeTurnedOff() + { + var options = new BitChartOptions { Interaction = { Crosshair = false } }; + var component = RenderChart(BitChartType.Line, options: options); + + component.FindAll(".bit-cht-band")[1].MouseEnter(); + + Assert.AreEqual(0, component.FindAll(".bit-cht-hover line").Count); + Assert.AreEqual(0, component.FindAll(".bit-cht-hover text").Count); + } + + [TestMethod] + public void TheCrosshairLabelCanBeTurnedOffOnItsOwn() + { + var options = new BitChartOptions { Interaction = { CrosshairLabel = false } }; + var component = RenderChart(BitChartType.Line, options: options); + + component.FindAll(".bit-cht-band")[1].MouseEnter(); + + Assert.AreEqual(1, component.FindAll(".bit-cht-hover line").Count); + Assert.AreEqual(0, component.FindAll(".bit-cht-hover text").Count); + } + + [TestMethod] + public void AnEmptyChartShouldNotBeATabStop() + { + var component = RenderComponent(p => p.Add(c => c.Data, new BitChartData())); + + Assert.AreEqual("-1", component.Find("svg").GetAttribute("tabindex")); + } + + // ---- keyboard navigation across series ---- + + [TestMethod] + public void LeftAndRightShouldWalkOneSeriesRatherThanTheWholeScene() + { + var component = RenderChart(); + var svg = component.Find("svg"); + + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); // Alpha / Jan + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); // Alpha / Feb + + var live = component.Find("[role=status]").TextContent; + StringAssert.Contains(live, "Feb"); + StringAssert.Contains(live, "Alpha"); + StringAssert.Contains(live, "series 1 of 2"); + } + + [TestMethod] + public void RightAtTheEndOfASeriesShouldWrapWithinIt() + { + var component = RenderChart(); + var svg = component.Find("svg"); + + for (int i = 0; i < 4; i++) svg.KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); + + // Three points: the fourth press comes back to the first, still inside the same series. + var live = component.Find("[role=status]").TextContent; + StringAssert.Contains(live, "Jan"); + StringAssert.Contains(live, "series 1 of 2"); + } + + [TestMethod] + public void DownShouldStepToTheOtherSeriesAtTheSameCategory() + { + var component = RenderChart(); + var svg = component.Find("svg"); + + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); // Alpha / Jan + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); // Alpha / Feb + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); // Beta / Feb + + var live = component.Find("[role=status]").TextContent; + StringAssert.Contains(live, "Feb", "stepping between series must stay on the same category"); + StringAssert.Contains(live, "Beta"); + StringAssert.Contains(live, "series 2 of 2"); + } + + [TestMethod] + public void UpFromTheFirstSeriesShouldWrapToTheLastOne() + { + var component = RenderChart(); + var svg = component.Find("svg"); + + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowUp" }); + + StringAssert.Contains(component.Find("[role=status]").TextContent, "series 2 of 2"); + } + + [TestMethod] + public void VerticalKeysShouldStillWalkAChartOfOneSeries() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Label = "Only", Data = { 1, 2, 3 } } } + }; + var component = RenderChart(BitChartType.Pie, data); + var svg = component.Find("svg"); + + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + svg.KeyDown(new KeyboardEventArgs { Key = "ArrowDown" }); + + var live = component.Find("[role=status]").TextContent; + StringAssert.Contains(live, "2 of 3", "with nothing to switch to, down has to keep walking the data"); + Assert.IsFalse(live.Contains("series"), "a single series is not worth announcing"); + } + + // ---- the interaction survives a re-render ---- + + [TestMethod] + public void AnOpenTooltipShouldSurviveARenderTheReaderDidNotAskFor() + { + var data = TwoSeries(); + var component = RenderChart(BitChartType.Bar, data); + + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + Assert.AreEqual(1, component.FindAll(".bit-cht-tt").Count); + + // A parent re-render (same data) must not blink the tooltip out from under the pointer. + component.Render(p => p.Add(c => c.Class, "re-rendered")); + + Assert.AreEqual(1, component.FindAll(".bit-cht-tt").Count); + } + + [TestMethod] + public void TheKeyboardPositionShouldSurviveARerender() + { + var component = RenderChart(); + component.Find("svg").KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); + var before = component.Find("[role=status]").TextContent; + + component.Render(p => p.Add(c => c.Class, "re-rendered")); + + Assert.AreEqual(1, component.FindAll(".bit-cht-focus-ring").Count); + Assert.AreEqual(before, component.Find("[role=status]").TextContent); + } + + [TestMethod] + public void HidingTheHoveredDatasetShouldDropTheTooltipRatherThanKeepAStaleOne() + { + var component = RenderChart(); + + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + Assert.AreEqual(1, component.FindAll(".bit-cht-tt").Count); + + component.InvokeAsync(() => component.Instance.ToggleDataset(0)); + + Assert.AreEqual(0, component.FindAll(".bit-cht-tt").Count); + } + + [TestMethod] + public void LosingTheHoveredDataShouldReportThatNothingIsActive() + { + var contexts = new List(); + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.OnElementHover, (BitChartTooltipContext? ctx) => contexts.Add(ctx)); + }); + + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + Assert.IsNotNull(contexts[^1]); + + component.InvokeAsync(() => component.Instance.ToggleDataset(0)); + + // Anyone driving a linked view off the callback has to be told the reading is gone. + Assert.IsNull(contexts[^1], "hiding the hovered data must report that nothing is active any more"); + } + + // ---- touch ---- + + [TestMethod] + public void TappingAnElementShouldShowItsTooltipOnATouchScreen() + { + var component = RenderChart(); + + component.FindAll(".bit-cht-data > g")[0] + .TriggerEvent("onpointerdown", new PointerEventArgs { PointerType = "touch" }); + + Assert.AreEqual(1, component.FindAll(".bit-cht-tt").Count); + } + + [TestMethod] + public void TappingTheEmptyPlotShouldShowTheCategoryUnderTheFinger() + { + var component = RenderChart(BitChartType.Line); + + component.FindAll(".bit-cht-band")[1] + .TriggerEvent("onpointerdown", new PointerEventArgs { PointerType = "touch" }); + + var tooltip = component.Find(".bit-cht-tt"); + StringAssert.Contains(tooltip.TextContent, "Feb"); + } + + [TestMethod] + public void AMousePressShouldNotRebuildAHoverItAlreadyHas() + { + int hovers = 0; + var component = RenderComponent(p => + { + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.OnElementHover, (BitChartTooltipContext? _) => hovers++); + }); + + component.FindAll(".bit-cht-data > g")[0].MouseEnter(); + // Re-found after the hover render, so the handler id is the current one. + component.FindAll(".bit-cht-data > g")[0] + .TriggerEvent("onpointerdown", new PointerEventArgs { PointerType = "mouse" }); + + Assert.AreEqual(1, hovers, "a mouse has already hovered by the time it presses"); + } + + // ---- imperative API ---- + + [TestMethod] + public void RefreshShouldRedrawFromDataMutatedInPlace() + { + var data = TwoSeries(); + var component = RenderChart(BitChartType.Bar, data); + Assert.AreEqual(6, component.FindAll(".bit-cht-data > g").Count); + + data.Labels.Add("Apr"); + data.Datasets[0].Data.Add(4); + data.Datasets[1].Data.Add(0); + component.InvokeAsync(component.Instance.Refresh); + + Assert.AreEqual(8, component.FindAll(".bit-cht-data > g").Count, + "Refresh has to rebuild the scene from the data as it now stands"); + } + + [TestMethod] + public void TheVisibilityApiShouldDriveTheSameStateAsTheLegend() + { + var component = RenderChart(); + var chart = component.Instance; + + Assert.IsTrue(chart.IsDatasetVisible(0)); + component.InvokeAsync(() => chart.SetDatasetVisible(0, false)); + + Assert.IsFalse(chart.IsDatasetVisible(0)); + Assert.AreEqual("false", component.FindAll(".bit-cht-lgd-itm")[0].GetAttribute("aria-pressed")); + Assert.AreEqual(3, component.FindAll(".bit-cht-data > g").Count, "only the second series is left"); + + component.InvokeAsync(() => chart.ToggleDataset(0)); + Assert.IsTrue(chart.IsDatasetVisible(0)); + Assert.AreEqual(6, component.FindAll(".bit-cht-data > g").Count); + } + + [TestMethod] + public void ADatasetMarkedHiddenShouldStayHiddenThroughTheApi() + { + var data = TwoSeries(); + data.Datasets[0].Hidden = true; + var component = RenderChart(BitChartType.Bar, data); + + Assert.IsFalse(component.Instance.IsDatasetVisible(0)); + component.InvokeAsync(() => component.Instance.SetDatasetVisible(0, true)); + + Assert.IsFalse(component.Instance.IsDatasetVisible(0), + "Hidden is the data's own answer; the visibility API only drives the chart's state"); + } + + [TestMethod] + public void TheDataIndexApiShouldToggleOneSlice() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 1, 2, 3 } } } + }; + var component = RenderChart(BitChartType.Doughnut, data); + var chart = component.Instance; + + Assert.IsTrue(chart.IsDataIndexVisible(1)); + component.InvokeAsync(() => chart.ToggleDataIndex(1)); + + Assert.IsFalse(chart.IsDataIndexVisible(1)); + Assert.AreEqual(2, component.FindAll(".bit-cht-data > g").Count); + } + + [TestMethod] + public void ResetVisibilityShouldBringEverythingBack() + { + var component = RenderChart(); + var chart = component.Instance; + + component.InvokeAsync(() => chart.SetDatasetVisible(0, false)); + component.InvokeAsync(() => chart.SetDatasetVisible(1, false)); + Assert.AreEqual(0, component.FindAll(".bit-cht-data > g").Count); + + component.InvokeAsync(chart.ResetVisibility); + + Assert.AreEqual(6, component.FindAll(".bit-cht-data > g").Count); + } + + [TestMethod] + public void TheVisibilityApiShouldIgnoreIndexesThatAreNotThere() + { + var component = RenderChart(); + + component.InvokeAsync(() => component.Instance.SetDatasetVisible(9, false)); + + Assert.IsFalse(component.Instance.IsDatasetVisible(9)); + Assert.AreEqual(6, component.FindAll(".bit-cht-data > g").Count); + } + + // ---- the screen-reader table stays a table, not a wall of cells ---- + + [TestMethod] + public void TheTableShouldNotRenderThousandsOfColumnsForOneLongSeries() + { + var data = new BitChartData(); + for (int i = 0; i < 3000; i++) data.Labels.Add($"L{i}"); + data.Datasets.Add(new BitChartDataset { Label = "S", Data = Enumerable.Range(0, 3000).Select(i => (double?)i).ToList() }); + + var component = RenderChart(BitChartType.Line, data); + + Assert.AreEqual(100, component.FindAll("table thead th").Count - 1); + Assert.AreEqual(100, component.FindAll("table tbody td").Count); + StringAssert.Contains(component.Find("table caption").TextContent, "3,000"); + StringAssert.Contains(component.Find("table caption").TextContent, "columns"); + } + + [TestMethod] + public void TheColumnLimitShouldBeConfigurable() + { + var data = new BitChartData(); + for (int i = 0; i < 20; i++) data.Labels.Add($"L{i}"); + data.Datasets.Add(new BitChartDataset { Label = "S", Data = Enumerable.Range(0, 20).Select(i => (double?)i).ToList() }); + + var component = RenderComponent(p => + { + p.Add(c => c.Data, data); + p.Add(c => c.MaxTableColumns, 5); + }); + + Assert.AreEqual(5, component.FindAll("table tbody td").Count); + } + + [TestMethod] + public void ATableThatFitsShouldNotClaimToBeTruncated() + { + var component = RenderChart(); + + Assert.AreEqual(3, component.FindAll("table tbody tr td").Count / 2); + Assert.IsFalse(component.Find("table caption").TextContent.Contains("columns")); + } + + [TestMethod] + public void TheTableShouldNameTheErrorIntervalBesideItsValue() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = + { + new BitChartDataset { Label = "S", Data = { 10, 20, 30 }, ErrorData = [2, new BitChartErrorBar(1, 4), null] } + } + }; + var component = RenderChart(BitChartType.Bar, data); + + var cells = component.FindAll("table tbody td").Select(c => c.TextContent).ToList(); + Assert.AreEqual("10 ±2", cells[0]); + Assert.AreEqual("20 +4/-1", cells[1]); + Assert.AreEqual("30", cells[2], "a value without an interval reads exactly as it always did"); + } + + // ---- clicking the plate between the elements ---- + + [TestMethod] + public void ClickingTheEmptyPlotShouldReportTheIndexUnderThePointer() + { + (int ds, int di)? clicked = null; + var component = RenderComponent(p => + { + p.Add(c => c.Type, BitChartType.Line); + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.OnElementClick, (ValueTuple e) => clicked = e); + }); + + component.FindAll(".bit-cht-band")[1].Click(); + + Assert.IsNotNull(clicked); + Assert.AreEqual(1, clicked!.Value.di, "the click reports the category it landed in"); + } + + [TestMethod] + public void TheEmptyPlotShouldOnlyLookClickableWhenItIs() + { + var plain = RenderChart(BitChartType.Line); + StringAssert.Contains(plain.Find(".bit-cht-band").GetAttribute("style"), "cursor:default"); + + var clickable = RenderComponent(p => + { + p.Add(c => c.Type, BitChartType.Line); + p.Add(c => c.Data, TwoSeries()); + p.Add(c => c.OnElementClick, (ValueTuple _) => { }); + }); + StringAssert.Contains(clickable.Find(".bit-cht-band").GetAttribute("style"), "cursor:pointer"); + } + + // ---- the focus ring traces the element ---- + + [TestMethod] + public void TheFocusRingShouldOutlineARoundedBarRatherThanACircleInIt() + { + var data = TwoSeries(); + data.Datasets[0].BorderRadius = 6; + var component = RenderChart(BitChartType.Bar, data); + + component.Find("svg").KeyDown(new KeyboardEventArgs { Key = "Home" }); + + var ring = component.Find(".bit-cht-focus-ring"); + Assert.AreEqual("path", ring.TagName.ToLowerInvariant(), + "a rounded bar is a path, so its ring has to be one too"); + Assert.AreEqual("none", ring.GetAttribute("fill")); + } + + [TestMethod] + public void TheFocusRingShouldOutlineAnArc() + { + var data = new BitChartData + { + Labels = { "A", "B", "C" }, + Datasets = { new BitChartDataset { Data = { 1, 2, 3 } } } + }; + var component = RenderChart(BitChartType.Doughnut, data); + + component.Find("svg").KeyDown(new KeyboardEventArgs { Key = "Home" }); + + Assert.AreEqual("path", component.Find(".bit-cht-focus-ring").TagName.ToLowerInvariant()); + } + + [TestMethod] + public void TheFocusRingShouldStillBoxAPlainBar() + { + var component = RenderChart(); + + component.Find("svg").KeyDown(new KeyboardEventArgs { Key = "Home" }); + + Assert.AreEqual("rect", component.Find(".bit-cht-focus-ring").TagName.ToLowerInvariant()); + } + + // ---- pinch zoom ---- + + [TestMethod] + public void PinchingApartShouldZoomInAroundTheFingers() + { + var data = new BitChartData + { + Datasets = { new BitChartDataset { Points = [new(0, 0), new(100, 100)] } } + }; + var options = new BitChartOptions { Zoom = { Enabled = true, Mode = BitChartZoomMode.X } }; + var component = RenderChart(BitChartType.Scatter, data, options); + var chart = component.Instance; + + var before = chart.GetAxisRange("x")!.Value; + component.InvokeAsync(() => chart.OnPinchZoom(0.5, 0.5, 2)); + var after = chart.GetAxisRange("x")!.Value; + + Assert.IsTrue(after.Max - after.Min < before.Max - before.Min, "spreading the fingers zooms in"); + } + + [TestMethod] + public void PinchingTogetherShouldZoomBackOut() + { + var data = new BitChartData + { + Datasets = { new BitChartDataset { Points = [new(0, 0), new(100, 100)] } } + }; + var options = new BitChartOptions { Zoom = { Enabled = true, Mode = BitChartZoomMode.X } }; + var component = RenderChart(BitChartType.Scatter, data, options); + var chart = component.Instance; + + component.InvokeAsync(() => chart.OnPinchZoom(0.5, 0.5, 4)); + var zoomed = chart.GetAxisRange("x")!.Value; + component.InvokeAsync(() => chart.OnPinchZoom(0.5, 0.5, 0.5)); + var after = chart.GetAxisRange("x")!.Value; + + Assert.IsTrue(after.Max - after.Min > zoomed.Max - zoomed.Min); + } + + [TestMethod] + [DataRow(0d)] + [DataRow(-1d)] + [DataRow(double.NaN)] + public void AMeaninglessPinchScaleShouldBeIgnored(double scale) + { + var options = new BitChartOptions { Zoom = { Enabled = true } }; + var component = RenderChart(BitChartType.Line, options: options); + var chart = component.Instance; + + var before = chart.GetAxisRange("y")!.Value; + component.InvokeAsync(() => chart.OnPinchZoom(0.5, 0.5, scale)); + + Assert.AreEqual(before, chart.GetAxisRange("y")!.Value); + } + + // ---- sparkline ---- + + [TestMethod] + public void ASparklineShouldStillCarryItsScreenReaderTable() + { + var component = RenderChart(BitChartType.Line, options: new BitChartOptions { Sparkline = true }); + + Assert.AreEqual(0, component.FindAll(".bit-cht-lgd").Count, "a sparkline has no legend"); + Assert.IsNotNull(component.Find("table"), "dropping the chrome must not drop the accessible data"); + Assert.AreEqual("0", component.Find("svg").GetAttribute("tabindex"), "it stays keyboard reachable"); + } +} + +internal static class BitChartTestExtensions +{ + /// Reads the CSV off a rendered chart (kept short so the assertions stay readable). + public static string Replace_ToCsv(this IRenderedComponent component) + => component.Instance.ToCsv().Replace("\r\n", "\n"); +} diff --git a/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartUtilsTests.cs b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartUtilsTests.cs new file mode 100644 index 0000000000..34089debdd --- /dev/null +++ b/src/BlazorUI/Tests/Bit.BlazorUI.Tests/Components/Extras/Chart/BitChartUtilsTests.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Bit.BlazorUI.Tests.Components.Extras.Chart; + +/// Unit tests for the pure helpers the renderer is built on. +[TestClass] +public class BitChartUtilsTests +{ + // ---- colors ---- + + [TestMethod] + [DataRow("#369", 51, 102, 153)] + [DataRow("#336699", 51, 102, 153)] + [DataRow("rgb(51, 102, 153)", 51, 102, 153)] + [DataRow("rgba(51,102,153,0.5)", 51, 102, 153)] + public void ColorUtilShouldParseTheCommonNotations(string color, int r, int g, int b) + { + Assert.IsTrue(BitChartColorUtil.TryParse(color, out var pr, out var pg, out var pb, out _)); + Assert.AreEqual(r, pr); + Assert.AreEqual(g, pg); + Assert.AreEqual(b, pb); + } + + [TestMethod] + public void ColorUtilShouldReadTheAlphaOfAnEightDigitHex() + { + Assert.IsTrue(BitChartColorUtil.TryParse("#33669980", out _, out _, out _, out var a)); + Assert.AreEqual(0.5, a, 0.01); + } + + [TestMethod] + [DataRow("")] + [DataRow("nonsense")] + [DataRow("#12345")] + [DataRow("rgb(1,2)")] + public void ColorUtilShouldRejectWhatItCannotParse(string color) + { + Assert.IsFalse(BitChartColorUtil.TryParse(color, out _, out _, out _, out _)); + } + + [TestMethod] + public void WithAlphaShouldProduceAnRgbaColor() + { + Assert.AreEqual("rgba(51,102,153,0.4)", BitChartColorUtil.WithAlpha("#336699", 0.4)); + } + + [TestMethod] + public void WithAlphaShouldLeaveUnparseableColorsAlone() + { + // CSS variables are handed straight to the browser, so they must survive untouched. + const string token = "var(--bit-clr-pri, #0078d4)"; + Assert.AreEqual(token, BitChartColorUtil.WithAlpha(token, 0.4)); + } + + [TestMethod] + public void AdjustShouldLightenAndDarken() + { + Assert.IsTrue(BitChartColorUtil.TryParse(BitChartColorUtil.Adjust("#808080", 0.5), out var lr, out _, out _, out _)); + Assert.IsTrue(BitChartColorUtil.TryParse(BitChartColorUtil.Adjust("#808080", -0.5), out var dr, out _, out _, out _)); + Assert.IsTrue(lr > 128); + Assert.IsTrue(dr < 128); + } + + [TestMethod] + public void PaletteShouldCycleAndHandleNegativeIndexes() + { + int n = BitChartColorUtil.DefaultPalette.Length; + Assert.AreEqual(BitChartColorUtil.Palette(0), BitChartColorUtil.Palette(n)); + Assert.AreEqual(BitChartColorUtil.Palette(n - 1), BitChartColorUtil.Palette(-1)); + } + + // ---- text measurement ---- + + [TestMethod] + public void TextMeasureShouldScaleWithLengthAndFontSize() + { + double small = BitChartTextMeasure.Width("Hello", 10); + double big = BitChartTextMeasure.Width("Hello", 20); + Assert.AreEqual(small * 2, big, 0.001); + Assert.IsTrue(BitChartTextMeasure.Width("Hello world", 12) > BitChartTextMeasure.Width("Hello", 12)); + } + + [TestMethod] + public void TextMeasureShouldKnowNarrowGlyphsFromWideOnes() + { + Assert.IsTrue(BitChartTextMeasure.Width("iiii", 12) < BitChartTextMeasure.Width("WWWW", 12)); + } + + [TestMethod] + public void TextMeasureShouldMakeBoldTextWider() + { + Assert.IsTrue(BitChartTextMeasure.Width("Total", 12, "bold") > BitChartTextMeasure.Width("Total", 12)); + } + + [TestMethod] + public void TextMeasureShouldReturnZeroForNothing() + { + Assert.AreEqual(0, BitChartTextMeasure.Width(null, 12)); + Assert.AreEqual(0, BitChartTextMeasure.Width("", 12)); + } + + [TestMethod] + [DataRow("日本語", "Japanese")] + [DataRow("中文标签", "Chinese")] + [DataRow("한국어", "Korean")] + public void TextMeasureShouldTreatFullWidthScriptsAsFullEms(string text, string script) + { + // A CJK glyph fills an em; measuring it at the Latin average would reserve far too little + // room and the axis labels would then overlap. + Assert.AreEqual(text.Length * 12, BitChartTextMeasure.Width(text, 12), 0.001, script); + } + + [TestMethod] + public void AFullWidthLabelShouldMeasureWiderThanTheSameCountOfLatinLetters() + { + Assert.IsTrue(BitChartTextMeasure.Width("東京都", 12) > BitChartTextMeasure.Width("abc", 12)); + } + + [TestMethod] + public void TextMeasureShouldIgnoreCombiningMarks() + { + // "e" plus a combining acute is one glyph wide, not two. The mark is an explicit escape so no + // editor or normalization pass can fold the pair into precomposed "é" and hollow the test out. + Assert.AreEqual(BitChartTextMeasure.Width("e", 12), BitChartTextMeasure.Width("e\u0301", 12), 0.001); + } + + [TestMethod] + public void TextMeasureShouldStillHandleUnknownLatinLikeCharacters() + { + // Beyond the table but not full-width: measured at the average rather than dropped. + Assert.IsTrue(BitChartTextMeasure.Width("Ω", 12) > 0); + } + + [TestMethod] + public void MultilineWidthShouldReturnTheWidestLine() + { + double expected = BitChartTextMeasure.Width("wide line here", 12); + Assert.AreEqual(expected, BitChartTextMeasure.MultilineWidth("hi\nwide line here\nyo", 12), 0.001); + } + + // ---- decimation ---- + + [TestMethod] + public void LttbShouldReduceToTheRequestedSampleCount() + { + var data = Enumerable.Range(0, 1000) + .Select(i => ((double)i, Math.Sin(i / 10.0) * 50 + 50, i, 0d)) + .ToList(); + + var sampled = BitChartDecimation.Lttb(data, 100); + + Assert.AreEqual(100, sampled.Count); + } + + [TestMethod] + public void LttbShouldKeepTheFirstAndLastPoint() + { + var data = Enumerable.Range(0, 500).Select(i => ((double)i, (double)i, i, 0d)).ToList(); + + var sampled = BitChartDecimation.Lttb(data, 50); + + Assert.AreEqual(data[0], sampled[0]); + Assert.AreEqual(data[^1], sampled[^1]); + } + + [TestMethod] + public void LttbShouldKeepTheSeriesInOrder() + { + var rnd = new Random(3); + var data = Enumerable.Range(0, 800).Select(i => ((double)i, rnd.NextDouble() * 100, i, 0d)).ToList(); + + var sampled = BitChartDecimation.Lttb(data, 120); + + for (int i = 1; i < sampled.Count; i++) + Assert.IsTrue(sampled[i].x >= sampled[i - 1].x, $"out of order at {i}"); + } + + [TestMethod] + public void LttbShouldKeepAPeakThatDefinesTheShape() + { + var data = Enumerable.Range(0, 300).Select(i => ((double)i, i == 150 ? 1000d : 1d, i, 0d)).ToList(); + + var sampled = BitChartDecimation.Lttb(data, 30); + + Assert.IsTrue(sampled.Any(p => p.y >= 1000), "the spike is what the chart is about; it must survive"); + } + + [TestMethod] + public void LttbShouldPassThroughWhenNoReductionIsNeeded() + { + var data = Enumerable.Range(0, 10).Select(i => ((double)i, (double)i, i, 0d)).ToList(); + + Assert.AreSame(data, BitChartDecimation.Lttb(data, 50)); + Assert.AreSame(data, BitChartDecimation.Lttb(data, 2)); + } + + // ---- time axis ---- + + [TestMethod] + [DataRow(1, BitChartTimeUnit.Hour)] + [DataRow(10, BitChartTimeUnit.Day)] + [DataRow(40, BitChartTimeUnit.Week)] + [DataRow(200, BitChartTimeUnit.Month)] + [DataRow(2000, BitChartTimeUnit.Year)] + public void TimeAxisShouldPickAUnitThatMatchesTheSpan(int days, BitChartTimeUnit expected) + { + var start = new DateTime(2026, 1, 1); + Assert.AreEqual(expected, BitChartTimeAxis.ChooseUnit(start, start.AddDays(days))); + } + + [TestMethod] + public void TimeAxisShouldStayWithinTheTickBudget() + { + var start = new DateTime(2026, 1, 1); + var ticks = BitChartTimeAxis.Ticks(start.ToOADate(), start.AddDays(365).ToOADate(), + BitChartTimeUnit.Day, null, 8); + + Assert.IsTrue(ticks.Count <= 8 * 3, $"got {ticks.Count} ticks"); + Assert.IsTrue(ticks.Count > 1); + } + + [TestMethod] + public void TimeAxisTicksShouldRiseAndStayInsideTheRange() + { + var start = new DateTime(2026, 3, 5, 4, 0, 0); + double min = start.ToOADate(), max = start.AddDays(90).ToOADate(); + + var ticks = BitChartTimeAxis.Ticks(min, max, BitChartTimeUnit.Auto, null, 10); + + for (int i = 0; i < ticks.Count; i++) + { + Assert.IsTrue(ticks[i].Value >= min - 1e-9 && ticks[i].Value <= max + 1e-9); + if (i > 0) Assert.IsTrue(ticks[i].Value > ticks[i - 1].Value); + } + } + + [TestMethod] + public void TimeAxisShouldUseTheCustomFormatterWhenGiven() + { + var start = new DateTime(2026, 1, 1); + var ticks = BitChartTimeAxis.Ticks(start.ToOADate(), start.AddDays(60).ToOADate(), + BitChartTimeUnit.Month, d => $"M{d.Month}", 10); + + Assert.IsTrue(ticks.All(t => t.Label.StartsWith("M")), string.Join("|", ticks.Select(t => t.Label))); + } + + [TestMethod] + public void TimeAxisShouldAlwaysProduceAtLeastOneTick() + { + double value = new DateTime(2026, 5, 5).ToOADate(); + Assert.AreEqual(1, BitChartTimeAxis.Ticks(value, value, BitChartTimeUnit.Year, null, 5).Count); + } + + [TestMethod] + public void QuarterFormatShouldNumberTheQuarter() + { + Assert.AreEqual("Q2 2026", BitChartTimeAxis.DefaultFormat(new DateTime(2026, 5, 1), BitChartTimeUnit.Quarter)); + } + + [TestMethod] + public void TimeAxisLabelsShouldFollowTheCultureTheyAreGiven() + { + var may = new DateTime(2026, 5, 1); + + Assert.AreEqual("May 2026", BitChartTimeAxis.DefaultFormat(may, BitChartTimeUnit.Month)); + Assert.AreEqual("mai 2026", BitChartTimeAxis.DefaultFormat(may, BitChartTimeUnit.Month, new CultureInfo("fr-FR"))); + } + + [TestMethod] + public void TimeAxisTicksShouldCarryTheCultureIntoTheirLabels() + { + var start = new DateTime(2026, 1, 1); + var ticks = BitChartTimeAxis.Ticks(start.ToOADate(), start.AddDays(120).ToOADate(), + BitChartTimeUnit.Month, null, 10, new CultureInfo("fr-FR")); + + Assert.IsTrue(ticks.Any(t => t.Label.StartsWith("janv")), string.Join("|", ticks.Select(t => t.Label))); + } + + [TestMethod] + public void TimeAxisShouldStayInvariantWhenNoCultureIsGiven() + { + var start = new DateTime(2026, 1, 1); + var ticks = BitChartTimeAxis.Ticks(start.ToOADate(), start.AddDays(120).ToOADate(), + BitChartTimeUnit.Month, null, 10); + + Assert.IsTrue(ticks.Any(t => t.Label.StartsWith("Jan")), string.Join("|", ticks.Select(t => t.Label))); + } + + // ---- point shapes ---- + + [TestMethod] + [DataRow(BitChartPointStyle.Circle, typeof(BitChartSvgCircle))] + [DataRow(BitChartPointStyle.Rect, typeof(BitChartSvgRect))] + [DataRow(BitChartPointStyle.RectRounded, typeof(BitChartSvgRect))] + [DataRow(BitChartPointStyle.Triangle, typeof(BitChartSvgPolygon))] + [DataRow(BitChartPointStyle.RectRot, typeof(BitChartSvgPolygon))] + [DataRow(BitChartPointStyle.Star, typeof(BitChartSvgPolygon))] + [DataRow(BitChartPointStyle.Cross, typeof(BitChartSvgPath))] + [DataRow(BitChartPointStyle.CrossRot, typeof(BitChartSvgPath))] + [DataRow(BitChartPointStyle.Dash, typeof(BitChartSvgPath))] + [DataRow(BitChartPointStyle.Line, typeof(BitChartSvgPath))] + public void PointShapesShouldBuildTheRightPrimitive(BitChartPointStyle style, Type expected) + { + var node = BitChartPointShapes.Build(style, 10, 10, 5, "#f00", "#00f", 1); + + Assert.IsInstanceOfType(node, expected); + } + + [TestMethod] + public void PointStyleNoneShouldBuildNothing() + { + Assert.IsNull(BitChartPointShapes.Build(BitChartPointStyle.None, 10, 10, 5, "#f00", "#00f", 1)); + } + + [TestMethod] + public void PointRotationShouldBecomeATransform() + { + var node = BitChartPointShapes.Build(BitChartPointStyle.Triangle, 10, 20, 5, "#f00", "#00f", 1, 45); + + StringAssert.Contains(node!.Transform!, "rotate(45 10 20)"); + } + + // ---- number formatting ---- + + [TestMethod] + public void SvgNumbersShouldAlwaysUseTheInvariantForm() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.AreEqual("1.5", BitChartSvg.N(1.5)); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [TestMethod] + public void SvgNumbersShouldNeverEmitNaNOrInfinity() + { + Assert.AreEqual("0", BitChartSvg.N(double.NaN)); + Assert.AreEqual("0", BitChartSvg.N(double.PositiveInfinity)); + Assert.AreEqual("0", BitChartSvg.N(double.NegativeInfinity)); + } + + [TestMethod] + public void DashShouldJoinTheSegmentsAndTolerateNull() + { + Assert.AreEqual("", BitChartSvg.Dash(null)); + Assert.AreEqual("6,4", BitChartSvg.Dash(new List { 6, 4 })); + } + + // ---- padding / corner helpers ---- + + [TestMethod] + public void PaddingShouldConvertFromASingleNumber() + { + BitChartPadding p = 8; + Assert.AreEqual(16, p.Vertical); + Assert.AreEqual(16, p.Horizontal); + } + + [TestMethod] + public void SymmetricPaddingShouldSplitVerticalAndHorizontal() + { + var p = BitChartPadding.Symmetric(4, 10); + Assert.AreEqual(8, p.Vertical); + Assert.AreEqual(20, p.Horizontal); + } + + [TestMethod] + public void CornerRadiusShouldConvertFromASingleNumber() + { + BitChartBorderRadiusCorners c = 6; + Assert.AreEqual(6, c.TopLeft); + Assert.AreEqual(6, c.BottomRight); + + var top = BitChartBorderRadiusCorners.Top(6); + Assert.AreEqual(6, top.TopLeft); + Assert.AreEqual(0, top.BottomRight); + } + + [TestMethod] + public void DatasetCountShouldPreferPointsThenRangesThenValues() + { + Assert.AreEqual(3, new BitChartDataset { Data = { 1, 2, 3 } }.Count); + Assert.AreEqual(2, new BitChartDataset { Data = { 1, 2, 3 }, RangeData = [(0, 1), (1, 2)] }.Count); + Assert.AreEqual(1, new BitChartDataset { Data = { 1, 2, 3 }, Points = [new(0, 0)] }.Count); + } + + // ---- robustness ---- + + [TestMethod] + [DataRow("#zzzzzz")] + [DataRow("#12g")] + [DataRow("#1234567")] + [DataRow("#")] + [DataRow("rgb(a,b,c)")] + public void AMalformedColorShouldBeRejectedRatherThanThrown(string color) + { + Assert.IsFalse(BitChartColorUtil.TryParse(color, out _, out _, out _, out _)); + // The helpers hand the value back untouched, so a typo shows up as a color the browser ignores + // instead of an exception out of the render. + Assert.AreEqual(color, BitChartColorUtil.WithAlpha(color, 0.5)); + Assert.AreEqual(color, BitChartColorUtil.Adjust(color, 0.5)); + } + + [TestMethod] + public void TheShortHexFormShouldSupportAnAlphaDigit() + { + Assert.IsTrue(BitChartColorUtil.TryParse("#f008", out var r, out var g, out var b, out var a)); + Assert.AreEqual(255, r); + Assert.AreEqual(0, g); + Assert.AreEqual(0, b); + Assert.AreEqual(136 / 255.0, a, 0.001); + } + + [TestMethod] + public void AStepSizeFarSmallerThanTheRangeShouldNotGenerateEndlessTicks() + { + var options = new BitChartScaleOptions { Id = "y", Min = 0, Max = 10_000_000 }; + options.Ticks.StepSize = 1; + var scale = new BitChartAxisScale(options, horizontal: false); + scale.SetDataRange(0, 10_000_000); + scale.SetPixelRange(600, 0); + + Assert.IsTrue(scale.Ticks.Count <= 100, $"got {scale.Ticks.Count} ticks"); + Assert.IsTrue(scale.Ticks.Count >= 2); + } + + [TestMethod] + public void ATimeAxisPointedAtValuesThatAreNotDatesShouldNotThrow() + { + foreach (var (min, max) in new[] { (-1e12, 1e12), (0d, 0d), (double.NaN, 5d), (1e9, -1e9) }) + { + var ticks = BitChartTimeAxis.Ticks(min, max, BitChartTimeUnit.Auto, null, 8); + Assert.IsTrue(ticks.Count > 0, $"{min}..{max} produced no ticks"); + } + } + + [TestMethod] + public void ATimeAxisAtTheEndOfTheCalendarShouldTerminate() + { + double end = DateTime.MaxValue.AddDays(-1).ToOADate(); + var ticks = BitChartTimeAxis.Ticks(end, end + 0.5, BitChartTimeUnit.Year, null, 5); + + Assert.IsTrue(ticks.Count > 0); + Assert.IsTrue(ticks.Count < 100); + } +} From 061e0393d3ecdcd09285184eb5d0d1c70c190cfa Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 14 Sep 2026 21:57:49 +0330 Subject: [PATCH 05/43] feat(blazorui): apply BitDataGrid improvements #13159 (#13162) --- .../Components/DataGrid/BitDataGrid.razor | 177 +- .../Components/DataGrid/BitDataGrid.razor.cs | 1105 +++++++++++- .../Components/DataGrid/BitDataGrid.scss | 89 +- .../Components/DataGrid/BitDataGrid.ts | 86 +- .../Components/DataGrid/BitDataGridCell.razor | 13 +- .../Components/DataGrid/BitDataGridColumn.cs | 68 + .../Components/DataGrid/BitDataGridRow.razor | 76 +- .../BitDataGridDataProcessor.cs | 82 +- .../Infrastructure/BitDataGridExcelWriter.cs | 136 +- .../BitDataGridQueryableProcessor.cs | 75 + .../DataGrid/Models/BitDataGridReadRequest.cs | 8 + .../DataGrid/Models/BitDataGridState.cs | 19 + .../DataGrid/Models/BitDataGridStrings.cs | 50 + .../Extras/DataGrid/BitDataGridDemo.razor | 354 +++- .../Extras/DataGrid/BitDataGridDemo.razor.cs | 139 +- .../DataGrid/BitDataGridDemo.razor.params.cs | 71 +- .../DataGrid/BitDataGridDemo.razor.samples.cs | 457 +++-- .../DataGrid/BitDataGridDemo.razor.scss | 7 + .../Extras/DataGrid/BitDataGridTests.cs | 1578 ++++++++++++++++- 19 files changed, 4251 insertions(+), 339 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor index 8478c07ac1..53b1668b51 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/BitDataGrid.razor @@ -7,13 +7,18 @@ @Columns -
    "auto", _ => "ltr" })"> +@* The two grid-owned Ctrl/⌘ shortcuts are conditional, so the capture-phase key guard that cancels + their browser defaults (see BitDataGrid.ts) reads whether they are live off the root rather than + guessing: a grid without them must leave Ctrl+A and Ctrl+C to the browser. *@ +
    "auto", _ => "ltr" })" + data-bit-dtg-copy="@(ClipboardCopy ? "true" : null)" + data-bit-dtg-select-all="@(SelectionMode == BitDataGridSelectionMode.Multiple ? "true" : null)"> @* Announces sort/filter/page changes to screen readers; these are otherwise silent view mutations. *@
    @_srAnnouncement
    @* ---------------------------------------------------------- Toolbar *@ - @if (ShowToolbar || ToolbarTemplate is not null || ShowColumnChooser || ShowCsvExport || ShowExcelExport || (Editable && NewItemFactory is not null)) + @if (ShowToolbar || ToolbarTemplate is not null || ShowColumnChooser || ShowCsvExport || ShowExcelExport || SearchActive || (Editable && NewItemFactory is not null)) {
    @@ -22,6 +27,29 @@ { } + @if (SearchActive) + { + @* type="search" gives the browser's own clear affordance and the right virtual + keyboard; the explicit clear button covers the browsers that render neither. *@ + + }
    @if (_filters.Count > 0) @@ -30,11 +58,11 @@ } @if (ShowCsvExport) { - + } @if (ShowExcelExport) { - + } @if (ShowColumnChooser) { @@ -51,12 +79,18 @@ @if (_showColumnChooserPanel) { -
    + @* Escape closes the panel from anywhere inside it, the dismissal every popup owes its keyboard + users; the toggle button keeps aria-expanded in sync either way. *@ +
    @foreach (var col in AllColumns) { + @* The last visible column can't be hidden - a grid with no columns would be an empty + shell - so its checkbox is disabled rather than silently refusing the click. *@ } @@ -70,7 +104,19 @@ @* ------------------------------------------------------- Grid viewport *@
    -
    + @* aria-multiselectable tells assistive tech that rows carry a meaningful aria-selected state + (only true for Multiple - Single selection is the default single-select behavior), and + aria-busy marks the grid as still updating while a load is in flight. *@ + @* A hierarchical grid is a treegrid, not a grid: that is what tells assistive tech the rows + nest, and what makes the per-row aria-level/aria-expanded meaningful. *@ + @* A grid needs an accessible name for assistive tech to announce it (and to tell several grids + on one page apart); AriaLabel names this one, falling back to the generic localized default. *@ +
    @if (ShowHeader) { @@ -78,6 +124,10 @@ @if (HasColumnGroups) {
    + @if (HasRowNumberColumn) + { +
    + } @if (HasReorderColumn) {
    @@ -105,6 +155,16 @@ }
    @{ var headerColIndex = 0; } + @if (HasRowNumberColumn) + { + headerColIndex++; + @* The "#" glyph reads as nothing useful to a screen reader, so the cell + carries a real name and hides the glyph from the accessibility tree. *@ +
    + +
    + } @if (HasReorderColumn) { headerColIndex++; @@ -167,18 +227,22 @@ } ; } + @* The header label ellipsises in a narrow column, so it carries its full + text as a native tooltip - except when a HeaderTemplate owns the markup + (and any tooltip it wants) itself. *@ @if (ColumnSortable(column)) { @* A real button gives native keyboard activation (Enter/Space) and an implicit button role, so no custom keydown handling is needed and Space won't scroll the page. *@ - } else { - + @headerInner } @@ -197,9 +261,14 @@ @if (ColumnResizable(column)) { - + @onpointerdown:stopPropagation="true" + @ondblclick="() => AutoFitAsync(column)" + @ondblclick:stopPropagation="true"> }
    } @@ -214,6 +283,10 @@ { @* The filter row is always the last header row, so its aria-rowindex is HeaderRowCount. *@
    + @if (HasRowNumberColumn) + { +
    + } @if (HasReorderColumn) {
    @@ -262,13 +335,23 @@ @if (Loading) {
    -
    @Strings.LoadingText
    +
    + @if (LoadingTemplate is not null) + { + @LoadingTemplate + } + else + { + + @Strings.LoadingText + } +
    } else if (TotalCount == 0 && PendingNewItem is null && !IsInfiniteMode && !UseServerVirtualization) {
    -
    +
    @if (EmptyTemplate is not null) { @EmptyTemplate @@ -289,7 +372,7 @@ @if (InfiniteItems.Count == 0 && !InfiniteLoading) {
    -
    +
    @if (EmptyTemplate is not null) { @EmptyTemplate @@ -324,7 +407,7 @@ {
    -
    +
    @@ -332,7 +415,7 @@ else if (!InfiniteHasMore) {
    -
    +
    @Strings.EndOfResultsText
    @@ -347,7 +430,7 @@ @if (_serverVirtualizeEmpty) {
    -
    +
    @if (EmptyTemplate is not null) { @EmptyTemplate @@ -366,7 +449,7 @@
    -
    +
    @@ -392,6 +475,10 @@ {
internal static class BitDataGridExcelWriter { @@ -54,8 +55,28 @@ internal static class BitDataGridExcelWriter """; - // Minimal style sheet: style 0 is the default cell, style 1 the bold header. The two fills and - // the empty border are mandatory filler (SpreadsheetML requires fill 0 = none and fill 1 = gray125). + // The cell-format (cellXfs) contract shared by both style sheets, so the sheet writer can pick a + // format without knowing whether the export is styled: + // 0 data | 1 header | 2 striped data | 3 data+date | 4 striped+date | 5 data+datetime | 6 striped+datetime + // An unstyled export has no stripe fill, so its striped entries are identical to their plain + // counterparts - the indices stay the same either way. + private const int StyleData = 0; + private const int StyleHeader = 1; + private const int StyleStripe = 2; + private const int StyleDate = 3; + private const int StyleStripeDate = 4; + private const int StyleDateTime = 5; + private const int StyleStripeDateTime = 6; + + // Built-in SpreadsheetML number formats, so no block is needed: 14 is the locale's short + // date, 22 its short date + time. Using the built-ins means the workbook renders dates the way the + // opening machine expects rather than pinning one culture's pattern into the file. + private const int DateNumFmtId = 14; + private const int DateTimeNumFmtId = 22; + + // Minimal style sheet: style 0 is the default cell, style 1 the bold header, and 3/5 the two date + // formats (2/4/6 mirror them, since an unstyled export has no stripe fill). The two fills and the + // empty border are mandatory filler (SpreadsheetML requires fill 0 = none and fill 1 = gray125). private const string StylesXml = """ @@ -64,7 +85,7 @@ internal static class BitDataGridExcelWriter - + """; @@ -105,9 +126,8 @@ public static byte[] Write( return stream.ToArray(); } - /// Builds the style sheet for a styled export. The cell-format (cellXfs) contract with - /// the sheet writer: index 0 = data row (the implicit default for cells with no s - /// attribute), 1 = header, 2 = alternating (striped) data row. + /// Builds the style sheet for a styled export, following the same cellXfs contract the + /// unstyled declares (see the Style* constants). private static string BuildStylesXml(BitDataGridExcelStyle style) { // "#rrggbb" (CSS) -> "FFRRGGBB" (SpreadsheetML ARGB); anything else falls back to default. @@ -164,16 +184,21 @@ int AddFill(string? argb) sb.Append(""); - void AppendXf(int fontId, int fillId) - => sb.Append($" sb.Append($" 0 ? " applyFill=\"1\"" : "") .Append(borderId > 0 ? " applyBorder=\"1\"" : "") + .Append(numFmtId > 0 ? " applyNumberFormat=\"1\"" : "") .Append("/>"); - sb.Append(""); - AppendXf(0, rowFill); // 0: data row (implicit default) - AppendXf(1, headerFill); // 1: header - AppendXf(0, stripeFill); // 2: striped data row + sb.Append(""); + AppendXf(0, rowFill); // 0: data row (implicit default) + AppendXf(1, headerFill); // 1: header + AppendXf(0, stripeFill); // 2: striped data row + AppendXf(0, rowFill, DateNumFmtId); // 3: date + AppendXf(0, stripeFill, DateNumFmtId); // 4: striped date + AppendXf(0, rowFill, DateTimeNumFmtId); // 5: date + time + AppendXf(0, stripeFill, DateTimeNumFmtId); // 6: striped date + time sb.Append(""); sb.Append(""); @@ -207,7 +232,7 @@ private static void WriteSheet( writer.Write(""); foreach (var column in columns) { - WriteInlineString(writer, column.DisplayTitle, styleIndex: 1); + WriteInlineString(writer, column.DisplayTitle, StyleHeader); } writer.Write(""); @@ -221,11 +246,12 @@ private static void WriteSheet( // Cell format 0 (the implicit default) is the data-row style; in a styled export the // alternating rows use format 2 (the stripe fill), matching the grid's nth-child(even) // striping (sheet data row 2 = data index 1). - var cellStyle = styled && rowIndex % 2 == 1 ? 2 : 0; + var stripe = styled && rowIndex % 2 == 1; + var cellStyle = stripe ? StyleStripe : StyleData; writer.Write(""); for (var colIndex = 0; colIndex < columns.Count; colIndex++) { - WriteCell(writer, columns[colIndex], item, cellStyle); + WriteCell(writer, columns[colIndex], item, cellStyle, stripe); // Mirror the rendered layout: a spanning cell covers its following neighbours, whose // own values/spans are skipped (a merged region keeps only its top-left cell's value). @@ -246,6 +272,14 @@ private static void WriteSheet( writer.Write(""); + // Turn the header row into Excel's own filter row, so the exported sheet opens with the same + // per-column sort/filter affordances the grid has. The schema puts autoFilter after sheetData + // and before mergeCells, so the order here is not interchangeable. + if (columns.Count > 0) + { + writer.Write($""); + } + if (merges is not null) { writer.Write($""); @@ -297,10 +331,34 @@ private static string CellRef(int columnIndex, int row) return letters + row.ToString(CultureInfo.InvariantCulture); } - private static void WriteCell(TextWriter writer, BitDataGridColumn column, TItem item, int styleIndex) + private static void WriteCell(TextWriter writer, BitDataGridColumn column, TItem item, int styleIndex, bool stripe) { var s = styleIndex > 0 ? $" s=\"{styleIndex}\"" : ""; - var value = column.GetValue(item); + // GetExportValue resolves the column's ExportValue selector when one is set (which is what + // gives a template-only column a real exported value), and the bound field's value otherwise. + var value = column.GetExportValue(item); + + // Dates go in as real date cells (a serial number under a date format) rather than text, so + // the sheet can sort, filter and compute on them; the display text is only the fallback for a + // value Excel's serial calendar cannot represent (see TryDateSerial). + if (value is DateOnly or DateTime or DateTimeOffset) + { + if (TryDateSerial(value, out var serial, out var hasTime)) + { + var dateStyle = hasTime + ? (stripe ? StyleStripeDateTime : StyleDateTime) + : (stripe ? StyleStripeDate : StyleDate); + writer.Write($""); + writer.Write(serial.ToString("0.##########", CultureInfo.InvariantCulture)); + writer.Write(""); + } + else + { + WriteInlineString(writer, column.GetFormattedExportValue(item), styleIndex); + } + return; + } + switch (value) { case null: @@ -316,7 +374,7 @@ private static void WriteCell(TextWriter writer, BitDataGridColumn // string instead of a numeric cell. case float f when !float.IsFinite(f): case double d when !double.IsFinite(d): - WriteInlineString(writer, column.GetFormattedValue(item), styleIndex); + WriteInlineString(writer, column.GetFormattedExportValue(item), styleIndex); break; // Native numeric cells keep their real value so spreadsheet math works on the export; // a column Format (e.g. "C2") is presentation-only and intentionally not applied here. @@ -326,11 +384,41 @@ private static void WriteCell(TextWriter writer, BitDataGridColumn writer.Write(""); break; default: - WriteInlineString(writer, column.GetFormattedValue(item), styleIndex); + WriteInlineString(writer, column.GetFormattedExportValue(item), styleIndex); break; } } + /// + /// Converts a date/time value to the serial number Excel stores dates as (days since 1899-12-30, + /// the fraction being the time of day), and reports whether it carries a time worth showing. + /// + /// Returns false - so the caller writes the display text instead - for anything before + /// 1900-03-01. Excel's serial calendar contains a deliberate 1900 leap-year bug that OADate does + /// not reproduce, so every earlier date would land one day off in the workbook. + /// + /// A is written as its own wall-clock time (the offset itself has no + /// representation in a date cell), matching the text an unconverted export would have carried. + /// + private static bool TryDateSerial(object value, out double serial, out bool hasTime) + { + var moment = value switch + { + DateOnly d => d.ToDateTime(TimeOnly.MinValue), + DateTime dt => dt, + DateTimeOffset dto => dto.DateTime, + _ => default + }; + + hasTime = moment.TimeOfDay != TimeSpan.Zero; + serial = 0; + // 61 is 1900-03-01, the first serial Excel and OADate agree on. + if (moment < new DateTime(1900, 3, 1)) return false; + + serial = moment.ToOADate(); + return true; + } + private static void WriteInlineString(TextWriter writer, string text, int styleIndex = 0) { writer.Write(styleIndex > 0 diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridQueryableProcessor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridQueryableProcessor.cs index fb951d9853..cbd5cae627 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridQueryableProcessor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Infrastructure/BitDataGridQueryableProcessor.cs @@ -19,6 +19,81 @@ public static IQueryable Apply( IReadOnlyDictionary> columns) => ApplySorts(ApplyFilters(source, filters, columns), sorts, columns); + /// Applies the quick-search term, then the filters and sorts, onto the queryable. + public static IQueryable Apply( + IQueryable source, + string? search, + IReadOnlyList filters, + IReadOnlyList sorts, + IReadOnlyDictionary> columns) + => ApplySorts(ApplyFilters(ApplySearch(source, search, columns.Values), filters, columns), sorts, columns); + + /// + /// Translates the grid-wide quick search into a single OR of case-insensitive Contains + /// predicates over the searchable string columns, so the provider runs it at the source + /// (e.g. one SQL WHERE … LIKE … OR … LIKE …). Non-string columns are skipped: their text is + /// produced by .NET formatting the provider cannot reproduce, so matching them would need the rows + /// in memory; when a searchable column exists but none of them translate, the search is left off + /// rather than silently emptying the grid. With no searchable column at all there is + /// nothing a term could ever match, so it matches no row - the same contract as + /// over an in-memory source. + /// + public static IQueryable ApplySearch( + IQueryable source, + string? search, + IEnumerable> columns) + { + if (string.IsNullOrWhiteSpace(search)) return source; + + var term = search.Trim().ToLower(); + var toLower = typeof(string).GetMethod(nameof(string.ToLower), Type.EmptyTypes)!; + var contains = typeof(string).GetMethod(nameof(string.Contains), new[] { typeof(string) })!; + + var anySearchable = false; + ParameterExpression? param = null; + Expression? body = null; + foreach (var column in columns) + { + if (!column.IsSearchable) continue; + anySearchable = true; + if (column.Accessor is null) continue; + var lambda = column.Accessor.PropertyLambda; + if (lambda.Body.Type != typeof(string)) continue; + + // Every column accessor builds its lambda over its own parameter instance, so rebind the + // member expressions onto one shared parameter before OR-ing them into a single predicate. + param ??= lambda.Parameters[0]; + var member = ReplaceParameter(lambda.Body, lambda.Parameters[0], param); + + var match = Expression.AndAlso( + Expression.NotEqual(member, Expression.Constant(null, typeof(string))), + Expression.Call(Expression.Call(member, toLower), contains, Expression.Constant(term))); + body = body is null ? match : Expression.OrElse(body, match); + } + + if (body is null || param is null) + { + // No searchable column at all: nothing to match against, so the term excludes every row + // (what the in-memory pipeline does). Some searchable columns exist but none translate: + // leave the search off rather than emptying a grid the provider simply cannot filter. + return anySearchable ? source : source.Where(_ => false); + } + + return source.Where(Expression.Lambda>(body, param)); + } + + private static Expression ReplaceParameter(Expression body, ParameterExpression from, ParameterExpression to) + => ReferenceEquals(from, to) ? body : new ParameterRebinder(from, to).Visit(body); + + private sealed class ParameterRebinder : ExpressionVisitor + { + private readonly ParameterExpression _from; + private readonly ParameterExpression _to; + public ParameterRebinder(ParameterExpression from, ParameterExpression to) { _from = from; _to = to; } + protected override Expression VisitParameter(ParameterExpression node) + => ReferenceEquals(node, _from) ? _to : base.VisitParameter(node); + } + public static IQueryable ApplyFilters( IQueryable source, IReadOnlyList filters, diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs index 51712093ad..d143e23f2f 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridReadRequest.cs @@ -24,5 +24,13 @@ public sealed class BitDataGridReadRequest /// public IReadOnlyList Groups { get; init; } = Array.Empty(); + /// + /// The grid-wide quick-search term (the grid's search box, or its SearchText parameter), or + /// null when no search is active. It is a free-text term the handler should match across the + /// columns it considers searchable, in addition to - not instead of - the per-column + /// . + /// + public string? Search { get; init; } + public CancellationToken CancellationToken { get; init; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridState.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridState.cs index 4795ce3bfa..4efc7b08a9 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridState.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridState.cs @@ -21,12 +21,31 @@ public sealed class BitDataGridState /// The user-selected page size, or null when the grid's PageSize parameter applies. public int? PageSize { get; set; } + /// The grid-wide quick-search term, or null when no search was active. + public string? Search { get; set; } + public List Sorts { get; set; } = new(); public List Filters { get; set; } = new(); public List Groups { get; set; } = new(); + /// + /// Whether groups were collapsed by default when the snapshot was taken (the state + /// GroupsInitiallyCollapsed sets and ExpandAllGroupsAsync/CollapseAllGroupsAsync + /// flip). Only meaningful together with . + /// + public bool GroupsCollapsed { get; set; } + + /// + /// The groups whose expanded state differs from , as the stable paths + /// the grid identifies them by. Restoring both fields brings back exactly which groups were open, + /// so a persisted view opens the way the user left it. + /// Tree-node expansion is deliberately not captured: a tree node is identified by whatever + /// KeyField returns, which is not guaranteed to be serializable. + /// + public List GroupExpansionOverrides { get; set; } = new(); + /// Per-column layout state (visibility, resized width, display order). public List Columns { get; set; } = new(); } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridStrings.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridStrings.cs index 587d0697cc..d8abf1270e 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridStrings.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/DataGrid/Models/BitDataGridStrings.cs @@ -8,6 +8,13 @@ namespace Bit.BlazorUI; /// public class BitDataGridStrings { + /// + /// Default accessible name of the grid itself (its role="grid" element), used when no + /// AriaLabel is given. A page with several grids should name each one through + /// AriaLabel instead, so screen-reader users can tell them apart. + /// + public string GridLabel { get; set; } = "Data grid"; + /// Shown when the grid has no rows to display. public string EmptyText { get; set; } = "No records to display."; @@ -17,6 +24,12 @@ public class BitDataGridStrings /// Header of the command (Edit/Delete) column. public string ActionsText { get; set; } = "Actions"; + /// Visible header glyph of the row-number column. + public string RowNumberHeader { get; set; } = "#"; + + /// Accessible name of the row-number column's header (the "#" glyph reads as nothing). + public string RowNumberLabel { get; set; } = "Row number"; + /// Toolbar button that begins adding a new row. public string AddRowText { get; set; } = "+ Add"; @@ -32,6 +45,21 @@ public class BitDataGridStrings /// Toolbar button that opens the column chooser. public string ColumnsText { get; set; } = "Columns"; + /// Accessible label of the column-chooser panel. + public string ColumnChooserLabel { get; set; } = "Choose columns"; + + /// Placeholder of the toolbar's quick-search box. + public string SearchPlaceholder { get; set; } = "Search…"; + + /// Accessible label of the toolbar's quick-search box. + public string SearchLabel { get; set; } = "Search all columns"; + + /// Accessible label of the button that clears the quick search. + public string ClearSearchLabel { get; set; } = "Clear search"; + + /// Tooltip of a resizable column's drag handle. + public string ResizeColumnTitle { get; set; } = "Drag to resize, or double-click to fit the content"; + public string EditText { get; set; } = "Edit"; public string DeleteText { get; set; } = "Delete"; public string SaveText { get; set; } = "Save"; @@ -69,6 +97,8 @@ public class BitDataGridStrings public string FilterOpGreaterThanOrEqual { get; set; } = "≥"; public string FilterOpLessThan { get; set; } = "<"; public string FilterOpLessThanOrEqual { get; set; } = "≤"; + public string FilterOpIsEmpty { get; set; } = "Is blank"; + public string FilterOpIsNotEmpty { get; set; } = "Is not blank"; public string BooleanTrueText { get; set; } = "True"; public string BooleanFalseText { get; set; } = "False"; @@ -127,10 +157,30 @@ public class BitDataGridStrings public string AnnouncementSortCleared { get; set; } = "Sorting by {0} removed"; public string AnnouncementFiltered { get; set; } = "Filter applied on {0}"; public string AnnouncementFilterCleared { get; set; } = "Filter on {0} cleared"; + /// Announced when every column filter is cleared at once (the toolbar's Clear filters + /// button, or ClearFiltersAsync). + public string AnnouncementFiltersCleared { get; set; } = "All filters cleared"; + /// Announced when every sort is removed at once (ClearSortsAsync). + public string AnnouncementSortsCleared { get; set; } = "Sorting cleared"; + /// Announced when every grouping is removed at once (ClearGroupsAsync). + public string AnnouncementGroupsCleared { get; set; } = "Grouping cleared"; + /// Announced after a bulk selection change. {0} = the number of selected rows. + public string AnnouncementRowsSelected { get; set; } = "{0} rows selected"; + /// Announced when the selection is cleared. + public string AnnouncementSelectionCleared { get; set; } = "Selection cleared"; /// {0} = current page, {1} = total pages. public string AnnouncementPage { get; set; } = "Page {0} of {1}"; /// Announced after a row is deleted (via the Delete button or the Delete key). public string AnnouncementRowDeleted { get; set; } = "Row deleted"; + /// {0} = the search term. + public string AnnouncementSearched { get; set; } = "Searching for {0}"; + public string AnnouncementSearchCleared { get; set; } = "Search cleared"; + /// {0} = column title. + public string AnnouncementGrouped { get; set; } = "Grouped by {0}"; + /// {0} = column title. + public string AnnouncementUngrouped { get; set; } = "Grouping by {0} removed"; + /// Announced after a clipboard copy. {0} = the number of rows copied. + public string AnnouncementRowsCopied { get; set; } = "{0} rows copied to the clipboard"; /// Footer aggregate labels. {0} = the formatted aggregate value. public string AggregateSumFormat { get; set; } = "Σ {0}"; diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor index fc69a24b0f..6075dbf944 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/DataGrid/BitDataGridDemo.razor @@ -4,10 +4,10 @@ + Description="BitDataGrid displays an information-rich collection of items with sorting, searching, filtering, paging, grouping, selection, inline editing, virtualization and Excel/CSV export." />
Bind a collection, declare columns, and you get sorting out of the box. - Click a header to sort ascending → descending → unsorted. - Hold Ctrl (or ⌘) and click additional headers for multi-column sorting. + Click a header to sort ascending → descending → unsorted - or set AllowUnsorted="false" + (on the grid or on one column) to cycle between ascending and descending only, for data whose + source order means nothing. Hold Ctrl (or ⌘) and click additional headers for multi-column sorting.

The item type flows from the grid to its columns automatically (no per-column TItem needed), a column binds to its property with the strongly typed Property selector (the string-based @@ -78,7 +79,13 @@
- Single or multiple selection with a select-all header checkbox and two-way binding. + SelectionMode picks between single-row selection (a click anywhere on the row) + and multiple selection with per-row checkboxes and a select-all box in the header, which + covers the rows of the current page and shows the indeterminate state while only some are + selected. SelectedItems is two-way bindable, and the selection is tracked by + the row's KeyField rather than by object reference, so it survives a refresh + that re-materializes the rows. Switching modes drops a selection the new mode can't hold; + IsRowSelectionDisabled excludes individual rows (select-all skips them).

@@ -89,7 +96,7 @@ @selectedProducts.Count selected
- @@ -100,8 +107,12 @@
- Add, edit, save, cancel and delete rows with type-aware editors (text, number, checkbox, date, enum). - Use EditTemplate on a column to supply your own editor. + Add, edit, save, cancel and delete rows through a command column, with the editor picked from + each column's type: text, number, checkbox (a tri-state select for a nullable bool?), + date, date-and-time (offsets and sub-minute precision preserved) and enum. Edits are buffered + and only written to the row on Save, so Cancel always leaves it untouched. + Editable="false" keeps a column read-only while the rest of the row is edited, + and EditTemplate supplies your own editor for a column.

@* TItem stays explicit here: EventCallback parameters (OnRowSave/OnRowDelete/OnRowCreate) @@ -130,9 +141,18 @@ Group by both Category and Supplier to see multi-level grouping. Beyond the built-in Aggregate types, AggregateBy computes a custom aggregate - here the Supplier column counts distinct suppliers per group and overall. + GroupsInitiallyCollapsed opens a grouped grid as a compact list of headers to + drill into, and ExpandAllGroupsAsync/CollapseAllGroupsAsync flip + every level at once - including groups that don't exist yet, so the choice survives a + regrouping or a data refresh.

- + Expand all groups + Collapse all groups + +
+ @@ -153,6 +173,9 @@ Click the ▸ toggle on the left of a row to expand its detail panel. A template-only column (no Field) is not sortable by default - give it a SortBy key selector to sort it, like the computed Value column here. + When the ordering itself is the unusual part rather than the key, a column can also + supply its own Comparer - the Stock column sorts in-stock rows ahead of + out-of-stock ones instead of by the raw number.
@@ -185,7 +208,7 @@ Total: @agg.FormattedValue
- +
@TableCaption
SeriesXY
@(ds.Label ?? "Series")@p.X.ToString(System.Globalization.CultureInfo.InvariantCulture)@p.Y.ToString(System.Globalization.CultureInfo.InvariantCulture)@Fmt(p.X)@Fmt(p.Y)
Series@label@(ci < data.Labels.Count ? data.Labels[ci] : "")
@(ds.Label ?? "Series")@(r is { } rr ? $"{rr.Low}–{rr.High}" : "")@(ci < ranges.Count && ranges[ci] is { } rr ? $"{Fmt(rr.Low)}–{Fmt(rr.High)}" : "")@(v?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "")@CellText(ds, ci)