Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/upset-cobras-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@react-chess-tools/react-chess-puzzle": patch
"@react-chess-tools/react-chess-game": patch
---

fix: sound and keyboard control improvements
11 changes: 6 additions & 5 deletions packages/react-chess-game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Supports **ref forwarding** and all standard **HTML div attributes** (className,

### ChessGame.Sounds

Provides sound effects for the chess game. Uses built-in sounds by default, but custom sounds can be provided as base64-encoded strings.
Provides sound effects for the chess game. Uses built-in sounds by default, but custom sounds can be provided as base64-encoded strings. Sounds are emitted for new moves only, so loading a static FEN does not trigger audio.

**Note:** This is a logic-only component that returns `null`. It sets up audio functionality via hooks.

Expand Down Expand Up @@ -234,13 +234,14 @@ Provides sound effects for the chess game. Uses built-in sounds by default, but

Enables keyboard navigation through the game history.

**Note:** This is a logic-only component that returns `null`. It sets up keyboard event listeners via hooks.
**Note:** This is a logic-only component that returns `null`. It sets up keyboard event listeners via hooks. When used alongside `ChessGame.Board` in the same `ChessGame.Root`, shortcuts automatically scope to the focused board.

#### Props

| Name | Type | Default | Description |
| ---------- | ------------------ | ------------------------- | --------------------------------------------- |
| `controls` | `KeyboardControls` | `defaultKeyboardControls` | Object mapping key names to handler functions |
| Name | Type | Default | Description |
| -------------- | ------------------------ | ------------------------- | ------------------------------------------------------------ |
| `controls` | `KeyboardControls` | `defaultKeyboardControls` | Object mapping key names to handler functions |
| `containerRef` | `RefObject<HTMLElement>` | Board in same root | Override the default focus scope used for keyboard shortcuts |

**Default Controls:**

Expand Down
3 changes: 1 addition & 2 deletions packages/react-chess-game/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@
"dist"
],
"scripts": {
"build": "tsup src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
"build": "tsup src/index.ts"
},
"keywords": [
"chess",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ const meta = {

export default meta;

const AutoFocusedBoard = () => {
const boardRef = React.useRef<HTMLDivElement | null>(null);

React.useEffect(() => {
boardRef.current?.focus({ preventScroll: true });
}, []);

return <ChessGame.Board ref={boardRef} />;
};

export const Default = () => (
<StoryContainer>
<StoryHeader
Expand All @@ -39,7 +49,7 @@ export const Default = () => (
<BoardWrapper>
<ChessGame.Root>
<ChessGame.KeyboardControls />
<ChessGame.Board />
<AutoFocusedBoard />
</ChessGame.Root>
</BoardWrapper>
<p className="text-size-xs text-text-muted text-center m-0 leading-relaxed">
Expand Down Expand Up @@ -83,7 +93,7 @@ export const WithKeyboardControls = () => (
d: (ctx) => ctx.methods.goToNextMove(),
}}
/>
<ChessGame.Board />
<AutoFocusedBoard />
</ChessGame.Root>
</BoardWrapper>
<div className="flex gap-1.5 justify-center flex-wrap">
Expand Down
105 changes: 95 additions & 10 deletions packages/react-chess-game/src/components/ChessGame/parts/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deepMergeChessboardOptions,
} from "../../../utils/board";
import { isLegalMove, requiresPromotion } from "../../../utils/chess";
import { useChessGameBoardContainerContext } from "../../../hooks/useChessGameBoardContainerContext";
import { useChessGameContext } from "../../../hooks/useChessGameContext";
import { useChessGameTheme } from "../../../theme/context";

Expand All @@ -19,8 +20,20 @@ export interface ChessGameProps extends React.HTMLAttributes<HTMLDivElement> {
}

export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
({ options = {}, className, style: userStyle, ...rest }, ref) => {
(
{
options = {},
className,
style: userStyle,
onPointerDownCapture,
tabIndex = 0,
...rest
},
ref,
) => {
const gameContext = useChessGameContext();
const { boardContainerRef, setBoardContainerElement } =
useChessGameBoardContainerContext();
const theme = useChessGameTheme();

if (!gameContext) {
Expand All @@ -43,7 +56,44 @@ export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
const [promotionMove, setPromotionMove] =
React.useState<Partial<Move> | null>(null);

// Track square width for responsive updates
const [squareWidth, setSquareWidth] = React.useState(80);

const focusBoardContainer = React.useCallback(() => {
boardContainerRef.current?.focus({ preventScroll: true });
}, [boardContainerRef]);

const setBoardContainerRef = React.useCallback(
(node: HTMLDivElement | null) => {
boardContainerRef.current = node;
setBoardContainerElement(node);

if (typeof ref === "function") {
ref(node);
return;
}

if (ref) {
ref.current = node;
}
},
[boardContainerRef, ref, setBoardContainerElement],
);

const handlePointerDownCapture = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
onPointerDownCapture?.(event);

if (!event.defaultPrevented) {
focusBoardContainer();
}
},
[focusBoardContainer, onPointerDownCapture],
);

const onSquareClick = (square: Square) => {
focusBoardContainer();

if (isGameOver) {
return;
}
Expand Down Expand Up @@ -87,6 +137,8 @@ export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
};

const onPromotionPieceSelect = (piece: string): void => {
focusBoardContainer();

if (promotionMove?.from && promotionMove?.to) {
makeMove({
from: promotionMove.from,
Expand All @@ -98,16 +150,33 @@ export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
};

const onSquareRightClick = () => {
focusBoardContainer();
setActiveSquare(null);
setPromotionMove(null);
};

// Calculate square width for precise positioning
const squareWidth = React.useMemo(() => {
if (typeof document === "undefined") return 80;
const squareElement = document.querySelector(`[data-square]`);
return squareElement?.getBoundingClientRect()?.width ?? 80;
}, [promotionMove]);
// Use ResizeObserver for responsive square width updates
React.useEffect(() => {
if (typeof window === "undefined" || !boardContainerRef.current) return;

const updateSquareWidth = () => {
const squareElement =
boardContainerRef.current?.querySelector("[data-square]");
if (squareElement) {
setSquareWidth(squareElement.getBoundingClientRect().width);
}
};

// Initial measurement
updateSquareWidth();

// Only use ResizeObserver if available (not in all test environments)
if (typeof ResizeObserver !== "undefined" && boardContainerRef.current) {
const observer = new ResizeObserver(updateSquareWidth);
observer.observe(boardContainerRef.current);
return () => observer.disconnect();
}
}, []);

// Calculate promotion square position
const promotionSquareLeft = React.useMemo(() => {
Expand Down Expand Up @@ -137,11 +206,13 @@ export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
},
dropSquareStyle: theme.state.dropSquare,
onPieceDrag: ({ piece, square }) => {
focusBoardContainer();
if (piece.pieceType[0] === turn) {
setActiveSquare(square as Square);
}
},
onPieceDrop: ({ sourceSquare, targetSquare }) => {
focusBoardContainer();
setActiveSquare(null);
const moveData = {
from: sourceSquare as Square,
Expand Down Expand Up @@ -173,8 +244,22 @@ export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
position: "relative" as const,
};

// Calculate promotion menu vertical position based on orientation
const isBlackOrientation = orientation === "b";
const promotionRank = promotionMove?.to?.[1];
const isTopRank = isBlackOrientation
? promotionRank === "1"
: promotionRank === "8";

return (
<div ref={ref} className={className} style={mergedStyle} {...rest}>
<div
ref={setBoardContainerRef}
className={className}
style={mergedStyle}
tabIndex={tabIndex}
onPointerDownCapture={handlePointerDownCapture}
{...rest}
>
<Chessboard options={mergedOptions} />
{promotionMove && (
<>
Expand All @@ -199,8 +284,8 @@ export const Board = React.forwardRef<HTMLDivElement, ChessGameProps>(
<div
style={{
position: "absolute",
top: promotionMove.to?.[1]?.includes("8") ? 0 : "auto",
bottom: promotionMove.to?.[1].includes("1") ? 0 : "auto",
top: isTopRank ? 0 : "auto",
bottom: isTopRank ? "auto" : 0,
left: promotionSquareLeft,
backgroundColor: "white",
width: squareWidth,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FC } from "react";
import type { FC, RefObject } from "react";
import { useChessGameBoardContainerContext } from "../../../hooks/useChessGameBoardContainerContext";
import { ChessGameContextType } from "../../../hooks/useChessGameContext";
import { useKeyboardControls } from "../../../hooks/useKeyboardControls";

Expand Down Expand Up @@ -27,11 +28,26 @@ export const defaultKeyboardControls: KeyboardControls = {
*/
type KeyboardControlsProps = {
controls?: KeyboardControls;
/**
* Optional ref to a container element to scope keyboard handling.
* When omitted, the controls automatically scope to the ChessGame.Board rendered
* within the same ChessGame.Root. Pass a custom ref to override that behavior.
*/
containerRef?: RefObject<HTMLElement | null>;
};

export const KeyboardControls: FC<KeyboardControlsProps> = ({ controls }) => {
const keyboardControls = { ...defaultKeyboardControls, ...controls };
useKeyboardControls(keyboardControls);
export const KeyboardControls: FC<KeyboardControlsProps> = ({
controls,
containerRef,
}) => {
const { boardContainerElement, boardContainerRef } =
useChessGameBoardContainerContext();

useKeyboardControls({
controls,
containerRef:
containerRef ?? (boardContainerElement ? boardContainerRef : undefined),
});
return null;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { Color } from "chess.js";
import { useChessGame } from "../../../hooks/useChessGame";
import { ChessGameBoardContainerContext } from "../../../hooks/useChessGameBoardContainerContext";
import { ChessGameContext } from "../../../hooks/useChessGameContext";
import { ThemeProvider } from "../../../theme/context";
import { mergeTheme } from "../../../theme/utils";
Expand Down Expand Up @@ -32,13 +33,26 @@ export const Root: React.FC<React.PropsWithChildren<RootProps>> = ({
timeControl,
autoSwitchOnMove,
});
const boardContainerRef = React.useRef<HTMLDivElement | null>(null);
const [boardContainerElement, setBoardContainerElement] =
React.useState<HTMLDivElement | null>(null);

// Merge partial theme with defaults
const mergedTheme = React.useMemo(() => mergeTheme(theme), [theme]);
const boardContainerContext = React.useMemo(
() => ({
boardContainerRef,
boardContainerElement,
setBoardContainerElement,
}),
[boardContainerElement],
);

return (
<ChessGameContext.Provider value={context}>
<ThemeProvider theme={mergedTheme}>{children}</ThemeProvider>
<ChessGameBoardContainerContext.Provider value={boardContainerContext}>
<ThemeProvider theme={mergedTheme}>{children}</ThemeProvider>
</ChessGameBoardContainerContext.Provider>
</ChessGameContext.Provider>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const Sounds: React.FC<SoundsProps> = ({ sounds }) => {
return {} as Record<Sound, HTMLAudioElement>;
}

return Object.entries({ ...defaultSounds, sounds }).reduce(
return Object.entries({ ...defaultSounds, ...sounds }).reduce(
(acc, [name, base64]) => {
acc[name as Sound] = new Audio(`data:audio/wav;base64,${base64}`);
return acc;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { render } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import "@testing-library/jest-dom";
import { ChessGame } from "../..";
import { Board } from "../Board";
Expand Down Expand Up @@ -107,6 +107,22 @@ describe("ChessGame.Board", () => {
expect(handleClick).toHaveBeenCalledTimes(1);
});

it("should focus the board container on pointer interaction", () => {
const { container } = render(
<ChessGame.Root>
<Board />
</ChessGame.Root>,
);

const board = container.firstElementChild as HTMLDivElement;

expect(document.activeElement).not.toBe(board);

fireEvent.pointerDown(board);

expect(document.activeElement).toBe(board);
});

it("should throw error when used outside ChessGame.Root", () => {
// Suppress console.error for this test
const consoleError = jest
Expand Down
Loading
Loading