Merge branch 'screen-reader'

This commit is contained in:
Lynn
2022-01-14 17:20:28 +01:00
7 changed files with 94 additions and 17 deletions

View File

@@ -44,6 +44,10 @@ body {
user-select: none; user-select: none;
} }
table.Game-rows {
margin: auto;
}
.Game-keyboard { .Game-keyboard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -170,3 +174,13 @@ a:active {
margin-top: 1em; margin-top: 1em;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.Game-sr-feedback,
.sr-only {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Row, RowState } from "./Row"; import { Row, RowState } from "./Row";
import dictionary from "./dictionary.json"; import dictionary from "./dictionary.json";
import { Clue, clue } from "./clue"; import { Clue, clue, describeClue } from "./clue";
import { Keyboard } from "./Keyboard"; import { Keyboard } from "./Keyboard";
import targetList from "./targets.json"; import targetList from "./targets.json";
import { dictionarySet, pick, resetRng, seed } from "./util"; import { dictionarySet, pick, resetRng, seed, speak } from "./util";
enum GameState { enum GameState {
Playing, Playing,
@@ -17,8 +17,7 @@ interface GameProps {
hidden: boolean; hidden: boolean;
} }
const targets = targetList const targets = targetList.slice(0, targetList.indexOf("murky") + 1); // Words no rarer than this one
.slice(0, targetList.indexOf("murky") + 1); // Words no rarer than this one
function randomTarget(wordLength: number) { function randomTarget(wordLength: number) {
const eligible = targets.filter((word) => word.length === wordLength); const eligible = targets.filter((word) => word.length === wordLength);
@@ -31,6 +30,7 @@ function Game(props: GameProps) {
const [currentGuess, setCurrentGuess] = useState<string>(""); const [currentGuess, setCurrentGuess] = useState<string>("");
const [wordLength, setWordLength] = useState(5); const [wordLength, setWordLength] = useState(5);
const [hint, setHint] = useState<string>(`Make your first guess!`); const [hint, setHint] = useState<string>(`Make your first guess!`);
const [srStatus, setSrStatus] = useState<string>(``);
const [target, setTarget] = useState(() => { const [target, setTarget] = useState(() => {
resetRng(); resetRng();
return randomTarget(wordLength); return randomTarget(wordLength);
@@ -57,6 +57,7 @@ function Game(props: GameProps) {
if (/^[a-z]$/.test(key)) { if (/^[a-z]$/.test(key)) {
setCurrentGuess((guess) => (guess + key).slice(0, wordLength)); setCurrentGuess((guess) => (guess + key).slice(0, wordLength));
setHint(""); setHint("");
setSrStatus("");
} else if (key === "Backspace") { } else if (key === "Backspace") {
setCurrentGuess((guess) => guess.slice(0, -1)); setCurrentGuess((guess) => guess.slice(0, -1));
setHint(""); setHint("");
@@ -72,7 +73,9 @@ function Game(props: GameProps) {
setGuesses((guesses) => guesses.concat([currentGuess])); setGuesses((guesses) => guesses.concat([currentGuess]));
setCurrentGuess((guess) => ""); setCurrentGuess((guess) => "");
if (currentGuess === target) { if (currentGuess === target) {
setHint("You won! (Enter to play again)"); setHint(
`You won! The answer was ${target.toUpperCase()}. (Enter to play again)`
);
setGameState(GameState.Won); setGameState(GameState.Won);
} else if (guesses.length + 1 === props.maxGuesses) { } else if (guesses.length + 1 === props.maxGuesses) {
setHint( setHint(
@@ -81,6 +84,7 @@ function Game(props: GameProps) {
setGameState(GameState.Lost); setGameState(GameState.Lost);
} else { } else {
setHint(""); setHint("");
speak(describeClue(clue(currentGuess, target)));
} }
} }
}; };
@@ -98,7 +102,7 @@ function Game(props: GameProps) {
}, [currentGuess, gameState]); }, [currentGuess, gameState]);
let letterInfo = new Map<string, Clue>(); let letterInfo = new Map<string, Clue>();
const rowDivs = Array(props.maxGuesses) const tableRows = Array(props.maxGuesses)
.fill(undefined) .fill(undefined)
.map((_, i) => { .map((_, i) => {
const guess = [...guesses, currentGuess][i] ?? ""; const guess = [...guesses, currentGuess][i] ?? "";
@@ -117,7 +121,13 @@ function Game(props: GameProps) {
<Row <Row
key={i} key={i}
wordLength={wordLength} wordLength={wordLength}
rowState={lockedIn ? RowState.LockedIn : RowState.Pending} rowState={
lockedIn
? RowState.LockedIn
: i === guesses.length
? RowState.Editing
: RowState.Pending
}
cluedLetters={cluedLetters} cluedLetters={cluedLetters}
/> />
); );
@@ -146,11 +156,10 @@ function Game(props: GameProps) {
setTarget(randomTarget(length)); setTarget(randomTarget(length));
setWordLength(length); setWordLength(length);
setHint(`${length} letters`); setHint(`${length} letters`);
(document.activeElement as HTMLElement)?.blur();
}} }}
></input> ></input>
<button <button
style={{ flex: "0" }} style={{ flex: "0 0 auto" }}
disabled={gameState !== GameState.Playing || guesses.length === 0} disabled={gameState !== GameState.Playing || guesses.length === 0}
onClick={() => { onClick={() => {
setHint( setHint(
@@ -163,8 +172,13 @@ function Game(props: GameProps) {
Give up Give up
</button> </button>
</div> </div>
{rowDivs} <table className="Game-rows" tabIndex={0}>
<p>{hint || `\u00a0`}</p> <tbody>{tableRows}</tbody>
</table>
<p role="alert">{hint || `\u00a0`}</p>
{/* <p role="alert" className="Game-sr-feedback">
{srStatus}
</p> */}
<Keyboard letterInfo={letterInfo} onKey={onKey} /> <Keyboard letterInfo={letterInfo} onKey={onKey} />
{seed ? ( {seed ? (
<div className="Game-seed-info"> <div className="Game-seed-info">

View File

@@ -13,7 +13,7 @@ export function Keyboard(props: KeyboardProps) {
]; ];
return ( return (
<div className="Game-keyboard"> <div className="Game-keyboard" aria-hidden="true">
{keyboard.map((row, i) => ( {keyboard.map((row, i) => (
<div key={i} className="Game-keyboard-row"> <div key={i} className="Game-keyboard-row">
{row.map((label, j) => { {row.map((label, j) => {
@@ -29,6 +29,7 @@ export function Keyboard(props: KeyboardProps) {
<div <div
tabIndex={-1} tabIndex={-1}
key={j} key={j}
role="button"
className={className} className={className}
onClick={() => { onClick={() => {
props.onKey(label); props.onKey(label);

View File

@@ -1,7 +1,8 @@
import { Clue, clueClass, CluedLetter } from "./clue"; import { Clue, clueClass, CluedLetter, clueWord } from "./clue";
export enum RowState { export enum RowState {
LockedIn, LockedIn,
Editing,
Pending, Pending,
} }
@@ -13,6 +14,7 @@ interface RowProps {
export function Row(props: RowProps) { export function Row(props: RowProps) {
const isLockedIn = props.rowState === RowState.LockedIn; const isLockedIn = props.rowState === RowState.LockedIn;
const isEditing = props.rowState === RowState.Editing;
const letterDivs = props.cluedLetters const letterDivs = props.cluedLetters
.concat(Array(props.wordLength).fill({ clue: Clue.Absent, letter: "" })) .concat(Array(props.wordLength).fill({ clue: Clue.Absent, letter: "" }))
.slice(0, props.wordLength) .slice(0, props.wordLength)
@@ -22,12 +24,22 @@ export function Row(props: RowProps) {
letterClass += " " + clueClass(clue); letterClass += " " + clueClass(clue);
} }
return ( return (
<div key={i} className={letterClass}> <td
key={i}
className={letterClass}
aria-live={isEditing ? "assertive" : "off"}
aria-label={
isLockedIn
? letter.toUpperCase() +
(clue === undefined ? "" : ": " + clueWord(clue))
: ""
}
>
{letter} {letter}
</div> </td>
); );
}); });
let rowClass = "Row"; let rowClass = "Row";
if (isLockedIn) rowClass += " Row-locked-in"; if (isLockedIn) rowClass += " Row-locked-in";
return <div className={rowClass}>{letterDivs}</div>; return <tr className={rowClass}>{letterDivs}</tr>;
} }

View File

@@ -39,3 +39,19 @@ export function clueClass(clue: Clue): string {
return "letter-correct"; return "letter-correct";
} }
} }
export function clueWord(clue: Clue): string {
if (clue === Clue.Absent) {
return "no";
} else if (clue === Clue.Elsewhere) {
return "elsewhere";
} else {
return "correct";
}
}
export function describeClue(clue: CluedLetter[]): string {
return clue
.map(({ letter, clue }) => letter.toUpperCase() + " " + clueWord(clue!))
.join(", ");
}

View File

@@ -3732,7 +3732,6 @@
"neighborhood", "neighborhood",
"fragments", "fragments",
"symbolic", "symbolic",
"wales",
"designated", "designated",
"prescribed", "prescribed",
"electricity", "electricity",

View File

@@ -24,3 +24,24 @@ export function resetRng(): void {
export function pick<T>(array: Array<T>): T { export function pick<T>(array: Array<T>): T {
return array[Math.floor(array.length * random())]; return array[Math.floor(array.length * random())];
} }
// https://a11y-guidelines.orange.com/en/web/components-examples/make-a-screen-reader-talk/
export function speak(
text: string,
priority: "polite" | "assertive" = "assertive"
) {
var el = document.createElement("div");
var id = "speak-" + Date.now();
el.setAttribute("id", id);
el.setAttribute("aria-live", priority || "polite");
el.classList.add("sr-only");
document.body.appendChild(el);
window.setTimeout(function () {
document.getElementById(id)!.innerHTML = text;
}, 100);
window.setTimeout(function () {
document.body.removeChild(document.getElementById(id)!);
}, 1000);
}