Skip to content

Commit

Permalink
refactor cards, improve usability and a11y
Browse files Browse the repository at this point in the history
- make the card system more flexible by switching to nested flexboxes
- improve keyboard accessibility
- make sure all elements have titles and focus indicators
- move wrapping from `NewGuess` into `SongSearch` and rename it to `NewGuess`
- move other `*Guess` components into `Guesses`
- collapse multiple remaining/skipped guesses into a card each
- add a resign game button
- keep refactoring colours, but don't fix them yet :(
  • Loading branch information
Artemis21 committed Mar 3, 2024
1 parent ab6ef69 commit c153158
Show file tree
Hide file tree
Showing 20 changed files with 594 additions and 407 deletions.
56 changes: 36 additions & 20 deletions web/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,35 @@ type ImageDef = {
alt: string;
};

type IconProps = {
fa: IconDefinition;
label?: string;
};

type IconDef = IconProps | IconDefinition;

type Flags = {
good?: boolean;
bad?: boolean;
active?: boolean;
padded?: boolean;
extended?: boolean;
centred?: boolean;
};

type CardProps = {
title: ReactNode;
title?: ReactNode;
details?: ReactNode;
children?: ReactNode;
onClick?: MouseEventHandler<HTMLDivElement>;
onClick?: MouseEventHandler<HTMLButtonElement>;
image?: ImageDef;
icon?: IconDefinition;
icon?: IconDef;
link?: string;
progress?: number;
} & Flags;

