@react-chess-tools/react-chess-game 0.5.2 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +399 -0
- package/dist/index.cjs +785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +278 -0
- package/dist/index.d.ts +278 -0
- package/dist/{index.mjs → index.js} +339 -196
- package/dist/index.js.map +1 -0
- package/package.json +19 -9
- package/src/components/ChessGame/Theme.stories.tsx +242 -0
- package/src/components/ChessGame/ThemePresets.stories.tsx +144 -0
- package/src/components/ChessGame/parts/Board.tsx +215 -204
- package/src/components/ChessGame/parts/KeyboardControls.tsx +9 -0
- package/src/components/ChessGame/parts/Root.tsx +13 -1
- package/src/components/ChessGame/parts/Sounds.tsx +9 -0
- package/src/components/ChessGame/parts/__tests__/Board.test.tsx +122 -0
- package/src/components/ChessGame/parts/__tests__/KeyboardControls.test.tsx +34 -0
- package/src/components/ChessGame/parts/__tests__/Root.test.tsx +50 -0
- package/src/components/ChessGame/parts/__tests__/Sounds.test.tsx +22 -0
- package/src/docs/Theming.mdx +281 -0
- package/src/hooks/useChessGame.ts +23 -7
- package/src/index.ts +19 -0
- package/src/theme/__tests__/context.test.tsx +60 -0
- package/src/theme/__tests__/defaults.test.ts +61 -0
- package/src/theme/__tests__/utils.test.ts +106 -0
- package/src/theme/context.tsx +37 -0
- package/src/theme/defaults.ts +22 -0
- package/src/theme/index.ts +36 -0
- package/src/theme/presets.ts +41 -0
- package/src/theme/types.ts +56 -0
- package/src/theme/utils.ts +47 -0
- package/src/utils/__tests__/board.test.ts +118 -0
- package/src/utils/board.ts +18 -9
- package/src/utils/chess.ts +25 -5
- package/README.MD +0 -190
- package/coverage/clover.xml +0 -6
- package/coverage/coverage-final.json +0 -1
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +0 -101
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -196
- package/coverage/lcov.info +0 -0
- package/dist/index.d.mts +0 -158
- package/dist/index.mjs.map +0 -1
|
@@ -12,229 +12,240 @@ import {
|
|
|
12
12
|
} from "../../../utils/board";
|
|
13
13
|
import { isLegalMove, requiresPromotion } from "../../../utils/chess";
|
|
14
14
|
import { useChessGameContext } from "../../../hooks/useChessGameContext";
|
|
15
|
+
import { useChessGameTheme } from "../../../theme/context";
|
|
15
16
|
|
|
16
|
-
export interface ChessGameProps {
|
|
17
|
+
export interface ChessGameProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
17
18
|
options?: ChessboardOptions;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export const Board
|
|
21
|
-
|
|
21
|
+
export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
|
|
22
|
+
({ options = {}, className, style: userStyle, ...rest }, ref) => {
|
|
23
|
+
const gameContext = useChessGameContext();
|
|
24
|
+
const theme = useChessGameTheme();
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
if (!gameContext) {
|
|
27
|
+
throw new Error("ChessGameContext not found");
|
|
28
|
+
}
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
const {
|
|
31
|
+
game,
|
|
32
|
+
currentFen,
|
|
33
|
+
orientation,
|
|
34
|
+
info,
|
|
35
|
+
isLatestMove,
|
|
36
|
+
methods: { makeMove },
|
|
37
|
+
} = gameContext;
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
const { turn, isGameOver } = info;
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
const [activeSquare, setActiveSquare] = React.useState<Square | null>(null);
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
const [promotionMove, setPromotionMove] =
|
|
44
|
+
React.useState<Partial<Move> | null>(null);
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const onSquareClick = (square: Square) => {
|
|
47
|
+
if (isGameOver) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
if (activeSquare === null) {
|
|
52
|
+
const squadreInfo = game.get(square);
|
|
53
|
+
if (squadreInfo && squadreInfo.color === turn) {
|
|
54
|
+
return setActiveSquare(square);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
52
57
|
}
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
58
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
if (
|
|
60
|
+
!isLegalMove(game, {
|
|
61
|
+
from: activeSquare,
|
|
62
|
+
to: square,
|
|
63
|
+
promotion: "q",
|
|
64
|
+
})
|
|
65
|
+
) {
|
|
66
|
+
return setActiveSquare(null);
|
|
67
|
+
}
|
|
65
68
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
if (
|
|
70
|
+
requiresPromotion(game, {
|
|
71
|
+
from: activeSquare,
|
|
72
|
+
to: square,
|
|
73
|
+
promotion: "q",
|
|
74
|
+
})
|
|
75
|
+
) {
|
|
76
|
+
return setPromotionMove({
|
|
77
|
+
from: activeSquare,
|
|
78
|
+
to: square,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setActiveSquare(null);
|
|
83
|
+
makeMove({
|
|
74
84
|
from: activeSquare,
|
|
75
85
|
to: square,
|
|
76
86
|
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const onPromotionPieceSelect = (piece: string): void => {
|
|
90
|
+
if (promotionMove?.from && promotionMove?.to) {
|
|
91
|
+
makeMove({
|
|
92
|
+
from: promotionMove.from,
|
|
93
|
+
to: promotionMove.to,
|
|
94
|
+
promotion: piece.toLowerCase(),
|
|
95
|
+
});
|
|
96
|
+
setPromotionMove(null);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
85
99
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
makeMove({
|
|
89
|
-
from: promotionMove.from,
|
|
90
|
-
to: promotionMove.to,
|
|
91
|
-
promotion: piece.toLowerCase(),
|
|
92
|
-
});
|
|
100
|
+
const onSquareRightClick = () => {
|
|
101
|
+
setActiveSquare(null);
|
|
93
102
|
setPromotionMove(null);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Calculate square width for precise positioning
|
|
106
|
+
const squareWidth = React.useMemo(() => {
|
|
107
|
+
if (typeof document === "undefined") return 80;
|
|
108
|
+
const squareElement = document.querySelector(`[data-square]`);
|
|
109
|
+
return squareElement?.getBoundingClientRect()?.width ?? 80;
|
|
110
|
+
}, [promotionMove]);
|
|
111
|
+
|
|
112
|
+
// Calculate promotion square position
|
|
113
|
+
const promotionSquareLeft = React.useMemo(() => {
|
|
114
|
+
if (!promotionMove?.to) return 0;
|
|
115
|
+
const column = promotionMove.to.match(/^[a-h]/)?.[0] ?? "a";
|
|
116
|
+
return (
|
|
117
|
+
squareWidth *
|
|
118
|
+
chessColumnToColumnIndex(
|
|
119
|
+
column,
|
|
120
|
+
8,
|
|
121
|
+
orientation === "b" ? "black" : "white",
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
}, [promotionMove, squareWidth, orientation]);
|
|
125
|
+
|
|
126
|
+
const baseOptions: ChessboardOptions = {
|
|
127
|
+
squareStyles: getCustomSquareStyles(game, info, activeSquare, theme),
|
|
128
|
+
boardOrientation: orientation === "b" ? "black" : "white",
|
|
129
|
+
position: currentFen,
|
|
130
|
+
showNotation: true,
|
|
131
|
+
showAnimations: isLatestMove,
|
|
132
|
+
lightSquareStyle: theme.board.lightSquare,
|
|
133
|
+
darkSquareStyle: theme.board.darkSquare,
|
|
134
|
+
canDragPiece: ({ piece }) => {
|
|
135
|
+
if (isGameOver) return false;
|
|
136
|
+
return piece.pieceType[0] === turn;
|
|
137
|
+
},
|
|
138
|
+
dropSquareStyle: theme.state.dropSquare,
|
|
139
|
+
onPieceDrag: ({ piece, square }) => {
|
|
140
|
+
if (piece.pieceType[0] === turn) {
|
|
141
|
+
setActiveSquare(square as Square);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
|
145
|
+
setActiveSquare(null);
|
|
146
|
+
const moveData = {
|
|
147
|
+
from: sourceSquare as Square,
|
|
148
|
+
to: targetSquare as Square,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Check if promotion is needed
|
|
152
|
+
if (requiresPromotion(game, { ...moveData, promotion: "q" })) {
|
|
153
|
+
setPromotionMove(moveData);
|
|
154
|
+
return false; // Prevent the move until promotion is selected
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return makeMove(moveData);
|
|
158
|
+
},
|
|
159
|
+
onSquareClick: ({ square }) => {
|
|
160
|
+
if (square.match(/^[a-h][1-8]$/)) {
|
|
161
|
+
onSquareClick(square as Square);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
onSquareRightClick: onSquareRightClick,
|
|
165
|
+
allowDrawingArrows: true,
|
|
166
|
+
animationDurationInMs: game.history().length === 0 ? 0 : 300,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const mergedOptions = deepMergeChessboardOptions(baseOptions, options);
|
|
170
|
+
|
|
171
|
+
const mergedStyle = {
|
|
172
|
+
...userStyle,
|
|
173
|
+
position: "relative" as const,
|
|
174
|
+
};
|
|
175
|
+
|
|
113
176
|
return (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
177
|
+
<div ref={ref} className={className} style={mergedStyle} {...rest}>
|
|
178
|
+
<Chessboard options={mergedOptions} />
|
|
179
|
+
{promotionMove && (
|
|
180
|
+
<>
|
|
181
|
+
{/* Backdrop overlay - click to cancel */}
|
|
182
|
+
<div
|
|
183
|
+
onClick={() => setPromotionMove(null)}
|
|
184
|
+
onContextMenu={(e) => {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
setPromotionMove(null);
|
|
187
|
+
}}
|
|
188
|
+
style={{
|
|
189
|
+
position: "absolute",
|
|
190
|
+
top: 0,
|
|
191
|
+
left: 0,
|
|
192
|
+
right: 0,
|
|
193
|
+
bottom: 0,
|
|
194
|
+
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
|
195
|
+
zIndex: 1000,
|
|
196
|
+
}}
|
|
197
|
+
/>
|
|
198
|
+
{/* Promotion piece selection */}
|
|
199
|
+
<div
|
|
200
|
+
style={{
|
|
201
|
+
position: "absolute",
|
|
202
|
+
top: promotionMove.to?.[1]?.includes("8") ? 0 : "auto",
|
|
203
|
+
bottom: promotionMove.to?.[1].includes("1") ? 0 : "auto",
|
|
204
|
+
left: promotionSquareLeft,
|
|
205
|
+
backgroundColor: "white",
|
|
206
|
+
width: squareWidth,
|
|
207
|
+
zIndex: 1001,
|
|
208
|
+
display: "flex",
|
|
209
|
+
flexDirection: "column",
|
|
210
|
+
boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.5)",
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
{["q", "r", "n", "b"].map((piece) => (
|
|
214
|
+
<button
|
|
215
|
+
key={piece}
|
|
216
|
+
onClick={() => onPromotionPieceSelect(piece)}
|
|
217
|
+
onContextMenu={(e) => {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
}}
|
|
220
|
+
style={{
|
|
221
|
+
width: "100%",
|
|
222
|
+
aspectRatio: "1",
|
|
223
|
+
display: "flex",
|
|
224
|
+
alignItems: "center",
|
|
225
|
+
justifyContent: "center",
|
|
226
|
+
padding: 0,
|
|
227
|
+
border: "none",
|
|
228
|
+
cursor: "pointer",
|
|
229
|
+
backgroundColor: "white",
|
|
230
|
+
}}
|
|
231
|
+
onMouseEnter={(e) => {
|
|
232
|
+
e.currentTarget.style.backgroundColor = "#f0f0f0";
|
|
233
|
+
}}
|
|
234
|
+
onMouseLeave={(e) => {
|
|
235
|
+
e.currentTarget.style.backgroundColor = "white";
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
{defaultPieces[
|
|
239
|
+
`${turn}${piece.toUpperCase()}` as keyof typeof defaultPieces
|
|
240
|
+
]()}
|
|
241
|
+
</button>
|
|
242
|
+
))}
|
|
243
|
+
</div>
|
|
244
|
+
</>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
120
247
|
);
|
|
121
|
-
},
|
|
122
|
-
|
|
123
|
-
const baseOptions: ChessboardOptions = {
|
|
124
|
-
squareStyles: getCustomSquareStyles(game, info, activeSquare),
|
|
125
|
-
boardOrientation: orientation === "b" ? "black" : "white",
|
|
126
|
-
position: currentFen,
|
|
127
|
-
showNotation: true,
|
|
128
|
-
showAnimations: isLatestMove,
|
|
129
|
-
canDragPiece: ({ piece }) => {
|
|
130
|
-
if (isGameOver) return false;
|
|
131
|
-
return piece.pieceType[0] === turn;
|
|
132
|
-
},
|
|
133
|
-
dropSquareStyle: {
|
|
134
|
-
backgroundColor: "rgba(255, 255, 0, 0.4)",
|
|
135
|
-
},
|
|
136
|
-
onPieceDrag: ({ piece, square }) => {
|
|
137
|
-
if (piece.pieceType[0] === turn) {
|
|
138
|
-
setActiveSquare(square as Square);
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
onPieceDrop: ({ sourceSquare, targetSquare }) => {
|
|
142
|
-
setActiveSquare(null);
|
|
143
|
-
const moveData = {
|
|
144
|
-
from: sourceSquare as Square,
|
|
145
|
-
to: targetSquare as Square,
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
// Check if promotion is needed
|
|
149
|
-
if (requiresPromotion(game, { ...moveData, promotion: "q" })) {
|
|
150
|
-
setPromotionMove(moveData);
|
|
151
|
-
return false; // Prevent the move until promotion is selected
|
|
152
|
-
}
|
|
248
|
+
},
|
|
249
|
+
);
|
|
153
250
|
|
|
154
|
-
|
|
155
|
-
},
|
|
156
|
-
onSquareClick: ({ square }) => {
|
|
157
|
-
if (square.match(/^[a-h][1-8]$/)) {
|
|
158
|
-
onSquareClick(square as Square);
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
onSquareRightClick: onSquareRightClick,
|
|
162
|
-
allowDrawingArrows: true,
|
|
163
|
-
animationDurationInMs: game.history().length === 0 ? 0 : 300,
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const mergedOptions = deepMergeChessboardOptions(baseOptions, options);
|
|
167
|
-
|
|
168
|
-
return (
|
|
169
|
-
<div style={{ position: "relative" }}>
|
|
170
|
-
<Chessboard options={mergedOptions} />
|
|
171
|
-
{promotionMove && (
|
|
172
|
-
<>
|
|
173
|
-
{/* Backdrop overlay - click to cancel */}
|
|
174
|
-
<div
|
|
175
|
-
onClick={() => setPromotionMove(null)}
|
|
176
|
-
onContextMenu={(e) => {
|
|
177
|
-
e.preventDefault();
|
|
178
|
-
setPromotionMove(null);
|
|
179
|
-
}}
|
|
180
|
-
style={{
|
|
181
|
-
position: "absolute",
|
|
182
|
-
top: 0,
|
|
183
|
-
left: 0,
|
|
184
|
-
right: 0,
|
|
185
|
-
bottom: 0,
|
|
186
|
-
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
|
187
|
-
zIndex: 1000,
|
|
188
|
-
}}
|
|
189
|
-
/>
|
|
190
|
-
{/* Promotion piece selection */}
|
|
191
|
-
<div
|
|
192
|
-
style={{
|
|
193
|
-
position: "absolute",
|
|
194
|
-
top: promotionMove.to?.[1]?.includes("8") ? 0 : "auto",
|
|
195
|
-
bottom: promotionMove.to?.[1].includes("1") ? 0 : "auto",
|
|
196
|
-
left: promotionSquareLeft,
|
|
197
|
-
backgroundColor: "white",
|
|
198
|
-
width: squareWidth,
|
|
199
|
-
zIndex: 1001,
|
|
200
|
-
display: "flex",
|
|
201
|
-
flexDirection: "column",
|
|
202
|
-
boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.5)",
|
|
203
|
-
}}
|
|
204
|
-
>
|
|
205
|
-
{["q", "r", "n", "b"].map((piece) => (
|
|
206
|
-
<button
|
|
207
|
-
key={piece}
|
|
208
|
-
onClick={() => onPromotionPieceSelect(piece)}
|
|
209
|
-
onContextMenu={(e) => {
|
|
210
|
-
e.preventDefault();
|
|
211
|
-
}}
|
|
212
|
-
style={{
|
|
213
|
-
width: "100%",
|
|
214
|
-
aspectRatio: "1",
|
|
215
|
-
display: "flex",
|
|
216
|
-
alignItems: "center",
|
|
217
|
-
justifyContent: "center",
|
|
218
|
-
padding: 0,
|
|
219
|
-
border: "none",
|
|
220
|
-
cursor: "pointer",
|
|
221
|
-
backgroundColor: "white",
|
|
222
|
-
}}
|
|
223
|
-
onMouseEnter={(e) => {
|
|
224
|
-
e.currentTarget.style.backgroundColor = "#f0f0f0";
|
|
225
|
-
}}
|
|
226
|
-
onMouseLeave={(e) => {
|
|
227
|
-
e.currentTarget.style.backgroundColor = "white";
|
|
228
|
-
}}
|
|
229
|
-
>
|
|
230
|
-
{defaultPieces[
|
|
231
|
-
`${turn}${piece.toUpperCase()}` as keyof typeof defaultPieces
|
|
232
|
-
]()}
|
|
233
|
-
</button>
|
|
234
|
-
))}
|
|
235
|
-
</div>
|
|
236
|
-
</>
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
);
|
|
240
|
-
};
|
|
251
|
+
Board.displayName = "ChessGame.Board";
|
|
@@ -16,6 +16,13 @@ export const defaultKeyboardControls: KeyboardControls = {
|
|
|
16
16
|
ArrowDown: (context) => context.methods.goToEnd(),
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Props for the KeyboardControls component
|
|
21
|
+
*
|
|
22
|
+
* Note: This is a logic-only component that returns null and does not render
|
|
23
|
+
* any DOM elements. It sets up keyboard controls via the useKeyboardControls hook.
|
|
24
|
+
* Therefore, it does not accept HTML attributes like className, style, etc.
|
|
25
|
+
*/
|
|
19
26
|
type KeyboardControlsProps = {
|
|
20
27
|
controls?: KeyboardControls;
|
|
21
28
|
};
|
|
@@ -31,3 +38,5 @@ export const KeyboardControls: React.FC<KeyboardControlsProps> = ({
|
|
|
31
38
|
useKeyboardControls(keyboardControls);
|
|
32
39
|
return null;
|
|
33
40
|
};
|
|
41
|
+
|
|
42
|
+
KeyboardControls.displayName = "ChessGame.KeyboardControls";
|
|
@@ -2,21 +2,33 @@ import React from "react";
|
|
|
2
2
|
import { Color } from "chess.js";
|
|
3
3
|
import { useChessGame } from "../../../hooks/useChessGame";
|
|
4
4
|
import { ChessGameContext } from "../../../hooks/useChessGameContext";
|
|
5
|
+
import { ThemeProvider } from "../../../theme/context";
|
|
6
|
+
import { mergeTheme } from "../../../theme/utils";
|
|
7
|
+
import type { PartialChessGameTheme } from "../../../theme/types";
|
|
5
8
|
|
|
6
9
|
export interface RootProps {
|
|
7
10
|
fen?: string;
|
|
8
11
|
orientation?: Color;
|
|
12
|
+
/** Optional theme configuration. Supports partial themes - only override the colors you need. */
|
|
13
|
+
theme?: PartialChessGameTheme;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
16
|
export const Root: React.FC<React.PropsWithChildren<RootProps>> = ({
|
|
12
17
|
fen,
|
|
13
18
|
orientation,
|
|
19
|
+
theme,
|
|
14
20
|
children,
|
|
15
21
|
}) => {
|
|
16
22
|
const context = useChessGame({ fen, orientation });
|
|
23
|
+
|
|
24
|
+
// Merge partial theme with defaults
|
|
25
|
+
const mergedTheme = React.useMemo(() => mergeTheme(theme), [theme]);
|
|
26
|
+
|
|
17
27
|
return (
|
|
18
28
|
<ChessGameContext.Provider value={context}>
|
|
19
|
-
{children}
|
|
29
|
+
<ThemeProvider theme={mergedTheme}>{children}</ThemeProvider>
|
|
20
30
|
</ChessGameContext.Provider>
|
|
21
31
|
);
|
|
22
32
|
};
|
|
33
|
+
|
|
34
|
+
Root.displayName = "ChessGame.Root";
|
|
@@ -2,6 +2,13 @@ import { useMemo } from "react";
|
|
|
2
2
|
import { defaultSounds, type Sound } from "../../../assets/sounds";
|
|
3
3
|
import { useBoardSounds } from "../../../hooks/useBoardSounds";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Props for the Sounds component
|
|
7
|
+
*
|
|
8
|
+
* Note: This is a logic-only component that returns null and does not render
|
|
9
|
+
* any DOM elements. It sets up board sounds via the useBoardSounds hook.
|
|
10
|
+
* Therefore, it does not accept HTML attributes like className, style, etc.
|
|
11
|
+
*/
|
|
5
12
|
export type SoundsProps = {
|
|
6
13
|
sounds?: Partial<Record<Sound, string>>;
|
|
7
14
|
};
|
|
@@ -23,3 +30,5 @@ export const Sounds: React.FC<SoundsProps> = ({ sounds }) => {
|
|
|
23
30
|
useBoardSounds(customSoundsAudios);
|
|
24
31
|
return null;
|
|
25
32
|
};
|
|
33
|
+
|
|
34
|
+
Sounds.displayName = "ChessGame.Sounds";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { ChessGame } from "../..";
|
|
5
|
+
import { Board } from "../Board";
|
|
6
|
+
|
|
7
|
+
describe("ChessGame.Board", () => {
|
|
8
|
+
it("should have correct displayName", () => {
|
|
9
|
+
expect(Board.displayName).toBe("ChessGame.Board");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should forward ref to div element", () => {
|
|
13
|
+
const ref = React.createRef<HTMLDivElement>();
|
|
14
|
+
|
|
15
|
+
render(
|
|
16
|
+
<ChessGame.Root>
|
|
17
|
+
<Board ref={ref} />
|
|
18
|
+
</ChessGame.Root>,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(ref.current).toBeInstanceOf(HTMLDivElement);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should apply custom className", () => {
|
|
25
|
+
const { container } = render(
|
|
26
|
+
<ChessGame.Root>
|
|
27
|
+
<Board className="custom-board-class" />
|
|
28
|
+
</ChessGame.Root>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const board = container.querySelector(".custom-board-class");
|
|
32
|
+
expect(board).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should merge multiple className props", () => {
|
|
36
|
+
const { container } = render(
|
|
37
|
+
<ChessGame.Root>
|
|
38
|
+
<Board className="class-1 class-2" />
|
|
39
|
+
</ChessGame.Root>,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const board = container.querySelector(".class-1");
|
|
43
|
+
expect(board).toHaveClass("class-1");
|
|
44
|
+
expect(board).toHaveClass("class-2");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should apply custom style", () => {
|
|
48
|
+
const customStyle = { border: "2px solid red", margin: "10px" };
|
|
49
|
+
|
|
50
|
+
const { container } = render(
|
|
51
|
+
<ChessGame.Root>
|
|
52
|
+
<Board style={customStyle} />
|
|
53
|
+
</ChessGame.Root>,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const board = container.firstElementChild as HTMLElement;
|
|
57
|
+
expect(board).toHaveStyle({ border: "2px solid red" });
|
|
58
|
+
expect(board).toHaveStyle({ margin: "10px" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should apply custom id", () => {
|
|
62
|
+
const { container } = render(
|
|
63
|
+
<ChessGame.Root>
|
|
64
|
+
<Board id="custom-board-id" />
|
|
65
|
+
</ChessGame.Root>,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const board = container.querySelector("#custom-board-id");
|
|
69
|
+
expect(board).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should apply data-* attributes", () => {
|
|
73
|
+
const { container } = render(
|
|
74
|
+
<ChessGame.Root>
|
|
75
|
+
<Board data-testid="board" data-custom="value" />
|
|
76
|
+
</ChessGame.Root>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const board = container.querySelector("[data-custom='value']");
|
|
80
|
+
expect(board).toHaveAttribute("data-testid", "board");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should apply aria-* attributes", () => {
|
|
84
|
+
const { container } = render(
|
|
85
|
+
<ChessGame.Root>
|
|
86
|
+
<Board aria-label="Chess board" aria-describedby="board-desc" />
|
|
87
|
+
</ChessGame.Root>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const board = container.firstElementChild as HTMLElement;
|
|
91
|
+
expect(board).toHaveAttribute("aria-label", "Chess board");
|
|
92
|
+
expect(board).toHaveAttribute("aria-describedby", "board-desc");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should accept custom onClick handler", () => {
|
|
96
|
+
const handleClick = jest.fn();
|
|
97
|
+
|
|
98
|
+
const { container } = render(
|
|
99
|
+
<ChessGame.Root>
|
|
100
|
+
<Board onClick={handleClick} />
|
|
101
|
+
</ChessGame.Root>,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const board = container.firstElementChild as HTMLElement;
|
|
105
|
+
board.click();
|
|
106
|
+
|
|
107
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should throw error when used outside ChessGame.Root", () => {
|
|
111
|
+
// Suppress console.error for this test
|
|
112
|
+
const consoleError = jest
|
|
113
|
+
.spyOn(console, "error")
|
|
114
|
+
.mockImplementation(() => {});
|
|
115
|
+
|
|
116
|
+
expect(() => {
|
|
117
|
+
render(<Board />);
|
|
118
|
+
}).toThrow("useChessGameContext must be used within a ChessGame component");
|
|
119
|
+
|
|
120
|
+
consoleError.mockRestore();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
import { ChessGame } from "../..";
|
|
5
|
+
import { KeyboardControls } from "../KeyboardControls";
|
|
6
|
+
|
|
7
|
+
describe("ChessGame.KeyboardControls", () => {
|
|
8
|
+
it("should have correct displayName", () => {
|
|
9
|
+
expect(KeyboardControls.displayName).toBe("ChessGame.KeyboardControls");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should render null (no DOM element)", () => {
|
|
13
|
+
const { container } = render(
|
|
14
|
+
<ChessGame.Root>
|
|
15
|
+
<ChessGame.KeyboardControls />
|
|
16
|
+
</ChessGame.Root>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
// KeyboardControls should not render any DOM elements
|
|
20
|
+
expect(container.querySelector("*")).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should throw error when used outside ChessGame.Root", () => {
|
|
24
|
+
const consoleError = jest
|
|
25
|
+
.spyOn(console, "error")
|
|
26
|
+
.mockImplementation(() => {});
|
|
27
|
+
|
|
28
|
+
expect(() => {
|
|
29
|
+
render(<ChessGame.KeyboardControls />);
|
|
30
|
+
}).toThrow();
|
|
31
|
+
|
|
32
|
+
consoleError.mockRestore();
|
|
33
|
+
});
|
|
34
|
+
});
|