chess-analysis-board
v1.0.4
Published
A comprehensive chess analysis board React component with move navigation, variation support, PGN import/export, and customizable keyboard shortcuts
Maintainers
Readme
Chess Analysis Board React Component
A comprehensive chess analysis board built with React, featuring move navigation, variation support, PGN import/export, and customizable keyboard shortcuts.
Features
Core Chess Functionality
- Interactive Chessboard: Drag-and-drop piece movement with validation
- Move Navigation: Navigate through games with arrow keys or custom shortcuts
- Variation Support: Full support for chess variations and sub-variations
- PGN Import/Export: Load and save games in standard PGN format with comments and variations
- Move Comments: Add and edit comments for any move
- Auto-scroll: Selected moves automatically scroll into view
User Interface
- Modern Design: Clean, professional interface similar to Lichess
- Responsive Layout: Adapts to different screen sizes
- Variation Display: Clear visual hierarchy for main lines and variations
- Comment Integration: Comments appear inline with proper spacing
Customization
- Board Flipping: Toggle between white and black perspectives
- Keyboard Shortcuts: Fully customizable navigation keys
- Settings Panel: User-friendly settings interface (Cmd+, or Ctrl+,)
Installation
npm install @mliebelt/pgn-parser chess.js react-chessboard use-immerBasic Usage
Standalone Component
import React from 'react';
import AnalysisBoard from './components/AnalysisBoard';
import 'chess-analysis-board/dist/chess-analysis-board.css'; // If consuming as a package
function App() {
return (
<div className="App">
<AnalysisBoard />
</div>
);
}
export default App;Accessing Generated PGN
The component can notify your app whenever the PGN changes:
import React, { useState } from 'react';
import AnalysisBoard from './components/AnalysisBoard';
function App() {
const [currentPgn, setCurrentPgn] = useState('');
// This runs on every change - just store it
const handlePgnChange = (pgn) => {
setCurrentPgn(pgn);
};
// This runs only when user clicks save
const handleSaveStudy = async () => {
await invoke('save_study', { pgn: currentPgn });
};
return (
<div>
<AnalysisBoard onPgnChange={handlePgnChange} />
<button onClick={handleSaveStudy}>Save Study</button>
</div>
);
}Desktop App Integration (Tauri)
For desktop applications, you can hide the PGN box and handle saving externally:
import React, { useState } from 'react';
import { invoke } from '@tauri-apps/api/tauri';
import AnalysisBoard from './components/AnalysisBoard';
import 'chess-analysis-board/dist/chess-analysis-board.css'; // Important: include library styles
function App() {
const [currentPgn, setCurrentPgn] = useState('');
const handlePgnChange = (newPgn) => {
setCurrentPgn(newPgn);
};
const handleSaveStudy = async () => {
try {
await invoke('save_study', { pgn: currentPgn });
console.log('Study saved successfully');
} catch (error) {
console.error('Failed to save study:', error);
}
};
return (
<div>
<AnalysisBoard
enablePgnBox={false} // Hide PGN box, handle saving externally
onPgnChange={handlePgnChange}
/>
<button onClick={handleSaveStudy}>Save Study</button>
</div>
);
}Using in another app (import the CSS)
When consuming this component from another project (e.g., a Tauri app), you must import the packaged CSS for proper layout and styling:
import AnalysisBoard from 'chess-analysis-board';
import 'chess-analysis-board/dist/chess-analysis-board.css';
export default function App() {
return <AnalysisBoard containerMode="embedded" />;
}Without the CSS import, the move list and text areas will appear unstyled/squashed.
With External Settings (Recommended for Apps)
import React, { useState, useEffect } from 'react';
import AnalysisBoard from './components/AnalysisBoard';
function App() {
const [appSettings, setAppSettings] = useState({
keyboard: {
flipBoard: 'f',
previousMove: 'k',
nextMove: 'j'
}
});
const [showSettingsModal, setShowSettingsModal] = useState(false);
const handleSettingsChange = (newKeyboardSettings) => {
setAppSettings({
...appSettings,
keyboard: newKeyboardSettings
});
// Save to localStorage, database, etc.
localStorage.setItem('chessSettings', JSON.stringify(appSettings));
};
return (
<div className="App">
<AnalysisBoard
externalSettings={appSettings.keyboard}
onSettingsChange={handleSettingsChange}
showExternalSettings={showSettingsModal}
onToggleSettings={setShowSettingsModal}
/>
</div>
);
}Tauri Desktop App Integration
For desktop applications using Tauri, the component can be fully integrated with native menus and persistent settings.
Frontend Integration
// App.jsx
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/tauri';
import { listen } from '@tauri-apps/api/event';
import AnalysisBoard from './components/AnalysisBoard';
function App() {
const [appSettings, setAppSettings] = useState({
keyboard: {
flipBoard: 'f',
previousMove: 'k',
nextMove: 'j'
}
});
const [showSettingsModal, setShowSettingsModal] = useState(false);
// Load settings from file when app starts
useEffect(() => {
loadSettings();
setupMenuHandlers();
}, []);
const loadSettings = async () => {
try {
const savedSettings = await invoke('load_settings');
if (savedSettings) {
setAppSettings(savedSettings);
}
} catch (error) {
console.log('No saved settings found, using defaults');
}
};
const setupMenuHandlers = async () => {
// Listen for menu events
await listen('menu', (event) => {
if (event.payload === 'settings') {
setShowSettingsModal(true);
}
});
};
const handleAppSettingsChange = async (newKeyboardSettings) => {
const updatedSettings = {
...appSettings,
keyboard: newKeyboardSettings
};
setAppSettings(updatedSettings);
// Save to file via Tauri
try {
await invoke('save_settings', { settings: updatedSettings });
} catch (error) {
console.error('Failed to save settings:', error);
}
};
return (
<div className="app">
<AnalysisBoard
externalSettings={appSettings.keyboard}
onSettingsChange={handleAppSettingsChange}
showExternalSettings={showSettingsModal}
onToggleSettings={setShowSettingsModal}
/>
</div>
);
}
export default App;Tauri Backend (Rust)
// src-tauri/src/main.rs
use tauri::Manager;
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Serialize, Deserialize)]
struct KeyboardSettings {
#[serde(rename = "flipBoard")]
flip_board: String,
#[serde(rename = "previousMove")]
previous_move: String,
#[serde(rename = "nextMove")]
next_move: String,
}
#[derive(Serialize, Deserialize)]
struct AppSettings {
keyboard: KeyboardSettings,
}
#[tauri::command]
fn load_settings() -> Result<AppSettings, String> {
let app_dir = tauri::api::path::app_data_dir(&tauri::Config::default())
.ok_or("Failed to get app data directory")?;
let settings_path = app_dir.join("settings.json");
match fs::read_to_string(settings_path) {
Ok(contents) => {
serde_json::from_str(&contents)
.map_err(|e| format!("Failed to parse settings: {}", e))
}
Err(_) => Err("Settings file not found".to_string())
}
}
#[tauri::command]
fn save_settings(settings: AppSettings) -> Result<(), String> {
let app_dir = tauri::api::path::app_data_dir(&tauri::Config::default())
.ok_or("Failed to get app data directory")?;
fs::create_dir_all(&app_dir)
.map_err(|e| format!("Failed to create app directory: {}", e))?;
let settings_path = app_dir.join("settings.json");
let json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
fs::write(settings_path, json)
.map_err(|e| format!("Failed to write settings: {}", e))
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![load_settings, save_settings])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}API Reference
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| externalSettings | Object \| null | null | External keyboard settings object |
| onSettingsChange | Function \| null | null | Callback when settings change |
| showExternalSettings | boolean | false | Whether to show settings modal externally |
| onToggleSettings | Function \| null | null | Callback to toggle settings modal |
| startingFen | string \| null | null | Custom starting position in FEN notation |
| startingPgn | string \| null | null | Load a complete game/analysis from PGN notation |
| onPgnChange | Function \| null | null | Callback when PGN changes (for external save functionality) |
| onError | Function \| null | null | Callback for error reporting (validation, parsing, conflicts) |
| enableFenInput | boolean | true | Whether to enable FEN input functionality |
| enablePgnBox | boolean | true | Whether to show the PGN input/output box |
| containerMode | string | 'standalone' | Layout mode: 'standalone' (viewport-based) or 'embedded' (container-relative) |
Settings Object Structure
{
flipBoard: 'f', // Key to flip board orientation
previousMove: 'k', // Key to go to previous move
nextMove: 'j' // Key to go to next move
}Error Handling
The component validates input and reports errors via the onError callback:
const handleError = (error) => {
console.error('Chess Analysis Error:', error);
switch (error.type) {
case 'fen_pgn_conflict':
showUserMessage('The starting position conflicts with the PGN');
break;
case 'invalid_pgn':
showUserMessage('Invalid PGN format');
break;
case 'invalid_pgn_moves':
showUserMessage('PGN moves are not legal from the starting position');
break;
case 'invalid_fen_in_pgn':
showUserMessage('Invalid FEN in PGN header');
break;
default:
showUserMessage('Chess analysis error occurred');
}
};
<AnalysisBoard
startingFen={customFen}
startingPgn={customPgn}
onError={handleError}
/>Error Types
| Type | Description | Details |
|------|-------------|---------|
| fen_pgn_conflict | startingFen prop conflicts with PGN's FEN header | providedFen, pgnFen, pgn |
| invalid_pgn | PGN contains no valid moves or is malformed | pgn |
| invalid_pgn_moves | First move in PGN is illegal from starting position | startingFen, firstMove, error |
| invalid_fen_in_pgn | FEN header in PGN is invalid | fen, error |
| pgn_parse_error | Failed to parse PGN syntax | pgn, error |
Container Modes
The component supports two layout modes for different integration scenarios:
Standalone Mode (Default)
<AnalysisBoard />
// or explicitly
<AnalysisBoard containerMode="standalone" />- Uses viewport-based sizing (
vw,vh) - Designed for full-page applications
- Components size themselves relative to the browser window
- Best for dedicated chess analysis applications
Embedded Mode
<AnalysisBoard containerMode="embedded" />- Uses container-relative sizing (
%,px) - Designed for integration into existing applications
- Components adapt to their container size
- Perfect for Tauri desktop apps, dashboards, or embedded widgets
Key differences in embedded mode:
- Board and moves panel are limited to reasonable max sizes
- Sections stack vertically on smaller screens
- No viewport units - works within any container
- Reduced padding and margins for compact layouts
Example for Tauri integration:
<div style={{ width: '1200px', height: '800px' }}>
<AnalysisBoard
containerMode="embedded"
enablePgnBox={false}
onPgnChange={handlePgnChange}
/>
</div>Default Keyboard Shortcuts
| Action | Default Key | Alternative | Description |
|--------|-------------|-------------|-------------|
| Next Move | j | → | Navigate to next move |
| Previous Move | k | ← | Navigate to previous move |
| Jump to Start | ↑ | - | Jump to beginning of game |
| Jump to End | ↓ | - | Jump to end of main line |
| Flip Board | f | - | Toggle board orientation |
| Toggle FEN Input | Shift+F | - | Show/hide FEN input section |
| Open Settings | Cmd+, / Ctrl+, | - | Open settings panel |
| Close Settings | Esc | Click outside | Close settings panel |
Customizable Settings
All keyboard shortcuts and UI behavior can be customized via the settings panel:
- Access: Press
Cmd+,(orCtrl+,) to open settings - Keyboard Shortcuts: Change any keyboard shortcut to your preference
- Board Orientation: View current board orientation
- Auto-scroll: Toggle automatic scrolling to keep selected move in view (default: enabled)
- Close: Press
Escor click outside to close
UI Component Control
The component supports selective enabling/disabling of UI sections for different use cases:
Disabling FEN Input
// Completely disable FEN input functionality
<AnalysisBoard enableFenInput={false} />When enableFenInput={false}:
- The FEN input section never appears
- The "Toggle FEN Input" option is removed from settings
- The Shift+F keyboard shortcut is disabled
- Users cannot change the starting position via the UI
Disabling PGN Box
// Hide the PGN input/output box entirely
<AnalysisBoard enablePgnBox={false} />When enablePgnBox={false}:
- The entire PGN box is hidden (no textarea, copy button, or load button)
- PGN generation still works internally for
onPgnChangecallback - Perfect for desktop apps that handle PGN saving externally
Combined Usage
// For embedded use cases - minimal UI with external PGN handling
<AnalysisBoard
enableFenInput={false}
enablePgnBox={false}
onPgnChange={handlePgnUpdate} // Just tracking changes
startingFen="rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
/>FEN Support
The component supports custom starting positions via FEN (Forsyth-Edwards Notation):
User Interface (when enabled)t
- Toggle Display: Press
Shift+F(or customize in settings) to show/hide the FEN input section - FEN Input: Paste FEN notation to set custom starting positions
- Validation: Invalid FEN strings are rejected with user feedback
- Optional Display: The FEN input section is hidden by default to keep the UI clean
Programmatic Control
// Set a custom starting position programmatically
<AnalysisBoard
startingFen="rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
/>
// Example: King and Pawn endgame
<AnalysisBoard
startingFen="8/8/4k3/8/8/4P3/4K3/8 w - - 0 1"
/>
// Load a complete game with moves, variations, and comments
<AnalysisBoard
startingPgn={`1. e4 e5 2. Nf3 Nc6 3. Bb5 {The Spanish Opening} a6
(3... f5 {The Schliemann Defense} 4. Nc3 fxe4 5. Nxe4)
4. Ba4 Nf6 5. O-O Be7 *`}
/>
// Load from a custom starting position with PGN
<AnalysisBoard
startingPgn={`[FEN "rnbqkb1r/ppp2ppp/4pn2/3p4/3P1B2/2N5/PPP1PPPP/R2QKBNR w KQkq - 0 1"]
1. Nb5 Na6 (1... Bd6 2. Nxd6+ cxd6 3. e3) 2. e3 c6 3. Nc3 *`}
/>PGN Support
The component supports full PGN import and export with:
- Move annotations: Comments, NAGs
- Variations: Nested variations and sub-variations
- Headers: Standard PGN headers
- Live updates: PGN updates as you play/navigate
- Custom starting positions: PGNs work with any starting FEN
Example PGN with Variations
1. e4 e5 2. Nf3 Nc6 3. Bb5 {The Spanish Opening} a6
(3... f5 {The Schliemann Defense} 4. Nc3 fxe4 5. Nxe4)
4. Ba4 Nf6 5. O-O Be7 *Styling
The component uses CSS classes that can be customized:
/* Main container */
.analysis-board-container { }
/* Chessboard area */
.analysis-board { }
/* Moves panel */
.move-history { }
/* Individual moves */
.move { }
.selected-move { }
/* Variations */
.variation-line { }
.variation-row { }
/* Comments */
.comment { }
.inline-comment { }
/* Settings modal */
.settings-overlay { }
.settings-modal { }Dependencies
react(^19.1.0)chess.js(^1.4.0) - Chess game logic and validationreact-chessboard(^4.7.3) - Interactive chessboard componentuse-immer(^0.11.0) - Immutable state management@mliebelt/pgn-parser- PGN parsing for variations and comments
Browser Compatibility
- Chrome/Edge 88+
- Firefox 85+
- Safari 14+
Testing
Below is the unit testing setup used
Testing Framework
- Vitest: Fast unit test runner with Vite integration
- React Testing Library: Component testing with user-centric queries
- JSDOM: DOM simulation for browser-like testing environment
- User Event: Realistic user interaction simulation
Test Structure
src/
├── components/
│ ├── __tests__/
│ │ ├── AnalysisBoard.test.jsx # Main component tests
│ │ └── pgn-parsing.test.js # PGN parsing logic tests
│ └── AnalysisBoard.jsx
└── test/
└── setup.js # Test environment configurationRunning Tests
# Run all tests once
npm run test:run
# Run tests in watch mode (development)
npm test
# Run specific test by name
npm run test:run -- -t "loads PGN via UI"
# Run tests with UI (requires @vitest/ui) - Spins up a UI accessible at localhost:51204 for investigating failed unit tests
npm run test:ui
# Run tests with coverage // NOT CURRENTLY SET UP
npm run test:run -- --coverageTest Categories
1. Component Rendering Tests
- Basic component rendering with default props
- Conditional rendering based on props (
enablePgnBox,enableFenInput) - Container mode variations (
standalonevsembedded)
2. PGN Functionality Tests
- Loading PGN from props (
startingPgn) - PGN parsing with variations and comments
- PGN generation and callback notifications
- Error handling for invalid PGN
3. FEN Support Tests
- Custom starting positions via
startingFenprop - FEN validation and error reporting
- FEN header conflicts with PGN
4. User Interaction Tests
- PGN loading via UI (textarea input)
- FEN input toggle (Shift+F keyboard shortcut)
- Move navigation and selection
- Settings panel interactions
5. Integration Tests
- PGN with complex variations and sub-variations
- Comments and move annotations
- Error boundary and validation
Test Configuration
Vitest Configuration (vitest.config.js)
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // Browser-like environment
setupFiles: ['./src/test/setup.js'],
globals: true, // Global test functions (describe, it, expect)
},
})Test Setup (src/test/setup.js)
import '@testing-library/jest-dom'
// Mock ResizeObserver for component tests
global.ResizeObserver = class ResizeObserver {
constructor(callback) { this.callback = callback }
observe() {}
unobserve() {}
disconnect() {}
}
// Mock getBoundingClientRect for layout tests
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: () => ({ width: 500, height: 500, top: 0, left: 0, bottom: 500, right: 500 }),
writable: true,
})
// Mock navigator.clipboard for user interactions
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: () => Promise.resolve(), readText: () => Promise.resolve('') },
writable: true,
configurable: true,
})Testing Best Practices
1. User-Centric Testing
// ✅ Good: Test from user's perspective
const textarea = screen.getByRole('textbox', { name: /pgn/i })
await user.type(textarea, '1. e4 e5 *')
// ❌ Avoid: Testing implementation details
const pgnInput = component.state.pgnInput2. Async Testing
// ✅ Good: Wait for async operations
await waitFor(() => {
expect(screen.getByText('e4')).toBeInTheDocument()
})
// ✅ Good: Use proper async/await
it('loads PGN via UI', async () => {
const user = userEvent.setup()
// ... test code
})3. Mocking External Dependencies
// Mock react-chessboard for faster, more reliable tests
vi.mock('react-chessboard', () => ({
Chessboard: ({ position, onPieceDrop }) => (
<div data-testid="chessboard" data-position={position}>
Mock Chessboard
</div>
),
}))4. Testing Error States
it('reports error for invalid PGN', async () => {
const mockOnError = vi.fn()
render(<AnalysisBoard startingPgn="invalid pgn" onError={mockOnError} />)
await waitFor(() => {
expect(mockOnError).toHaveBeenCalledWith(
expect.objectContaining({ type: 'pgn_parse_error' })
)
})
})Common Testing Patterns
Testing Keyboard Shortcuts
// For complex keyboard combinations, use fireEvent
fireEvent.keyDown(document, { key: 'F', shiftKey: true })
// For simple typing, use userEvent
await user.keyboard('{Shift>}F{/Shift}')Testing Multiple Elements
// When expecting multiple instances of the same text
const e3Moves = within(movesList).getAllByText('e3')
expect(e3Moves).toHaveLength(2) // Verify exactly 2 instancesTesting Complex User Flows
it('loads PGN with variations via UI', async () => {
const user = userEvent.setup()
render(<AnalysisBoard />)
// 1. Get UI elements
const textarea = document.querySelector('.pgn-textarea')
const loadButton = document.querySelector('.load-pgn-button')
// 2. Simulate user input
fireEvent.change(textarea, {
target: { value: '1. d4 d5 2. Nc3 Nf6 3. Bf4 { comment } e6 *' }
})
// 3. Trigger action
await user.click(loadButton)
// 4. Verify results
await waitFor(() => {
const movesList = document.querySelector('.moves-list')
expect(within(movesList).getByText('d4')).toBeInTheDocument()
expect(within(movesList).getByText('comment')).toBeInTheDocument()
})
})Test Coverage
The test suite covers:
- ✅ Component rendering and props
- ✅ PGN parsing and generation
- ✅ FEN validation and conflicts
- ✅ User interactions (keyboard, mouse)
- ✅ Error handling and edge cases
- ✅ Settings and customization
- ✅ Container modes and responsive behavior
Debugging Tests
Visual Test Output
# Use screen.debug() in tests to see DOM structure
screen.debug() // Prints current DOM stateTest UI for Interactive Debugging
# Install and use Vitest UI for visual test debugging
npm install --save-dev @vitest/ui
npm run test:uiRunning Specific Tests
# Run tests matching a pattern
npm run test:run -- -t "UI Interactions"
# Run tests in a specific file
npm run test:run -- AnalysisBoard.test.jsx
# Run tests with verbose output
npm run test:run -- --reporter=verboseContributing
- Fork the repository
- Create a feature branch
- Make your changes
- Add tests for new functionality
- Ensure all tests pass:
npm run test:run - Submit a pull request
License
MIT License - see LICENSE file for details.
