Skip to content

♿ Make the Minesweeper demo keyboard accessible - #147

Merged
sdougbrown merged 3 commits into
umpire-tools:mainfrom
joshuamooredev:a11y/minesweeper-navigation
Sep 3, 2026
Merged

♿ Make the Minesweeper demo keyboard accessible#147
sdougbrown merged 3 commits into
umpire-tools:mainfrom
joshuamooredev:a11y/minesweeper-navigation

Conversation

@joshuamooredev

Copy link
Copy Markdown
Contributor

Closes #146.

Problem

The Minesweeper board exposed all 64 cells in the normal Tab sequence and did
not announce game events to screen-reader users. The supporting controls and
inspector also lacked accessible structure, and the board overflowed on narrow
viewports around 360px.

Approach

  • Added ARIA grid, row, and gridcell semantics.
  • Added roving tabindex so the board occupies one place in the Tab sequence.
  • Added wrapping arrow-key navigation and row-based Home/End behavior.
  • Extracted the focus calculation into a pure helper with focused Bun tests.
  • Added a polite live region for game start, dig, flag, win, and loss events.
  • Added accessible labels for the interaction mode, status, board, and inspector.
  • Allowed the board cells to shrink and stacked the inspector below the board on
    narrow viewports.

I used roving tabindex instead of 64 individual tab stops because the board is
one composite grid widget. Tab enters and leaves the grid, while the arrow keys
handle directional navigation within it.

Tradeoffs

Cells shrink below their desktop size on narrow screens so all eight columns
remain visible without horizontal overflow. The board remains functional at the
approximately 360px target from the issue.

Verification

  • yarn --cwd docs test — 3 tests passed
  • yarn docs:build — passed
  • Keyboard-tested Tab entry/exit, four-direction wrapping, Home/End, Enter,
    Space, and Flag mode
  • Tested at 360px with no horizontal overflow
  • Tested with Windows Narrator
  • Disabled Narrator Scan Mode while interacting with the ARIA grid
  • Confirmed cell coordinates and states were announced
  • Confirmed game start, dig, flag, win, and loss announcements

@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes the Minesweeper demo a keyboard-navigable ARIA grid, adds screen-reader announcements and accessible structure, and adjusts its responsive layout.

  • Adds roving focus with wrapping arrow-key and row-based Home/End navigation.
  • Adds live announcements for game and cell events.
  • Adds ARIA structure and labels for the board, controls, status, and inspector.
  • Adds responsive cell sizing and focused tests for the navigation helper.

Confidence Score: 4/5

The ARIA-disabled state on flagged cells should be corrected before merging because it tells assistive-technology users that the supported unflag action is unavailable.

Flagged cells remain operable through toggleFlag, but the new accessibility metadata exposes them as disabled whenever engine availability is false.

Files Needing Attention: docs/src/components/MinesweeperDemo.tsx

Important Files Changed

Filename Overview
docs/src/components/MinesweeperDemo.tsx Adds the accessible grid, roving focus, and live announcements, but exposes actionable flagged cells as ARIA-disabled.
docs/src/lib/minesweeper-focus.ts Implements straightforward wrapping and row-edge focus calculations.
docs/src/lib/minesweeper-focus.test.ts Covers horizontal and vertical wrapping plus Home/End behavior.
docs/src/styles/components/_components.minesweeper-demo.css Introduces nested row-grid styling, screen-reader-only presentation, and narrow-viewport layout adjustments.
docs/package.json Adds a Bun test script for the documentation project.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Tab[Tab enters active grid cell] --> Key{Keyboard input}
  Key -->|Arrow keys| Move[Calculate wrapped cell]
  Key -->|Home or End| Edge[Select row edge]
  Key -->|Enter or Space| Activate[Activate focused cell]
  Move --> Focus[Update activeCell and focus button]
  Edge --> Focus
  Activate --> Mode{Interaction mode}
  Mode -->|Dig| Reveal[Reveal cell]
  Mode -->|Flag| Flag[Toggle flag]
  Reveal --> Announce[Update polite live region]
  Flag --> Announce
