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.

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions