@react-chess-tools/react-chess-game 1.0.0 → 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.
@@ -14,229 +14,238 @@ import { isLegalMove, requiresPromotion } from "../../../utils/chess";
14
14
  import { useChessGameContext } from "../../../hooks/useChessGameContext";
15
15
  import { useChessGameTheme } from "../../../theme/context";
16
16
 
17
- export interface ChessGameProps {
17
+ export interface ChessGameProps extends React.HTMLAttributes<HTMLDivElement> {
18
18
  options?: ChessboardOptions;
19
19
  }
20
20
 
21
- export const Board: React.FC<ChessGameProps> = ({ options = {} }) => {
22
- const gameContext = useChessGameContext();
23
- const theme = useChessGameTheme();
21
+ export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
22
+ ({ options = {}, className, style: userStyle, ...rest }, ref) => {
23
+ const gameContext = useChessGameContext();
24
+ const theme = useChessGameTheme();
24
25
 
25
- if (!gameContext) {
26
- throw new Error("ChessGameContext not found");
27
- }
26
+ if (!gameContext) {
27
+ throw new Error("ChessGameContext not found");
28
+ }
28
29
 
29
- const {
30
- game,
31
- currentFen,
32
- orientation,
33
- info,
34
- isLatestMove,
35
- methods: { makeMove },
36
- } = gameContext;
30
+ const {
31
+ game,
32
+ currentFen,
33
+ orientation,
34
+ info,
35
+ isLatestMove,
36
+ methods: { makeMove },
37
+ } = gameContext;
37
38
 
38
- const { turn, isGameOver } = info;
39
+ const { turn, isGameOver } = info;
39
40
 
40
- const [activeSquare, setActiveSquare] = React.useState<Square | null>(null);
41
+ const [activeSquare, setActiveSquare] = React.useState<Square | null>(null);
41
42
 
42
- const [promotionMove, setPromotionMove] =
43
- React.useState<Partial<Move> | null>(null);
43
+ const [promotionMove, setPromotionMove] =
44
+ React.useState<Partial<Move> | null>(null);
44
45
 
45
- const onSquareClick = (square: Square) => {
46
- if (isGameOver) {
47
- return;
48
- }
46
+ const onSquareClick = (square: Square) => {
47
+ if (isGameOver) {
48
+ return;
49
+ }
49
50
 
50
- if (activeSquare === null) {
51
- const squadreInfo = game.get(square);
52
- if (squadreInfo && squadreInfo.color === turn) {
53
- 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;
54
57
  }
55
- return;
56
- }
57
58
 
58
- if (
59
- !isLegalMove(game, {
60
- from: activeSquare,
61
- to: square,
62
- promotion: "q",
63
- })
64
- ) {
65
- return setActiveSquare(null);
66
- }
59
+ if (
60
+ !isLegalMove(game, {
61
+ from: activeSquare,
62
+ to: square,
63
+ promotion: "q",
64
+ })
65
+ ) {
66
+ return setActiveSquare(null);
67
+ }
67
68
 
68
- if (
69
- requiresPromotion(game, {
70
- from: activeSquare,
71
- to: square,
72
- promotion: "q",
73
- })
74
- ) {
75
- 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({
76
84
  from: activeSquare,
77
85
  to: square,
78
86
  });
79
- }
80
-
81
- setActiveSquare(null);
82
- makeMove({
83
- from: activeSquare,
84
- to: square,
85
- });
86
- };
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
+ };
87
99
 
88
- const onPromotionPieceSelect = (piece: string): void => {
89
- if (promotionMove?.from && promotionMove?.to) {
90
- makeMove({
91
- from: promotionMove.from,
92
- to: promotionMove.to,
93
- promotion: piece.toLowerCase(),
94
- });
100
+ const onSquareRightClick = () => {
101
+ setActiveSquare(null);
95
102
  setPromotionMove(null);
96
- }
97
- };
98
-
99
- const onSquareRightClick = () => {
100
- setActiveSquare(null);
101
- setPromotionMove(null);
102
- };
103
-
104
- // Calculate square width for precise positioning
105
- const squareWidth = React.useMemo(() => {
106
- if (typeof document === "undefined") return 80;
107
- const squareElement = document.querySelector(`[data-square]`);
108
- return squareElement?.getBoundingClientRect()?.width ?? 80;
109
- }, [promotionMove]);
110
-
111
- // Calculate promotion square position
112
- const promotionSquareLeft = React.useMemo(() => {
113
- if (!promotionMove?.to) return 0;
114
- 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
+
115
176
  return (
116
- squareWidth *
117
- chessColumnToColumnIndex(
118
- column,
119
- 8,
120
- orientation === "b" ? "black" : "white",
121
- )
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>
122
247
  );
123
- }, [promotionMove, squareWidth, orientation]);
124
-
125
- const baseOptions: ChessboardOptions = {
126
- squareStyles: getCustomSquareStyles(game, info, activeSquare, theme),
127
- boardOrientation: orientation === "b" ? "black" : "white",
128
- position: currentFen,
129
- showNotation: true,
130
- showAnimations: isLatestMove,
131
- lightSquareStyle: theme.board.lightSquare,
132
- darkSquareStyle: theme.board.darkSquare,
133
- canDragPiece: ({ piece }) => {
134
- if (isGameOver) return false;
135
- return piece.pieceType[0] === turn;
136
- },
137
- dropSquareStyle: theme.state.dropSquare,
138
- onPieceDrag: ({ piece, square }) => {
139
- if (piece.pieceType[0] === turn) {
140
- setActiveSquare(square as Square);
141
- }
142
- },
143
- onPieceDrop: ({ sourceSquare, targetSquare }) => {
144
- setActiveSquare(null);
145
- const moveData = {
146
- from: sourceSquare as Square,
147
- to: targetSquare as Square,
148
- };
149
-
150
- // Check if promotion is needed
151
- if (requiresPromotion(game, { ...moveData, promotion: "q" })) {
152
- setPromotionMove(moveData);
153
- return false; // Prevent the move until promotion is selected
154
- }
248
+ },
249
+ );
155
250
 
156
- return makeMove(moveData);
157
- },
158
- onSquareClick: ({ square }) => {
159
- if (square.match(/^[a-h][1-8]$/)) {
160
- onSquareClick(square as Square);
161
- }
162
- },
163
- onSquareRightClick: onSquareRightClick,
164
- allowDrawingArrows: true,
165
- animationDurationInMs: game.history().length === 0 ? 0 : 300,
166
- };
167
-
168
- const mergedOptions = deepMergeChessboardOptions(baseOptions, options);
169
-
170
- return (
171
- <div style={{ position: "relative" }}>
172
- <Chessboard options={mergedOptions} />
173
- {promotionMove && (
174
- <>
175
- {/* Backdrop overlay - click to cancel */}
176
- <div
177
- onClick={() => setPromotionMove(null)}
178
- onContextMenu={(e) => {
179
- e.preventDefault();
180
- setPromotionMove(null);
181
- }}
182
- style={{
183
- position: "absolute",
184
- top: 0,
185
- left: 0,
186
- right: 0,
187
- bottom: 0,
188
- backgroundColor: "rgba(0, 0, 0, 0.1)",
189
- zIndex: 1000,
190
- }}
191
- />
192
- {/* Promotion piece selection */}
193
- <div
194
- style={{
195
- position: "absolute",
196
- top: promotionMove.to?.[1]?.includes("8") ? 0 : "auto",
197
- bottom: promotionMove.to?.[1].includes("1") ? 0 : "auto",
198
- left: promotionSquareLeft,
199
- backgroundColor: "white",
200
- width: squareWidth,
201
- zIndex: 1001,
202
- display: "flex",
203
- flexDirection: "column",
204
- boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.5)",
205
- }}
206
- >
207
- {["q", "r", "n", "b"].map((piece) => (
208
- <button
209
- key={piece}
210
- onClick={() => onPromotionPieceSelect(piece)}
211
- onContextMenu={(e) => {
212
- e.preventDefault();
213
- }}
214
- style={{
215
- width: "100%",
216
- aspectRatio: "1",
217
- display: "flex",
218
- alignItems: "center",
219
- justifyContent: "center",
220
- padding: 0,
221
- border: "none",
222
- cursor: "pointer",
223
- backgroundColor: "white",
224
- }}
225
- onMouseEnter={(e) => {
226
- e.currentTarget.style.backgroundColor = "#f0f0f0";
227
- }}
228
- onMouseLeave={(e) => {
229
- e.currentTarget.style.backgroundColor = "white";
230
- }}
231
- >
232
- {defaultPieces[
233
- `${turn}${piece.toUpperCase()}` as keyof typeof defaultPieces
234
- ]()}
235
- </button>
236
- ))}
237
- </div>
238
- </>
239
- )}
240
- </div>
241
- );
242
- };
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";
@@ -30,3 +30,5 @@ export const Root: React.FC<React.PropsWithChildren<RootProps>> = ({
30
30
  </ChessGameContext.Provider>
31
31
  );
32
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
+ });