Skip to content

The Fluent UI collection field still drops keyboard focus when a control unmounts or disables itself #337

Description

@phmatray

Problem / motivation

#318 (landed as #324) swept every control in the MudBlazor adapter whose activation destroys its own
reachability, routing them all through a new shared helper
FormCraft.ForMudBlazor/Fields/FocusRestore.cs. The Fluent UI adapter — shipped "at parity with
MudBlazor" in #278 (landed as #291) — has the identical control shapes and received none of it.

So the WCAG 2.1 2.4.3 Focus Order (Level A) failure #318 fixed is still live in
FormCraft.ForFluentUI: activating one of these leaves focus on <body>, and the next Tab
restarts from the top of the document.

Verified against origin/dev at the time #324 merged, in
FormCraft.ForFluentUI/Features/CollectionField/FluentUICollectionFieldComponent.razor:

Line Control Why focus is lost
13/16 Add@if (Configuration.CanAdd && !HasReachedMax), OnClick="@AddItem" unmounts itself on the click that reaches MaxItems
59/62 delete@if (Configuration.CanRemove && !HasReachedMin), OnClick="@(() => RemoveItem(index))" unmounts that row's own delete button; on reaching MinItems it unmounts every row's at once
45/46 Move upDisabled="@(index == 0)" browsers drop focus from a newly-disabled element
52/53 Move downDisabled="@(index == Items.Count - 1)" same at the last index

This is a strict subset of #318, not a full mirror. The Fluent adapter has no file-upload field yet
(the README lists file upload under Not yet covered), so the two upload controls #318 fixed have no
counterpart here. Only the four collection controls apply.

Why this was not folded into #324. FocusRestore.FocusSafelyAsync takes
MudBlazor.MudBaseButton, so it cannot be reused as-is — porting it is a design decision about where
the seam goes, not a copy-paste, and #324 was already the sweep of one adapter.

Proposed solution

Move the framework-neutral half of FocusRestore into FormCraft core and give each adapter a thin
typed wrapper over it.

The valuable part of that helper is not the MudBlazor type — it is the swallow-safe catch list, which
mentions no UI framework at all: JSException, JSDisconnectedException, OperationCanceledException,
ObjectDisposedException, InvalidOperationException. That list was got wrong once already (#281
shipped without JSException and a failed focus escaped the click handler, which on Blazor Server tears
down the circuit), which is exactly the kind of knowledge that must not exist twice.

So: a core helper taking a Func<ValueTask> (or an ElementReference), plus per-adapter wrappers that
accept that adapter's button type and hand its FocusAsync in. Core stays free of UI-framework types —
the invariant the architecture rests on — and both adapters share the one thing worth sharing.

Then wire the four Fluent controls to it with the same focus targets #318 settled on for MudBlazor:

Control Target
row delete the delete button taking the vacated slot → the previous row's → Add → the collection header
Add, only when it reaches MaxItems the new row's header
Move up/down, when the item lands at an end that row's still-enabled counterpart, in the direction the item was travelling

Alternatives considered

  • A Fluent-typed twin of FocusRestore, duplicating the catch list. Smallest diff, no core change,
    and each adapter keeps its own types. Rejected: it copies the one part that is genuinely hard-won and
    framework-neutral, and The security pipeline is duplicated line-for-line in both UI adapters #321 already exists because the security pipeline was duplicated between these
    same two adapters line-for-line. Two copies of a catch list is how the missing JSException would
    come back.
  • Put the whole helper in core, typed against a shared button abstraction. Rejected: there is no such
    abstraction and inventing one to serve focus alone is a large seam for a small need — the adapters'
    button types have nothing else in common. Func<ValueTask> is the narrow waist that already fits.
  • Do nothing; document Fluent as not covering focus management. Rejected: the adapter is advertised
    at parity, and a Level A failure is not a documentation matter. The README claim was already narrowed
    to the MudBlazor adapter in fix(mudblazor): move focus deliberately after a self-unmounting control (#318) #324 precisely so this issue could be filed honestly rather than papered
    over.

Area

FormCraft.ForFluentUI — collection field, accessibility / focus management; plus a small UI-agnostic
helper in FormCraft core


Follow-up from #318 (landed as #324). Related: #278, #291, #321, #281

🧠 Brainstorm

Problem / context

Two adapters, one defect shape, one of them already fixed. The interesting question is not what to do
in Fluent — #318 settled the targets and the mechanism — but where the shared code lives, because the
answer decides whether the next adapter (or the next control) inherits the fix or re-implements it.

What is actually framework-specific in FocusRestore? Only the parameter type. The body is:

try { await target.FocusAsync(); }
catch (JSException) { } catch (JSDisconnectedException) { }
catch (OperationCanceledException) { } catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }

FocusAsync() is not an interface — MudBlazor's MudBaseButton and Fluent's button components each
declare their own — so the only thing the two share is the shape of the call, which is exactly what a
delegate captures.

