cm-chessboard
Version:
A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.
408 lines (383 loc) • 16.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, PIECE} from "../../Chessboard.js"
import {Svg} from "../../lib/Svg.js"
import {Utils} from "../../lib/Utils.js"
const DISPLAY_STATE = {
hidden: "hidden",
displayRequested: "displayRequested",
shown: "shown"
}
const translations = {
de: {
choosePromotion: "Bauernumwandlung wählen",
promotionDialogTitle: "Bauernumwandlung",
pieces: {q: "Dame", r: "Turm", b: "Läufer", n: "Springer"},
promoteTo: "Umwandeln in"
},
en: {
choosePromotion: "Choose promotion piece",
promotionDialogTitle: "Pawn promotion",
pieces: {q: "Queen", r: "Rook", b: "Bishop", n: "Knight"},
promoteTo: "Promote to"
}
}
export const PROMOTION_DIALOG_RESULT_TYPE = {
pieceSelected: "pieceSelected",
canceled: "canceled"
}
export class PromotionDialog extends Extension {
/** @constructor */
constructor(chessboard, props = {}) {
super(chessboard)
this.props = {
language: navigator.language.substring(0, 2).toLowerCase()
}
Object.assign(this.props, props)
if (this.props.language !== "de" && this.props.language !== "en") {
this.props.language = "en"
}
this.t = translations[this.props.language]
this.pieceOrder = ["q", "r", "b", "n"]
this.focusedIndex = 0
this.previouslyFocusedElement = null
this.registerExtensionPoint(EXTENSION_POINT.afterRedrawBoard, this.extensionPointRedrawBoard.bind(this))
this.registerExtensionPoint(EXTENSION_POINT.destroy, this.destroy.bind(this))
chessboard.showPromotionDialog = this.showPromotionDialog.bind(this)
chessboard.isPromotionDialogShown = this.isPromotionDialogShown.bind(this)
this.promotionDialogGroup = Svg.addElement(chessboard.view.interactiveTopLayer, "g", {
class: "promotion-dialog-group",
role: "dialog",
"aria-modal": "true",
"aria-label": this.t.choosePromotion
})
// Create live region for announcements
this.liveRegion = document.createElement("div")
this.liveRegion.setAttribute("aria-live", "polite")
this.liveRegion.setAttribute("aria-atomic", "true")
this.liveRegion.className = "cm-chessboard-promotion-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;"
chessboard.context.appendChild(this.liveRegion)
this.state = {
displayState: DISPLAY_STATE.hidden,
callback: null,
dialogParams: {
square: null,
color: null
}
}
// Bind keyboard handler
this.handleKeyDown = this.handleKeyDown.bind(this)
}
// public (chessboard.showPromotionDialog)
showPromotionDialog(square, color, callback) {
this.previouslyFocusedElement = document.activeElement
this.focusedIndex = 0
this.state.dialogParams.square = square
this.state.dialogParams.color = color
this.state.callback = callback
this.setDisplayState(DISPLAY_STATE.displayRequested)
this.showTimeoutId = setTimeout(() => {
this.showTimeoutId = null
if (!this.chessboard.view) return // destroyed before timeout fired
this.chessboard.view.positionsAnimationTask.then(() => {
if (this.state.displayState !== DISPLAY_STATE.displayRequested) return
this.setDisplayState(DISPLAY_STATE.shown)
this.announce(this.t.choosePromotion + ": " +
this.pieceOrder.map(p => this.t.pieces[p]).join(", "))
})
})
}
// public (chessboard.isPromotionDialogShown)
isPromotionDialogShown() {
return this.state.displayState === DISPLAY_STATE.shown ||
this.state.displayState === DISPLAY_STATE.displayRequested
}
// private
extensionPointRedrawBoard() {
this.redrawDialog()
}
drawPieceButton(piece, point, index) {
const squareWidth = this.chessboard.view.squareWidth
const squareHeight = this.chessboard.view.squareHeight
const pieceType = piece.charAt(1)
const pieceName = this.t.pieces[pieceType]
const buttonGroup = Svg.addElement(this.promotionDialogGroup, "g", {
class: "promotion-dialog-button-group",
role: "button",
tabindex: index === 0 ? "0" : "-1",
"aria-label": pieceName,
"data-piece": piece,
"data-index": index
})
Svg.addElement(buttonGroup,
"rect", {
x: point.x, y: point.y, width: squareWidth, height: squareHeight,
class: "promotion-dialog-button",
"data-piece": piece
})
this.chessboard.view.drawPiece(buttonGroup, piece, point)
}
redrawDialog() {
while (this.promotionDialogGroup.firstChild) {
this.promotionDialogGroup.removeChild(this.promotionDialogGroup.firstChild)
}
if (this.state.displayState === DISPLAY_STATE.shown) {
const squareWidth = this.chessboard.view.squareWidth
const squareHeight = this.chessboard.view.squareHeight
const squareCenterPoint = this.chessboard.view.squareToPoint(this.state.dialogParams.square)
squareCenterPoint.x = squareCenterPoint.x + squareWidth / 2
squareCenterPoint.y = squareCenterPoint.y + squareHeight / 2
this.turned = false
const rank = parseInt(this.state.dialogParams.square.charAt(1), 10)
if (this.chessboard.getOrientation() === COLOR.white && rank < 5 ||
this.chessboard.getOrientation() === COLOR.black && rank >= 5) {
this.turned = true
}
const turned = this.turned
const offsetY = turned ? -4 * squareHeight : 0
const offsetX = squareCenterPoint.x + squareWidth > this.chessboard.view.width ? -squareWidth : 0
Svg.addElement(this.promotionDialogGroup,
"rect", {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y + offsetY,
width: squareWidth,
height: squareHeight * 4,
class: "promotion-dialog"
})
const dialogParams = this.state.dialogParams
if (turned) {
this.drawPieceButton(PIECE[dialogParams.color + "q"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y - squareHeight
}, 0)
this.drawPieceButton(PIECE[dialogParams.color + "r"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y - squareHeight * 2
}, 1)
this.drawPieceButton(PIECE[dialogParams.color + "b"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y - squareHeight * 3
}, 2)
this.drawPieceButton(PIECE[dialogParams.color + "n"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y - squareHeight * 4
}, 3)
} else {
this.drawPieceButton(PIECE[dialogParams.color + "q"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y
}, 0)
this.drawPieceButton(PIECE[dialogParams.color + "r"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y + squareHeight
}, 1)
this.drawPieceButton(PIECE[dialogParams.color + "b"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y + squareHeight * 2
}, 2)
this.drawPieceButton(PIECE[dialogParams.color + "n"], {
x: squareCenterPoint.x + offsetX,
y: squareCenterPoint.y + squareHeight * 3
}, 3)
}
}
}
promotionDialogOnClickPiece(event) {
if (event.button !== 2) {
// Find piece data from target or parent button group
let piece = event.target.dataset.piece
if (!piece && event.target.closest) {
const buttonGroup = event.target.closest(".promotion-dialog-button-group")
if (buttonGroup) {
piece = buttonGroup.dataset.piece
}
}
if (piece) {
this.selectPiece(piece)
} else {
this.promotionDialogOnCancel(event)
}
}
}
selectPiece(piece) {
if (this.state.callback) {
this.state.callback({
type: PROMOTION_DIALOG_RESULT_TYPE.pieceSelected,
square: this.state.dialogParams.square,
piece: piece
})
}
this.setDisplayState(DISPLAY_STATE.hidden)
}
promotionDialogOnCancel(event) {
if (this.state.displayState === DISPLAY_STATE.shown) {
event.preventDefault()
this.setDisplayState(DISPLAY_STATE.hidden)
if(this.state.callback) {
this.state.callback({type: PROMOTION_DIALOG_RESULT_TYPE.canceled})
}
}
}
contextMenu(event) {
event.preventDefault()
this.setDisplayState(DISPLAY_STATE.hidden)
if(this.state.callback) {
this.state.callback({type: PROMOTION_DIALOG_RESULT_TYPE.canceled})
}
}
setDisplayState(displayState) {
const prevState = this.state.displayState
this.state.displayState = displayState
if (displayState === DISPLAY_STATE.shown) {
this.clickDelegate = Utils.delegate(this.chessboard.view.svg,
"pointerdown",
"*",
this.promotionDialogOnClickPiece.bind(this))
this.contextMenuListener = this.contextMenu.bind(this)
this.chessboard.view.svg.addEventListener("contextmenu", this.contextMenuListener)
// Add keyboard listener
document.addEventListener("keydown", this.handleKeyDown)
} else if (displayState === DISPLAY_STATE.hidden) {
if (this.clickDelegate) {
this.clickDelegate.remove()
this.clickDelegate = null
}
if (this.contextMenuListener && this.chessboard.view) {
this.chessboard.view.svg.removeEventListener("contextmenu", this.contextMenuListener)
this.contextMenuListener = null
}
// Remove keyboard listener
document.removeEventListener("keydown", this.handleKeyDown)
// Restore focus (only if the dialog was actually shown before)
if (prevState === DISPLAY_STATE.shown &&
this.previouslyFocusedElement && this.previouslyFocusedElement.focus) {
this.previouslyFocusedElement.focus()
}
}
this.redrawDialog()
// Focus first button after redraw when shown
if (displayState === DISPLAY_STATE.shown) {
this.focusTimeoutId = setTimeout(() => {
this.focusTimeoutId = null
this.focusButton(0)
}, 0)
}
}
handleKeyDown(event) {
if (this.state.displayState !== DISPLAY_STATE.shown) {
return
}
switch (event.key) {
case "ArrowDown":
event.preventDefault()
if (this.turned) {
this.focusedIndex = (this.focusedIndex - 1 + 4) % 4
} else {
this.focusedIndex = (this.focusedIndex + 1) % 4
}
this.focusButton(this.focusedIndex)
break
case "ArrowRight":
event.preventDefault()
this.focusedIndex = (this.focusedIndex + 1) % 4
this.focusButton(this.focusedIndex)
break
case "ArrowUp":
event.preventDefault()
if (this.turned) {
this.focusedIndex = (this.focusedIndex + 1) % 4
} else {
this.focusedIndex = (this.focusedIndex - 1 + 4) % 4
}
this.focusButton(this.focusedIndex)
break
case "ArrowLeft":
event.preventDefault()
this.focusedIndex = (this.focusedIndex - 1 + 4) % 4
this.focusButton(this.focusedIndex)
break
case "Enter":
case " ":
event.preventDefault()
const buttons = this.promotionDialogGroup.querySelectorAll(".promotion-dialog-button-group")
if (buttons[this.focusedIndex]) {
const piece = buttons[this.focusedIndex].dataset.piece
this.selectPiece(piece)
}
break
case "Escape":
event.preventDefault()
this.setDisplayState(DISPLAY_STATE.hidden)
if (this.state.callback) {
this.state.callback({type: PROMOTION_DIALOG_RESULT_TYPE.canceled})
}
break
case "Tab":
// Trap focus within dialog
event.preventDefault()
if (event.shiftKey) {
this.focusedIndex = (this.focusedIndex - 1 + 4) % 4
} else {
this.focusedIndex = (this.focusedIndex + 1) % 4
}
this.focusButton(this.focusedIndex)
break
}
}
focusButton(index) {
const buttons = this.promotionDialogGroup.querySelectorAll(".promotion-dialog-button-group")
buttons.forEach((btn, i) => {
btn.setAttribute("tabindex", i === index ? "0" : "-1")
})
if (buttons[index]) {
buttons[index].focus()
const pieceType = this.pieceOrder[index]
this.announce(this.t.pieces[pieceType])
}
}
announce(message) {
if (!this.liveRegion) return
this.liveRegion.textContent = ""
if (this.announceTimeoutId) {
clearTimeout(this.announceTimeoutId)
}
// Small delay to ensure screen readers pick up the change
this.announceTimeoutId = setTimeout(() => {
this.announceTimeoutId = null
if (this.liveRegion) {
this.liveRegion.textContent = message
}
}, 50)
}
destroy() {
// Close the dialog first so its listeners are removed
if (this.state.displayState === DISPLAY_STATE.shown) {
this.setDisplayState(DISPLAY_STATE.hidden)
}
// Cancel any pending timeouts so callbacks don't fire on a destroyed board
if (this.showTimeoutId) {
clearTimeout(this.showTimeoutId)
this.showTimeoutId = null
}
if (this.focusTimeoutId) {
clearTimeout(this.focusTimeoutId)
this.focusTimeoutId = null
}
if (this.announceTimeoutId) {
clearTimeout(this.announceTimeoutId)
this.announceTimeoutId = null
}
document.removeEventListener("keydown", this.handleKeyDown)
if (this.liveRegion && this.liveRegion.parentNode) {
this.liveRegion.parentNode.removeChild(this.liveRegion)
this.liveRegion = null
}
delete this.chessboard.showPromotionDialog
delete this.chessboard.isPromotionDialogShown
}
}