Skip to content

Focus trap always focuses the first tabbable child, and HoverCard opens on programmatic focus #504

Description

@mathewtaylor

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:235Timer(Context.OpenDelay) HandleFocus:263Context.Open immediately
AsChild HandleMouseEnterForContext:120Timer(Context.OpenDelay) HandleFocusForContext:146Context.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions