@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +399 -0
  3. package/dist/index.cjs +785 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +278 -0
  6. package/dist/index.d.ts +278 -0
  7. package/dist/{index.mjs → index.js} +339 -196
  8. package/dist/index.js.map +1 -0
  9. package/package.json +19 -9
  10. package/src/components/ChessGame/Theme.stories.tsx +242 -0
  11. package/src/components/ChessGame/ThemePresets.stories.tsx +144 -0
  12. package/src/components/ChessGame/parts/Board.tsx +215 -204
  13. package/src/components/ChessGame/parts/KeyboardControls.tsx +9 -0
  14. package/src/components/ChessGame/parts/Root.tsx +13 -1
  15. package/src/components/ChessGame/parts/Sounds.tsx +9 -0
  16. package/src/components/ChessGame/parts/__tests__/Board.test.tsx +122 -0
  17. package/src/components/ChessGame/parts/__tests__/KeyboardControls.test.tsx +34 -0
  18. package/src/components/ChessGame/parts/__tests__/Root.test.tsx +50 -0
  19. package/src/components/ChessGame/parts/__tests__/Sounds.test.tsx +22 -0
  20. package/src/docs/Theming.mdx +281 -0
  21. package/src/hooks/useChessGame.ts +23 -7
  22. package/src/index.ts +19 -0
  23. package/src/theme/__tests__/context.test.tsx +60 -0
  24. package/src/theme/__tests__/defaults.test.ts +61 -0
  25. package/src/theme/__tests__/utils.test.ts +106 -0
  26. package/src/theme/context.tsx +37 -0
  27. package/src/theme/defaults.ts +22 -0
  28. package/src/theme/index.ts +36 -0
  29. package/src/theme/presets.ts +41 -0
  30. package/src/theme/types.ts +56 -0
  31. package/src/theme/utils.ts +47 -0
  32. package/src/utils/__tests__/board.test.ts +118 -0
  33. package/src/utils/board.ts +18 -9
  34. package/src/utils/chess.ts +25 -5
  35. package/README.MD +0 -190
  36. package/coverage/clover.xml +0 -6
  37. package/coverage/coverage-final.json +0 -1
  38. package/coverage/lcov-report/base.css +0 -224
  39. package/coverage/lcov-report/block-navigation.js +0 -87
  40. package/coverage/lcov-report/favicon.png +0 -0
  41. package/coverage/lcov-report/index.html +0 -101
  42. package/coverage/lcov-report/prettify.css +0 -1
  43. package/coverage/lcov-report/prettify.js +0 -2
  44. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  45. package/coverage/lcov-report/sorter.js +0 -196
  46. package/coverage/lcov.info +0 -0
  47. package/dist/index.d.mts +0 -158
  48. 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: React.FC<ChessGameProps> = ({ options = {} }) => {
21
- const gameContext = useChessGameContext();
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
- if (!gameContext) {
24
- throw new Error("ChessGameContext not found");
25
- }
26
+ if (!gameContext) {
27
+ throw new Error("ChessGameContext not found");
28
+ }
26
29
 
27
- const {
28
- game,
29
- currentFen,
30
- orientation,
31
- info,
32
- isLatestMove,
33
- methods: { makeMove },
34
- } = gameContext;
30
+ const {
31
+ game,
32
+ currentFen,
33
+ orientation,
34
+ info,
35
+ isLatestMove,
36
+ methods: { makeMove },
37
+ } = gameContext;
35
38
 
36
- const { turn, isGameOver } = info;
39
+ const { turn, isGameOver } = info;
37
40
 
38
- const [activeSquare, setActiveSquare] = React.useState<Square | null>(null);
41
+ const [activeSquare, setActiveSquare] = React.useState<Square | null>(null);
39
42
 
40
- const [promotionMove, setPromotionMove] =
41
- React.useState<Partial<Move> | null>(null);
43
+ const [promotionMove, setPromotionMove] =
44
+ React.useState<Partial<Move> | null>(null);
42
45
 
43
- const onSquareClick = (square: Square) => {
44
- if (isGameOver) {
45
- return;
46
- }
46
+ const onSquareClick = (square: Square) => {
47
+ if (isGameOver) {
48
+ return;
49
+ }
47
50
 
48
- if (activeSquare === null) {
49
- const squadreInfo = game.get(square);
50
- if (squadreInfo && squadreInfo.color === turn) {
51
- return setActiveSquare(square);
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
- if (
57
- !isLegalMove(game, {
58
- from: activeSquare,
59
- to: square,
60
- promotion: "q",
61
- })
62
- ) {
63
- return setActiveSquare(null);
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
- if (
67
- requiresPromotion(game, {
68
- from: activeSquare,
69
- to: square,
70
- promotion: "q",
71
- })
72
- ) {
73
- return setPromotionMove({
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
- setActiveSquare(null);
80
- makeMove({
81
- from: activeSquare,
82
- to: square,
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
- const onPromotionPieceSelect = (piece: string): void => {
87
- if (promotionMove?.from && promotionMove?.to) {
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
- const onSquareRightClick = () => {
98
- setActiveSquare(null);
99
- setPromotionMove(null);
100
- };
101
-
102
- // Calculate square width for precise positioning
103
- const squareWidth = React.useMemo(() => {
104
- if (typeof document === "undefined") return 80;
105
- const squareElement = document.querySelector(`[data-square]`);
106
- return squareElement?.getBoundingClientRect()?.width ?? 80;
107
- }, [promotionMove]);
108
-
109
- // Calculate promotion square position
110
- const promotionSquareLeft = React.useMemo(() => {
111
- if (!promotionMove?.to) return 0;
112
- const column = promotionMove.to.match(/^[a-h]/)?.[0] ?? "a";
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
- squareWidth *
115
- chessColumnToColumnIndex(
116
- column,
117
- 8,
118
- orientation === "b" ? "black" : "white",
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
- }, [promotionMove, squareWidth, orientation]);
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
- return makeMove(moveData);
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
+ });