Step 4 - User interface
5 min czytania
User interface
A thin rendering layer over the engine. Views know how to draw a state and how to report intent;
they hold no game logic and decide nothing. Each view exports a factory that receives its mount
element and a callback, and returns a render(state) function.
DOM structure
<main class="app">
<h1 class="app__title">Tic Tac Toe</h1>
<section class="controls" aria-label="Game settings">
<fieldset> <!-- Mode: Two players / Play vs computer (radio group) -->
<label> <!-- Difficulty: select, disabled in two-player mode -->
<fieldset> <!-- Starting mark: X / O / Alternate (radio group) -->
<button class="btn btn--primary" data-action="new-game">New game</button>
</section>
<p class="status" role="status" aria-live="polite" aria-atomic="true"></p>
<div class="board" role="grid" aria-label="Tic tac toe board, 3 by 3">
<div role="row">
<div role="gridcell">
<button class="cell" data-index="0" aria-label="Row 1, column 1, empty"></button>
</div>
…nine cells total…
</div>
</div>
<section class="scoreboard" aria-label="Scores">…</section>
</main>
Rules for the markup:
- Cells are real
<button>elements. Never<div onclick>, never<td>. - The grid is built once at startup. Rendering updates existing nodes; it never re-creates the board, because replacing nodes destroys focus and breaks screen-reader position.
data-indexon each cell is the engine index0..8.
Rendering
createBoardView(root, onSelect) returns render(state, options) which, for each cell:
- sets the text content to
X,Oor the empty string; - sets
aria-labeltoRow {r}, column {c}, {empty | X | O}, one-based for humans; - sets
disabledwhen the cell is occupied, the game is over, or it is the computer's turn; - toggles
.cell--x/.cell--ofor styling and.cell--winningfor cells inwinningLine; - sets
aria-disabledin step withdisabled.
Rendering must be idempotent: calling render twice with the same state produces identical DOM and
does not restart animations. Compare before writing — only assign a property when its value changes.
Status line and announcements
createStatusView(root) renders exactly one sentence, drawn from src/ui/strings.ts:
| Situation | Text |
|---|---|
| In progress, human turn | Your turn — X / Your turn — O (two-player mode: X to move) |
| In progress, computer thinking | Computer is thinking… |
| Win, vs computer, human won | You win! |
| Win, vs computer, computer won | Computer wins. |
| Win, two players | X wins! / O wins! |
| Draw | It's a draw. |
The status element is the single aria-live="polite" region. Do not add a second live region
anywhere; competing regions produce overlapping announcements. The scoreboard updates silently
(aria-live="off"), since its content is redundant with the outcome announcement.
Input
Pointer/touch. One delegated click listener on .board reads data-index from
event.target.closest('.cell') and calls onSelect(index). Ignore clicks on disabled cells and on
the gaps between cells. Set touch-action: manipulation on cells to remove the 300 ms tap delay.
Keyboard (src/ui/keyboard.ts), roving tabindex:
- Exactly one cell has
tabindex="0"at a time; all others havetabindex="-1". The board is a single tab stop. ArrowRight/ArrowLeft/ArrowUp/ArrowDownmove focus by one cell, wrapping at the edges.Homemoves to index 0,Endto index 8.EnterandSpaceselect the focused cell. Prevent default onSpaceso the page does not scroll.- Arrow navigation moves focus over occupied and disabled cells too, so a screen-reader user can
inspect the whole board. Use
aria-disabledplus a click guard rather than thedisabledattribute if the browser skips disabled buttons in your implementation — but then keeparia-disabledand the guard perfectly in sync. - After a move, focus stays on the played cell. After New game, focus moves to the centre cell (index 4) and the roving tabindex resets there.
Global shortcuts: N starts a new game, ? toggles a small keyboard-help panel. Shortcuts are
ignored while focus is inside a select or a text field.
Computer turn feedback
When the computer is to move, the UI disables all cells, shows the thinking status and schedules the AI call after a delay of 300–500 ms so the move is perceptible. The delay is cosmetic only: the result must not depend on it, and it must be cancelled if a new game starts first. Never block the main thread with a synchronous loop to simulate thinking.
Strings
Every user-visible string lives in src/ui/strings.ts as a flat exported object. No string literals
in view code. This keeps a later translation a single-file change without pulling in an i18n library.
Acceptance criteria
- The board renders as nine
<button>elements inside arole="grid"with rows, each carrying a correctdata-index. - Views contain no game rules:
src/ui/never decides a winner, validates a move or picks an AI move. -
renderis idempotent — a second call with the same state changes no DOM attribute, verified by a mutation-observer test. - The full game is playable with the keyboard alone: arrows wrap in both axes,
Home/Endwork,EnterandSpaceplace a mark, andSpacenever scrolls the page. - Exactly one cell has
tabindex="0"at any moment. - Every cell exposes an
aria-labelnaming its one-based row, column and content, updated after every move. - There is exactly one
aria-liveregion in the document, and outcomes are announced through it. - Occupied cells, all cells after the game ends, and all cells during the computer's turn cannot be activated by mouse, touch or keyboard.
- Winning cells receive
.cell--winning, and the win is also conveyed as text. - After a move focus remains on the played cell; after New game focus lands on the centre.
- No user-visible string appears outside
src/ui/strings.ts. -
npm run verifypasses.
Dyskusja
Komentarze: 0Brak komentarzy. Rozpocznij dyskusję.