Skip to content

Commit d7472d3

Browse files
committed
Perf changes, aria thinning etc
1 parent fd77e08 commit d7472d3

18 files changed

Lines changed: 472 additions & 219 deletions

src/components/BanRow.tsx

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export function BanRow({ team }: BanRowProps) {
1515
const teamDisplayName = team === 0 ? "Blue team" : "Red team";
1616
const teamBans = bans[team];
1717
const banOrder = team === 0 ? teamBans : [...teamBans].reverse();
18-
const banTabIndex = team === 0 ? 2 : 4; // Blue bans = 2, Red bans = 4
1918

2019
const handleImageError = (e: React.SyntheticEvent<HTMLImageElement>) => {
2120
e.currentTarget.src = "/assets/champions/-1.png";
@@ -28,37 +27,39 @@ export function BanRow({ team }: BanRowProps) {
2827
}
2928
};
3029

31-
const handleBanKeyDown = (e: React.KeyboardEvent, actualIndex: ActionIndex) => {
32-
const ban = teamBans[actualIndex];
33-
if (ban && (e.key === "Enter" || e.key === " ")) {
34-
e.preventDefault();
35-
startBanOverride(team, actualIndex);
36-
}
37-
};
38-
3930
return (
4031
<div className="ban-row">
4132
{banOrder.map((ban: string | null, i: number) => {
4233
const actualIndex = (team === 0 ? i : teamBans.length - 1 - i) as ActionIndex;
4334
const isBeingOverridden = overridingBanData?.team === team && overridingBanData?.banIndex === actualIndex;
4435
const banName = ban ? championByKey.get(ban)?.name : null;
36+
const className = clsx("ban-slot", ban && "swappable", isBeingOverridden && "overriding");
37+
const image = (
38+
<img
39+
src={ban ? `/assets/champions/${ban}.png` : "/assets/ban_placeholder.svg"}
40+
alt={ban ? `Banned champion ${ban}` : "Empty ban slot"}
41+
decoding="async"
42+
onError={handleImageError}
43+
/>
44+
);
45+
46+
if (!ban) {
47+
return (
48+
<div key={`${team}-${actualIndex}`} className={className}>
49+
{image}
50+
</div>
51+
);
52+
}
4553

4654
return (
47-
<div
55+
<button
4856
key={`${team}-${actualIndex}`}
49-
className={clsx("ban-slot", ban && "swappable", isBeingOverridden && "overriding")}
50-
onClick={ban ? () => handleBanClick(actualIndex) : undefined}
51-
onKeyDown={ban ? (e) => handleBanKeyDown(e, actualIndex) : undefined}
52-
tabIndex={ban ? banTabIndex : -1}
53-
role={ban ? "button" : undefined}
54-
aria-label={ban ? `Override ${banName ?? ban} for ${teamDisplayName}` : undefined}>
55-
<img
56-
src={ban ? `/assets/champions/${ban}.png` : "/assets/ban_placeholder.svg"}
57-
alt={ban ? `Banned champion ${ban}` : "Empty ban slot"}
58-
decoding="async"
59-
onError={handleImageError}
60-
/>
61-
</div>
57+
type="button"
58+
className={className}
59+
onClick={() => handleBanClick(actualIndex)}
60+
aria-label={`Override ${banName ?? ban} for ${teamDisplayName}`}>
61+
{image}
62+
</button>
6263
);
6364
})}
6465
</div>

src/components/ButtonDraftAction.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
import { useDraftStore } from "@/lib/store/draftStore";
22
import { ACTION_TYPE } from "@/lib/store/constants";
33
import { clsx } from "clsx";
4+
import { useShallow } from "zustand/react/shallow";
45

56
export function ButtonDraftAction() {
6-
const actionType = useDraftStore((state) => state.getCurrentActionType());
7-
const isDraftComplete = useDraftStore((state) => state.isDraftComplete);
8-
const lockIn = useDraftStore((state) => state.lockIn);
9-
const selectedChampion = useDraftStore((state) => state.selectedChampion);
10-
const reset = useDraftStore((state) => state.reset);
11-
const isOverridingAny = useDraftStore((state) => state.isOverridingAny());
12-
const cancelAnyOverride = useDraftStore((state) => state.cancelAnyOverride);
13-
14-
// Subscribe to the actual state that affects champion availability
15-
const unavailableChampions = useDraftStore((state) => state.getUnavailableChampions());
7+
const { actionType, cancelAnyOverride, isDraftComplete, isOverridingAny, lockIn, reset, selectedChampion, unavailableChampions } =
8+
useDraftStore(
9+
useShallow(
10+
(state) => ({
11+
actionType: state.getCurrentActionType(),
12+
cancelAnyOverride: state.cancelAnyOverride,
13+
isDraftComplete: state.isDraftComplete,
14+
isOverridingAny: state.isOverridingAny(),
15+
lockIn: state.lockIn,
16+
reset: state.reset,
17+
selectedChampion: state.selectedChampion,
18+
unavailableChampions: state.getUnavailableChampions(),
19+
})
20+
)
21+
);
1622

1723
const buttonLabel = isOverridingAny
1824
? "CANCEL"
@@ -51,7 +57,6 @@ export function ButtonDraftAction() {
5157
onClick={handleClick}
5258
disabled={isDisabled}
5359
className={clsx("btn-primary-action", isOverridingAny && "override-mode")}
54-
tabIndex={9}
5560
aria-describedby={isDisabled ? "draft-button-tooltip" : undefined}>
5661
<span>{buttonLabel}</span>
5762
</button>

src/components/ButtonReset.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,8 @@ export function ButtonReset() {
3030
type="button"
3131
className="bottom-control"
3232
aria-label="Reset"
33-
title="Reset"
3433
onClick={handleClick}
35-
disabled={!hasProgressToReset}
36-
tabIndex={10}>
34+
disabled={!hasProgressToReset}>
3735
<IconTrash aria-hidden="true" />
3836
</button>
3937
);

src/components/ButtonUndo.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,8 @@ export function ButtonUndo() {
1313
type="button"
1414
className="bottom-control"
1515
aria-label="Undo"
16-
title="Undo"
1716
onClick={undoStep}
18-
disabled={!canUndo}
19-
tabIndex={10}>
17+
disabled={!canUndo}>
2018
<IconUndo aria-hidden="true" />
2119
</button>
2220
);

src/components/ChampionList.tsx

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,39 @@
11
import { useDraftStore } from "@/lib/store/draftStore";
2-
import { searchChampions, championsMap, type Champion } from "@/datasets/championPreprocessed";
3-
import { useEffect, useEffectEvent } from "react";
2+
import { championsMap, searchChampions, type Champion } from "@/datasets/championPreprocessed";
3+
import { useEffect, useEffectEvent, useMemo } from "react";
44
import { clsx } from "clsx";
5+
import { useShallow } from "zustand/react/shallow";
56

67
interface ChampionListProps {
78
searchQuery: string;
8-
roleFilters: string[];
9+
roleFilters: Array<string>;
910
}
1011

1112
export function ChampionList({ searchQuery, roleFilters }: ChampionListProps) {
12-
const selectChampion = useDraftStore((state) => state.selectChampion);
13-
const selectedChampion = useDraftStore((state) => state.selectedChampion);
14-
const isDraftComplete = useDraftStore((state) => state.isDraftComplete);
15-
const isOverridingAny = useDraftStore((state) => state.isOverridingAny());
16-
const cancelAnyOverride = useDraftStore((state) => state.cancelAnyOverride);
13+
const { cancelAnyOverride, isDraftComplete, isOverridingAny, selectChampion, selectedChampion, unavailableChampions } =
14+
useDraftStore(
15+
useShallow(
16+
(state) => ({
17+
cancelAnyOverride: state.cancelAnyOverride,
18+
isDraftComplete: state.isDraftComplete,
19+
isOverridingAny: state.isOverridingAny(),
20+
selectChampion: state.selectChampion,
21+
selectedChampion: state.selectedChampion,
22+
unavailableChampions: state.getUnavailableChampions(),
23+
})
24+
)
25+
);
1726

18-
// Subscribe to the actual state that affects champion availability
19-
const unavailableChampions = useDraftStore((state) => state.getUnavailableChampions());
27+
const roleFilterSet = useMemo(() => new Set(roleFilters), [roleFilters]);
2028

21-
let displayChampions = championsMap;
22-
23-
if (searchQuery.trim()) {
24-
displayChampions = searchChampions(searchQuery);
25-
}
29+
const displayChampions = useMemo(() => {
30+
const baseChampions = searchQuery.trim() ? searchChampions(searchQuery) : championsMap;
31+
if (roleFilterSet.size === 0) {
32+
return baseChampions;
33+
}
2634

27-
if (roleFilters.length > 0) {
28-
displayChampions = displayChampions.filter((champion) => champion.roles.some((role) => roleFilters.includes(role)));
29-
}
35+
return baseChampions.filter((champion) => champion.roles.some((role) => roleFilterSet.has(role)));
36+
}, [roleFilterSet, searchQuery]);
3037

3138
const handleChampionClick = (e: React.MouseEvent<HTMLButtonElement>) => {
3239
if (isDraftComplete && !isOverridingAny) return;
@@ -63,7 +70,7 @@ export function ChampionList({ searchQuery, roleFilters }: ChampionListProps) {
6370

6471
return (
6572
<>
66-
{displayChampions.map((champ: Champion) => {
73+
{displayChampions.map((champ: Champion, index: number) => {
6774
const isAvailable = isDraftComplete && !isOverridingAny ? false : !unavailableChampions.has(champ.key);
6875
const isSelected = selectedChampion === champ.key;
6976

@@ -77,16 +84,12 @@ export function ChampionList({ searchQuery, roleFilters }: ChampionListProps) {
7784
className={clsx(isSelected && "selected", isOverridingAny && "override-mode")}
7885
aria-label={`${isSelected ? "Selected" : "Select"} ${champ.name}${
7986
isOverridingAny ? " (Override Mode)" : ""
80-
}`}
81-
role="option"
82-
aria-selected={isSelected}
83-
tabIndex={8}>
87+
}`}>
8488
<img
8589
src={`/assets/champions/${champ.key}.png`}
8690
alt=""
87-
aria-hidden="true"
8891
decoding="async"
89-
loading="eager"
92+
loading={index < 24 ? "eager" : "lazy"}
9093
onError={handleImageError}
9194
/>
9295
<span>{champ.name}</span>

src/components/DraftAnnouncer.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,5 @@ export function DraftAnnouncer() {
2727

2828
if (!announcement) return null;
2929

30-
return (
31-
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only" tabIndex={-1}>
32-
{announcement}
33-
</div>
34-
);
30+
return <div role="status" className="sr-only">{announcement}</div>;
3531
}

src/components/DropdownFile.tsx

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1-
import { useState, useRef } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import { IconDownload } from "@/components/Icons";
33
import { useDraftStore } from "@/lib/store/draftStore";
44

55
export function DropdownFile() {
66
const [isExpanded, setIsExpanded] = useState(false);
77
const containerRef = useRef<HTMLDivElement>(null);
88
const fileInputRef = useRef<HTMLInputElement>(null);
9+
const activeImportIdRef = useRef(0);
10+
const activeReaderRef = useRef<FileReader | null>(null);
911

1012
const exportDraft = useDraftStore((state) => state.exportDraft);
1113
const importDraft = useDraftStore((state) => state.importDraft);
1214

15+
useEffect(() => {
16+
return () => {
17+
if (activeReaderRef.current?.readyState === FileReader.LOADING) {
18+
activeReaderRef.current.abort();
19+
}
20+
};
21+
}, []);
22+
1323
const handleExport = () => {
1424
try {
1525
const draftData = exportDraft();
@@ -39,10 +49,28 @@ export function DropdownFile() {
3949
const file = event.target.files?.[0];
4050
if (!file) return;
4151

52+
activeImportIdRef.current += 1;
53+
const importId = activeImportIdRef.current;
54+
55+
if (activeReaderRef.current?.readyState === FileReader.LOADING) {
56+
activeReaderRef.current.abort();
57+
}
58+
4259
const reader = new FileReader();
43-
reader.onload = (e) => {
60+
activeReaderRef.current = reader;
61+
62+
reader.onload = () => {
63+
if (importId !== activeImportIdRef.current) {
64+
return;
65+
}
66+
4467
try {
45-
const jsonData = JSON.parse(e.target?.result as string);
68+
const rawContents = reader.result;
69+
if (typeof rawContents !== "string") {
70+
throw new Error("Imported draft must be a text-based JSON file.");
71+
}
72+
73+
const jsonData: unknown = JSON.parse(rawContents);
4674
importDraft(jsonData);
4775
console.log("Draft imported successfully");
4876
alert("Draft imported successfully!");
@@ -55,9 +83,19 @@ export function DropdownFile() {
5583
}
5684
}
5785
};
86+
87+
reader.onerror = () => {
88+
if (importId !== activeImportIdRef.current) {
89+
return;
90+
}
91+
92+
console.error("Failed to import draft:", reader.error);
93+
alert("Failed to read draft file. Please try again.");
94+
};
95+
5896
reader.readAsText(file);
5997

60-
// Reset the input so the same file can bbe imported again
98+
// Reset the input so the same file can be imported again.
6199
event.target.value = "";
62100
};
63101

@@ -75,28 +113,25 @@ export function DropdownFile() {
75113
<button
76114
type="button"
77115
id="dropdown-trigger"
78-
aria-label="File Menu"
79-
aria-haspopup="menu"
116+
aria-label="File"
80117
aria-expanded={isExpanded}
81118
aria-controls="dropdown-menu"
82-
tabIndex={1}
83119
onFocus={() => setIsExpanded(true)}
84120
onBlur={(e) => {
85121
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
86122
setIsExpanded(false);
87123
}
88124
}}>
89-
<IconDownload />
125+
<IconDownload aria-hidden="true" />
90126
</button>
91-
<div role="menu" id="dropdown-menu" aria-labelledby="dropdown-trigger">
127+
<div id="dropdown-menu" aria-labelledby="dropdown-trigger">
92128
<div>
93129
{menuItems.map((item, index) => (
94130
<button
95131
key={index}
96132
type="button"
97-
role="menuitem"
98133
className="dropdown-item"
99-
tabIndex={isExpanded ? 1 : -1}
134+
tabIndex={isExpanded ? 0 : -1}
100135
onClick={() => {
101136
item.action();
102137
setIsExpanded(false);

src/components/Header.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ export function Header() {
66
return (
77
<header>
88
<Link to="/" id="header-logo">
9-
<img src="/metadata/icon.svg" alt="Logo" />
9+
<img src="/metadata/icon.svg" alt="" />
1010
<h1>SimDraft</h1>
1111
</Link>
1212
<div id="header-controls">
1313
<DropdownFile />
1414
<div id="info-popover-container">
15-
<button type="button" aria-label="Info" aria-describedby="info-popover" tabIndex={1}>
15+
<button type="button" aria-label="Info" aria-describedby="info-popover">
1616
<span aria-hidden="true">?</span>
1717
</button>
1818
<div role="tooltip" id="info-popover">
@@ -43,7 +43,7 @@ export function Header() {
4343
</div>
4444
</div>
4545
</div>
46-
<a href="https://github.com/MGSimard/simdraft" target="_blank" rel="noopener noreferrer" tabIndex={1}>
46+
<a href="https://github.com/MGSimard/simdraft" target="_blank" rel="noopener noreferrer">
4747
<IconGithub aria-hidden="true" />
4848
</a>
4949
</div>

0 commit comments

Comments
 (0)