Plan: docs/plans/2026-08-29-focus-trap-and-hovercard-focus.md
Two defects reported externally against 3.15.0, confirmed by decompiling the shipped package and reading the shipped JS. I re-confirmed both against develop at 3.16.0 — neither is already fixed.
1. The focus trap offers no way to set initial focus
focus-trap.js:57-61 ends createFocusTrap with an unconditional focusableElements[0].focus(). createFocusTrap(container) takes one argument, and so does IFocusManager.TrapFocus(ElementReference container), so there is no override anywhere in the chain.
Primitives/Dialog/BbDialogContent.razor already renders its container with @ref="_contentRef" (line 14) and tabindex="-1" (line 20), and passes that same reference to TrapFocus on line 104 — so the container is focusable and in hand, and the trap reaches past it to the first tabbable descendant. Radix, the pattern this follows, focuses the container by default and exposes onOpenAutoFocus to redirect.
Consequence: any dialog whose first tabbable descendant has an on-focus side effect fires it on open, unasked. The only consumer workaround is a dummy tabindex="0" element, which adds a phantom tab stop.
Not in the original report: [data-autofocus] is already honoured, but by a different mechanism — portal.js:168-190 focuses it on a blazorblueprint:visible event, and BbDropdownMenuContent.razor:165 relies on it. So the two can race inside a dialog today, with the winner decided by event ordering. Teaching the trap the same attribute both fixes the reported gap and closes that race.
2. BbHoverCardTrigger opens on any focus, ignoring OpenDelay
| path |
mouse |
focus |
AsChild="false" |
HandleMouseEnter:235 → Timer(Context.OpenDelay) |
HandleFocus:263 → Context.Open immediately |
AsChild |
HandleMouseEnterForContext:120 → Timer(Context.OpenDelay) |
HandleFocusForContext:146 → Context.Open immediately |
The important half is that it opens on any focus, including focus moved programmatically by a focus trap or an explicit .focus(). Gating on :focus-visible separates user intent from a programmatic move; keyboard users still get the card, because Tab sets :focus-visible.
Worth testing rather than assuming: Primitives/Table/BbTableRow.razor:58-59 records that the team deliberately avoided focus-visible: once because programmatic .focus() does not fire it reliably across browsers.
One deliberate departure from the report
The report also asks for OpenDelay to apply to the focus path, for consistency. I do not think it should. OpenDelay defaults to 700 ms. The delay exists because a pointer sweeps across elements incidentally; Tab does not — landing on a trigger is deliberate. Making a keyboard user wait 700 ms for what a mouse user gets by resting still is an accessibility regression, and WCAG 1.4.13 is the reason the focus path exists at all.
Once the :focus-visible gate lands, the reported symptom is gone and what remains is a deliberate difference. If consistency is still wanted, a separate FocusOpenDelay defaulting to 0 is the safer shape.
Scope
TrapFocus gains an optional argument — optional because IFocusManager is public and a required parameter would break anyone implementing it — plumbed through the four call sites: BbDialogContent.razor:104, BbSheetContent.razor:105, BbDrawerContent.razor:80, BbDialogProvider.razor:140.
Making container-focus the default (full Radix parity) is not included. That changes behaviour for every existing dialog and belongs in a major.
Plan:
docs/plans/2026-08-29-focus-trap-and-hovercard-focus.mdTwo defects reported externally against 3.15.0, confirmed by decompiling the shipped package and reading the shipped JS. I re-confirmed both against
developat 3.16.0 — neither is already fixed.1. The focus trap offers no way to set initial focus
focus-trap.js:57-61endscreateFocusTrapwith an unconditionalfocusableElements[0].focus().createFocusTrap(container)takes one argument, and so doesIFocusManager.TrapFocus(ElementReference container), so there is no override anywhere in the chain.Primitives/Dialog/BbDialogContent.razoralready renders its container with@ref="_contentRef"(line 14) andtabindex="-1"(line 20), and passes that same reference toTrapFocuson line 104 — so the container is focusable and in hand, and the trap reaches past it to the first tabbable descendant. Radix, the pattern this follows, focuses the container by default and exposesonOpenAutoFocusto redirect.Consequence: any dialog whose first tabbable descendant has an on-focus side effect fires it on open, unasked. The only consumer workaround is a dummy
tabindex="0"element, which adds a phantom tab stop.Not in the original report:
[data-autofocus]is already honoured, but by a different mechanism —portal.js:168-190focuses it on ablazorblueprint:visibleevent, andBbDropdownMenuContent.razor:165relies on it. So the two can race inside a dialog today, with the winner decided by event ordering. Teaching the trap the same attribute both fixes the reported gap and closes that race.2.
BbHoverCardTriggeropens on any focus, ignoringOpenDelayAsChild="false"HandleMouseEnter:235→Timer(Context.OpenDelay)HandleFocus:263→Context.OpenimmediatelyAsChildHandleMouseEnterForContext:120→Timer(Context.OpenDelay)HandleFocusForContext:146→Context.OpenimmediatelyThe important half is that it opens on any focus, including focus moved programmatically by a focus trap or an explicit
.focus(). Gating on:focus-visibleseparates user intent from a programmatic move; keyboard users still get the card, because Tab sets:focus-visible.Worth testing rather than assuming:
Primitives/Table/BbTableRow.razor:58-59records that the team deliberately avoidedfocus-visible:once because programmatic.focus()does not fire it reliably across browsers.One deliberate departure from the report
The report also asks for
OpenDelayto apply to the focus path, for consistency. I do not think it should.OpenDelaydefaults to 700 ms. The delay exists because a pointer sweeps across elements incidentally; Tab does not — landing on a trigger is deliberate. Making a keyboard user wait 700 ms for what a mouse user gets by resting still is an accessibility regression, and WCAG 1.4.13 is the reason the focus path exists at all.Once the
:focus-visiblegate lands, the reported symptom is gone and what remains is a deliberate difference. If consistency is still wanted, a separateFocusOpenDelaydefaulting to 0 is the safer shape.Scope
TrapFocusgains an optional argument — optional becauseIFocusManageris public and a required parameter would break anyone implementing it — plumbed through the four call sites:BbDialogContent.razor:104,BbSheetContent.razor:105,BbDrawerContent.razor:80,BbDialogProvider.razor:140.Making container-focus the default (full Radix parity) is not included. That changes behaviour for every existing dialog and belongs in a major.