@react-chess-tools/react-chess-game 0.5.2 → 1.0.0

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 (39) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/index.cjs +775 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/{index.d.mts → index.d.cts} +118 -12
  5. package/dist/index.d.ts +264 -0
  6. package/dist/{index.mjs → index.js} +171 -38
  7. package/dist/index.js.map +1 -0
  8. package/package.json +18 -9
  9. package/src/components/ChessGame/Theme.stories.tsx +242 -0
  10. package/src/components/ChessGame/ThemePresets.stories.tsx +144 -0
  11. package/src/components/ChessGame/parts/Board.tsx +6 -4
  12. package/src/components/ChessGame/parts/Root.tsx +11 -1
  13. package/src/docs/Theming.mdx +281 -0
  14. package/src/hooks/useChessGame.ts +23 -7
  15. package/src/index.ts +19 -0
  16. package/src/theme/__tests__/context.test.tsx +75 -0
  17. package/src/theme/__tests__/defaults.test.ts +61 -0
  18. package/src/theme/__tests__/utils.test.ts +106 -0
  19. package/src/theme/context.tsx +37 -0
  20. package/src/theme/defaults.ts +22 -0
  21. package/src/theme/index.ts +36 -0
  22. package/src/theme/presets.ts +41 -0
  23. package/src/theme/types.ts +56 -0
  24. package/src/theme/utils.ts +47 -0
  25. package/src/utils/__tests__/board.test.ts +118 -0
  26. package/src/utils/board.ts +18 -9
  27. package/src/utils/chess.ts +25 -5
  28. package/coverage/clover.xml +0 -6
  29. package/coverage/coverage-final.json +0 -1
  30. package/coverage/lcov-report/base.css +0 -224
  31. package/coverage/lcov-report/block-navigation.js +0 -87
  32. package/coverage/lcov-report/favicon.png +0 -0
  33. package/coverage/lcov-report/index.html +0 -101
  34. package/coverage/lcov-report/prettify.css +0 -1
  35. package/coverage/lcov-report/prettify.js +0 -2
  36. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  37. package/coverage/lcov-report/sorter.js +0 -196
  38. package/coverage/lcov.info +0 -0
  39. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,242 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import React, { useState } from "react";
