Skip to content

Drop the HTML5 required attribute once MudBlazor honours caller-supplied aria-required #263

Description

@phmatray

Problem / motivation

#199 asked for aria-required="true" without the HTML5 required attribute — the accessibility
annotation without the browser-validation attribute the project's convention forbids. That turned out to
be unreachable on MudBlazor 9.8.0, so #199 shipped the compromise (option A): drive Required from
IsRequired and accept the HTML5 attribute.

Why it was unreachable. Decompiled from MudBlazor.dll 9.8.0 — MudInput<T>.BuildRenderTree:

// <input> branch                                    // <textarea> branch
AddMultipleAttributes(38, UserAttributes);           AddMultipleAttributes(15, UserAttributes);
...                                                  ...
AddAttribute(54, "required",      Required);         AddAttribute(30, "required",      Required);
AddAttribute(55, "aria-required", Required…);        AddAttribute(31, "aria-required", Required…);

The caller's UserAttributes splat lands before MudBlazor's own writes, and the Blazor render tree
resolves duplicate attributes last-write-wins. So a caller can never override aria-required, and one
bool drives both attributes together. Confirmed identical on 9.5.0, 9.7.0 and 9.8.0.

Why this is fixable rather than a fact of life. MudBlazor 9.7/9.8 added a helper that does exactly
what is needed — but wired it only to the hidden-input presenter <div>, not to the real input:

/// Caller-provided UserAttributes take precedence over the computed accessibility fallbacks.
private Dictionary<string, object?>? GetDisplayUserAttributes()
{
    var dictionary = new Dictionary<string, object>(UserAttributes, StringComparer.OrdinalIgnoreCase);
    dictionary.TryAdd("aria-required", Required.ToString().ToLowerInvariant());   // TryAdd → caller wins
    ...
}

And MudBlazor's own AGENTS.md:213 states the rule the ordinary render path violates:

When generating HTML or ARIA attributes in component code, prefer fallback values so caller-provided
attributes can override them whenever feasible; do not hard-force generated attributes unless the
behavior truly requires it.

Proposed solution

Upstream first, then revert the compromise here.

A verified patch already exists at /Users/philippe/repo/phmatray/public/mudblazor-aria-required.patch
(76 insertions, 10 deletions). It extends MudBlazor's own pattern to the real <input>/<textarea>:
aria-required, aria-invalid and aria-describedby move into a caller-wins dictionary
(GetInputUserAttributes()), while required deliberately stays bound to the parameter — which is
precisely what makes the pair separable.

Verified locally before filing:

Check Result
dotnet build -c Release (net8.0 / net9.0 / net10.0) clean, 0 warnings
Existing MudBlazor tests (TextField, Select, Autocomplete, NumericField, DatePicker) 681 passed, 0 failed
Two new tests, patch reverted 2/2 FAIL — the guard bites
Two new tests, patch applied 2/2 pass

Once a MudBlazor release carries it, FormCraft flips to Required="false" plus
aria-required="true" through UserAttributes, dropping the HTML5 attribute — which resolves the
CLAUDE.md convention tension #199 had to amend rather than satisfy.

Alternatives considered

  • Stay on option A forever. Works today and is not broken — the HTML5 attribute is inert under the
    form's novalidate (novalidate is applied by script to the first form on the page, so the documented guarantee can miss #206). Rejected as the end state only because it required amending a documented
    convention to accommodate an upstream limitation, and because required still matches :required /
    :invalid in consumer CSS.
  • Fork MudBlazor / ship a patched build. Rejected: enormous maintenance cost for two attributes.
  • Post-render JS interop to strip required. Rejected: a JS dependency, invisible to bUnit, and
    fragile across re-renders — it would make the "no HTML5 attribute" claim untestable.
  • Keep the annotation in the accessible name (append "(required)" to aria-label). This was option
    B during Required fields are not identified to assistive technology on either render path #199's triage. Rejected then and now: a text convention rather than the standard programmatic
    flag.

Area

FormCraft.ForMudBlazor — field rendering (both render paths), accessibility, upstream dependency


Follow-up from #199 (landed as #254). Related: #190, #204, #206, #203

⛔ Blocked on the upstream MudBlazor PR being opened, merged and released. The FormCraft-side work
in the plan below is small; the wait is not. Do not start it before a MudBlazor release carries the fix
— the tests would be red for reasons no code change here can address.

🧠 Brainstorm

Problem / context

FormCraft's stated convention (CLAUDE.md) is that Required() adds validation but not the HTML5
required attribute, with browser validation disabled via novalidate. #190 enforced that literally by
removing MudBlazor's Required from the collection path. #199 discovered the cost — every required field
was announcing aria-required="false", an affirmatively wrong statement to a screen reader — and had to
choose between two things the convention had accidentally welded together:

  1. HTML5 required — browser constraint validation. Unwanted by convention.
  2. aria-required="true" — accessibility annotation. Wanted, and lost as collateral.

MudBlazor 9.8.0 offers no way to have (2) without (1). #199 chose (1)+(2) over neither, and amended the
convention to say what it always meant: it governs browser constraint validation, not ARIA.

Approaches

A. Fix upstream, then revert here. Extend MudBlazor's GetDisplayUserAttributes pattern to the real
input; once released, FormCraft sets Required="false" + aria-required="true" via UserAttributes.
Pros: satisfies the original convention and WCAG; fixes it for every MudBlazor consumer; the patch is
small, idiomatic, and honours their own AGENTS.md. Cons: blocks on an external maintainer.

B. Accept option A as permanent. Pros: zero further work. Cons: leaves a documented convention
amended around a third-party limitation, and required still matches CSS selectors consumers may rely
on.

C. Abstract behind a FormCraft-owned input wrapper. Pros: full control. Cons: reimplementing
MudInput to change two attributes; enormous surface, permanent maintenance.

Recommendation

A, with B as the standing fallback (which is what ships today, so there is no accessibility risk
while waiting). The patch is written and verified; the remaining cost is the upstream round-trip. If the
PR is rejected or stalls indefinitely, close this as "won't do" and keep B — that is a legitimate
outcome, not a failure.

📋 Spec

Goal

A .Required(...) field renders aria-required="true" and no HTML5 required attribute, on both
render paths — the outcome #199 originally specified.

Scope

  • Open the upstream MudBlazor PR from the prepared patch.
  • On release: bump the MudBlazor pin in Directory.Packages.props.
  • Flip EffectiveNativeRequired's consumers so the ARIA annotation travels via UserAttributes while
    MudBlazor's Required parameter is left false for the inference case.
  • Preserve .WithNativeRequired() as the opt-in to MudBlazor's native semantics (asterisk + HTML5
    attribute) — that stays parameter-driven and is unaffected.
  • Restore CLAUDE.md's convention wording and update the README release note.

Non-goals

Behaviour

flowchart TD
    A["field.Required(\"…\")"] --> B{MudBlazor release<br/>honours caller ARIA?}
    B -->|"no — today"| C["Required=true<br/>aria-required=true + HTML5 required"]
    B -->|"yes — after upstream"| D["Required=false<br/>aria-required=true via UserAttributes"]
    D --> E["announced ✅<br/>no HTML5 attribute ✅<br/>no asterisk unless .WithNativeRequired()"]
Loading

⚠️ Note the behavioural consequence in D: dropping Required also drops MudBlazor's asterisk for
plain .Required(...) fields. #199 shipped that asterisk and documented it as a visible WCAG 3.3.2
identification, so removing it is a real regression unless FormCraft renders its own marker. Decide
this deliberately
— it is the one part of this change that is not a pure win.

Key files

  • Directory.Packages.props — the MudBlazor version pin.
  • FormCraft.ForMudBlazor/Fields/MudBlazorFieldComponentBase.cs — EffectiveNativeRequired, NativeRequired.Resolve.
  • FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor.cs — AddCommonFieldAttributes, RenderBooleanField.
  • FormCraft.ForMudBlazor.UnitTests/Fields/AriaRequiredTests.cs, CollectionRequiredTests.cs, Components/RenderPipelineParityTests.cs.
  • CLAUDE.md, README.md.

Validation rules

  • A .Required(...) field renders aria-required="true" and no required attribute, both paths.
  • .WithNativeRequired() still renders Required="true" — asterisk and HTML5 attribute included.
  • .WithNativeRequired(false) on a required field still suppresses the annotation.
  • RenderPipelineParityTests still compares aria-required across both paths and still bites.

Edge cases

  • Checkboxes already use the UserAttributes route (Required fields are not identified to assistive technology on either render path #199), because MudCheckBox emits no
    aria-required of its own. They need no change — and are the proof the mechanism works.
  • The asterisk regression above — needs an explicit decision, not a silent drop.
  • Version floor: the flip must not ship before the pin is raised, or every ARIA assertion goes red.

Assumptions

  • The upstream patch is accepted broadly as written. If MudBlazor's maintainers prefer a different shape
    (e.g. an opt-in parameter), this plan's Task 2+ adapts to whatever lands.
  • Target is a minor. 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: land the upstream fix, then drop FormCraft's HTML5-attribute compromise.

Architecture: all FormCraft changes in FormCraft.ForMudBlazor; core untouched. Both render paths
change together or RenderPipelineParityTests fails — that is intended.

Tech stack: .NET 8 / 10 multi-target, Blazor, MudBlazor 9, xUnit + bUnit + Shouldly.

Global constraints:

  • Base branch dev; commit as Philippe Matray <phmatray@gmail.com>; conventional commits.
  • TreatWarningsAsErrors=true; dotnet test --filter is inert (MTP0001) — run the whole suite.
  • ⛔ Task 1 is a hard gate. Tasks 2-4 cannot go green until a MudBlazor release carries the fix.
  • Resolve the required flag through the single NativeRequired.Resolve(...) implementation (Required fields are not identified to assistive technology on either render path #199).
  • .WithNativeRequired() must keep meaning "MudBlazor's native decoration", asterisk included.

Task 1: Open the upstream MudBlazor PR

Files: none in this repo. The patch is at /Users/philippe/repo/phmatray/public/mudblazor-aria-required.patch.

Interfaces: MudInput.GetInputUserAttributes() — caller-wins fallbacks for aria-required, aria-invalid, aria-describedby.

  • Step 1: Fork MudBlazor/MudBlazor, branch from dev, apply the patch with git apply.
  • Step 2: Run their build and the affected suites → confirm 0 warnings and the ~681 component tests green, and that the two new tests fail with the production hunk reverted.
  • Step 3: Open the PR, citing AGENTS.md:213 and the existing GetDisplayUserAttributes() precedent, and stating the consumer case (server-side validation with novalidate).
  • Step 4: Record the upstream PR URL in a comment on this issue.
  • Step 5: Wait for merge + release. Stop here until then — nothing below can pass.

Task 2: Raise the MudBlazor pin

Files: modify Directory.Packages.props.

Interfaces: none.

  • Step 1: Bump MudBlazor to the first release carrying the fix.
  • Step 2: Run dotnet build -c Release and dotnet test -c Release → both green on the existing behaviour, proving the bump alone changes nothing.
  • Step 3: Commit: build(deps): raise MudBlazor to <version> for caller-overridable ARIA.

Task 3: Flip both render paths to the ARIA-only mechanism

Files: modify FormCraft.ForMudBlazor/Fields/MudBlazorFieldComponentBase.cs; modify FormCraft.ForMudBlazor/Features/CollectionField/CollectionFieldComponent.razor.cs; modify AriaRequiredTests.cs and CollectionRequiredTests.cs.

Interfaces: the inference contributes aria-required via UserAttributes; MudBlazor's Required parameter is reserved for the explicit .WithNativeRequired() opt-in.

  • Step 1: Write the failing tests: a .Required(...) field renders aria-required="true" and no required attribute, on both paths, for text, numeric, date and select.
  • Step 2: Run the suite → FAIL (the HTML5 attribute is still present).
  • Step 3: Split the resolution — keep NativeRequired.Resolve(...) for the explicit opt-in driving Required, and route the IsRequired inference into an aria-required user attribute on both paths.
  • Step 4: Decide the asterisk question from the spec explicitly — either render a FormCraft-owned marker or accept its loss for plain .Required(...) fields — and pin the decision with a test.
  • Step 5: Invert the CollectionRequiredTests cases that assert the HTML5 attribute's presence (they were inverted the other way by Required fields are not identified to assistive technology on either render path #199).
  • Step 6: Run the suite → PASS, whole suite green.
  • Step 7: Commit: feat(mudblazor): announce required fields without the HTML5 required attribute.

Task 4: Restore the convention wording and document

Files: modify CLAUDE.md; modify README.md; modify FormCraft.ForMudBlazor/Extensions/FieldBuilderExtensions.cs (WithNativeRequired XML doc).

Interfaces: none new.

  • Step 1: Restore CLAUDE.md's validation convention to its literal form — Required() adds validation but not the HTML5 attribute — noting that ARIA annotation is separate and now independently achievable.
  • Step 2: Add a README ## 🎉 Unreleased bullet explaining that the HTML5 attribute is gone again, what it means for the asterisk, and that .WithNativeRequired() restores native semantics.
  • Step 3: Update WithNativeRequired's XML doc — the Level A warning on false still applies, but the "why the HTML5 attribute comes back" paragraph becomes historical.
  • Step 4: Run dotnet build -c Release and dotnet test -c Release → both green.
  • Step 5: Commit: docs: restore the validation convention now ARIA is independent.

Activity

  1. phmatray commented on Aug 12, 2026

    @phmatray
    OwnerAuthor

    Upstream PR opened

    MudBlazor/MudBlazor#13613 — MudBlazor/MudBlazor#13613

    Input: Let UserAttributes override the computed ARIA attributes, targeting their dev branch from
    phmatray:fix/mudinput-user-attributes-aria-override.

    That closes Task 1 of this issue's plan. Tasks 2-4 stay blocked until the PR is merged and a
    MudBlazor release carries it — the version pin has to move before the FormCraft-side flip can go green.

    The patch is unchanged from the one described above: GetInputUserAttributes() applies the computed
    ARIA values with TryAdd so a caller wins, mirroring GetDisplayUserAttributes(); required stays
    bound to the parameter, which is what makes the pair separable.

    If the PR is rejected or stalls, closing this issue as "won't do" and keeping today's option-A
    behaviour is a legitimate outcome — it ships correct accessibility already, just with the HTML5
    attribute attached.

  2. phmatray commented on Aug 17, 2026

    @phmatray
    OwnerAuthor

    Upstream mechanism changed — the gate and the consumer contract did not.

    MudBlazor/MudBlazor#13613 went through review and no longer adds a GetInputUserAttributes() helper.
    The fallbacks are now expressed by attribute ordering instead: the aria-* literals move above the
    @attributes="UserAttributes" splat, so last-write-wins resolves in the caller's favour, with no
    per-render dictionary. required stays below the splat and remains non-overridable.

    So the parts of this plan that describe the upstream implementation (the GetInputUserAttributes()
    dictionary in the Why this is fixable section and the Interfaces line) are stale. Nothing else
    changes:

    • The gate is the same — this stays blocked until #13613 merges and ships in a MudBlazor release.
    • What FormCraft has to do is the same — pass aria-required="true" through AdditionalAttributes
      and stop setting Required.
    • The version to pin in Task 1 is still whichever release first carries the fix.

    Verified on the PR branch (still open, awaiting a maintainer): full MudBlazor.UnitTests suite green (5611 passed), and both
    override tests fail if the ordering is inverted.

  3. phmatray commented on Aug 19, 2026

    @phmatray
    OwnerAuthor

    Upstream is merged. MudBlazor/MudBlazor#13613 was approved and merged on 2026-08-17 as
    ff10b3b
    (MudInput: Let UserAttributes override computed ARIA attributes). The only edit the maintainer made
    was deleting the explanatory razor comment.

    Still blocked, but only on a release. The latest MudBlazor release is v9.8.0 (2026-08-05), which
    predates the merge, and this repo pins 9.8.0 in Directory.Packages.props. Task 1 stays "raise the
    pin" — to whatever version first ships ff10b3b.

    The scope of this issue just got wider, in a good way

    Within a day of merging, the maintainer generalised the pattern across the library:

    PR components
    #13641 MudCardMedia, MudImage, MudNavGroup, MudNavMenu
    #13642 MudFileUpload, MudRangeInput, MudMask, MudRadioGroup
    #13644 MudCheckBox, MudSwitch, MudRadio (aria-hidden on overridden labels)

    Every aria-required emission in the library now uses the same ordering — fallback written first,
    @attributes="UserAttributes" after it, required left below the splat:

    aria-required="@Required.ToString().ToLowerInvariant()"
    @attributes="UserAttributes"
    ...
    required="@Required"

    Two consequences for this plan:

    • File upload can go ARIA-only too. MudFileUpload now honours a caller-supplied aria-required,
      so the marking added for Mark required file-upload fields — the one field type #199 left unannounced #262 can drop its HTML5 attribute in the same sweep rather than staying an
      exception.
    • Select and autocomplete are still out of scope. Upstream emits no aria-required on them at all
      (MudSelect has a caller-wins GetInputUserAttributes(), but nothing puts aria-required in it), so
      that gap is unchanged and is not fixed by raising the pin.

    Task 2 should therefore cover text, numeric, date and file upload, not the three originally listed.

  4. phmatray commented on Aug 24, 2026

    @phmatray
    OwnerAuthor

    Unblocked — the gate is open. MudBlazor v9.9.0 shipped 2026-08-24 and contains
    ff10b3b
    (#13613), plus the follow-on sweep (#13641, #13642, #13644).

    Verified against the shipped NuGet binary, not the source — ilspycmd on
    MudBlazor 9.9.0/lib/net10.0/MudBlazor.dll:

    MudInput<T>.BuildRenderTree (both branches)

    AddAttribute(15, "aria-describedby", …)      AddAttribute(38, …)
    AddAttribute(16, "aria-invalid", …)          AddAttribute(39, …)
    AddAttribute(17, "aria-required", …)         AddAttribute(40, …)   ← fallbacks
    AddMultipleAttributes(18, UserAttributes)    AddMultipleAttributes(41, …)  ← caller wins
    …
    AddAttribute(31, "required", Required)       AddAttribute(55, …)   ← still after, still not overridable
    

    For contrast, the same method on 9.8.0 — measured earlier in #199 — had the splat at 15/38 and
    aria-required at 31/55, i.e. the reverse, which is exactly why #199 could not be done the right way.

    MudFileUpload<T> is the same shape (aria-required 15 → splat 16 → required 22), so file upload is
    in scope for this issue now
    , as flagged in the previous comment.

    MudSelect<T> still emits zero aria-required occurrences, so select/autocomplete remain out of
    scope and are not fixed by the version bump.

    State of the tasks

    • Task 1 — raise the pin. Directory.Packages.props still reads 9.8.0; no Renovate PR has opened
      yet (v9.9.0 is hours old). 9.9.0 is the version to pin.
    • Task 2 — flip to ARIA-only on both render paths, for text, numeric, date and file upload.
    • Tasks 3–4 unchanged.

    Nothing else stands in the way.

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

    priority:lowNice to havestatus:blockedBlocked by external dependencytype:featureNew feature or enhancement request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions