Context
The shared ProductTable component powers the filter UI on /wallets/find-wallet and /layer-2/networks. An accessibility/semantic-HTML audit surfaced several issues:
- Persona cards (top of
/wallets/find-wallet) use <h3> inside a <button> for typography. The headings break the document outline and aren't real section headings — the cards are filter checkboxes, not content sections.
- Sidebar filter group titles ("Device", "Network support", etc.) are
<p> tags inside accordion triggers — there's no programmatic grouping of the related controls beneath them.
- Switch toggles in
SwitchFilterInput.tsx have no accessible name. The visible label is a sibling <p>, not associated via htmlFor or aria-labelledby. A screen reader announces "switch, checked" with no identifying name.
- Description text for Switch toggles also has no
aria-describedby link to the control.
Phase 1 — adding an sr-only <h2> to fix the h1 → h3 outline jump on /wallets/find-wallet — is addressed in #18112. This issue covers the remaining work.
Affected pages
/wallets/find-wallet
/layer-2/networks (benefits automatically from changes to shared ProductTable components)
Goals
- Filter groups carry real semantic grouping via
<fieldset> + <legend>.
- Persona cards are checkbox controls, not headings.
- Every Switch and Checkbox has a programmatically associated label (and description, where one exists).
- Visual output on both pages is unchanged.
- Improvements to shared components benefit both consumer pages.
Non-goals (explicit)
- Do not replace Radix
Checkbox/Switch with native <input> elements. The Radix primitives are already WCAG-equivalent and already styled in src/components/ui/. The accessibility wins in this issue are upstream of those primitives.
- Do not change visible styling. Reviewers should be able to confirm via Chromatic baselines that nothing visual moved.
- Do not hand-edit non-English JSON files under
src/intl/. The intl-pipeline propagates English changes (src/scripts/intl-pipeline/, spec at tests/specs/PIPELINE-SPEC.md).
- Do not introduce a new
component-*.json i18n namespace for this work — strings fit cleanly into existing namespaces.
Foundation: shadcn Field component family
This work uses the shadcn Field component family (docs), which provides FieldSet, FieldLegend, Field, FieldLabel, and FieldDescription — covering both the grouping problem and the label-association problem with one idiomatic API.
Install (once, as part of PR 1):
pnpm dlx shadcn@latest add field
Visual-diff expectation: shadcn ships defaults that rarely match this project's visuals. After install, review and override className on FieldSet/FieldLegend/FieldDescription (and the others as needed) to preserve current spacing, typography, and layout on both /wallets/find-wallet and /layer-2/networks. This is the standard shadcn-adoption flow in this repo.
Dependency note: the install likely adds @radix-ui/react-label if not present. Confirm via the package.json diff during review.
Plan
Broken into two PRs.
PR 1 — Sidebar a11y (low-risk, no visual change, benefits both pages)
Install shadcn Field:
- Run
pnpm dlx shadcn@latest add field.
- Tune
className defaults across src/components/ui/field.tsx to match this project's design system; verify on both consumer pages before moving on.
Sidebar filter group structure:
src/components/ProductTable/Filter.tsx:
- Wrap
filter.items.map(...) (line ~107) in <FieldSet> with an sr-only <FieldLegend> matching the existing visible group title (filter.title).
- For nested options under Mobile/Desktop/Browser (lines ~119–134), wrap in a nested
<FieldSet> with an sr-only <FieldLegend> describing the sub-group (e.g. "Mobile platforms", "Desktop platforms", "Browser engines").
Switch label association:
src/components/ProductTable/FilterInputs/SwitchFilterInput.tsx:
- Replace the sibling
<p>{label}</p> and description <p> with the shadcn Field composition: <Field> containing <FieldLabel>, the existing <Switch>, and (when present) <FieldDescription>. Let Field/FieldLabel handle id wiring and aria-describedby instead of doing it by hand.
Translations (English source only — pipeline handles the rest):
src/intl/en/table.json — add keys for the nested sub-group legends (e.g. table-mobile-platforms, table-desktop-platforms, table-browser-engines). Outer group legends already exist as filter.title, no new keys needed.
Files touched:
src/components/ui/field.tsx (NEW, via shadcn add)
src/components/ProductTable/Filter.tsx
src/components/ProductTable/FilterInputs/SwitchFilterInput.tsx
src/intl/en/table.json
package.json / pnpm-lock.yaml (likely diff from shadcn install)
PR 2 — Persona cards refactor (/wallets/find-wallet only)
File: src/components/ProductTable/PresetFilters.tsx
- Wrap the
presets.map(...) grid in <FieldSet> with an sr-only <FieldLegend> (e.g. "Filter wallets by user type").
- Replace the outer
<button> with a <FieldLabel> (or <label> composed via Field) containing a Radix <Checkbox> from src/components/ui/checkbox (visually hidden via sr-only but keeping focus reachability).
- Replace the fake-checkbox
<div> indicator with a <span aria-hidden> driven off the Checkbox's data-state (or off peer-data-[state=checked] selectors on the wrapper).
- Drop the
<h3> — persona title becomes a <span> with the same typography classes.
- Wire
onCheckedChange to call existing handleSelectPreset(idx).
Translations:
src/intl/en/page-wallets-find-wallet.json — add a key for the persona group legend (e.g. page-find-wallet-persona-legend). Persona titles already have keys.
Files touched:
src/components/ProductTable/PresetFilters.tsx
src/intl/en/page-wallets-find-wallet.json
src/components/FindWalletProductTable/FindWalletProductTable.stories.tsx (likely needs story tweaks)
Translation rules (read carefully)
- Every new visible and sr-only string must use an
src/intl/en/*.json key.
- Find-wallet-only strings →
src/intl/en/page-wallets-find-wallet.json.
- Strings used by the shared
ProductTable (rendered on both find-wallet and L2 networks) → src/intl/en/table.json.
- Do not edit any other locale's JSON. The intl-pipeline (
src/scripts/intl-pipeline/) propagates from English.
Component policy
- Use existing Radix-based
ui/checkbox and ui/switch primitives.
- Add the shadcn
Field family via pnpm dlx shadcn@latest add field, then override className defaults to preserve current visuals.
- Do not introduce other new
ui/* components for this work.
Acceptance criteria
Testing checklist
References
Generated by Claude Opus 4.7
Context
The shared
ProductTablecomponent powers the filter UI on/wallets/find-walletand/layer-2/networks. An accessibility/semantic-HTML audit surfaced several issues:/wallets/find-wallet) use<h3>inside a<button>for typography. The headings break the document outline and aren't real section headings — the cards are filter checkboxes, not content sections.<p>tags inside accordion triggers — there's no programmatic grouping of the related controls beneath them.SwitchFilterInput.tsxhave no accessible name. The visible label is a sibling<p>, not associated viahtmlFororaria-labelledby. A screen reader announces "switch, checked" with no identifying name.aria-describedbylink to the control.Phase 1 — adding an
sr-only<h2>to fix theh1→h3outline jump on/wallets/find-wallet— is addressed in #18112. This issue covers the remaining work.Affected pages
/wallets/find-wallet/layer-2/networks(benefits automatically from changes to sharedProductTablecomponents)Goals
<fieldset>+<legend>.Non-goals (explicit)
Checkbox/Switchwith native<input>elements. The Radix primitives are already WCAG-equivalent and already styled insrc/components/ui/. The accessibility wins in this issue are upstream of those primitives.src/intl/. The intl-pipeline propagates English changes (src/scripts/intl-pipeline/, spec attests/specs/PIPELINE-SPEC.md).component-*.jsoni18n namespace for this work — strings fit cleanly into existing namespaces.Foundation: shadcn
Fieldcomponent familyThis work uses the shadcn
Fieldcomponent family (docs), which providesFieldSet,FieldLegend,Field,FieldLabel, andFieldDescription— covering both the grouping problem and the label-association problem with one idiomatic API.Install (once, as part of PR 1):
Visual-diff expectation: shadcn ships defaults that rarely match this project's visuals. After install, review and override
classNameonFieldSet/FieldLegend/FieldDescription(and the others as needed) to preserve current spacing, typography, and layout on both/wallets/find-walletand/layer-2/networks. This is the standard shadcn-adoption flow in this repo.Dependency note: the install likely adds
@radix-ui/react-labelif not present. Confirm via thepackage.jsondiff during review.Plan
Broken into two PRs.
PR 1 — Sidebar a11y (low-risk, no visual change, benefits both pages)
Install shadcn Field:
pnpm dlx shadcn@latest add field.classNamedefaults acrosssrc/components/ui/field.tsxto match this project's design system; verify on both consumer pages before moving on.Sidebar filter group structure:
src/components/ProductTable/Filter.tsx:filter.items.map(...)(line ~107) in<FieldSet>with ansr-only<FieldLegend>matching the existing visible group title (filter.title).<FieldSet>with ansr-only<FieldLegend>describing the sub-group (e.g. "Mobile platforms", "Desktop platforms", "Browser engines").Switch label association:
src/components/ProductTable/FilterInputs/SwitchFilterInput.tsx:<p>{label}</p>and description<p>with the shadcnFieldcomposition:<Field>containing<FieldLabel>, the existing<Switch>, and (when present)<FieldDescription>. LetField/FieldLabelhandle id wiring andaria-describedbyinstead of doing it by hand.Translations (English source only — pipeline handles the rest):
src/intl/en/table.json— add keys for the nested sub-group legends (e.g.table-mobile-platforms,table-desktop-platforms,table-browser-engines). Outer group legends already exist asfilter.title, no new keys needed.Files touched:
PR 2 — Persona cards refactor (
/wallets/find-walletonly)File:
src/components/ProductTable/PresetFilters.tsxpresets.map(...)grid in<FieldSet>with ansr-only<FieldLegend>(e.g. "Filter wallets by user type").<button>with a<FieldLabel>(or<label>composed viaField) containing a Radix<Checkbox>fromsrc/components/ui/checkbox(visually hidden viasr-onlybut keeping focus reachability).<div>indicator with a<span aria-hidden>driven off the Checkbox'sdata-state(or offpeer-data-[state=checked]selectors on the wrapper).<h3>— persona title becomes a<span>with the same typography classes.onCheckedChangeto call existinghandleSelectPreset(idx).Translations:
src/intl/en/page-wallets-find-wallet.json— add a key for the persona group legend (e.g.page-find-wallet-persona-legend). Persona titles already have keys.Files touched:
Translation rules (read carefully)
src/intl/en/*.jsonkey.src/intl/en/page-wallets-find-wallet.json.ProductTable(rendered on both find-wallet and L2 networks) →src/intl/en/table.json.src/scripts/intl-pipeline/) propagates from English.Component policy
ui/checkboxandui/switchprimitives.Fieldfamily viapnpm dlx shadcn@latest add field, then overrideclassNamedefaults to preserve current visuals.ui/*components for this work.Acceptance criteria
/wallets/find-walletheading outline:h1→h2(sr-only) → (no further headings inside the finder UI). Verified with browser DevTools accessibility tree.<fieldset>(viaFieldSet) with ansr-only<legend>matching the visible group title.Switchin the sidebar has a connected<label>and (where applicable)aria-describedbypointing at the description, both wired via the shadcnFieldcomposition./wallets/find-walletrender as a<fieldset>of checkbox controls; no<h3>elements remain inside them; no<button>element remains as the toggle./wallets/find-walletand/layer-2/networks.npx tsc --noEmitclean.pnpm lintclean.Testing checklist
pnpm devand visually compare both pages againstdevbaseline.pnpm storybook— verify changed component stories.pnpm chromatic(or rely on CI) — review visual diffs.References
<h2>on find-wallet).src/components/ProductTable/app/[locale]/wallets/find-wallet/page.tsxsrc/components/Layer2NetworksTable/src/components/Quiz/QuizWidget/QuizRadioGroup.tsxtests/specs/PIPELINE-SPEC.mdGenerated by Claude Opus 4.7