Skip to content

fix(dashboard-v2): make UpcomingPanel rows keyboard-accessible [Phase 2d]#633

Merged
barach6662001-bit merged 1 commit intomainfrom
replit/phase-2d-upcomingpanel-a11y
Apr 24, 2026
Merged

fix(dashboard-v2): make UpcomingPanel rows keyboard-accessible [Phase 2d]#633
barach6662001-bit merged 1 commit intomainfrom
replit/phase-2d-upcomingpanel-a11y

Conversation

@barach6662001-bit
Copy link
Copy Markdown
Owner

Phase 2d — UpcomingPanel keyboard accessibility

Continues the surgical a11y paydown from #629 (FieldCard, Phase 2b) and #632 (OperationsTimeline, Phase 2c) on the only remaining clickable-row debt the user asked to fix this round.

Before

pages/DashboardV2/components/UpcomingPanel rendered each upcoming-operation row as:

<li onClick={() => navigate(`/operations/${op.id}`)} style={{ cursor: 'pointer' }}>

No role, no tabIndex, no onKeyDown, no accessible name. Unreachable by keyboard, unannounced by AT.

After

Same contract proven in #629 / #632:

  • 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:
    • {operationType}, {fieldName}, {DD MMM}
    • {operationType}, {fieldName}, {DD MMM}, {area} га when an area suffix is shown
  • Decorative date chip and area pill carry aria-hidden="true" — their content is already in aria-label
  • Token-driven :focus-visible ring (outline: 2px solid var(--brand); outline-offset: 2px;) in the CSS module, with .item:focus { outline: none; } for mouse focus
  • Inline cursor: pointer migrated into the CSS module so the focus rule has no runtime style fight

Scope (per request)

In: UpcomingPanel.tsx, UpcomingPanel.module.css, new UpcomingPanel.test.tsx.
Out: legacy components/dashboard/OperationsTimeline.tsx, FieldStatusCard, KpiHeroRow, AppLayout, Sidebar, OperationDetail, OperationsList, Card / Surface API. No new dependencies, no route changes, no API or business-logic changes, no redesign.

Why no "decorative button → span" step

Unlike FieldCard (Phase 2b), this row has no nested <button> to convert. Noted explicitly in the JSDoc so future phases don't expect that step.

Tests (17, new file)

  • Empty state (3): no ops; only completed ops; only out-of-window ops
  • Render (5): row content; area when > 0; area omitted when 0; weather strip; exactly one button per row
  • Accessibility (4): aria-label without/with area; tabIndex=0; decorative chips not exposed as buttons
  • Activation (5): click; Enter; Space; Space preventDefault; ignored keys; multi-row id routing

Validation

  • pnpm --filter frontend test100 / 100 (17 new + 83 existing)
  • npx tsc -b --noEmit → clean
  • npx eslint on changed files → clean
  • npx vite build → green (✓ built in 26 s)

… 2d]

  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)
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