diff --git a/.github/workflows/remove-working-on-it-label.yml b/.github/workflows/remove-working-on-it-label.yml new file mode 100644 index 000000000..bbac0e9c2 --- /dev/null +++ b/.github/workflows/remove-working-on-it-label.yml @@ -0,0 +1,21 @@ +name: Remove "working on it" label on close + +on: + issues: + types: [closed] + +permissions: + issues: write + +jobs: + remove-label: + if: contains(github.event.issue.labels.*.name, 'working on it') + runs-on: ubuntu-latest + steps: + - name: Remove the label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh issue edit "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --remove-label "working on it" diff --git a/.gitignore b/.gitignore index 0b4db82f9..ee83847c1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ src/BlazorBlueprint.Components/wwwroot/blazorblueprint.css node_modules/ tailwindcss.exe tailwindcss-linux +tailwindcss-macos # IDE .vscode/ @@ -43,20 +44,15 @@ NUL .env.local .env.*.local -# Local test apps -tests/BlazorBlueprint.IssueTester/ - -# Local LLM docs -.llms +# Published output +publish/ # Development Tools .claude -.audits -.scratchpad.md -themes -docs -scripts/release.sh -scripts/release-*.sh -scripts/compare-theme.sh -scripts/nuget-versions.sh -scripts/run-ui-tests.sh +devkit/ + +# External libs +pro/ +/.vscode-ai-images/ + +docs/notes/checkpoint.md diff --git a/BlazorBlueprint.sln b/BlazorBlueprint.sln index 7cf2e7ff1..36226b91c 100644 --- a/BlazorBlueprint.sln +++ b/BlazorBlueprint.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Icons.Heroi EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Icons.Feather", "src\BlazorBlueprint.Icons.Feather\BlazorBlueprint.Icons.Feather.csproj", "{078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Icons.FontAwesome", "src\BlazorBlueprint.Icons.FontAwesome\BlazorBlueprint.Icons.FontAwesome.csproj", "{A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Demo.Shared", "demos\BlazorBlueprint.Demo.Shared\BlazorBlueprint.Demo.Shared.csproj", "{D1A2B3C4-E5F6-7890-ABCD-EF1234567890}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorBlueprint.Demo.Wasm", "demos\BlazorBlueprint.Demo.Wasm\BlazorBlueprint.Demo.Wasm.csproj", "{E2B3C4D5-F6A7-8901-BCDE-F12345678901}" @@ -113,6 +115,18 @@ Global {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}.Release|x64.Build.0 = Release|Any CPU {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}.Release|x86.ActiveCfg = Release|Any CPU {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA}.Release|x86.Build.0 = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x64.Build.0 = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Debug|x86.Build.0 = Debug|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|Any CPU.Build.0 = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x64.ActiveCfg = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x64.Build.0 = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x86.ActiveCfg = Release|Any CPU + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7}.Release|x86.Build.0 = Release|Any CPU {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU {D1A2B3C4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -184,6 +198,7 @@ Global {C5E92A4D-9E3B-4C1A-B8D4-E1F5A6C7B9D2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6B3C876A-CB06-42DA-974E-6620581CE719} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {078BABD8-4ACB-4002-95E8-99AB3DA3EBEA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {A1F2E3D4-B5C6-4789-A0B1-C2D3E4F5A6B7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {D1A2B3C4-E5F6-7890-ABCD-EF1234567890} = {A39C23D2-F2C0-258D-165A-CF1E7FEE6E7B} {E2B3C4D5-F6A7-8901-BCDE-F12345678901} = {A39C23D2-F2C0-258D-165A-CF1E7FEE6E7B} {F3C4D5E6-A7B8-9012-CDEF-123456789012} = {A39C23D2-F2C0-258D-165A-CF1E7FEE6E7B} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d5675a35..dc2415c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,348 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-08-05 + +### Added + +- **`BbXAxis.Scale`** — `BbYAxis` has carried `Scale` for some time; the X axis had no equivalent, which went unnoticed while no chart in the library used a numeric X axis. Plotting the first genuine one surfaced it immediately: a value axis includes zero by default, so heights of 160-190 occupied the last sixth of the plot and the correlation they were meant to show was squeezed into a corner. Set it to scale the axis to the data range instead. It is deliberately opt-in on both axes — suppressing zero exaggerates small differences, so it should be a decision rather than a default, and it is the wrong choice wherever the distance from zero is part of what the reader should take away. + +- **`BbCopyText.ValueFunc`** — Requested in [#453](https://github.com/blazorblueprintui/ui/issues/453) by [@DCarlson12](https://github.com/DCarlson12): some text is only known at copy time, derived from state that moves or expensive enough that computing it for every render is wasted. `Value` had to be a string already in hand, so the only way to get a fresh one was to recompute it on every render and hold it in a field. `ValueFunc` is a `Func` evaluated when the user actually copies. Naming follows the convention the library already uses for this shape — `CellClassFunc`, `DayClassFunc`. `Value` still wins when set, and the precedence test is deliberately **emptiness rather than null**: `Value` is a `string`, and a bound-but-unset one reaches the empty string naturally, which under a literal first-non-null reading would win and silently copy nothing. `Value` also loses its `[EditorRequired]` attribute, since supplying only a func is now a complete configuration and the analyzer would otherwise nag everyone who does. The value is resolved once per click rather than per render, so an expensive func is not called speculatively and `OnCopied` cannot report a different string from the one placed on the clipboard. The asynchronous counterpart is deliberately **not** part of this: awaiting a consumer's task before writing spends the transient user activation clipboard writes require, and several browsers — Safari most strictly — then reject the write, which the existing fallback silently swallows. That needs a JS-layer change handing the promise to `ClipboardItem`, tracked in [#466](https://github.com/blazorblueprintui/ui/issues/466). + +### Changed + +- **`HtmlSanitizer` moved from the 9.1 prerelease line to stable 9.1.982** — The beta pin was deliberate but always temporary, and it is now unnecessary. The reason for being on 9.1 at all has not changed: the 9.0.x stable line hard-pins `AngleSharp` to exactly `[0.17.1]`, which carries [GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg), and because the pin is exact the transitive cannot be lifted from here without `NU1608` — a hard error under this repo's `TreatWarningsAsErrors`. What has changed is that 9.1 now has a stable release, so that fix no longer costs us a prerelease. `HtmlSanitizer` and `AngleSharp.Css` were both prerelease transitive dependencies of a package published to NuGet, which resolves fine for consumers but can trip supply-chain policies that ban prereleases outright. The resolved graph is now `HtmlSanitizer` 9.1.982, `AngleSharp` 1.7.0 and `AngleSharp.Css` 1.0.1 — no prerelease anywhere, and no vulnerable package. Sanitiser output was compared across the same 41 inputs used to verify the original move — XSS vectors, Quill rich-text markup, Markdig output, malformed and non-ASCII HTML — and is byte-identical between 9.1.949-beta and 9.1.982, with no executable vector surviving either. The comment above the `PackageReference` has been rewritten: it existed to stop someone reverting to 9.0.x by mistake, which is still worth preventing, but it no longer describes a beta pin that no longer exists. + +### Fixed + +- **BbPopover: Escape did nothing, and the trigger could not be clicked to recover** — Opening a popover and pressing Escape left it open. Worse than a no-op, because `BbPopoverTrigger` sets `pointer-events: none` on itself while open — a deliberate guard against double-toggling — so the trigger could not be clicked either. The only way out was to click somewhere else on the page, and for anyone working from the keyboard there was no way out at all. `CloseOnEscape` has always defaulted to `true` and the handler behind it was correct; it simply never ran. It is bound with `@onkeydown` on the content element, which only observes the key once focus is inside the popover — and opening one does not move focus. Focus stays on the trigger, which lives in an entirely different part of the DOM now that content renders through the portal, so the keydown bubbled nowhere near the handler. `BbPopoverContent` now watches for Escape at the document while open, using the `onEscapeKey` helper that has been sitting unused in `click-outside.js`, alongside the click-outside listener it already registers there and torn down with it. The element-level handler stays: the document listener fires on bubble, so when focus genuinely is inside the popover the original handler still runs first and the new one finds the popover already closed. `BbSelect` was never affected — it routes Escape through its keyboard-navigation module, which is document-level for the same reason. +- **BbCopyText: tooltips stayed on screen once shown, accumulating one per hover** — Hovering a copyable value left its tooltip behind permanently; hovering several left several, and because each sits at the coordinates it was given, a subsequent scroll scattered them across unrelated text. The cause sits at the boundary between Blazor's rendering and JS's. `BbFloatingPortal` shows floating content by calling into `positioning.js`, which writes `visibility`, `opacity` and `pointer-events` straight onto the element with `!important` — the style attribute is JS's from that moment on. Blazor, though, diffs against what it last *rendered*, not against what the DOM actually holds. Its markup for a closed portal is byte-identical to the markup it rendered before the portal opened, so on close it correctly concludes nothing changed and emits no style update at all. The element keeps the visible values JS left on it. Nothing was broken in the close path itself: `isHovered` reached `false`, the component re-rendered, `data-state` duly flipped to `"closed"`, the auto-update subscription was disposed, and a `MutationObserver` recorded no further writes. The style simply had no owner willing to reset it. Hiding is now explicit and symmetric with showing: a new `IPositioningService.HidePositionAsync` writes the off-screen hidden state back through the same JS path, mirroring `GetInitialStyle()`. Components whose markup changes elsewhere while open — `BbTooltip` among them — were never affected, because any other difference gives Blazor a reason to re-emit the attribute; that is why this looked component-specific rather than structural. +- **BbCurrencyInput multiplied its value by 100 every time the field was focused** — Reported in [#438](https://github.com/blazorblueprintui/ui/issues/438) against `CurrencyCode="EUR"`: focus the field, leave it, and `999,99` came back as `99.999,00`, compounding on every visit. The component was reading three different cultures for one value. `FormatCurrency` and `TryParseValue` used the **currency's** culture — `EUR` maps to `de-DE`, so comma decimal and dot group separator. `JsOnFocus` swapped the display to an **invariant** string for editing, producing `999.99` with a dot. `GetJsConfig` then handed the JS sanitiser the **ambient** culture's separator, which on Blazor Server is the *server's* rather than the user's — so on the docs site it was a dot for everyone regardless of their locale, and the sanitiser happily left the dot in place. On blur, `TryParseValue` read that `999.99` back through `de-DE`, stripped the dot as a *group* separator, and parsed `99999`. Nothing was wrong with any single step; they simply disagreed about what a dot meant. All three now resolve through the currency's culture, so the string written on focus is the string the parser expects on blur. The reporter's own diagnosis — a thousands separator being misread — turned out to be exactly right, just one layer further down than the parse routine they suspected. Note this was never locale-specific to the reporter: any currency whose culture disagrees with the server's on the decimal separator was affected, which on a `de-DE`-backed `EUR` field is every user of a default-configured Blazor Server app. Verified across USD, EUR, JPY (no decimal places) and a currency using the Arabic decimal separator `٫`, each stable across repeated focus/blur cycles. +- **BbNumericInput: the stepper buttons kept a fixed height when the field was resized** — Reported as part of [#328](https://github.com/blazorblueprintui/ui/issues/328) and split out as [#465](https://github.com/blazorblueprintui/ui/issues/465). `ButtonClass` was a **static** member, so it could not observe anything about the instance — including `Class`, which is the only way to size this component since it has no size parameter. Each button was `h-5`, and two of them summed to exactly the input's default `h-10`; the pieces lined up by coincidence rather than by construction, which is why the mismatch only appeared once a consumer changed the height. Passing `Class="h-8"` shrank the field to 32px through TailwindMerge while the stepper stayed at 40px, overhanging the field at both ends with its rounded corners no longer meeting the input's border. The buttons now take half the stepper column via `flex-1` instead of a fixed height, and the row is `items-stretch` rather than `items-center`, so the column sizes itself from the field rather than from a height of its own — whatever sets that height. `min-h-0` lets them shrink past the chevron's intrinsic height for genuinely small fields. `BbCurrencyInput` was checked for the same shape and is unaffected: it renders a currency symbol rather than stepper buttons. +- **Scatter, line and area series could not plot against a real X value** — Reported in [#439](https://github.com/blazorblueprintui/ui/issues/439), where every scatter example on the docs site appeared to compare a Y value against another Y value rather than X against Y. It did, and the cause was not the demo data. `BbScatter`, `BbLine` and `BbArea` emitted a flat list of Y values, which leaves ECharts to derive each point's X from its *index*. That is right for a category axis, and it is why the ordinary categorical chart has always looked correct. It is wrong for a value axis: an axis of `Type="AxisType.Value"` ignores the `data` it is given, so the X values passed to `BbXAxis` via `DataKey` were dropped and the points were plotted against ordinal position regardless. The two halves failed in a way that hid each other — the axis looked configured, the labels came from the right property, and the plot was a Y-over-position chart wearing X's labels. There was no arrangement of the existing parameters that produced a genuine X:Y plot, which also made the answer to the reporter's follow-up question "you can't", and made the example in `BbScatter`'s own XML documentation — which advertised exactly that arrangement — wrong. The three series now take **`XDataKey`**, naming the property that holds each point's X value, and emit explicit `[x, y]` pairs. Leave it unset and nothing changes: the flat list is still emitted, categorical charts are untouched, and the composite bar-plus-scatter overlay keeps working as before. Rows missing either coordinate become a null entry rather than a partial pair, which ECharts would otherwise render against a coerced zero; the returned list stays parallel to the source data rather than being compacted, because `SymbolSizeKey` zips against it by index and a compacted list would have silently misassigned every bubble size after the first gap. + +--- + +## 2026-08-03 + +### Added + +- **BbSidebarProvider: `Open`, `OpenChanged`, `OpenMobile` and `OpenMobileChanged`** — The sidebar was the one stateful component in the library offering the uncontrolled half of the open-state contract without the controlled half. Twelve components — `BbDialog`, `BbSheet`, `BbPopover`, `BbTooltip`, `BbDrawer`, `BbCollapsible`, `BbDropdownMenu`, `BbHoverCard`, `BbContextMenu`, `BbAlertDialog`, `BbCommandDialog` and `BbSelectValue` — expose `Open` alongside `OpenChanged`, and none expose one without the other, so anyone reaching for `@bind-Open` on a sidebar was applying a convention the rest of the library had taught them and finding only `DefaultOpen`. The sidebar even consumed the pattern internally, rendering its mobile drawer as a `BbSheet` with `Open`/`OpenChanged` bound. Driving it from outside meant taking the cascaded `SidebarContext` as a `[CascadingParameter]`, subscribing to `StateChanged` and calling `InvokeAsync(StateHasChanged)` by hand — workable, and still supported for read-only access, but a lot of ceremony next to a binding. The provider now takes **`Open`** (`bool?`) with **`OpenChanged`**, following the same nullable discriminator as its siblings: leave it null and nothing changes — the provider owns the state, seeded from `DefaultOpen` and persisted through `CookieKey` exactly as before — bind it and the consumer's value becomes the source of truth, with the trigger, the rail and the Ctrl/Cmd + B shortcut all raising the callback instead of changing state behind the binding. Because the sidebar carries **two** independent states, `Open` is the *desktop* one, matching what `DefaultOpen` has always meant and what the cookie has always stored; the mobile drawer gets its own **`OpenMobile`**/**`OpenMobileChanged`** pair, and either may be controlled without the other. Binding `Open` **turns cookie persistence off**, both the read on startup and the write on change: with the value owned outside the component the cookie would compete with the binding on reload rather than restore it, and a consumer who has taken ownership of the state is the one who should decide where it lives. The whole addition is opt-in and non-breaking — an existing `BbSidebarProvider` that sets neither parameter behaves identically. Prompted by [@muheebthewizard](https://github.com/muheebthewizard) going looking for parameters that should have been there. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) + +### Fixed + +- **BbColorPicker: the picker was unusable in locales that write decimals with a comma** — The component built its inline styles by interpolating raw `double` values straight into the style string, and Razor renders a bare `@someDouble` through the *current* culture. Under `fr-FR`, `de-DE`, `es-ES`, `pt-BR` and every other comma-decimal locale, the saturation/brightness gradient came out as `hsl(200,5, 100%, 50%)` and the thumb positions as `left: 33,333333333333336%` — the extra comma turns each into a syntax error, so the browser discarded the declaration outright. The result was a color area with no hue gradient and hue, saturation and alpha handles all pinned to the left edge: the picker rendered, took clicks, and simply never reflected the color. Nothing logged, because invalid CSS is dropped silently. All five style interpolations, plus the alpha term in `ToRgbString` and `ToHslString`, now format through `CultureInfo.InvariantCulture` so the separator is a dot regardless of the ambient culture. The parsing side was already invariant — `ParseHex` reads through `NumberStyles.HexNumber` with `InvariantCulture` — so only output was affected. Hue additionally formats as `"0.##"` rather than the default `"G"`, which switches to scientific notation below 1e-5 and would emit `hsl(9.9E-06, …)`, invalid for the same reason. Contributed by [@CholmesFr](https://github.com/CholmesFr). ([#436](https://github.com/blazorblueprintui/ui/pull/436)) +- **Sidebar docs: the API reference was incomplete where it mattered most, and wrong in four places** — Someone reaching for `Open`/`OpenChanged` on `BbSidebar` found the parameters absent from the component and absent from the source, with nothing on the page explaining what to use instead. Open state is owned by `BbSidebarProvider` and reached through the cascaded `SidebarContext`, but the context was not documented at all — neither were `BbSidebar` itself nor `BbSidebarTrigger`, the component that answers the question. The reference now covers all three, including the context's full control surface (`IsOpen`, `Open`, `OpenMobile`, `IsMobile`, `Side`, `ToggleSidebar()`, `SetOpen(bool)`, `SetOpenMobile(bool)` and the `StateChanged` event), with a note on why `IsOpen` is the one to read: it resolves to the mobile drawer state below the mobile breakpoint and the desktop state above it, so code that reads `Open` directly is subtly wrong on a phone. A new "Controlling the Sidebar" section and code example show the three routes — `DefaultOpen` for the initial value, `BbSidebarTrigger` from markup, and the cascaded context from your own component, including the `StateChanged` subscription needed if your own markup reflects the open state. Separately, four of the five documented `BbSidebarMenuButton` parameters had the wrong type: `Size`, `Variant` and `AsChild` were listed as `string` with lowercase string defaults when all three are enums (`SidebarMenuButtonSize`, `SidebarMenuButtonVariant`, `SidebarMenuButtonElement`), so following the docs produced a compile error rather than a wrong result; `IsActive` was listed as `bool` when it is `bool?`, where leaving it null is meaningful because active state is then derived from `Href` and `Match`. Those are corrected, and the undocumented `Href`, `Match` and `OnClick` added. ([#442](https://github.com/blazorblueprintui/ui/issues/442)) +- **Inputs had no visible focus indicator at all** — The text-input family set `focus-visible:outline-none` (or `focus:outline-none`) and drew nothing in its place, so the browser's native focus ring was removed and never replaced: a focused input looked exactly like an unfocused one. That is a plain [WCAG 2.4.7](https://www.w3.org/WAI/WCAG21/Understanding/focus-visible.html) failure for anyone navigating by keyboard, and three people in [discussion #355](https://github.com/blazorblueprintui/ui/discussions/355) reported it as the reason they chose a different library. It was an omission rather than a design decision — `BbButton`, `BbCheckbox`, `BbSwitch`, `BbTabs`, `BbToggle`, `BbSlider`, `BbCalendar`, `BbRadioGroupItem` and `BbPaginationLink` had all along used `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`, and the input family simply never got it. Inputs now use the same ring **without** `ring-offset-2`: the library's form rows leave only a 3px gap between a label and its control, and an offset ring extends 4px, so the offset version overlapped the label by a pixel on every labelled field. Hugging the border keeps the ring clear of the label and, on a bordered control, reads better than a detached halo — buttons and the other standalone controls keep their offset, the same split shadcn/ui makes. The ring is applied to `BbInput`, `BbInputField`, `BbInputGroupInput`, `BbInputGroupTextarea`, `BbNumericInput` (both the field and its stepper buttons), `BbCurrencyInput`, `BbMaskedInput`, `BbCombobox`, `BbMultiSelect` (both the trigger and the tag-remove button), `BbSelectTrigger`, `BbNativeSelect` and `BbInputOTP`. `BbTextarea` was a third case — it did indicate focus, but by tinting the border via `focus-visible:border-ring`, so the library gave three different answers to the same question; it now matches everything else. `BbDrawerItem`, `BbDatePickerInput` and `BbResponsiveNavTrigger` had the ring but were missing `ring-offset-2`, and are brought in line too. Deliberate variations are left alone: the sidebar components ring in `ring-sidebar-ring`, their own theme token, and omit the offset because it would bleed into adjacent rows; the event-calendar chips and day numbers likewise skip the offset for density; `BbBubbleContent` keeps its softer inline treatment for links and buttons inside a chat bubble; and overlay containers and roving-focus menu items keep `outline-none` on purpose, since they are focused programmatically or indicate selection through `data-[highlighted]` styling instead. Reported by [@eldo-xy](https://github.com/eldo-xy), [@JaweedSaleem](https://github.com/JaweedSaleem) and [@HugoVG](https://github.com/HugoVG). ([#457](https://github.com/blazorblueprintui/ui/issues/457)) +- **BbCopyText: the tooltip was clipped inside scrollable containers** — The component rendered its tooltip as a nested, absolutely-positioned `span` rather than through the portal every other overlay in the library uses. Absolute positioning is still subject to the overflow of its containing block — `z-index` does not exempt it from clipping, only from stacking — so any ancestor with `overflow: hidden` or `overflow: auto` cut the tooltip off at its edge, which in practice meant a copyable id inside a scrolling table or panel showed a sliver of a tooltip or none at all. It now renders through `BbFloatingPortal`, the same infrastructure behind `BbTooltip`, so it escapes the container entirely and behaves the way the reporter reasonably expected it to. Positioning uses the fixed strategy: the portal wrapper is itself `position: fixed`, so an absolute (document-relative) coordinate would be applied as though it were viewport-relative and place the tooltip off-screen by the page's scroll offset — a difference invisible on an unscrolled page and obvious on a scrolled one. The show/hide triggers, the copied state and the localized strings are unchanged; the opacity-and-translate transition went away with the old markup, since the portal mounts the tooltip on open rather than keeping a transparent copy in the layout. Note that this brings `BbCopyText` under the same requirement as the library's other overlays: `` must be present in the root layout. The demo page gains a scrollable-container example covering the reported case. Reported by [@SimonLiebers-Dev](https://github.com/SimonLiebers-Dev). ([#452](https://github.com/blazorblueprintui/ui/issues/452)) +- **BbSelect, BbPopover and BbDropdownMenu: closing one as its page was torn down could kill the Blazor Server circuit** — `CleanupAsync` in each of these primitives has two callers: the context state-change handler that runs when the overlay closes, and `DisposeAsync`. A close racing a teardown — navigating away from a page with an open select, or a conditional render removing one — puts both in flight at once, and each block checked its field for null, awaited, and then dereferenced the *field* again on the far side of that await. The second caller ran to completion while the first was suspended and nulled the field, so the first resumed and dereferenced null. The resulting `NullReferenceException` matched none of the `JSDisconnectedException`/`JSException`/`TaskCanceledException`/`ObjectDisposedException` filters guarding those calls, so it escaped `CleanupAsync`, escaped an unguarded `await CleanupAsync()` in `DisposeAsync`, and surfaced inside the renderer's disposal queue — where an unhandled exception is not a logged error but a dead circuit, giving every user on that page the reconnect banner. It was rare and non-deterministic by nature, needing the two calls to interleave on a live circuit: one production app saw 16 occurrences across 10 users in four months. Each cleanup block now takes ownership of what it is about to release, clearing the field *before* the first await, so a second caller finds nothing to do instead of racing for the same reference; `DisposeAsync` additionally guards its cleanup call and returns early if already disposed, so a teardown-time failure can no longer reach the renderer whatever its cause. `BbDropdownMenuContent` also read `Context.ContentId` through a cascading parameter declared non-null that is genuinely absent once its provider is gone — a second route to the same crash, now read defensively. Reported with a production stack trace by [@cscaminaci](https://github.com/cscaminaci). ([#441](https://github.com/blazorblueprintui/ui/issues/441)) +- **BbRangeSlider: tick marks collapsed onto the left edge in the same comma-decimal locales** — The identical bug, in the one place in the slider that had been missed: `StartPercentageValue`, `EndPercentageValue` and `RangePercentageValue` already formatted through `CultureInfo.InvariantCulture`, but the tick-mark loop interpolated its computed percentage directly, so a tick at one third rendered `left: 33,333333333333336%` and every tick stacked at position zero while the thumbs and the active range — which went through the invariant properties — sat correctly. Only sliders that set `TickValues` with `ShowTickMarks` were affected, which is why it survived alongside code that had already been fixed. The tick percentage now formats through `InvariantCulture`. The three existing percentage properties are also switched from the default `"G"` format to `"0.##"`: they were never affected by the separator, but `G` emits scientific notation below 1e-5, so a thumb resting just above `Min` on a wide range (`Min="0" Max="1000000000"`, value `1`) rendered `left: 1E-07%` — invalid CSS by a different route. A sweep of the component and primitive layers found no other raw numeric interpolation into a style attribute. ([#443](https://github.com/blazorblueprintui/ui/issues/443)) +- **BbDashboardGrid: stale JS→.NET callbacks to disposed grids flooded the browser console on Blazor Server** — Every navigation away from a page hosting a grid, every circuit reconnect, and every grid re-initialisation logged a burst of `JsOnBreakpointChanged failed` / `JsOnCompactComplete failed` errors — `There is no tracked object with id 'N'. Perhaps the DotNetObjectReference instance was already disposed.` Three compounding causes in `dashboard-grid.js`. The breakpoint `ResizeObserver` watched `document.body` rather than the grid, so it survived the grid's removal from the page and kept invoking the (now disposed) reference on any subsequent body resize, anywhere in the app. `initializeDashboardGrid` overwrote the instance map entry for an id without tearing the previous instance down, stacking a second pair of observers on the stale reference whenever a grid re-initialised. And every callback site logged every rejection with `console.error`, so each of these expected disposal races surfaced as an error even though nothing was actually wrong. Re-initialisation now disposes the existing instance first — `disposeDashboardGrid` already did exactly the right teardown, it was just never called on that path. Both observers now self-dispose once the grid element is no longer connected to the document, which is what actually stops the stale breakpoint callbacks after navigation; the observer deliberately still watches `document.body`, since recalculating breakpoints on body resizes that do not originate from the grid (a sidebar collapse, say) is current behaviour consumers may rely on — only the orphaned-after-disposal case is removed. And the eight `invokeMethodAsync` call sites now go through a shared `invokeSafely` helper that drops disposed-reference and `JSDisconnectedException` rejections silently while logging everything else exactly as before, so a genuine JS→.NET failure stays visible. Contributed by [@netclectic](https://github.com/netclectic). ([#440](https://github.com/blazorblueprintui/ui/issues/440)) + +--- + +## 2026-07-21 + +### Added + +- **BbDataGridPropertyColumn: `HeaderTemplate`** — The property column explicitly returned `null` for the column interface's header-template slot, so its header was locked to plain text from `Title` (or the name inferred from `Property`). Wanting an icon in the header — or any markup at all — meant abandoning the property column for a `BbDataGridTemplateColumn` and hand-rolling `SortBy`, `FilterBy` and the cell rendering that the property expression had been providing for free. `HeaderTemplate` now fills that slot. It replaces the **title text only**, not the whole header cell: the grid keeps rendering the sort indicator, filter icon, pin icon, `Groupable` ellipsis menu and resize handle around the supplied content, so a `Sortable`/`Groupable` column with an icon header still sorts on click, still shows its arrow and priority badge, and still offers "Group by" with nothing extra from the consumer. The parameter matches `BbDataGridTemplateColumn.HeaderTemplate` in both shape (`RenderFragment`) and behaviour, so the two column types stay consistent. For icon-only headers, keep `Title` set — the column chooser, the column menu's labels and (as of [#432](https://github.com/blazorblueprintui/ui/issues/432), below) the header cell's own accessible name all read from it. ([#423](https://github.com/blazorblueprintui/ui/issues/423)) +- **BbNumericInput / BbCurrencyInput: `EnableWheelStep`** — Scrolling the mouse wheel over a focused numeric input can now step its value. The wheel is mapped onto `ArrowUp`/`ArrowDown` and pushed through the same `JsOnKeyDown` path the arrow keys already use, so `Min`, `Max`, `Step` and every clamping rule stay in one place in C# rather than being reimplemented in JS. It is **opt-in and off by default**, because stepping can only work by calling `preventDefault()` on the wheel event, which takes the scroll away from the page: a long form whose pointer happens to cross a focused numeric input would otherwise stop scrolling and quietly change a number instead, with nothing on screen to explain it. With `EnableWheelStep` unset no wheel listener is attached at all — not an attached listener that returns early — so scrolling is exactly what it was for everyone who does not opt in. That follows the opt-out precedent set by `BbSidebarProvider.EnableToggleShortcut` in [#403](https://github.com/blazorblueprintui/ui/pull/403), and matches it on reactivity too: changing the parameter after the first render attaches or removes the listener instead of only applying at startup. Wheel distance is **accumulated** rather than counted one step per event, because a trackpad emits dozens of momentum events for a single flick — a measured 25-event macOS momentum burst (`deltaY` tapering −12 → −1, 163px in total) stepped the value 25 times, which on a Blazor Server circuit was also 25 `invokeMethodAsync` round-trips for one gesture; it now steps once, for one round-trip. The threshold is 100px, one detent on a standard mouse wheel in Chrome, Edge and Safari and normalised from Firefox's line and page delta modes, so a discrete wheel notch still steps once and does so immediately; the remainder is carried between events, and the accumulator resets after a 200ms idle gap so two deliberate flicks are not summed into one step. The parameter is available on `BbNumericInput`, `BbCurrencyInput`, `BbFormFieldNumericInput` and `BbFormFieldCurrencyInput`. Contributed by [@whis](https://github.com/whis). ([#421](https://github.com/blazorblueprintui/ui/pull/421)) + +### Fixed + +- **BbTooltipTrigger: an `AsChild` child that ignores the trigger context now says so** — `BbTooltipTrigger.AsChild` defaults to `true` in the Components layer, and in that mode the trigger renders *no element and no handlers at all* — only a cascading `TriggerContext` that the child is expected to consume and wire the hover/focus behaviour up from. `BbButton` does exactly that, which is why the documented `` composition works. Anything that does not — plain markup, text, or a bare ``, which is the natural thing to reach for in a table cell or beside a field label — produced a trigger with nothing listening for hover, so the tooltip could never open. No exception, no console error, no visual clue: the markup looked right and simply did nothing, and every example on the demo page either set `AsChild="false"` or wrapped a `BbButton`, so nothing on the page contradicted it. The trigger now reports the case: on first render, an `AsChild` trigger whose context was never touched by any child logs a warning through `ILogger` naming both ways out (`AsChild="false"`, or a child that consumes the context). Consumption is recorded by the context itself — reading any of its members marks it, which covers every child that applies the id or aria attributes as it renders, and a custom child that only touches the context inside event handlers can call the new `TriggerContext.NotifyConsumed()` to acknowledge it — so legitimate compositions, including a tooltip trigger nested inside a dialog or popover trigger, stay silent. The warning is gated on the host application reporting the `Development` environment (resolved once, by name, from whichever environment abstraction the render mode registers), so it costs a cached boolean read in production and never reaches anyone's telemetry. The default is deliberately left at `true` for now: flipping it would add a wrapping `span` to every existing correct usage, which is a breaking change held for the next major. ([#425](https://github.com/blazorblueprintui/ui/issues/425)) +- **BbHoverCardTrigger: the same silent `AsChild` no-op now warns as well** — `BbHoverCardTrigger` has exactly the shape [#425](https://github.com/blazorblueprintui/ui/issues/425) described for the tooltip, and exactly the same failure. `AsChild` defaults to `true` in the Components layer, and in that mode the trigger renders *no element and no handlers at all* — only a cascading `TriggerContext` that the child is expected to consume and wire the hover, focus and element-reference callbacks up from. `BbButton` does that, which is why `` works. Anything that does not — an avatar image, a `@username` span, a bare `` next to a name — left the hover card with nothing listening for `mouseenter`, so it could never open, and said nothing about it: no exception, no console error, no visual clue, just markup that looked correct and did nothing on hover. The trigger now reports it, using the machinery [#425](https://github.com/blazorblueprintui/ui/issues/425) already put in place: on first render, an `AsChild` trigger whose cascaded context was never touched by any child logs a warning through `ILogger` naming both ways out (`AsChild="false"` for plain content, or a child that consumes the context). Consumption is recorded by the context itself — reading any of its members marks it, which covers every child that applies the trigger id or aria attributes as it renders, and a custom child that only touches the context from inside event handlers can call `TriggerContext.NotifyConsumed()` to acknowledge it — so correct compositions stay silent, including a hover card trigger nested inside a dialog, popover or dropdown trigger, where reading the parent context to merge its click and aria behaviour marks that parent consumed too. The warning is gated on the host application reporting the `Development` environment, resolved once by name from whichever environment abstraction the render mode registers, so it costs a cached boolean read in production and never reaches anyone's telemetry. The `AsChild` default is deliberately left at `true`: flipping it would add a wrapping `div` to every existing correct usage, and that breaking change is tracked separately for the next major. The hover card demo page gains a plain-content and icon section covering the case, and the `AsChild` documentation in both layers now spells the contract out instead of describing it as merely "passing trigger behavior to child components". ([#433](https://github.com/blazorblueprintui/ui/issues/433)) +- **BbDataGrid: a column produced indirectly moved to the end, or vanished from the grid entirely** — Columns register themselves with the grid from their own `OnInitialized`, and the grid appended each one to a list, so column order was really *component-initialisation* order. On an interactive circuit that stops matching declaration order the moment a column is produced indirectly: a column rendered by a wrapper component initialises a render pass later than the columns declared beside it, and so drifted to the end of the grid. (Deriving the column from `BbDataGridPropertyColumn` with `@inherits` was never affected — only wrapping.) Worse, the grid initialises its column state — the ordered, per-column visibility record that the header and body are rendered from — from whichever columns had registered by the first render pass to see any, and then latched. Every column arriving after that point was missing from that record and was therefore dropped from the grid: no header, no cells, no console error, no exception. A column behind an `await` — a wrapper that loads lookup data before rendering its inner column, say — reliably disappeared, and prerender and the interactive circuit disagreed about the columns, so the grid visibly reshuffled as the page came alive. Late registrations are now merged into the existing column state instead of ignored, each one placed next to its neighbours rather than appended, so it appears where it belongs; a user's own reordering and visibility choices are left untouched. Alongside that, `BbDataGridPropertyColumn`, `BbDataGridTemplateColumn` and `BbDataGridHierarchyColumn` gain an **`Order`** parameter that positions a column explicitly, as a zero-based index among the data columns, for cases where initialisation order cannot match declaration order. Columns that leave `Order` unset keep their existing registration order and each column that sets it is inserted at that index — so a grid that sets `Order` nowhere is laid out exactly as before — and the select and expand columns keep their fixed leading positions regardless. ([#424](https://github.com/blazorblueprintui/ui/issues/424)) +- **BbDataGrid: an icon-only `HeaderTemplate` left the column unnamed for screen readers** — `BbDataGridHeaderCell` set `aria-sort` on the `` but never `aria-label`, so the cell's accessible name came entirely from its own content. That is exactly right for the default header, whose content *is* the title text — but a `HeaderTemplate` replaces that text with arbitrary markup, and the case the template exists to serve, an icon on its own, contributes no text at all. `Title` was already sitting on the column and was already being used for the column chooser, the column menu, the filter button and the "Group by" item, yet it never reached the header cell, so the column announced as empty and the only clue was that there was none: nothing in the UI hints that a column has no name. The documented workaround was to hand-write an `sr-only` span inside every such template, which worked but put the burden on the consumer for something the grid already knew and failed silently the first time anyone forgot. The grid now supplies the name itself: while a `HeaderTemplate` is in play and `Title` is non-empty, the header cell is labelled with the title, so an icon-only header is announced correctly with nothing extra from the consumer. The rule is deliberately narrow, because `aria-label` *replaces* an element's content as its accessible name rather than adding to it — a plain text header is left untouched and keeps being announced from its own text, gaining neither a redundant nor a conflicting label, and a column with a template but no title is left alone too. Where a template renders its own text, the title now wins the announcement; that is the intended trade, since `Title` is already the canonical name for the column everywhere else in the grid and the header agreeing with the column chooser and the column menu is worth more than echoing decorative header markup. It also means the existing `sr-only` workaround degrades gracefully instead of double-announcing — a template that already carries screen-reader text is announced once, by title, so nobody has to go and unpick theirs. This applies identically to `BbDataGridPropertyColumn.HeaderTemplate` and `BbDataGridTemplateColumn.HeaderTemplate`, which have the same shape and the same exposure. Underneath, the Primitives-layer `BbDataGridHeaderCell` gains an **`AriaLabel`** parameter carrying the same guidance, so a grid assembled directly from the primitives — where a checkbox-only or icon-only header cell hits the identical gap — can name its header cells too; leaving it unset preserves naming from content, so no existing markup changes. `aria-sort`, keyboard sorting and the header's click behaviour are unaffected. ([#432](https://github.com/blazorblueprintui/ui/issues/432)) +- **BbDataGrid: dragging a column rightwards dropped it one position too far** — Dragging a header cell to the right overshot the drop indicator by exactly one column: dropping `Name` onto the right half of `Department` in a `Name, Email, Department, Role, Salary` grid landed it *after* `Role`, and dropping onto the last column pushed it to the very end regardless of which half of the cell was released over. Dragging leftwards was correct, which is what made the fault look so arbitrary in use. The two halves of the reorder had disagreed about whether the dragged column was still in the list. The `drop` handler in `datagrid-columns.js` took the target's index across the rendered header cells — a list that still contains the column being dragged — while `DataGridColumnState.ReorderColumn` lifts that column out *first* and treats the index it is given as an insertion point into what remains. Moving rightwards, the removal shifts everything after the old position down by one, so the index arrived one too high. The clamp inside `ReorderColumn` masked the symptom in exactly one case, the drop past the final column, which is why the grid appeared to behave at the right-hand edge. The JS layer now reports the drop as the gesture it actually is — *which* column received it and *which side* of that column the pointer was released over — and `BbDataGrid.OnColumnReordered` resolves that to a position against the entry list with the dragged column already excluded. Naming a column instead of a slot also removes a second, quieter mismatch: the header row is not a one-to-one view of the column order, because hidden columns have no header cell at all and pinned columns are re-partitioned to the edges of the row irrespective of their stored order, so a header-cell index was never safely translatable in the first place. A grid carrying a right-pinned column now places a drop at the end of the unpinned run correctly rather than parking the column behind the pinned one. Pinned, select and expand columns are untouched by any of this: they remain undraggable and refuse drops, and `ReorderColumn` itself is unchanged, so the programmatic reordering used by `DataGridContext.ReorderColumn` keeps its existing semantics — now spelled out on the method, since the remove-then-insert convention is the thing that was easy to get wrong. The DataGrid demo gains a reordering example that combines pinned columns with a selection column, so the component-level drag path is exercised alongside the positional special cases rather than only in isolation. ([#434](https://github.com/blazorblueprintui/ui/issues/434)) + +--- + +## 2026-07-20 + +### Fixed + +- **AngleSharp vulnerability inherited through HtmlSanitizer (GHSA-pgww-w46g-26qg)** — Assemblies referencing `BlazorBlueprint.Components` with NuGet audit enabled had begun to fail their builds. `HtmlSanitizer` 9.0.892 hard-pins `AngleSharp` to exactly `[0.17.1]` (and `AngleSharp.Css` to `[0.17.0]`), which the advisory flags as Moderate. Because the pin is exact, the transitive `AngleSharp` cannot be lifted out with a direct `PackageReference` — attempting it raises `NU1608` against both `HtmlSanitizer` and `AngleSharp.Css`, and leaves 9.0.892 running against an `AngleSharp` major version it was never compiled for. 9.0.892 is also the newest release on the stable line, so no stable version resolves the advisory. `HtmlSanitizer` is therefore bumped to **9.1.949-beta**, whose dependency chain resolves `AngleSharp` 1.5.1 — past the 1.0.0 fix — clearing the advisory. The library's only use of the package is `new HtmlSanitizer()` and `Sanitize(string)` in `BbRichTextEditor` and `BbMarkdownEditor`; sanitizer output was compared across 41 inputs (XSS vectors, Quill rich-text markup, Markdig output, malformed and non-ASCII HTML) and is byte-identical between the two versions, so no behavioural change is expected. Note that this makes `HtmlSanitizer` — and `AngleSharp.Css` 1.0.0-beta.216 — a **prerelease** transitive dependency of Components; it will be moved back to the 9.1.x stable line as soon as one is published, tracked in [#426](https://github.com/blazorblueprintui/ui/issues/426). ([#422](https://github.com/blazorblueprintui/ui/pull/422)) + +--- + +## 2026-07-19 + +### Added + +- **BbFileUpload: `Id`** — The underlying `` has always carried a generated id, but it was private, so there was no way to point a `