export function Card({
title,
details,
children,
onClick,
image,
Expand All @@ -37,7 +46,7 @@ export function Card({
progress,
...flags
}: CardProps) {
const inner = Inner({ icon, image, title, progress, sub: children });
const inner = <Inner {...{ icon, image, title, details, progress, children }} />;
const outerClass = classModifiers("card", { button: link || onClick, ...flags });
if (link && link.startsWith("/")) {
return (
Expand All @@ -51,34 +60,34 @@ export function Card({
{inner}
</a>
);
} else {
// FIXME: We should be using a button element if there is onClick, but for
// some reason it's causing styling issues on the genre picker.
} else if (onClick) {
return (
<div className={outerClass} onClick={onClick} role="button">
<button className={outerClass} onClick={onClick}>
{inner}
</div>
</button>
);
} else {
return <div className={outerClass}>{inner}</div>;
}
}

function Inner({
icon,
image,
title,
details,
progress,
sub,
children,
}: {
icon?: IconDefinition;
icon?: IconDef;
image?: ImageDef;
title?: ReactNode;
details?: ReactNode;
progress?: number;
sub?: ReactNode;
children?: ReactNode;
}) {
if (typeof title === "string") {
title = <p className="card__title">{title}</p>;
} else {
title = <div className="card__title">{title}</div>;
if (icon && !("fa" in icon)) {
icon = { fa: icon };
}
return (
<>
Expand All @@ -88,14 +97,21 @@ function Inner({
style={{ width: `${Math.max(0, Math.min(1, progress)) * 100}%` }}
/>
)}
{icon && <FontAwesomeIcon icon={icon} className="card__icon" />}
{icon && (
<div className="card__icon">
<FontAwesomeIcon icon={icon.fa} title={icon.label} />
</div>
)}
{image && (
<div className="card__image">
<img src={image.src} alt={image.alt} />
</div>
)}
{title}
{sub && <div className="card__sub">{sub}</div>}
<div className="card__body">
{title && <h2 className="card__title">{title}</h2>}
{details && <p className="card__details">{details}</p>}
{children}
</div>
</>
);
}
32 changes: 19 additions & 13 deletions web/components/GameOver.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,22 @@ export function GameOver({ game }: { game: Game }) {
const guessPlural = guesses.length === 1 ? "guess" : "guesses";
const comment = COMMENTS[Math.min(guesses.length, COMMENTS.length) - 1];
outcome = (
<Card title={<>You won!&ensp;&bull;&ensp;{type}</>} icon={faCrown} good>
You took{" "}
<b>
{guesses.length} {guessPlural}
</b>{" "}
- {comment}
</Card>
<Card
icon={faCrown}
title={<>You won!&ensp;&bull;&ensp;{type}</>}
// prettier-ignore
details={<>You took <b>{guesses.length} {guessPlural}</b> - {comment}</>}
good
/>
);
} else {
outcome = (
<Card title={<>You Lost&ensp;&bull;&ensp;{type}</>} icon={faHeartCrack} bad>
But you discovered a new song!
</Card>
<Card
icon={faHeartCrack}
title={<>You Lost&ensp;&bull;&ensp;{type}</>}
details="But you discovered a new song!"
bad
/>
);
}
return (
Expand All @@ -48,9 +51,12 @@ export function GameOver({ game }: { game: Game }) {
{outcome}
<TrackCard track={track} link />
<Attribution />
<Card title="New Game" icon={faPlay} link="/">
Click to play again
</Card>
<Card
icon={faPlay}
title="New Game"
details="Click to play again"
link="/"
/>
</div>
</Scrollable>
);
Expand Down
74 changes: 0 additions & 74 deletions web/components/Guess.tsx

This file was deleted.

111 changes: 87 additions & 24 deletions web/components/Guesses.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { Game } from "../api";
import { Track } from "../api";
import {
fa0,
fa1,
fa2,
fa3,
fa4,
fa5,
fa6,
fa7,
fa8,
fa9,
faForward,
faQuestion,
} from "@fortawesome/free-solid-svg-icons";
import { Card } from "./Card";
import { TrackCard } from "./TrackCard";
import { Game, useResignGame } from "../api";
import { classModifiers } from "../utils";
import { GuessQuery } from "./Game";
import { WrongGuess, SkippedGuess, EmptyGuess, NewGuess } from "./Guess";
import { NewGuess } from "./NewGuess";
import { Scrollable } from "./Scrollable";

export function Guesses({
game: { id, guesses, timedGuess, constants },
game: { id, guesses, constants },
guessQuery,
setGuessQuery,
}: {
Expand All @@ -13,31 +31,76 @@ export function Guesses({
setGuessQuery: (q: GuessQuery) => void;
}) {
const guessEls = [];
for (let n = 0; n < constants.maxGuesses; n++) {
if (n < guesses.length) {
const guess = guesses[n].track;
if (guess !== null) {
guessEls.push(<WrongGuess track={guess} key={n} />);
} else {
guessEls.push(<SkippedGuess key={n} />);
}
} else if (n === guesses.length) {
guessEls.push(
<NewGuess
key={n}
gameId={id}
timedGuess={timedGuess}
guess={guessQuery}
setGuess={setGuessQuery}
/>,
);
} else {
guessEls.push(<EmptyGuess key={n} />);
let i = 0;
while (i < guesses.length) {
const start = i;
let guess = guesses[i];
let skipped = 0;
while (guess && guess.track === null) {
skipped++;
guess = guesses[++i];
}
if (skipped > 0) {
guessEls.push(<SkippedGuesses key={start} count={skipped} />);
}
if (guess) {
guessEls.push(<WrongGuess track={guess.track} key={i} />);
}
i++;
}
return (
<Scrollable>
<div className="card_stack">{guessEls}</div>
<div className="card_stack">
{guessEls}
<NewGuess gameId={id} guess={guessQuery} setGuess={setGuessQuery} />
<RemainingGuesses remaining={constants.maxGuesses - guesses.length - 1} />
<ResignButton gameId={id} />
</div>
</Scrollable>
);
}

function ResignButton({ gameId }: { gameId: number }) {
const { mutate, isLoading } = useResignGame();
const className = classModifiers("link_button", { danger: true });
if (isLoading) return <button className={className}>...</button>;
const onClick = async () => {
if (confirm("Are you sure you want to resign this game?")) {
await mutate({ gameId });
}
};
return (
<button className={className} onClick={onClick}>
Give Up
</button>
);
}

function WrongGuess({ track }: { track: Track }) {
return <TrackCard track={track} bad />;
}

function SkippedGuesses({ count }: { count: number }) {
let title;
if (count === 1) title = "Skipped";
else title = `Skipped ${count} guesses`;
return <Card title={title} icon={faForward} />;
}

function RemainingGuesses({ remaining }: { remaining: number }) {
let title, icon, iconLabel;
if (remaining === 1) {
title = "...more guess";
icon = fa1;
iconLabel = "1";
} else if (remaining < 10) {
icon = [fa0, fa1, fa2, fa3, fa4, fa5, fa6, fa7, fa8, fa9][remaining];
title = "...more guesses";
iconLabel = `${remaining}`;
} else {
// icon has no semantic meaning in this case, so we don't need a label
icon = faQuestion;
title = `${remaining} more guesses`;
}
return <Card title={title} icon={{ fa: icon, label: iconLabel }} />;
}
Loading

0 comments on commit c153158

Please sign in to comment.