Skip to content

fix(fields): make FieldCard keyboard-accessible [Phase 2b]#629

Merged
barach6662001-bit merged 3 commits intomainfrom
replit/phase-2b-fieldcard-a11y
Apr 24, 2026
Merged

fix(fields): make FieldCard keyboard-accessible [Phase 2b]#629
barach6662001-bit merged 3 commits intomainfrom
replit/phase-2b-fieldcard-a11y

Conversation

@barach6662001-bit
Copy link
Copy Markdown
Owner

Summary

Phase 2b — narrow accessibility fix on FieldCard paying 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:

  1. Card's as prop only accepts block-level tags; adding 'a' would require touching the Card / Surface API, which is forbidden by the brief.
  2. Wrapping Card in <Link> would nest the hover-overlay's real <button> inside an <a> — invalid HTML per the WHATWG spec.
  3. The test brief explicitly requires both Enter and Space activation. Native anchor semantics activate on Enter only — that's a button affordance.

The accepted-fallback button-pattern matches the test requirements exactly and stays inside scope.

Changes

  • role="button", tabIndex={0}, onKeyDown on the Card surface — activation on Enter and Space (preventDefault on Space stops page scroll).
  • 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):

# Case
1 primary render — name + area + cadastral + crop
2 cadastral omitted when undefined
3 hover-CTA label is present but is NOT exposed as a button role
4 not-seeded fallback when currentCrop is undefined
5 card has accessible role="button" and full aria-label (with crop)
6 card has accessible aria-label (without crop)
7 card is keyboard-reachable (tabIndex=0)
8 click → navigate
9 Enter → navigate
10 Space → navigate
11 unrelated keys (Tab, Escape, Shift) → no navigate

Validation

  • npx vitest run src/design-system src/pages/Fields69/69
  • npx tsc --noEmit clean
  • npx eslint on touched files clean
  • npx vite build succeeds (PWA SW generated; pre-existing chunk-size warning unchanged)

Files

File Change
frontend/src/pages/Fields/components/FieldCard.tsx role/tabIndex/onKeyDown/aria-label on Card, button→span on overlay CTA, JSDoc updated
frontend/src/pages/Fields/components/FieldCard.module.css :focus-visible ring, focus-coupled hover overlay, :focus outline reset
frontend/src/pages/Fields/components/__tests__/FieldCard.test.tsx rewritten — 4 → 11 focused cases

Phase progression

Phase 1a–1f migrated the dashboard row to the Card primitive. Phase 2a (#627) extended that to FieldCard and surfaced the legacy a11y debt (onClick on a non-semantic wrapper). Phase 2b closes that debt without touching the visual contract or the design-system API.

  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.
@barach6662001-bit barach6662001-bit force-pushed the replit/phase-2b-fieldcard-a11y branch from 583cf0b to 0216528 Compare April 24, 2026 13:15
@barach6662001-bit barach6662001-bit merged commit d2b763d into main Apr 24, 2026
3 checks passed
@barach6662001-bit barach6662001-bit deleted the replit/phase-2b-fieldcard-a11y branch April 24, 2026 13:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant