Skip to content

fix(dashboard): make FieldStatusCard rows keyboard-accessible [Phase 2f]#636

Merged
barach6662001-bit merged 2 commits intomainfrom
replit/phase-2f-fieldstatuscard-a11y
Apr 24, 2026
Merged

fix(dashboard): make FieldStatusCard rows keyboard-accessible [Phase 2f]#636
barach6662001-bit merged 2 commits intomainfrom
replit/phase-2f-fieldstatuscard-a11y

Conversation

@barach6662001-bit
Copy link
Copy Markdown
Owner

Phase 2f — FieldStatusCard keyboard accessibility

Closes the last low-risk plain <div onClick> row finding from the Phase 2e clickable-elements audit (Finding 1). Identical surgical pattern to #629 (FieldCard, Phase 2b) and #633 (UpcomingPanel, Phase 2d) — no redesign.

Before

The header Усі поля CTA and the empty-state Додати поле CTA were already real <button>s — verified in Phase 2e (they are listed in the audit's false-positives appendix). The remaining debt was the per-row wrapper:

<div className={s.row} onClick={() => navigate('/fields')}>
  {/* field name, area bar, crop tag, area text */}
</div>

With cursor: pointer in the CSS module but no role, no tabIndex, no onKeyDown, no accessible name.

After

Same contract as #629 / #633:

  • role="button" + tabIndex={0} + onKeyDown (Enter / Space + preventDefault on Space)
  • Unrelated keys (Tab, Escape, Shift, arrows) are ignored
  • aria-label mirrors the visible row content: "{fieldName}, {cropLabel}, {area} га"
  • Decorative cropTag / area / bar carry aria-hidden="true" — their content is already in aria-label (the bar has no text of its own; it is a pure visual indicator)
  • Token-driven :focus-visible ring in the CSS module (outline: 2px solid var(--brand); outline-offset: 2px;) paired with the existing .row:hover background, so keyboard and mouse focus look identical

Why field-specific labels for a shared destination

Every row navigates to the same /fields page, but assistive-tech users still need to tell rows apart. This deliberate decision matches the explicit recommendation in the audit.

Why no "decorative button → span" step

There is no nested <button> inside the row. The header Усі поля CTA lives outside the row tree. So the FieldCard / Phase 2b conversion step does not apply here.

Scope (per request)

In: FieldStatusCard.tsx, FieldStatusCard.module.css, new FieldStatusCard.test.tsx.
Out: WarehousesList, NotificationBell, CommandPalette, AppLayout, Sidebar, legacy components/dashboard/*, Card / Surface API. No new dependencies, no route changes, no API or business-logic changes, no redesign. Audit phase order preserved.

Tests (17, new file)

  • Empty state (3): empty fields → placeholder; addField calls onAddField prop when supplied; falls back to navigate when prop omitted
  • Render (3): one row per field with name/crop/area; caps at 6 rows when 7+ supplied; notSeeded fallback when currentCrop is missing
  • Accessibility (4): aria-label format with crop; with notSeeded fallback; tabIndex=0; decorative chips/bar not exposed as buttons
  • Activation (5): click; Enter; Space; Space preventDefault; ignored keys; header Усі поля button (real <button>) still works

Validation

  • pnpm --filter frontend test116 / 116 (17 new + 99 existing)
  • npx tsc -b --noEmit → clean
  • npx eslint on changed files → clean
  • npx vite build → green (✓ built in 31 s)

Replit Agent and others added 2 commits April 24, 2026 15:10
  Closes the last low-risk plain <div onClick> row finding from the
  Phase 2e clickable-elements audit (docs/accessibility/clickable-
  elements-audit.md, Finding 1). Pattern is identical to Phases 2b /
  2d (FieldCard, UpcomingPanel) — surgical paydown, no redesign.

  Before
  ------
  The header "Усі поля" CTA and the empty-state "Add field" CTA in
  pages/Dashboard/components/FieldStatusCard were already real
  <button> elements (verified during the Phase 2e audit pass — they
  are listed in the false-positives appendix). The remaining debt
  was the per-row clickable wrapper:

    <div className={s.row} onClick={() => navigate('/fields')}>
      {field name, area bar, crop tag, area text}
    </div>

  with cursor: pointer in the CSS module but no role, no tabIndex,
  no keyDown handler, no accessible name. Keyboard users could not
  reach or activate the row; screen readers heard the field name,
  crop, and area as static text with no destination context.

  After
  -----
  Each row gains the same FieldCard / UpcomingPanel 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:
        "{fieldName}, {cropLabel}, {area} га"
    * the decorative crop tag, area pill and progress bar are marked
      aria-hidden because their content is duplicated in aria-label
      (the bar is purely a visual indicator with no textual content
      of its own)
    * a token-driven :focus-visible ring (var(--brand), 2px, offset
      2px) lives in the CSS module, paired with the existing
      .row:hover background so keyboard and mouse focus look
      identical (preserves hover/focus parity)

  Notes / scope
  -------------
  Field-specific aria-labels are deliberate even though every row
  navigates to the same /fields destination — assistive-tech users
  still need to tell rows apart. This matches the recommendation in
  the Phase 2e audit document.

  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 (/fields), the field
  filter / sort / .slice(0, 6) logic, the empty-state behaviour
  including the optional onAddField prop, 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 — WarehousesList,
  NotificationBell, CommandPalette, AppLayout, Sidebar and the
  legacy components/dashboard/* files remain untouched per the
  explicit scope and per the audit's recommended phase ordering.

  Tests
  -----
  New file: pages/Dashboard/components/__tests__/FieldStatusCard.test.tsx
  17 cases across 4 groups:

    empty state
      - renders getStarted placeholder when fields=[]
      - addField CTA calls onAddField prop when supplied
      - addField CTA navigates to /fields when onAddField omitted

    primary render
      - one row per field with name / crop / area text
      - caps the list at the first 6 fields
      - falls back to notSeeded label when currentCrop is missing

    accessibility
      - aria-label = "{fieldName}, {cropLabel}, {area} га"
      - aria-label uses notSeeded fallback when currentCrop missing
      - tabIndex=0 (keyboard reachable)
      - decorative crop tag / area pill / bar not exposed as buttons

    activation
      - click navigates to /fields
      - Enter navigates
      - Space navigates
      - Space calls preventDefault (page scroll suppressed)
      - Tab / Escape / Shift / ArrowDown do nothing
      - header "Усі поля" button (real <button>) still navigates

  Validation
  ----------
    pnpm --filter frontend test  → 116 / 116 (17 new + 99 existing)
    npx tsc -b --noEmit          → clean
    npx eslint <changed files>   → clean
    npx vite build               → green (✓ built in 31 s)
@barach6662001-bit barach6662001-bit merged commit 1e322b3 into main Apr 24, 2026
3 checks passed
@barach6662001-bit barach6662001-bit deleted the replit/phase-2f-fieldstatuscard-a11y branch April 24, 2026 15:15
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