Loading

Reviews (1): Last reviewed commit: "♿ a11y: make Minesweeper keyboard access..." | Re-trigger Greptile

Comment thread docs/src/components/MinesweeperDemo.tsx Outdated
}
aria-rowindex={cell.y + 1}
aria-colindex={cell.x + 1}
aria-disabled={!cellAvailability.enabled}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Actionable cells announced disabled

When a screen-reader user focuses a flagged cell in Flag mode, aria-disabled announces it as disabled even though activating it removes the flag, misleading the user about the available unflag action.

Suggested change
aria-disabled={!cellAvailability.enabled}
aria-disabled={
!cellAvailability.enabled &&
!(conditions.flagMode && value === "flagged")
}

@joshuamooredev

joshuamooredev commented Sep 1, 2026 via email

Copy link
Copy Markdown
Contributor Author

@umpire-bot umpire-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is marked... FOUL BALL. 👉

import { moveFocus } from "./minesweeper-focus.js";

describe("moveFocus", () => {
it("moves horizontally and wraps within the row", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap: the moveFocus tests only verify wrapping at grid edges (right at x=7, left at x=0, down at y=7, up at y=0) plus home/end. No test covers standard in-grid movement (e.g. right from a center cell), so a regression in the non-wrapping x + 1 / y + 1 paths in minesweeper-focus.ts:17,21,25,29 would go undetected. Add a basic movement assertion per direction.

Comment thread docs/src/components/MinesweeperDemo.tsx Outdated
"c-minesweeper-demo__mode-button",
!conditions.flagMode &&
"c-minesweeper-demo__mode-button is-active",
"c-minesweeper-demo__mode-button is-active",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like c-minesweeper-demo__mode-button is duplicated here. pre-existing but would appreciate cleanup to just append is-active for these cases. probably duplicated from a refactor to use cls(). same thing at line 461

@sdougbrown sdougbrown left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requesting changes to hoist the row indexes etc to module scope 😄

Comment thread docs/src/components/MinesweeperDemo.tsx Outdated
const rowCells = CELL_ORDER.slice(
rowIndex * BOARD_WIDTH,
(rowIndex + 1) * BOARD_WIDTH,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah you need to separate into rows for aria semantics ok cool.

not a big deal perf-wise for this size of a grid, but for this exercise let's try to do this differently.

it's kind of pointless repeated work on every render even if not a big perf hit. the board dimensions are constant, so the row grouping is static. it's the same kind of invariant that CELL_ORDER and EMPTY_BOARD already hoist to module scope. would recommend to precompute the rows once at module level and just map:

  const CELL_ROWS = Array.from({ length: BOARD_HEIGHT }, (_, rowIndex) =>
    CELL_ORDER.slice(rowIndex * BOARD_WIDTH, (rowIndex + 1) * BOARD_WIDTH),
  );
  // render: CELL_ROWS.map((rowCells, rowIndex) => (...))

That keeps the render-body focused on the actual per-cell work and matches the file's own existing pattern. It also trims a bit of the diff (the .slice logic disappears from the JSX).

Comment thread docs/src/components/MinesweeperDemo.tsx Outdated
className={cls(
"c-minesweeper-demo__cell",
!isRevealed && "c-minesweeper-demo__cell is-hidden",
isRevealed && "c-minesweeper-demo__cell is-revealed",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ha repeated c-minesweeper-demo__cell here too. cls should concat so we don't need the duplicates haha

@sdougbrown sdougbrown left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice work 👍

@sdougbrown
sdougbrown merged commit b56620f into umpire-tools:main Sep 3, 2026
6 checks passed
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.

♿ Make the Minesweeper demo keyboard-navigable and screen-reader-announceable

2 participants