|
2 | 2 | import { confetti } from '@neoconfetti/svelte';
|
3 | 3 | import { enhance } from '$app/forms';
|
4 | 4 | import type { PageData, ActionData } from './$types';
|
| 5 | + import { reduced_motion } from './reduced-motion'; |
5 | 6 |
|
6 | 7 | /** @type {import('./$types').PageData} */
|
7 | 8 | export let data: PageData;
|
|
25 | 26 | */
|
26 | 27 | let classnames: Record<string, 'exact' | 'close' | 'missing'>;
|
27 | 28 |
|
| 29 | + /** |
| 30 | + * A map of descriptions for all letters that have been guessed, |
| 31 | + * used for adding text for assistive technology (e.g. screen readers) |
| 32 | + * @type {Record<string, string>} |
| 33 | + */ |
| 34 | + let description: Record<string, string>; |
| 35 | +
|
28 | 36 | $: {
|
29 | 37 | classnames = {};
|
| 38 | + description = {}; |
30 | 39 |
|
31 | 40 | data.answers.forEach((answer, i) => {
|
32 | 41 | const guess = data.guesses[i];
|
|
36 | 45 |
|
37 | 46 | if (answer[i] === 'x') {
|
38 | 47 | classnames[letter] = 'exact';
|
| 48 | + description[letter] = 'correct'; |
39 | 49 | } else if (!classnames[letter]) {
|
40 | 50 | classnames[letter] = answer[i] === 'c' ? 'close' : 'missing';
|
| 51 | + description[letter] = answer[i] === 'c' ? 'present' : 'absent'; |
41 | 52 | }
|
42 | 53 | }
|
43 | 54 | });
|
|
83 | 94 | <meta name="description" content="A Wordle clone written in SvelteKit" />
|
84 | 95 | </svelte:head>
|
85 | 96 |
|
| 97 | +<h1 class="visually-hidden">Sverdle</h1> |
| 98 | + |
86 | 99 | <form
|
87 | 100 | method="POST"
|
88 | 101 | action="?/enter"
|
|
98 | 111 | <div class="grid" class:playing={!won} class:bad-guess={form?.badGuess}>
|
99 | 112 | {#each Array(6) as _, row}
|
100 | 113 | {@const current = row === i}
|
101 |
| - |
| 114 | + <h2 class="visually-hidden">Row {row + 1}</h2> |
102 | 115 | <div class="row" class:current>
|
103 | 116 | {#each Array(5) as _, column}
|
104 | 117 | {@const answer = data.answers[row]?.[column]}
|
105 |
| - |
106 |
| - <input |
107 |
| - name="guess" |
108 |
| - disabled={!current} |
109 |
| - readonly |
110 |
| - class:exact={answer === 'x'} |
111 |
| - class:close={answer === 'c'} |
112 |
| - aria-selected={current && column === data.guesses[row].length} |
113 |
| - value={data.guesses[row]?.[column] ?? ''} |
114 |
| - /> |
| 118 | + {@const value = data.guesses[row]?.[column] ?? ''} |
| 119 | + {@const selected = current && column === data.guesses[row].length} |
| 120 | + {@const exact = answer === 'x'} |
| 121 | + {@const close = answer === 'c'} |
| 122 | + {@const missing = answer === '_'} |
| 123 | + <div class="letter" class:exact class:close class:missing class:selected> |
| 124 | + {value} |
| 125 | + <span class="visually-hidden"> |
| 126 | + {#if exact} |
| 127 | + (correct) |
| 128 | + {:else if close} |
| 129 | + (present) |
| 130 | + {:else if missing} |
| 131 | + (absent) |
| 132 | + {:else} |
| 133 | + empty |
| 134 | + {/if} |
| 135 | + </span> |
| 136 | + <input name="guess" disabled={!current} type="hidden" {value} /> |
| 137 | + </div> |
115 | 138 | {/each}
|
116 | 139 | </div>
|
117 | 140 | {/each}
|
|
122 | 145 | {#if !won && data.answer}
|
123 | 146 | <p>the answer was "{data.answer}"</p>
|
124 | 147 | {/if}
|
125 |
| - <button data-key="enter" aria-selected="true" class="restart" formaction="?/restart"> |
| 148 | + <button data-key="enter" class="restart selected" formaction="?/restart"> |
126 | 149 | {won ? 'you won :)' : `game over :(`} play again?
|
127 | 150 | </button>
|
128 | 151 | {:else}
|
129 | 152 | <div class="keyboard">
|
130 |
| - <button data-key="enter" aria-selected={submittable} disabled={!submittable}>enter</button> |
| 153 | + <button data-key="enter" class:selected={submittable} disabled={!submittable}>enter</button> |
131 | 154 |
|
132 | 155 | <button
|
133 | 156 | on:click|preventDefault={update}
|
|
150 | 173 | formaction="?/update"
|
151 | 174 | name="key"
|
152 | 175 | value={letter}
|
| 176 | + aria-label="{letter} {description[letter] || ''}" |
153 | 177 | >
|
154 | 178 | {letter}
|
155 | 179 | </button>
|
|
165 | 189 | <div
|
166 | 190 | style="position: absolute; left: 50%; top: 30%"
|
167 | 191 | use:confetti={{
|
| 192 | + particleCount: $reduced_motion ? 0 : undefined, |
168 | 193 | force: 0.7,
|
169 | 194 | stageWidth: window.innerWidth,
|
170 | 195 | stageHeight: window.innerHeight,
|
|
225 | 250 | margin: 0 0 0.2rem 0;
|
226 | 251 | }
|
227 | 252 |
|
228 |
| - .grid.bad-guess .row.current { |
229 |
| - animation: wiggle 0.5s; |
| 253 | + @media (prefers-reduced-motion: no-preference) { |
| 254 | + .grid.bad-guess .row.current { |
| 255 | + animation: wiggle 0.5s; |
| 256 | + } |
230 | 257 | }
|
231 | 258 |
|
232 | 259 | .grid.playing .row.current {
|
233 | 260 | filter: drop-shadow(3px 3px 10px var(--color-bg-0));
|
234 | 261 | }
|
235 | 262 |
|
236 |
| - input { |
| 263 | + .letter { |
237 | 264 | aspect-ratio: 1;
|
238 | 265 | width: 100%;
|
239 | 266 | display: flex;
|
|
250 | 277 | color: rgba(0, 0, 0, 0.7);
|
251 | 278 | }
|
252 | 279 |
|
253 |
| - input:disabled:not(.exact):not(.close) { |
| 280 | + .letter.missing { |
254 | 281 | background: rgba(255, 255, 255, 0.5);
|
255 | 282 | color: rgba(0, 0, 0, 0.5);
|
256 | 283 | }
|
257 | 284 |
|
258 |
| - input.exact { |
| 285 | + .letter.exact { |
259 | 286 | background: var(--color-theme-2);
|
260 | 287 | color: white;
|
261 | 288 | }
|
262 | 289 |
|
263 |
| - input.close { |
| 290 | + .letter.close { |
264 | 291 | border: 2px solid var(--color-theme-2);
|
265 | 292 | }
|
266 | 293 |
|
267 |
| - input:focus { |
268 |
| - outline: none; |
269 |
| - } |
270 |
| -
|
271 |
| - [aria-selected='true'] { |
| 294 | + .selected { |
272 | 295 | outline: 2px solid var(--color-theme-1);
|
273 | 296 | }
|
274 | 297 |
|
275 |
| - input:not(:disabled)::selection { |
276 |
| - background: transparent; |
277 |
| - color: var(--color-theme-1); |
278 |
| - } |
279 |
| -
|
280 | 298 | .controls {
|
281 | 299 | text-align: center;
|
282 | 300 | justify-content: center;
|
|
0 commit comments