cm-chessboard
Version:
A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.
583 lines (538 loc) • 23.2 kB
JavaScript
/**
* Author and copyright: Stefan Haack (https://shaack.com)
* Repository: https://github.com/shaack/cm-chessboard
* License: MIT, see file 'LICENSE'
*/
import {Extension, EXTENSION_POINT} from "../../model/Extension.js"
import {COLOR, INPUT_EVENT_TYPE} from "../../Chessboard.js"
import {piecesTranslations, renderPieceTitle} from "./I18n.js"
import {Utils} from "../../lib/Utils.js"
import {Svg} from "../../lib/Svg.js"
const translations = {
de: {
chessboard: "Schachbrett",
pieces_lists: "Figurenlisten",
board_as_table: "Schachbrett als Tabelle",
move_piece: "Figur bewegen",
from: "Zug von",
to: "Zug nach",
move: "Zug ausführen",
input_white_enabled: "Eingabe Weiß aktiviert",
input_black_enabled: "Eingabe Schwarz aktiviert",
input_disabled: "Eingabe deaktiviert",
pieces: "Figuren",
empty_square: "leer",
move_from: "Zug von",
move_to: "Zug nach",
move_canceled: "Zug abgebrochen"
},
en: {
chessboard: "Chessboard",
pieces_lists: "Pieces lists",
board_as_table: "Chessboard as table",
move_piece: "Move piece",
from: "Move from",
to: "Move to",
move: "Make move",
input_white_enabled: "Input white enabled",
input_black_enabled: "Input black enabled",
input_disabled: "Input disabled",
pieces: "Pieces",
empty_square: "empty",
move_from: "Move from",
move_to: "Move to",
move_canceled: "Move canceled"
}
}
export class Accessibility extends Extension {
constructor(chessboard, props) {
super(chessboard)
this.props = {
language: navigator.language.substring(0, 2).toLowerCase(), // supports "de" and "en" for now, used for pieces naming
brailleNotationInAlt: true, // show the braille notation of the position in the alt attribute of the SVG image
movePieceForm: true, // display a form to move a piece (from, to, move)
boardAsTable: true, // display the board additionally as HTML table
piecesAsList: true, // display the pieces additionally as List
keyboardMoveInput: true, // enable keyboard navigation on the board with arrow keys
visuallyHidden: true // hide all those extra outputs visually but keep them accessible for screen readers and braille displays
}
Object.assign(this.props, props)
if (this.props.language !== "de" && this.props.language !== "en") {
this.props.language = "en"
}
this.lang = this.props.language
this.tPieces = piecesTranslations[this.lang]
this.t = translations[this.lang]
this.components = []
if(this.props.movePieceForm || this.props.boardAsTable || this.props.piecesAsList) {
const container = document.createElement("div")
container.classList.add("cm-chessboard-accessibility")
this.chessboard.context.appendChild(container)
if(this.props.visuallyHidden) {
container.classList.add("visually-hidden")
}
if (this.props.movePieceForm) {
this.components.push(new MovePieceForm(container, this))
}
if (this.props.boardAsTable) {
this.components.push(new BoardAsTable(container, this))
}
if (this.props.piecesAsList) {
this.components.push(new PiecesAsList(container, this))
}
}
if (this.props.brailleNotationInAlt) {
this.components.push(new BrailleNotationInAlt(this))
}
if (this.props.keyboardMoveInput) {
this.components.push(new KeyboardMoveInput(this))
}
}
}
class BrailleNotationInAlt {
constructor(extension) {
this.extension = extension
extension.registerExtensionPoint(EXTENSION_POINT.positionChanged, () => {
this.redraw()
})
}
redraw() {
const pieces = this.extension.chessboard.state.position.getPieces()
let listW = piecesTranslations[this.extension.lang].colors.w.toUpperCase() + ":"
let listB = piecesTranslations[this.extension.lang].colors.b.toUpperCase() + ":"
for (const piece of pieces) {
const pieceName = piece.type === "p" ? "" : piecesTranslations[this.extension.lang].pieces[piece.type].toUpperCase()
if (piece.color === "w") {
listW += " " + pieceName + piece.position
} else {
listB += " " + pieceName + piece.position
}
}
const altText = `${listW}
${listB}`
this.extension.chessboard.view.svg.setAttribute("alt", altText)
}
}
class MovePieceForm {
constructor(container, extension) {
this.chessboard = extension.chessboard
this.movePieceFormContainer = Utils.createDomElement(`
<div>
<h3 id="hl_form_${this.chessboard.id}">${extension.t.move_piece}</h3>
<form aria-labelledby="hl_form_${this.chessboard.id}">
<label for="move_piece_input_from_${this.chessboard.id}">${extension.t.from}</label>
<input class="input-from" type="text" size="2" id="move_piece_input_from_${this.chessboard.id}"/>
<label for="move_piece_input_to_${this.chessboard.id}">${extension.t.to}</label>
<input class="input-to" type="text" size="2" id="move_piece_input_to_${this.chessboard.id}"/>
<button type="submit" class="button-move">${extension.t.move}</button>
</form>
</div>`)
this.form = this.movePieceFormContainer.querySelector("form")
this.inputFrom = this.form.querySelector(".input-from")
this.inputTo = this.form.querySelector(".input-to")
this.moveButton = this.form.querySelector(".button-move")
this.form.addEventListener("submit", (evt) => {
evt.preventDefault()
if (this.chessboard.state.moveInputCallback({
chessboard: this.chessboard,
type: INPUT_EVENT_TYPE.validateMoveInput,
squareFrom: this.inputFrom.value,
squareTo: this.inputTo.value
})) {
this.chessboard.movePiece(this.inputFrom.value, this.inputTo.value,
true).then(() => {
this.inputFrom.value = ""
this.inputTo.value = ""
this.updateButtonState()
})
}
})
container.appendChild(this.movePieceFormContainer)
extension.registerExtensionPoint(EXTENSION_POINT.moveInputToggled, () => {
this.redraw()
})
// Update button state when input values change
this.inputFrom.addEventListener("input", () => {
this.updateButtonState()
})
this.inputTo.addEventListener("input", () => {
this.updateButtonState()
})
this.keydownListener = (event) => {
if (event.shiftKey && event.altKey && event.code === 'KeyE') {
event.preventDefault()
this.inputFrom.focus()
}
}
document.addEventListener("keydown", this.keydownListener)
extension.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
document.removeEventListener("keydown", this.keydownListener)
})
// Initial button state
this.updateButtonState()
}
isValidSquare(value) {
const square = value.trim().toLowerCase()
if (square.length !== 2) {
return false
}
const file = square.charAt(0)
const rank = square.charAt(1)
return file >= 'a' && file <= 'h' && rank >= '1' && rank <= '8'
}
updateButtonState() {
const inputEnabled = this.chessboard.state.inputWhiteEnabled || this.chessboard.state.inputBlackEnabled
const isValidFrom = this.isValidSquare(this.inputFrom.value)
const isValidTo = this.isValidSquare(this.inputTo.value)
this.moveButton.disabled = !inputEnabled || !isValidFrom || !isValidTo
}
redraw() {
if (this.inputFrom) {
if (this.chessboard.state.inputWhiteEnabled || this.chessboard.state.inputBlackEnabled) {
this.inputFrom.disabled = false
this.inputTo.disabled = false
} else {
this.inputFrom.disabled = true
this.inputTo.disabled = true
}
this.updateButtonState()
}
}
}
class BoardAsTable {
constructor(container, extension) {
this.extension = extension
this.chessboard = extension.chessboard
this.boardAsTableContainer = Utils.createDomElement(`<div><h3 id="hl_table_${this.chessboard.id}">${extension.t.board_as_table}</h3><div class="table"></div></div>`)
this.boardAsTable = this.boardAsTableContainer.querySelector(".table")
container.appendChild(this.boardAsTableContainer)
extension.registerExtensionPoint(EXTENSION_POINT.positionChanged, () => {
this.redraw()
})
extension.registerExtensionPoint(EXTENSION_POINT.boardChanged, () => {
this.redraw()
})
}
redraw() {
const squares = this.chessboard.state.position.squares.slice()
const ranks = ["a", "b", "c", "d", "e", "f", "g", "h"]
const files = ["8", "7", "6", "5", "4", "3", "2", "1"]
if (this.chessboard.state.orientation === COLOR.black) {
ranks.reverse()
files.reverse()
squares.reverse()
}
let html = `<table aria-labelledby="hl_table_${this.chessboard.id}"><tr><th></th>`
for (const rank of ranks) {
html += `<th scope='col'>${rank}</th>`
}
html += "</tr>"
for (let x = 7; x >= 0; x--) {
html += `<tr><th scope="row">${files[7 - x]}</th>`
for (let y = 0; y < 8; y++) {
const pieceCode = squares[y % 8 + x * 8]
let color, name
if (pieceCode) {
color = pieceCode.charAt(0)
name = pieceCode.charAt(1)
html += `<td>${renderPieceTitle(this.extension.lang, name, color)}</td>`
} else {
html += `<td></td>`
}
}
html += "</tr>"
}
html += "</table>"
this.boardAsTable.innerHTML = html
}
}
class PiecesAsList {
constructor(container, extension) {
this.extension = extension
this.chessboard = extension.chessboard
this.piecesListContainer = Utils.createDomElement(`<div><h3 id="hl_lists_${this.chessboard.id}">${extension.t.pieces_lists}</h3><div class="list"></div></div>`)
this.piecesList = this.piecesListContainer.querySelector(".list")
container.appendChild(this.piecesListContainer)
extension.registerExtensionPoint(EXTENSION_POINT.positionChanged, () => {
this.redraw()
})
}
redraw() {
const pieces = this.chessboard.state.position.getPieces()
let listW = ""
let listB = ""
for (const piece of pieces) {
if (piece.color === "w") {
listW += `<li class="list-inline-item">${renderPieceTitle(this.extension.lang, piece.type)} ${piece.position}</li>`
} else {
listB += `<li class="list-inline-item">${renderPieceTitle(this.extension.lang, piece.type)} ${piece.position}</li>`
}
}
this.piecesList.innerHTML = `
<h4 id="white_${this.chessboard.id}">${this.extension.t.pieces} ${this.extension.tPieces.colors_long.w}</h4>
<ul aria-labelledby="white_${this.chessboard.id}" class="list-inline">${listW}</ul>
<h4 id="black_${this.chessboard.id}">${this.extension.t.pieces} ${this.extension.tPieces.colors_long.b}</h4>
<ul aria-labelledby="black_${this.chessboard.id}" class="list-inline">${listB}</ul>`
}
}
class KeyboardMoveInput {
constructor(extension) {
this.extension = extension
this.chessboard = extension.chessboard
this.t = extension.t
this.tPieces = extension.tPieces
// Current focus position (file 0-7, rank 0-7)
this.focusedFile = 0 // a-h
this.focusedRank = 0 // 1-8 (0 = rank 1)
// Move selection state
this.fromSquare = null
// Create focus indicator group in SVG
this.focusIndicatorGroup = Svg.addElement(
this.chessboard.view.markersTopLayer,
"g",
{class: "keyboard-focus-indicator"}
)
// Create live region for screen reader announcements
this.liveRegion = document.createElement("div")
this.liveRegion.setAttribute("aria-live", "polite")
this.liveRegion.setAttribute("aria-atomic", "true")
this.liveRegion.className = "cm-chessboard-keyboard-live-region visually-hidden"
this.liveRegion.style.cssText = "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;"
this.chessboard.context.appendChild(this.liveRegion)
// Make SVG focusable
this.chessboard.view.svg.setAttribute("tabindex", "0")
this.chessboard.view.svg.setAttribute("role", "application")
this.chessboard.view.svg.setAttribute("aria-label", this.t.chessboard)
// Bind event handlers
this.handleKeyDown = this.handleKeyDown.bind(this)
this.handleFocus = this.handleFocus.bind(this)
this.handleBlur = this.handleBlur.bind(this)
this.chessboard.view.svg.addEventListener("keydown", this.handleKeyDown)
this.chessboard.view.svg.addEventListener("focus", this.handleFocus)
this.chessboard.view.svg.addEventListener("blur", this.handleBlur)
// Register extension points
extension.registerExtensionPoint(EXTENSION_POINT.afterRedrawBoard, () => {
this.redrawFocusIndicator()
})
extension.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
this.destroy()
})
extension.registerExtensionPoint(EXTENSION_POINT.moveInputToggled, () => {
// Reset move selection when input is toggled
this.fromSquare = null
this.redrawFocusIndicator()
})
}
handleFocus() {
this.redrawFocusIndicator()
this.announceCurrentSquare()
}
handleBlur() {
this.clearFocusIndicator()
}
handleKeyDown(event) {
const orientation = this.chessboard.state.orientation
switch (event.key) {
case "ArrowRight":
event.preventDefault()
if (orientation === COLOR.white) {
this.focusedFile = Math.min(7, this.focusedFile + 1)
} else {
this.focusedFile = Math.max(0, this.focusedFile - 1)
}
this.redrawFocusIndicator()
this.announceCurrentSquare()
break
case "ArrowLeft":
event.preventDefault()
if (orientation === COLOR.white) {
this.focusedFile = Math.max(0, this.focusedFile - 1)
} else {
this.focusedFile = Math.min(7, this.focusedFile + 1)
}
this.redrawFocusIndicator()
this.announceCurrentSquare()
break
case "ArrowUp":
event.preventDefault()
if (orientation === COLOR.white) {
this.focusedRank = Math.min(7, this.focusedRank + 1)
} else {
this.focusedRank = Math.max(0, this.focusedRank - 1)
}
this.redrawFocusIndicator()
this.announceCurrentSquare()
break
case "ArrowDown":
event.preventDefault()
if (orientation === COLOR.white) {
this.focusedRank = Math.max(0, this.focusedRank - 1)
} else {
this.focusedRank = Math.min(7, this.focusedRank + 1)
}
this.redrawFocusIndicator()
this.announceCurrentSquare()
break
case "Enter":
case " ":
event.preventDefault()
this.selectSquare()
break
case "Escape":
event.preventDefault()
if (this.fromSquare) {
this.fromSquare = null
this.redrawFocusIndicator()
this.announce(this.t.move_canceled)
}
break
}
}
selectSquare() {
if (!this.chessboard.state.inputWhiteEnabled && !this.chessboard.state.inputBlackEnabled) {
return // Input not enabled
}
const square = this.getCurrentSquare()
const piece = this.chessboard.getPiece(square)
if (!this.fromSquare) {
// Selecting "from" square
if (piece) {
const pieceColor = piece.charAt(0)
// Check if the piece color matches enabled input
if ((pieceColor === "w" && this.chessboard.state.inputWhiteEnabled) ||
(pieceColor === "b" && this.chessboard.state.inputBlackEnabled)) {
// Trigger moveInputStarted callback
const startResult = this.chessboard.state.moveInputCallback({
chessboard: this.chessboard,
type: INPUT_EVENT_TYPE.moveInputStarted,
square: square,
squareFrom: square,
piece: piece
})
if (startResult) {
this.fromSquare = square
this.redrawFocusIndicator()
this.announce(this.t.move_from + " " + square)
}
}
}
} else {
// Selecting "to" square
if (square === this.fromSquare) {
// Clicking same square cancels
this.fromSquare = null
this.redrawFocusIndicator()
this.announce(this.t.move_canceled)
} else {
// Try to make the move
const fromPiece = this.chessboard.getPiece(this.fromSquare)
const result = this.chessboard.state.moveInputCallback({
chessboard: this.chessboard,
type: INPUT_EVENT_TYPE.validateMoveInput,
squareFrom: this.fromSquare,
squareTo: square,
piece: fromPiece
})
if (result) {
const fromSquare = this.fromSquare
this.fromSquare = null
this.chessboard.movePiece(fromSquare, square, true).then(() => {
this.redrawFocusIndicator()
})
this.announce(this.t.move_to + " " + square)
} else {
// Invalid move - check if clicking on own piece to start new move
if (piece) {
const pieceColor = piece.charAt(0)
if ((pieceColor === "w" && this.chessboard.state.inputWhiteEnabled) ||
(pieceColor === "b" && this.chessboard.state.inputBlackEnabled)) {
const startResult = this.chessboard.state.moveInputCallback({
chessboard: this.chessboard,
type: INPUT_EVENT_TYPE.moveInputStarted,
square: square,
squareFrom: square,
piece: piece
})
if (startResult) {
this.fromSquare = square
this.redrawFocusIndicator()
this.announce(this.t.move_from + " " + square)
}
}
}
}
}
}
}
getCurrentSquare() {
const file = String.fromCharCode(97 + this.focusedFile) // 'a' + file
const rank = this.focusedRank + 1
return `${file}${rank}`
}
announceCurrentSquare() {
const square = this.getCurrentSquare()
const piece = this.chessboard.getPiece(square)
let announcement = square
if (piece) {
const pieceType = piece.charAt(1)
const pieceColor = piece.charAt(0)
announcement += " " + renderPieceTitle(this.extension.lang, pieceType, pieceColor)
} else {
announcement += " " + this.t.empty_square
}
if (this.fromSquare) {
announcement += " (" + this.t.move_from + " " + this.fromSquare + ")"
}
this.announce(announcement)
}
announce(message) {
this.liveRegion.textContent = ""
setTimeout(() => {
this.liveRegion.textContent = message
}, 50)
}
redrawFocusIndicator() {
this.clearFocusIndicator()
// Only show if SVG is focused
if (document.activeElement !== this.chessboard.view.svg) {
return
}
const square = this.getCurrentSquare()
const point = this.chessboard.view.squareToPoint(square)
const squareWidth = this.chessboard.view.squareWidth
const squareHeight = this.chessboard.view.squareHeight
// Draw focus indicator
Svg.addElement(this.focusIndicatorGroup, "rect", {
x: point.x,
y: point.y,
width: squareWidth,
height: squareHeight,
class: "keyboard-focus"
})
// If we have a from square selected, also highlight it
if (this.fromSquare) {
const fromPoint = this.chessboard.view.squareToPoint(this.fromSquare)
Svg.addElement(this.focusIndicatorGroup, "rect", {
x: fromPoint.x,
y: fromPoint.y,
width: squareWidth,
height: squareHeight,
class: "keyboard-from-square"
})
}
}
clearFocusIndicator() {
while (this.focusIndicatorGroup.firstChild) {
this.focusIndicatorGroup.removeChild(this.focusIndicatorGroup.firstChild)
}
}
destroy() {
this.chessboard.view.svg.removeEventListener("keydown", this.handleKeyDown)
this.chessboard.view.svg.removeEventListener("focus", this.handleFocus)
this.chessboard.view.svg.removeEventListener("blur", this.handleBlur)
if (this.liveRegion && this.liveRegion.parentNode) {
this.liveRegion.parentNode.removeChild(this.liveRegion)
}
this.clearFocusIndicator()
}
}