You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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:
unmounts that row's own delete button; on reaching MinItems it unmounts every row's at once
45/46
Move up — Disabled="@(index == 0)"
browsers drop focus from a newly-disabled element
52/53
Move down — Disabled="@(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
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:
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
A framework-neutral swallow-safe focus helper in FormCraft core.
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.
Base branch dev; commit as Philippe Matray <phmatray@gmail.com>; conventional commits.
TreatWarningsAsErrors=true — the build fails on any warning.
Per-class test filter: dotnet test <proj>.csproj -c Release -- --filter-class <FQN>.
⛔ dotnet test --filter is inert here (MTP0001); run the full suite before claiming done.
⛔ No MudBlazor or Fluent type may leak into FormCraft core.
⛔ Do not prune a per-index @ref store per render. A @ref on a component is captured once at
creation and is not re-run on later renders; pruning permanently loses retained rows' references
(measured under Five more controls drop keyboard focus when they unmount or disable themselves #318 — every removal silently fell through to Add). Decide staleness from what is
rendered now instead.
⛔ Move focus from OnAfterRenderAsync, not the handler — the target row may not exist, or be at
that index, until the next batch is applied.
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 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 belowMaxItems (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.
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.
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 withMudBlazor" 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 Tabrestarts from the top of the document.
Verified against
origin/devat the time #324 merged, inFormCraft.ForFluentUI/Features/CollectionField/FluentUICollectionFieldComponent.razor:@if (Configuration.CanAdd && !HasReachedMax),OnClick="@AddItem"MaxItems@if (Configuration.CanRemove && !HasReachedMin),OnClick="@(() => RemoveItem(index))"MinItemsit unmounts every row's at onceDisabled="@(index == 0)"Disabled="@(index == Items.Count - 1)"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.FocusSafelyAsynctakesMudBlazor.MudBaseButton, so it cannot be reused as-is — porting it is a design decision about wherethe seam goes, not a copy-paste, and #324 was already the sweep of one adapter.
Proposed solution
Move the framework-neutral half of
FocusRestoreintoFormCraftcore and give each adapter a thintyped 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 (#281shipped without
JSExceptionand a failed focus escaped the click handler, which on Blazor Server tearsdown the circuit), which is exactly the kind of knowledge that must not exist twice.
So: a core helper taking a
Func<ValueTask>(or anElementReference), plus per-adapter wrappers thataccept that adapter's button type and hand its
FocusAsyncin. 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:
MaxItemsAlternatives considered
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
JSExceptionwouldcome back.
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.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:FocusAsync()is not an interface — MudBlazor'sMudBaseButtonand Fluent's button components eachdeclare 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 anElementReferenceoverload; each adapter keepsa 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
MudBaseButtonandMudIconButtonand found it, but Fluent is adifferent library and the equivalent must be confirmed before the plan below relies on it. If a Fluent
button does not expose one, the
ElementReferenceoverload is the fallback for every target.📋 Spec
Goal
In
FormCraft.ForFluentUI, activating a collection control that unmounts or disables itself leaveskeyboard focus on a visible, still-operable element of the same field — never on
<body>— matching theguarantee #318 established for MudBlazor.
Scope
FormCraftcore.FormCraft.ForMudBlazor'sFocusRestorebecomes a thin wrapper over it (behaviour unchanged; Five more controls drop keyboard focus when they unmount or disable themselves #318'stests must pass unmodified, which is the proof of that).
FluentUICollectionFieldComponentcontrols wired to it with Five more controls drop keyboard focus when they unmount or disable themselves #318's focus targets.Non-goals
Behaviour
flowchart TD A["core: SafelyAsync(Func<ValueTask>)"] --> 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"]Key files
FormCraft/Forms/Rendering/— the new core helperFormCraft.ForMudBlazor/Fields/FocusRestore.cs— becomes a wrapperFormCraft.ForFluentUI/Features/CollectionField/FluentUICollectionFieldComponent.razor(+.razor.cs)FormCraft.ForFluentUI.UnitTests/— new focus tests, mirroringCollectionFocusTestsFormCraft.ForMudBlazor.UnitTests/TestSupport/FocusAssertingTestBase.cs— the assertion technique to copyValidation rules
AddItemwhenMaxItemsis 0(the default), where Add does not unmount.
Edge cases
FocusAsync()— confirm before relying on it; fall back to theElementReferenceoverload for every target if so.MinItems— every delete button unmounts at once; fall back toAdd, then the header.
assert that rather than a fallback.
Assumptions
@ref-capture caveat belowapplies to them too. Confirm at implementation time.
dev.🛠️ Implementation plan
Goal: no control in
FormCraft.ForFluentUIdrops keyboard focus to<body>by unmounting ordisabling itself, and the swallow-safe catch list exists exactly once.
Architecture: the framework-neutral helper goes in
FormCraftcore; no UI-framework type mayfollow 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:
dev; commit asPhilippe Matray <phmatray@gmail.com>; conventional commits.TreatWarningsAsErrors=true— the build fails on any warning.dotnet test <proj>.csproj -c Release -- --filter-class <FQN>.⛔
dotnet test --filteris inert here (MTP0001); run the full suite before claiming done.FormCraftcore.CLAUDE.md.@refstore per render. A@refon a component is captured once atcreation and is not re-run on later renders; pruning permanently loses retained rows' references
(measured under Five more controls drop keyboard focus when they unmount or disable themselves #318 — every removal silently fell through to Add). Decide staleness from what is
rendered now instead.
OnAfterRenderAsync, not the handler — the target row may not exist, or be atthat index, until the next batch is applied.
move after Add as a worse regression than the bug.
Task 1: Extract the framework-neutral focus helper into core
Files: create
FormCraft/Forms/Rendering/FocusRestore.cs; modifyFormCraft.ForMudBlazor/Fields/FocusRestore.cs; testFormCraft.UnitTests/Rendering/FocusRestoreTests.cs.Interfaces:
internal static Task SafelyAsync(Func<ValueTask> focus)and anElementReferenceoverload, holding the catch list.FormCraft.ForMudBlazor'sFocusRestoredelegate to it, keeping itsMudBaseButtonsignature.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; createFormCraft.ForFluentUI.UnitTests/TestSupport/FocusAssertingTestBase.cs.Interfaces: a Fluent-typed
FocusSafelyAsync(...)over the core helper.FocusAsync()— write a throwaway test that focuses one and dumps the recordedJSInteropinvocations. Do not assume: Five more controls drop keyboard focus when they unmount or disable themselves #318 measured this for MudBlazor only.ElementReferenceoverload is required instead.FormCraft.ForMudBlazor.UnitTests/TestSupport/FocusAssertingTestBase.cs, and write the finding from Step 2 into its class remarks.Loosemode focus always succeeds, so without this the catch has zero coverage).test(fluentui): establish the focus-assertion technique.Task 3: Keep focus in the list after removing a Fluent collection row
Files: modify
FluentUICollectionFieldComponent.razorand.razor.cs; createFormCraft.ForFluentUI.UnitTests/Fields/CollectionFocusTests.cs.Interfaces: a per-index delete-button reference store, an Add reference, and a per-row header
ElementReference.MinItems(every delete button unmounts) focuses Add; withCanAddfalse too, focus lands on the row/collection header.OnAfterRenderAsync).fix(fluentui): keep focus in the list after removing a collection row.Task 4: Add and reorder
Files: modify
FluentUICollectionFieldComponent.razorand.razor.cs; extendCollectionFocusTests.cs.Interfaces: reuses Task 3's stores, plus per-index reorder-button references.
MaxItems— focus moves into the new row.MaxItems(the default,MaxItems == 0) does not move focus at all — the button survives and the user is already on it.AddItem,MoveItemUpandMoveItemDown.fix(fluentui): move focus deliberately after add and reorder.Task 5: Prove per-field isolation, and document
Files: extend
CollectionFocusTests.cs; modifyREADME.md; modifyCLAUDE.md; modifyFormCraft.ForFluentUI/README.md.Interfaces: none new.
CLAUDE.mdso the focus rule covers both adapters and names the core helper as the single home of the catch list.## 🎉 Unreleasedentry 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.dotnet build -c Releaseanddotnet test -c Release→ both green.docs(fluentui): record the collection focus targets.