Approaches

A. Framework-neutral core helper + thin per-adapter wrappers. Core exposes something like
FocusRestore.SafelyAsync(Func<ValueTask> focus) and an ElementReference overload; each adapter keeps
a one-line wrapper taking its own button type. Pros: the catch list — the part with the incident
history — lives once; core takes on no UI types; adding a third adapter is a one-line wrapper. Cons:
one more indirection, and a Func<ValueTask> allocation per call (irrelevant at focus frequency).

B. Fluent-typed twin. Copy the helper, swap the type. Pros: zero core change, trivially obvious.
Cons: duplicates the catch list, which is precisely the knowledge that must not drift; and it deepens
the exact complaint #321 files about these two adapters.

C. A shared button abstraction in core that both adapters' buttons implement. Pros: strongly typed
end-to-end. Cons: the adapters' button types are third-party and cannot implement a FormCraft
interface; it would need wrapper components, a large seam for one small need.

Recommendation

A. It puts exactly the framework-neutral part in the framework-neutral place and leaves each adapter
owning its own types. B is tempting for the smaller diff and is the option to fall back to only if the
core placement turns out to drag UI concepts along with it — but on inspection it does not, since the
delegate hides every framework type.

Note the Fluent adapter's controls should be checked for whether they even expose a FocusAsync()
#318 measured that for MudBlazor's MudBaseButton and MudIconButton and found it, but Fluent is a
different library and the equivalent must be confirmed before the plan below relies on it. If a Fluent
button does not expose one, the ElementReference overload is the fallback for every target.

📋 Spec

Goal

In FormCraft.ForFluentUI, activating a collection control that unmounts or disables itself leaves
keyboard focus on a visible, still-operable element of the same field — never on <body> — matching the
guarantee #318 established for MudBlazor.

Scope

Non-goals

Behaviour

flowchart TD
    A["core: SafelyAsync(Func&lt;ValueTask&gt;)"] --> B["catch list lives here, once"]
    C["ForMudBlazor wrapper<br/>(MudBaseButton)"] --> A
    D["ForFluentUI wrapper<br/>(Fluent button)"] --> A
    C --> E["upload + collection controls"]
    D --> F["collection controls"]
Loading

Key files

  • FormCraft/Forms/Rendering/ — the new core helper
  • FormCraft.ForMudBlazor/Fields/FocusRestore.cs — becomes a wrapper
  • FormCraft.ForFluentUI/Features/CollectionField/FluentUICollectionFieldComponent.razor (+ .razor.cs)
  • FormCraft.ForFluentUI.UnitTests/ — new focus tests, mirroring CollectionFocusTests
  • FormCraft.ForMudBlazor.UnitTests/TestSupport/FocusAssertingTestBase.cs — the assertion technique to copy

Validation rules

  • After each of the four actions, exactly one focus request is recorded, on the intended target.
  • A focus call that throws never breaks the underlying action.
  • With two collection fields on one form, focus lands in the acted-on field.
  • Focus is not moved where it would not have been lost — notably AddItem when MaxItems is 0
    (the default), where Add does not unmount.
  • Five more controls drop keyboard focus when they unmount or disable themselves #318's MudBlazor tests pass unmodified after the extraction.

Edge cases

  • A Fluent button with no FocusAsync() — confirm before relying on it; fall back to the
    ElementReference overload for every target if so.
  • Removing the only row / reaching MinItems — every delete button unmounts at once; fall back to
    Add, then the header.
  • A single item cannot be reordered at all (the handlers early-return), so no focus is issued —
    assert that rather than a fallback.
  • Prerender/SSR and a disposed component — covered by the shared catch list.

Assumptions

  • Fluent's collection buttons are components (not plain elements), so the @ref-capture caveat below
    applies to them too. Confirm at implementation time.
  • Target is a patch. Base branch is dev.

🛠️ Implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: no control in FormCraft.ForFluentUI drops keyboard focus to <body> by unmounting or
disabling itself, and the swallow-safe catch list exists exactly once.

Architecture: the framework-neutral helper goes in FormCraft core; no UI-framework type may
follow it there
. Each adapter keeps a thin typed wrapper.

Tech stack: .NET 8 / 10 multi-target, Blazor, Fluent UI Blazor v5, MudBlazor 9.8.0, xUnit + bUnit +
Shouldly.

Global constraints:

Task 1: Extract the framework-neutral focus helper into core

Files: create FormCraft/Forms/Rendering/FocusRestore.cs; modify FormCraft.ForMudBlazor/Fields/FocusRestore.cs; test FormCraft.UnitTests/Rendering/FocusRestoreTests.cs.

Interfaces: internal static Task SafelyAsync(Func<ValueTask> focus) and an ElementReference overload, holding the catch list.

  • Step 1: Write the failing test in core — a delegate that throws each of the five swallowed exception types completes without throwing, and one that succeeds is invoked exactly once.
  • Step 2: Run that suite → FAIL (helper does not exist).
  • Step 3: Add the core helper, moving the catch list and its doc-comment verbatim from the MudBlazor copy.
  • Step 4: Make FormCraft.ForMudBlazor's FocusRestore delegate to it, keeping its MudBaseButton signature.
  • Step 5: Run the whole suite → PASS, with Five more controls drop keyboard focus when they unmount or disable themselves #318's MudBlazor focus tests unmodified — that is the proof the extraction is behaviour-preserving.
  • Step 6: Commit: refactor(core): move the swallow-safe focus call into core.

Task 2: Confirm the Fluent controls can take focus, and add the wrapper

Files: create FormCraft.ForFluentUI/Fields/FocusRestore.cs; create FormCraft.ForFluentUI.UnitTests/TestSupport/FocusAssertingTestBase.cs.

Interfaces: a Fluent-typed FocusSafelyAsync(...) over the core helper.

  • Step 1: Determine whether the Fluent button components expose a public FocusAsync() — write a throwaway test that focuses one and dumps the recorded JSInterop invocations. Do not assume: Five more controls drop keyboard focus when they unmount or disable themselves #318 measured this for MudBlazor only.
  • Step 2: Run it and read the dump; record the identifier and the parameter type, or conclude the ElementReference overload is required instead.
  • Step 3: Port the focus-assertion helpers into the Fluent test project, mirroring FormCraft.ForMudBlazor.UnitTests/TestSupport/FocusAssertingTestBase.cs, and write the finding from Step 2 into its class remarks.
  • Step 4: Add the Fluent wrapper over the core helper, with a test that a failing focus call does not throw (set the interop to throw — in Loose mode focus always succeeds, so without this the catch has zero coverage).
  • Step 5: Delete the throwaway test. Run the suite → PASS.
  • Step 6: Commit: test(fluentui): establish the focus-assertion technique.

Task 3: Keep focus in the list after removing a Fluent collection row

Files: modify FluentUICollectionFieldComponent.razor and .razor.cs; create FormCraft.ForFluentUI.UnitTests/Fields/CollectionFocusTests.cs.

Interfaces: a per-index delete-button reference store, an Add reference, and a per-row header ElementReference.

  • Step 1: Write the failing tests — three rows, remove the middle one, assert focus is on the delete button now occupying that slot; removing the last row falls back to the previous row's.
  • Step 2: Add the fallback tests: removing down to MinItems (every delete button unmounts) focuses Add; with CanAdd false too, focus lands on the row/collection header.
  • Step 3: Run the suite → FAIL.
  • Step 4: Implement the per-index capture and the deferred focus move, following the two ⛔ constraints above (no per-render pruning; move from OnAfterRenderAsync).
  • Step 5: Run the suite → PASS.
  • Step 6: Commit: fix(fluentui): keep focus in the list after removing a collection row.

Task 4: Add and reorder

Files: modify FluentUICollectionFieldComponent.razor and .razor.cs; extend CollectionFocusTests.cs.

Interfaces: reuses Task 3's stores, plus per-index reorder-button references.

  • Step 1: Write the failing test for Add at MaxItems — focus moves into the new row.
  • Step 2: Write the test that Add below MaxItems (the default, MaxItems == 0) does not move focus at all — the button survives and the user is already on it.
  • Step 3: Write the failing reorder tests: moving an item to index 0 focuses its Move down; to the last index, its Move up; and a mid-list move focuses the button for the direction it was travelling (the case that catches an always-prefer-up implementation).
  • Step 4: Add the single-item case: both reorder buttons are disabled and the handlers no-op, so no focus is issued.
  • Step 5: Run the suite → FAIL.
  • Step 6: Implement the focus moves at the end of AddItem, MoveItemUp and MoveItemDown.
  • Step 7: Run the suite → PASS, whole suite green.
  • Step 8: Commit: fix(fluentui): move focus deliberately after add and reorder.

Task 5: Prove per-field isolation, and document

Files: extend CollectionFocusTests.cs; modify README.md; modify CLAUDE.md; modify FormCraft.ForFluentUI/README.md.

Interfaces: none new.

  • Step 1: Add a test rendering two Fluent collection fields on one form, acting on the second, asserting focus landed in the second — the per-instance references are what make this hold.
  • Step 2: Update CLAUDE.md so the focus rule covers both adapters and names the core helper as the single home of the catch list.
  • Step 3: Update the README ## 🎉 Unreleased entry from fix(mudblazor): move focus deliberately after a self-unmounting control (#318) #324, which currently scopes the guarantee to "the MudBlazor adapter", now that Fluent is covered too.
  • Step 4: Run dotnet build -c Release and dotnet test -c Release → both green.
  • Step 5: Commit: docs(fluentui): record the collection focus targets.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions