Merge branch 'screen-reader'
This commit is contained in:
14
src/App.css
14
src/App.css
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
36
src/Game.tsx
36
src/Game.tsx
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
20
src/Row.tsx
20
src/Row.tsx
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/clue.ts
16
src/clue.ts
@@ -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(", ");
|
||||||
|
}
|
||||||
|
|||||||
@@ -3732,7 +3732,6 @@
|
|||||||
"neighborhood",
|
"neighborhood",
|
||||||
"fragments",
|
"fragments",
|
||||||
"symbolic",
|
"symbolic",
|
||||||
"wales",
|
|
||||||
"designated",
|
"designated",
|
||||||
"prescribed",
|
"prescribed",
|
||||||
"electricity",
|
"electricity",
|
||||||
|
|||||||
21
src/util.ts
21
src/util.ts
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user