fix(fields): make FieldCard keyboard-accessible [Phase 2b]#629
Merged
barach6662001-bit merged 3 commits intomainfrom Apr 24, 2026
Merged
fix(fields): make FieldCard keyboard-accessible [Phase 2b]#629barach6662001-bit merged 3 commits intomainfrom
barach6662001-bit merged 3 commits intomainfrom
Conversation
Pays down the accessibility debt left over from Phase 2a. The
FieldCard tile in the fields grid was visually migrated to the
design-system <Card> primitive but kept its legacy onClick on a
non-semantic wrapper, leaving the entire grid unreachable by
keyboard and undescribed for assistive tech.
A real <a> / <Link> would have been the cleanest solution but is
incompatible with the constraints — Card's `as` prop does not
accept `'a'` and the spec forbids touching the Card / Surface
API in this PR. Wrapping Card in a <Link> would also nest a
real <button> (the hover-CTA) inside an <a>, which is invalid
HTML. The accepted-fallback button-pattern is therefore the
right fit and matches the test requirements (Enter + Space
activation are native button semantics, not anchor semantics).
Changes
─────────
* role="button", tabIndex={0}, onKeyDown on the Card surface,
activating on Enter and Space (preventDefault on Space stops
the page from scrolling).
* aria-label summarises the tile's primary content (name +
area + crop when known) so screen readers announce a useful
destination instead of "button".
* The hover-overlay CTA is now a presentational <span> instead
of a real <button> — the parent surface is the activation
target, and nesting a real button inside a role="button"
element is invalid markup. The overlay container itself is
marked aria-hidden because its contents duplicate the card's
own aria-label.
* CSS module gains a token-driven :focus-visible ring
(`outline: 2px solid var(--brand); outline-offset: 2px`) and
also surfaces the hover-overlay on focus-visible so keyboard
users see the same affordance mouse users do. `.card:focus`
is reset to `outline: none` so mouse-driven focus stays
ring-free, matching the platform default focus model.
Out of scope (preserved verbatim)
─────────────────────────────────
* All visual styling, layout, navigation route and i18n strings.
* Card / Surface API. No new dependencies.
* FieldStatusCard, KpiHeroRow, AppLayout, Sidebar, FieldDetail,
OperationDetail.
Tests
─────
`__tests__/FieldCard.test.tsx` rewritten to 11 cases (was 4):
primary render: name + area + cadastral + crop
cadastral omitted when undefined
hover-CTA label is present but is NOT exposed as a button role
not-seeded fallback when currentCrop is undefined
card has accessible role=button and full aria-label (with crop)
card has accessible aria-label (without crop)
card is keyboard-reachable (tabIndex=0)
click → navigate
Enter → navigate
Space → navigate
unrelated keys (Tab, Escape, Shift) → no navigate
Validation
──────────
* npx vitest run src/design-system src/pages/Fields → 69/69
* npx tsc --noEmit → clean
* npx eslint on touched files → clean
* npx vite build → succeeds (frontend bundle builds, PWA SW
generated; pre-existing chunk-size warning unchanged)
Phase 2b — narrow accessibility fix on top of the Phase 2a
surface migration. Visual contract preserved.
583cf0b to
0216528
Compare
This was referenced Apr 24, 2026
barach6662001-bit
added a commit
that referenced
this pull request
Apr 24, 2026
… 2d] (#633) Phase 2d of the accessibility paydown that began with FieldCard (#629, Phase 2b) and continued in OperationsTimeline (#632, Phase 2c). Same proven pattern, applied to the v2 IA "Upcoming" panel — the only remaining clickable-row debt the user asked to fix this round. Before ------ Each row in pages/DashboardV2/components/UpcomingPanel was a <li> with onClick={navigate} and an inline style={{cursor: 'pointer'}} — no role, no tabIndex, no keyDown handler, no accessible name. AT users heard nothing meaningful and keyboard users could not reach or activate the row at all. After ----- Each row gains the same FieldCard / OperationsTimeline contract: * role="button" + tabIndex={0} * onKeyDown handles Enter and Space * Space calls preventDefault to suppress page scroll * unrelated keys (Tab, Escape, Shift, ArrowDown, ...) are ignored * aria-label summarises the row exactly as it reads visually: "{operationType}, {fieldName}, {DD MMM}" "{operationType}, {fieldName}, {DD MMM}, {area} га" (when area > 0) * the decorative date chip and area pill are aria-hidden because their content is duplicated verbatim in aria-label * a token-driven :focus-visible ring (var(--brand), 2px, offset 2) lives in the CSS module, with .item:focus reset for mouse focus * inline cursor: pointer migrated into the CSS module so the focus-visible rule lives next to it (no runtime style override fight) Notes / scope ------------- There is no nested <button> inside the row, so the FieldCard "decorative button → presentation span" step from Phase 2b does not apply here. Visual layout, the navigation target (/operations/{id}), the weather strip, the filter / sort / windowDays logic and every i18n string are preserved verbatim. The Card / Surface API is not touched, no dependencies are added, no routes change, and no other clickable-row debt is swept in this PR — legacy components/dashboard/OperationsTimeline, FieldStatusCard, KpiHeroRow, AppLayout, Sidebar are all untouched per the explicit scope. Tests ----- New file: pages/DashboardV2/components/__tests__/UpcomingPanel.test.tsx 17 cases across 4 groups: empty state - empty operations array → placeholder - all operations completed → placeholder - operations outside the 7-day window → placeholder primary render - one row per upcoming op, type/field/date chip rendered - area suffix shown when areaProcessed > 0 - area suffix omitted when areaProcessed is 0 - optional weather strip rendered when prop supplied - exactly one button per row (no nested interactives) accessibility - aria-label = "{op}, {field}, {date}" - aria-label = "{op}, {field}, {date}, {area} га" with area - tabIndex=0 (keyboard reachable) - decorative date chip / area pill not exposed as buttons activation - click navigates to /operations/{id} - Enter navigates - Space navigates - Space calls preventDefault (page scroll suppressed) - Tab / Escape / Shift / ArrowDown do nothing - multi-row: routes to the correct id Validation ---------- pnpm --filter frontend test → 100/100 passing (17 new + 83 existing) npx tsc -b --noEmit → clean npx eslint <changed files> → clean npx vite build → green (✓ built in 26s) Co-authored-by: Replit Agent <agent@replit.local>
This was referenced Apr 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2b — narrow accessibility fix on
FieldCardpaying down the debt left over from Phase 2a (#627). Visual contract preserved verbatim.Why the fallback button-pattern (and not a real link)
A real
<a>/ react-router<Link>would have been the cleanest semantic answer, but is incompatible with this PR's constraints:Card'sasprop only accepts block-level tags; adding'a'would require touching the Card / Surface API, which is forbidden by the brief.Cardin<Link>would nest the hover-overlay's real<button>inside an<a>— invalid HTML per the WHATWG spec.The accepted-fallback button-pattern matches the test requirements exactly and stays inside scope.
Changes
role="button",tabIndex={0},onKeyDownon theCardsurface — activation on Enter and Space (preventDefaulton Space stops page scroll).aria-labelsummarises the tile's primary content (name + area + crop when known) so screen readers announce a useful destination instead ofbutton.<span>instead of a real<button>— the parent surface is the activation target, and nesting a real button inside arole="button"element is invalid markup. The overlay container itself is markedaria-hiddenbecause its contents duplicate the card's ownaria-label.:focus-visiblering (outline: 2px solid var(--brand); outline-offset: 2px) and also surfaces the hover-overlay on:focus-visibleso keyboard users see the same affordance mouse users do..card:focusis reset tooutline: noneso mouse-driven focus stays ring-free, matching the platform-default focus model.Out of scope (preserved verbatim)
FieldStatusCard,KpiHeroRow,AppLayout,Sidebar,FieldDetail,OperationDetail.Tests
__tests__/FieldCard.test.tsxrewritten to 11 cases (was 4):currentCropis undefinedrole="button"and fullaria-label(with crop)aria-label(without crop)tabIndex=0)Validation
npx vitest run src/design-system src/pages/Fields→ 69/69npx tsc --noEmitcleannpx eslinton touched files cleannpx vite buildsucceeds (PWA SW generated; pre-existing chunk-size warning unchanged)Files
frontend/src/pages/Fields/components/FieldCard.tsxfrontend/src/pages/Fields/components/FieldCard.module.css:focus-visiblering, focus-coupled hover overlay,:focusoutline resetfrontend/src/pages/Fields/components/__tests__/FieldCard.test.tsxPhase progression
Phase 1a–1f migrated the dashboard row to the Card primitive. Phase 2a (#627) extended that to
FieldCardand surfaced the legacy a11y debt (onClickon a non-semantic wrapper). Phase 2b closes that debt without touching the visual contract or the design-system API.