3
+ import { ChessGame } from "./index";
4
+ import { defaultGameTheme, themes } from "../../theme";
5
+ import type { ChessGameTheme, PartialChessGameTheme } from "../../theme/types";
6
+
7
+ const meta = {
8
+ title: "react-chess-game/Theme/Playground",
9
+ component: ChessGame.Root,
10
+ tags: ["theme"],
11
+ decorators: [
12
+ (Story) => (
13
+ <div style={{ maxWidth: "900px" }}>
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
18
+ } satisfies Meta<typeof ChessGame.Root>;
19
+
20
+ export default meta;
21
+
22
+ // Color picker component
23
+ const ColorInput: React.FC<{
24
+ label: string;
25
+ value: string;
26
+ onChange: (value: string) => void;
27
+ }> = ({ label, value, onChange }) => {
28
+ // Extract hex from rgba for color picker
29
+ const rgbaToHex = (rgba: string): string => {
30
+ const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
31
+ if (match) {
32
+ const r = parseInt(match[1]).toString(16).padStart(2, "0");
33
+ const g = parseInt(match[2]).toString(16).padStart(2, "0");
34
+ const b = parseInt(match[3]).toString(16).padStart(2, "0");
35
+ return `#${r}${g}${b}`;
36
+ }
37
+ return value.startsWith("#") ? value : "#000000";
38
+ };
39
+
40
+ const hexToRgba = (hex: string, alpha: number = 0.5): string => {
41
+ const r = parseInt(hex.slice(1, 3), 16);
42
+ const g = parseInt(hex.slice(3, 5), 16);
43
+ const b = parseInt(hex.slice(5, 7), 16);
44
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
45
+ };
46
+
47
+ return (
48
+ <div
49
+ style={{
50
+ display: "flex",
51
+ alignItems: "center",
52
+ gap: "8px",
53
+ marginBottom: "8px",
54
+ }}
55
+ >
56
+ <input
57
+ type="color"
58
+ value={rgbaToHex(value)}
59
+ onChange={(e) => onChange(hexToRgba(e.target.value))}
60
+ style={{ width: "40px", height: "30px", cursor: "pointer" }}
61
+ />
62
+ <span style={{ fontSize: "12px", minWidth: "100px" }}>{label}</span>
63
+ <input
64
+ type="text"
65
+ value={value}
66
+ onChange={(e) => onChange(e.target.value)}
67
+ style={{ fontSize: "11px", width: "180px", padding: "4px" }}
68
+ />
69
+ </div>
70
+ );
71
+ };
72
+
73
+ // Background color picker (for board squares)
74
+ const BgColorInput: React.FC<{
75
+ label: string;
76
+ value: string;
77
+ onChange: (value: string) => void;
78
+ }> = ({ label, value, onChange }) => {
79
+ return (
80
+ <div
81
+ style={{
82
+ display: "flex",
83
+ alignItems: "center",
84
+ gap: "8px",
85
+ marginBottom: "8px",
86
+ }}
87
+ >
88
+ <input
89
+ type="color"
90
+ value={value}
91
+ onChange={(e) => onChange(e.target.value)}
92
+ style={{ width: "40px", height: "30px", cursor: "pointer" }}
93
+ />
94
+ <span style={{ fontSize: "12px", minWidth: "100px" }}>{label}</span>
95
+ <input
96
+ type="text"
97
+ value={value}
98
+ onChange={(e) => onChange(e.target.value)}
99
+ style={{ fontSize: "11px", width: "180px", padding: "4px" }}
100
+ />
101
+ </div>
102
+ );
103
+ };
104
+
105
+ export const Playground = () => {
106
+ const [theme, setTheme] = useState<ChessGameTheme>(defaultGameTheme);
107
+ const [copied, setCopied] = useState(false);
108
+
109
+ const updateTheme = (path: string[], value: string) => {
110
+ setTheme((prev) => {
111
+ const newTheme = JSON.parse(JSON.stringify(prev));
112
+ let current: Record<string, unknown> = newTheme;
113
+ for (let i = 0; i < path.length - 1; i++) {
114
+ current = current[path[i]] as Record<string, unknown>;
115
+ }
116
+ if (path[path.length - 2] === "board") {
117
+ current[path[path.length - 1]] = { backgroundColor: value };
118
+ } else {
119
+ current[path[path.length - 1]] = value;
120
+ }
121
+ return newTheme;
122
+ });
123
+ };
124
+
125
+ const copyTheme = () => {
126
+ const themeCode = `const myTheme: PartialChessGameTheme = ${JSON.stringify(theme, null, 2)};`;
127
+ navigator.clipboard.writeText(themeCode);
128
+ setCopied(true);
129
+ setTimeout(() => setCopied(false), 2000);
130
+ };
131
+
132
+ const loadPreset = (preset: ChessGameTheme) => {
133
+ setTheme(preset);
134
+ };
135
+
136
+ return (
137
+ <div style={{ display: "flex", gap: "24px", flexWrap: "wrap" }}>
138
+ <div style={{ flex: "1", minWidth: "300px" }}>
139
+ <h3 style={{ marginBottom: "16px" }}>Theme Editor</h3>
140
+
141
+ <div style={{ marginBottom: "16px" }}>
142
+ <strong>Load Preset:</strong>
143
+ <div style={{ display: "flex", gap: "8px", marginTop: "8px" }}>
144
+ <button onClick={() => loadPreset(themes.default)}>Default</button>
145
+ <button onClick={() => loadPreset(themes.lichess)}>Lichess</button>
146
+ <button onClick={() => loadPreset(themes.chessCom)}>
147
+ Chess.com
148
+ </button>
149
+ </div>
150
+ </div>
151
+
152
+ <div style={{ marginBottom: "16px" }}>
153
+ <strong>Board Colors</strong>
154
+ <div style={{ marginTop: "8px" }}>
155
+ <BgColorInput
156
+ label="Light Square"
157
+ value={
158
+ (theme.board.lightSquare as { backgroundColor: string })
159
+ .backgroundColor
160
+ }
161
+ onChange={(v) => updateTheme(["board", "lightSquare"], v)}
162
+ />
163
+ <BgColorInput
164
+ label="Dark Square"
165
+ value={
166
+ (theme.board.darkSquare as { backgroundColor: string })
167
+ .backgroundColor
168
+ }
169
+ onChange={(v) => updateTheme(["board", "darkSquare"], v)}
170
+ />
171
+ </div>
172
+ </div>
173
+
174
+ <div style={{ marginBottom: "16px" }}>
175
+ <strong>State Colors</strong>
176
+ <div style={{ marginTop: "8px" }}>
177
+ <ColorInput
178
+ label="Last Move"
179
+ value={theme.state.lastMove}
180
+ onChange={(v) => updateTheme(["state", "lastMove"], v)}
181
+ />
182
+ <ColorInput
183
+ label="Active Square"
184
+ value={theme.state.activeSquare}
185
+ onChange={(v) => updateTheme(["state", "activeSquare"], v)}
186
+ />
187
+ <ColorInput
188
+ label="Check"
189
+ value={theme.state.check}
190
+ onChange={(v) => updateTheme(["state", "check"], v)}
191
+ />
192
+ </div>
193
+ </div>
194
+
195
+ <div style={{ marginBottom: "16px" }}>
196
+ <strong>Indicator Colors</strong>
197
+ <div style={{ marginTop: "8px" }}>
198
+ <ColorInput
199
+ label="Move Dot"
200
+ value={theme.indicators.move}
201
+ onChange={(v) => updateTheme(["indicators", "move"], v)}
202
+ />
203
+ <ColorInput
204
+ label="Capture Ring"
205
+ value={theme.indicators.capture}
206
+ onChange={(v) => updateTheme(["indicators", "capture"], v)}
207
+ />
208
+ </div>
209
+ </div>
210
+
211
+ <button
212
+ onClick={copyTheme}
213
+ style={{
214
+ padding: "8px 16px",
215
+ backgroundColor: copied ? "#4caf50" : "#2196f3",
216
+ color: "white",
217
+ border: "none",
218
+ borderRadius: "4px",
219
+ cursor: "pointer",
220
+ }}
221
+ >
222
+ {copied ? "Copied!" : "Copy Theme Code"}
223
+ </button>
224
+ </div>
225
+
226
+ <div style={{ flex: "1", minWidth: "350px" }}>
227
+ <h3 style={{ marginBottom: "16px" }}>Preview</h3>
228
+ <div style={{ maxWidth: "400px" }}>
229
+ <ChessGame.Root
230
+ fen="r1bqkb1r/pppp1ppp/2n2n2/4p2Q/2B1P3/8/PPPP1PPP/RNB1K1NR w KQkq - 4 4"
231
+ theme={theme}
232
+ >
233
+ <ChessGame.Board />
234
+ </ChessGame.Root>
235
+ </div>
236
+ <p style={{ fontSize: "12px", color: "#666", marginTop: "8px" }}>
237
+ Click on a piece to see move indicators. The position shows a check.
238
+ </p>
239
+ </div>
240
+ </div>
241
+ );
242
+ };
@@ -0,0 +1,144 @@
1
+ import type { Meta } from "@storybook/react";
2
+ import React from "react";
3
+ import { ChessGame } from "./index";
4
+ import { themes } from "../../theme";
5
+
6
+ const meta = {
7
+ title: "react-chess-game/Theme/Presets",
8
+ component: ChessGame.Root,
9
+ tags: ["theme", "presets"],
10
+ decorators: [
11
+ (Story) => (
12
+ <div style={{ maxWidth: "1200px" }}>
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ } satisfies Meta<typeof ChessGame.Root>;
18
+
19
+ export default meta;
20
+
21
+ // Position with a move played (to show lastMove highlight)
22
+ const POSITION_WITH_MOVE =
23
+ "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2";
24
+
25
+ export const DefaultTheme = () => (
26
+ <div style={{ maxWidth: "500px" }}>
27
+ <h3>Default Theme</h3>
28
+ <p style={{ fontSize: "14px", color: "#666", marginBottom: "16px" }}>
29
+ The original colors matching the classic chessboard look.
30
+ </p>
31
+ <ChessGame.Root theme={themes.default}>
32
+ <ChessGame.Board />
33
+ </ChessGame.Root>
34
+ </div>
35
+ );
36
+
37
+ export const LichessTheme = () => (
38
+ <div style={{ maxWidth: "500px" }}>
39
+ <h3>Lichess Theme</h3>
40
+ <p style={{ fontSize: "14px", color: "#666", marginBottom: "16px" }}>
41
+ Green highlights inspired by Lichess.org style.
42
+ </p>
43
+ <ChessGame.Root theme={themes.lichess}>
44
+ <ChessGame.Board />
45
+ </ChessGame.Root>
46
+ </div>
47
+ );
48
+
49
+ export const ChessComTheme = () => (
50
+ <div style={{ maxWidth: "500px" }}>
51
+ <h3>Chess.com Theme</h3>
52
+ <p style={{ fontSize: "14px", color: "#666", marginBottom: "16px" }}>
53
+ Green board with yellow highlights inspired by Chess.com.
54
+ </p>
55
+ <ChessGame.Root theme={themes.chessCom}>
56
+ <ChessGame.Board />
57
+ </ChessGame.Root>
58
+ </div>
59
+ );
60
+
61
+ export const CustomThemeExample = () => {
62
+ // Example of a custom dark theme
63
+ const darkTheme = {
64
+ board: {
65
+ lightSquare: { backgroundColor: "#4a4a4a" },
66
+ darkSquare: { backgroundColor: "#2d2d2d" },
67
+ },
68
+ state: {
69
+ lastMove: "rgba(100, 150, 255, 0.5)",
70
+ check: "rgba(255, 50, 50, 0.6)",
71
+ activeSquare: "rgba(100, 150, 255, 0.5)",
72
+ dropSquare: { backgroundColor: "rgba(100, 150, 255, 0.3)" },
73
+ },
74
+ indicators: {
75
+ move: "rgba(200, 200, 200, 0.2)",
76
+ capture: "rgba(255, 100, 100, 0.3)",
77
+ },
78
+ };
79
+
80
+ return (
81
+ <div style={{ maxWidth: "500px" }}>
82
+ <h3>Custom Dark Theme Example</h3>
83
+ <p style={{ fontSize: "14px", color: "#666", marginBottom: "16px" }}>
84
+ Example of a fully custom theme with dark colors and blue highlights.
85
+ </p>
86
+ <ChessGame.Root theme={darkTheme}>
87
+ <ChessGame.Board />
88
+ </ChessGame.Root>
89
+ <details style={{ marginTop: "16px" }}>
90
+ <summary style={{ cursor: "pointer", fontSize: "14px" }}>
91
+ View theme code
92
+ </summary>
93
+ <pre
94
+ style={{
95
+ fontSize: "11px",
96
+ background: "#f5f5f5",
97
+ padding: "12px",
98
+ overflow: "auto",
99
+ }}
100
+ >
101
+ {JSON.stringify(darkTheme, null, 2)}
102
+ </pre>
103
+ </details>
104
+ </div>
105
+ );
106
+ };
107
+
108
+ export const PartialThemeOverride = () => {
109
+ // Only override specific colors
110
+ const partialTheme = {
111
+ state: {
112
+ lastMove: "rgba(147, 112, 219, 0.5)", // Purple
113
+ check: "rgba(255, 165, 0, 0.6)", // Orange
114
+ },
115
+ };
116
+
117
+ return (
118
+ <div style={{ maxWidth: "500px" }}>
119
+ <h3>Partial Theme Override</h3>
120
+ <p style={{ fontSize: "14px", color: "#666", marginBottom: "16px" }}>
121
+ Only override specific colors (purple last move, orange check). Other
122
+ colors use defaults.
123
+ </p>
124
+ <ChessGame.Root fen={POSITION_WITH_MOVE} theme={partialTheme}>
125
+ <ChessGame.Board />
126
+ </ChessGame.Root>
127
+ <details style={{ marginTop: "16px" }}>
128
+ <summary style={{ cursor: "pointer", fontSize: "14px" }}>
129
+ View theme code
130
+ </summary>
131
+ <pre
132
+ style={{
133
+ fontSize: "11px",
134
+ background: "#f5f5f5",
135
+ padding: "12px",
136
+ overflow: "auto",
137
+ }}
138
+ >
139
+ {JSON.stringify(partialTheme, null, 2)}
140
+ </pre>
141
+ </details>
142
+ </div>
143
+ );
144
+ };
@@ -12,6 +12,7 @@ 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
17
  export interface ChessGameProps {
17
18
  options?: ChessboardOptions;
@@ -19,6 +20,7 @@ export interface ChessGameProps {
19
20
 
20
21
  export const Board: React.FC<ChessGameProps> = ({ options = {} }) => {
21
22
  const gameContext = useChessGameContext();
23
+ const theme = useChessGameTheme();
22
24
 
23
25
  if (!gameContext) {
24
26
  throw new Error("ChessGameContext not found");
@@ -121,18 +123,18 @@ export const Board: React.FC<ChessGameProps> = ({ options = {} }) => {
121
123
  }, [promotionMove, squareWidth, orientation]);
122
124
 
123
125
  const baseOptions: ChessboardOptions = {
124
- squareStyles: getCustomSquareStyles(game, info, activeSquare),
126
+ squareStyles: getCustomSquareStyles(game, info, activeSquare, theme),
125
127
  boardOrientation: orientation === "b" ? "black" : "white",
126
128
  position: currentFen,
127
129
  showNotation: true,
128
130
  showAnimations: isLatestMove,
131
+ lightSquareStyle: theme.board.lightSquare,
132
+ darkSquareStyle: theme.board.darkSquare,
129
133
  canDragPiece: ({ piece }) => {
130
134
  if (isGameOver) return false;
131
135
  return piece.pieceType[0] === turn;
132
136
  },
133
- dropSquareStyle: {
134
- backgroundColor: "rgba(255, 255, 0, 0.4)",
135
- },
137
+ dropSquareStyle: theme.state.dropSquare,
136
138
  onPieceDrag: ({ piece, square }) => {
137
139
  if (piece.pieceType[0] === turn) {
138
140
  setActiveSquare(square as Square);
@@ -2,21 +2,31 @@ 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
  };