cm-chessboard 8.11.4 → 8.12.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.
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(tree:*)"
4
+ "Bash(tree:*)",
5
+ "Bash(gh pr:*)"
5
6
  ],
6
7
  "deny": [],
7
8
  "ask": []
@@ -32,6 +32,12 @@
32
32
  white-space: nowrap;
33
33
  border: 0; }
34
34
 
35
+ .cm-chessboard-accessibility button:disabled,
36
+ .cm-chessboard-accessibility button:disabled:hover {
37
+ opacity: 0.5;
38
+ cursor: not-allowed;
39
+ pointer-events: none; }
40
+
35
41
  .cm-chessboard.default .board .square.white {
36
42
  fill: #ecdab9; }
37
43
 
@@ -291,4 +297,22 @@
291
297
  .cm-chessboard.black-and-white .coordinates .coordinate.white {
292
298
  fill: #909090; }
293
299
 
300
+ /* Keyboard focus indicator for accessibility */
301
+ .cm-chessboard .keyboard-focus-indicator .keyboard-focus {
302
+ fill: none;
303
+ stroke: #0066cc;
304
+ stroke-width: 3px;
305
+ pointer-events: none; }
306
+
307
+ .cm-chessboard .keyboard-focus-indicator .keyboard-from-square {
308
+ fill: rgba(0, 102, 204, 0.3);
309
+ stroke: #0066cc;
310
+ stroke-width: 2px;
311
+ pointer-events: none; }
312
+
313
+ /* High contrast focus indicator */
314
+ .cm-chessboard:focus {
315
+ outline: 2px solid #0066cc;
316
+ outline-offset: 2px; }
317
+
294
318
  /*# sourceMappingURL=chessboard.css.map */
@@ -1,30 +1,37 @@
1
1
  svg.cm-chessboard .promotion-dialog-group rect, svg.cm-chessboard .promotion-dialog-group g[data-piece] {
2
- animation: fade-in 0.25s ease-in; }
3
-
2
+ animation: fade-in 0.25s ease-in;
3
+ }
4
4
  svg.cm-chessboard .promotion-dialog-group .promotion-dialog {
5
5
  fill: white;
6
6
  fill-opacity: 0.7;
7
- stroke: rgba(0, 0, 0, 0.4); }
8
-
7
+ stroke: rgba(0, 0, 0, 0.4);
8
+ }
9
+ svg.cm-chessboard .promotion-dialog-group .promotion-dialog-button-group {
10
+ outline: none;
11
+ }
12
+ svg.cm-chessboard .promotion-dialog-group .promotion-dialog-button-group:focus .promotion-dialog-button {
13
+ stroke: rgba(0, 0, 0, 0.3);
14
+ stroke-width: 1px;
15
+ }
9
16
  svg.cm-chessboard .promotion-dialog-group .promotion-dialog-button {
10
17
  fill: transparent;
11
- cursor: pointer; }
12
- svg.cm-chessboard .promotion-dialog-group .promotion-dialog-button:hover {
13
- fill: rgba(0, 0, 0, 0.2); }
14
-
15
- svg.cm-chessboard .promotion-dialog-group .promotion-dialog-button-group {
16
- outline: none; }
17
- svg.cm-chessboard .promotion-dialog-group .promotion-dialog-button-group:focus .promotion-dialog-button {
18
- stroke: rgba(0, 0, 0, 0.3);
19
- stroke-width: 2px; }
20
-
18
+ cursor: pointer;
19
+ outline: none;
20
+ }
21
+ svg.cm-chessboard .promotion-dialog-group .promotion-dialog-button:hover {
22
+ fill: rgba(0, 0, 0, 0.2);
23
+ }
21
24
  svg.cm-chessboard .promotion-dialog-group .piece {
22
- pointer-events: none; }
25
+ pointer-events: none;
26
+ }
23
27
 
24
28
  @keyframes fade-in {
25
29
  0% {
26
- opacity: 0; }
30
+ opacity: 0;
31
+ }
27
32
  100% {
28
- opacity: 1; } }
33
+ opacity: 1;
34
+ }
35
+ }
29
36
 
30
- /*# sourceMappingURL=promotion-dialog.css.map */
37
+ /*# sourceMappingURL=promotion-dialog.css.map */
@@ -1,9 +1 @@
1
- {
2
- "version": 3,
3
- "file": "promotion-dialog.css",
4
- "sources": [
5
- "promotion-dialog.scss"
6
- ],
7
- "names": [],
8
- "mappings": "AAAA,AAGI,GAHD,AAAA,cAAc,CACf,uBAAuB,CAErB,IAAI,EAHR,GAAG,AAAA,cAAc,CACf,uBAAuB,CAEf,CAAC,CAAA,AAAA,UAAC,AAAA,EAAY;EAClB,SAAS,EAAE,qBAAqB,GACjC;;AALL,AAOI,GAPD,AAAA,cAAc,CACf,uBAAuB,CAMrB,iBAAiB,CAAC;EAChB,IAAI,EAAE,KAAK;EACX,YAAY,EAAE,GAAG;EACjB,MAAM,EAAE,kBAAkB,GAC3B;;AAXL,AAaI,GAbD,AAAA,cAAc,CACf,uBAAuB,CAYrB,wBAAwB,CAAC;EACvB,IAAI,EAAE,WAAW;EACjB,MAAM,EAAE,OAAO,GAKhB;EApBL,AAiBM,GAjBH,AAAA,cAAc,CACf,uBAAuB,CAYrB,wBAAwB,CAIpB,KAAK,CAAC;IACN,IAAI,EAAE,kBAAkB,GACzB;;AAnBP,AAsBI,GAtBD,AAAA,cAAc,CACf,uBAAuB,CAqBrB,MAAM,CAAC;EACL,cAAc,EAAE,IAAI,GACrB;;AAIL,UAAU,CAAV,OAAU;EACR,EAAE;IACA,OAAO,EAAE,CAAC;EAEZ,IAAI;IACF,OAAO,EAAE,CAAC"
9
- }
1
+ {"version":3,"sourceRoot":"","sources":["promotion-dialog.scss"],"names":[],"mappings":"AAGI;EACE;;AAGF;EACE;EACA;EACA;;AAGF;EACE;;AACA;EACE;EACA;;AAGJ;EACE;EACA;EAMA;;AAJA;EACE;;AAOJ;EACE;;;AAON;EACE;IACE;;EAEF;IACE","file":"promotion-dialog.css"}
@@ -11,6 +11,13 @@ svg.cm-chessboard {
11
11
  stroke: rgba(0, 0, 0, 0.4);
12
12
  }
13
13
 
14
+ .promotion-dialog-button-group {
15
+ outline: none;
16
+ &:focus .promotion-dialog-button {
17
+ stroke: rgba(0, 0, 0, 0.3);
18
+ stroke-width: 1px; }
19
+ }
20
+
14
21
  .promotion-dialog-button {
15
22
  fill: transparent;
16
23
  cursor: pointer;
@@ -18,6 +25,9 @@ svg.cm-chessboard {
18
25
  &:hover {
19
26
  fill: rgba(0, 0, 0, 0.2)
20
27
  }
28
+
29
+ outline: none;
30
+
21
31
  }
22
32
 
23
33
  .piece {
@@ -26,6 +36,8 @@ svg.cm-chessboard {
26
36
  }
27
37
  }
28
38
 
39
+
40
+
29
41
  @keyframes fade-in {
30
42
  0% {
31
43
  opacity: 0;
@@ -26,8 +26,16 @@
26
26
  <li>Display the chessboard additionally as HTML table</li>
27
27
  <li>Display a form to move a piece (from, to, move)</li>
28
28
  <li>Display the pieces additionally as List</li>
29
+ <li>Keyboard navigation on the board (arrow keys to navigate, Enter/Space to select, Escape to cancel)</li>
29
30
  <li>Display these features visually hidden or not</li>
30
31
  </ul>
32
+ <h3>Keyboard Navigation</h3>
33
+ <p>Click the board or press Tab to focus it, then use:</p>
34
+ <ul>
35
+ <li><strong>Arrow keys</strong> - Navigate between squares</li>
36
+ <li><strong>Enter / Space</strong> - Select a piece (first press) or target square (second press)</li>
37
+ <li><strong>Escape</strong> - Cancel the current move</li>
38
+ </ul>
31
39
  <h3>The chessboard</h3>
32
40
  <div class="board" id="board"></div>
33
41
  <button style="margin-bottom: 10px"
@@ -53,6 +61,7 @@
53
61
  boardAsTable: true, // display the board additionally as HTML table
54
62
  movePieceForm: true, // display a form to move a piece (from, to, move)
55
63
  piecesAsList: true, // display the pieces additionally as List
64
+ keyboardMoveInput: true, // enable keyboard navigation on the board with arrow keys
56
65
  visuallyHidden: false // hide all those extra outputs visually but keep them accessible for screen readers and braille displays
57
66
  }
58
67
 
@@ -20,20 +20,28 @@
20
20
  onclick="window.switchOrientation()">Switch Orientation
21
21
  </button>
22
22
  <script type="module">
23
- import {Chessboard, COLOR, INPUT_EVENT_TYPE} from "../../src/Chessboard.js"
24
- import {PromotionDialog} from "../../src/extensions/promotion-dialog/PromotionDialog.js"
25
- import {Markers} from "../../src/extensions/markers/Markers.js"
23
+ import {Chessboard, COLOR, INPUT_EVENT_TYPE} from "../../src/Chessboard.js?v=1"
24
+ import {PromotionDialog} from "../../src/extensions/promotion-dialog/PromotionDialog.js?v=1"
25
+ import {Markers} from "../../src/extensions/markers/Markers.js?v=1"
26
+ import {Accessibility} from "../../src/extensions/accessibility/Accessibility.js?v=1"
26
27
 
27
- const position = "4k3/1P6/8/8/6r1/8/8/2R1K3 w - - 0 1"
28
+ const position = "4k3/1P6/8/8/6r1/8/61p/2R1K3 w - - 0 1"
28
29
  const chessboard = new Chessboard(document.getElementById("chessboard"), {
29
30
  position: position,
30
31
  assetsUrl: "../../assets/",
31
- extensions: [{class: PromotionDialog}, {class: Markers}]
32
+ extensions: [{class: PromotionDialog}, {class: Markers}, {class: Accessibility, props: {
33
+ brailleNotationInAlt: true, // show the braille notation of the game in the alt attribute of the SVG
34
+ boardAsTable: true, // display the board additionally as HTML table
35
+ movePieceForm: true, // display a form to move a piece (from, to, move)
36
+ piecesAsList: true, // display the pieces additionally as List
37
+ visuallyHidden: false // hide all those extra outputs visually but keep them accessible for screen readers and braille displays
38
+ }}]
32
39
  })
33
40
  chessboard.enableMoveInput((event) => {
34
41
  if (event.type === INPUT_EVENT_TYPE.validateMoveInput) {
35
- if (event.squareTo.charAt(1) === "8" && event.piece.charAt(1) === "p") {
36
- chessboard.showPromotionDialog(event.squareTo, COLOR.white, (result) => {
42
+ if ((event.squareTo.charAt(1) === "8" || event.squareTo.charAt(1) === "1") && event.piece.charAt(1) === "p") {
43
+ const pieceColor = event.piece.charAt(0)
44
+ chessboard.showPromotionDialog(event.squareTo, pieceColor, (result) => {
37
45
  console.log("Promotion result", result)
38
46
  if (result && result.piece) {
39
47
  chessboard.setPiece(result.square, result.piece, true)
package/index.html CHANGED
@@ -42,9 +42,9 @@
42
42
  <li><a href="examples/extensions/arrows-extension.html">Arrows extension</a></li>
43
43
  </ul>
44
44
  </li>
45
- <li>Draw HTML over the board with <a href="examples/extensions/html-layer-extension.html">HTML Layer extension</a></li>
46
- <li>Allow displaying a promotion dialog with <a href="examples/extensions/promotion-dialog-extension.html">PromotionDialog extension</a></li>
47
- <li>Make the board accessible with screen-reader support with <a href="examples/extensions/accessibility-extension.html">Accessibility extension</a></li>
45
+ <li>Draw HTML over the board with the <a href="examples/extensions/html-layer-extension.html">HTML Layer extension</a></li>
46
+ <li>Allow displaying a promotion dialog with the <a href="examples/extensions/promotion-dialog-extension.html">PromotionDialog extension</a></li>
47
+ <li>Make the board accessible, add screen-reader support with the <a href="examples/extensions/accessibility-extension.html">Accessibility extension</a></li>
48
48
  </ul>
49
49
  </div>
50
50
  <h2>Chessboard</h2>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cm-chessboard",
3
- "version": "8.11.4",
3
+ "version": "8.12.0",
4
4
  "description": "A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.",
5
5
  "keywords": [
6
6
  "chess",
@@ -8,6 +8,8 @@ import {COLOR, INPUT_EVENT_TYPE} from "../../Chessboard.js"
8
8
  import {piecesTranslations, renderPieceTitle} from "./I18n.js"
9
9
  import {Utils} from "../../lib/Utils.js"
10
10
 
11
+ import {Svg} from "../../lib/Svg.js"
12
+
11
13
  const translations = {
12
14
  de: {
13
15
  chessboard: "Schachbrett",
@@ -21,6 +23,10 @@ const translations = {
21
23
  input_black_enabled: "Eingabe Schwarz aktiviert",
22
24
  input_disabled: "Eingabe deaktiviert",
23
25
  pieces: "Figuren",
26
+ empty_square: "leer",
27
+ move_from: "Zug von",
28
+ move_to: "Zug nach",
29
+ move_canceled: "Zug abgebrochen"
24
30
  },
25
31
  en: {
26
32
  chessboard: "Chessboard",
@@ -33,7 +39,11 @@ const translations = {
33
39
  input_white_enabled: "Input white enabled",
34
40
  input_black_enabled: "Input black enabled",
35
41
  input_disabled: "Input disabled",
36
- pieces: "Pieces"
42
+ pieces: "Pieces",
43
+ empty_square: "empty",
44
+ move_from: "Move from",
45
+ move_to: "Move to",
46
+ move_canceled: "Move canceled"
37
47
  }
38
48
  }
39
49
 
@@ -46,6 +56,7 @@ export class Accessibility extends Extension {
46
56
  movePieceForm: true, // display a form to move a piece (from, to, move)
47
57
  boardAsTable: true, // display the board additionally as HTML table
48
58
  piecesAsList: true, // display the pieces additionally as List
59
+ keyboardMoveInput: true, // enable keyboard navigation on the board with arrow keys
49
60
  visuallyHidden: true // hide all those extra outputs visually but keep them accessible for screen readers and braille displays
50
61
  }
51
62
  Object.assign(this.props, props)
@@ -76,6 +87,9 @@ export class Accessibility extends Extension {
76
87
  if (this.props.brailleNotationInAlt) {
77
88
  this.components.push(new BrailleNotationInAlt(this))
78
89
  }
90
+ if (this.props.keyboardMoveInput) {
91
+ this.components.push(new KeyboardMoveInput(this))
92
+ }
79
93
  }
80
94
  }
81
95
 
@@ -135,6 +149,7 @@ class MovePieceForm {
135
149
  true).then(() => {
136
150
  this.inputFrom.value = ""
137
151
  this.inputTo.value = ""
152
+ this.updateButtonState()
138
153
  })
139
154
  }
140
155
  })
@@ -142,6 +157,13 @@ class MovePieceForm {
142
157
  extension.registerExtensionPoint(EXTENSION_POINT.moveInputToggled, () => {
143
158
  this.redraw()
144
159
  })
160
+ // Update button state when input values change
161
+ this.inputFrom.addEventListener("input", () => {
162
+ this.updateButtonState()
163
+ })
164
+ this.inputTo.addEventListener("input", () => {
165
+ this.updateButtonState()
166
+ })
145
167
  this.keydownListener = (event) => {
146
168
  if (event.shiftKey && event.altKey && event.code === 'KeyE') {
147
169
  event.preventDefault()
@@ -152,6 +174,25 @@ class MovePieceForm {
152
174
  extension.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
153
175
  document.removeEventListener("keydown", this.keydownListener)
154
176
  })
177
+ // Initial button state
178
+ this.updateButtonState()
179
+ }
180
+
181
+ isValidSquare(value) {
182
+ const square = value.trim().toLowerCase()
183
+ if (square.length !== 2) {
184
+ return false
185
+ }
186
+ const file = square.charAt(0)
187
+ const rank = square.charAt(1)
188
+ return file >= 'a' && file <= 'h' && rank >= '1' && rank <= '8'
189
+ }
190
+
191
+ updateButtonState() {
192
+ const inputEnabled = this.chessboard.state.inputWhiteEnabled || this.chessboard.state.inputBlackEnabled
193
+ const isValidFrom = this.isValidSquare(this.inputFrom.value)
194
+ const isValidTo = this.isValidSquare(this.inputTo.value)
195
+ this.moveButton.disabled = !inputEnabled || !isValidFrom || !isValidTo
155
196
  }
156
197
 
157
198
  redraw() {
@@ -159,12 +200,11 @@ class MovePieceForm {
159
200
  if (this.chessboard.state.inputWhiteEnabled || this.chessboard.state.inputBlackEnabled) {
160
201
  this.inputFrom.disabled = false
161
202
  this.inputTo.disabled = false
162
- this.moveButton.disabled = false
163
203
  } else {
164
204
  this.inputFrom.disabled = true
165
205
  this.inputTo.disabled = true
166
- this.moveButton.disabled = true
167
206
  }
207
+ this.updateButtonState()
168
208
  }
169
209
  }
170
210
  }
@@ -248,3 +288,295 @@ class PiecesAsList {
248
288
  <ul aria-labelledby="black_${this.chessboard.id}" class="list-inline">${listB}</ul>`
249
289
  }
250
290
  }
291
+
292
+ class KeyboardMoveInput {
293
+ constructor(extension) {
294
+ this.extension = extension
295
+ this.chessboard = extension.chessboard
296
+ this.t = extension.t
297
+ this.tPieces = extension.tPieces
298
+
299
+ // Current focus position (file 0-7, rank 0-7)
300
+ this.focusedFile = 0 // a-h
301
+ this.focusedRank = 0 // 1-8 (0 = rank 1)
302
+
303
+ // Move selection state
304
+ this.fromSquare = null
305
+
306
+ // Create focus indicator group in SVG
307
+ this.focusIndicatorGroup = Svg.addElement(
308
+ this.chessboard.view.markersTopLayer,
309
+ "g",
310
+ {class: "keyboard-focus-indicator"}
311
+ )
312
+
313
+ // Create live region for screen reader announcements
314
+ this.liveRegion = document.createElement("div")
315
+ this.liveRegion.setAttribute("aria-live", "polite")
316
+ this.liveRegion.setAttribute("aria-atomic", "true")
317
+ this.liveRegion.className = "cm-chessboard-keyboard-live-region visually-hidden"
318
+ 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;"
319
+ this.chessboard.context.appendChild(this.liveRegion)
320
+
321
+ // Make SVG focusable
322
+ this.chessboard.view.svg.setAttribute("tabindex", "0")
323
+ this.chessboard.view.svg.setAttribute("role", "application")
324
+ this.chessboard.view.svg.setAttribute("aria-label", this.t.chessboard)
325
+
326
+ // Bind event handlers
327
+ this.handleKeyDown = this.handleKeyDown.bind(this)
328
+ this.handleFocus = this.handleFocus.bind(this)
329
+ this.handleBlur = this.handleBlur.bind(this)
330
+
331
+ this.chessboard.view.svg.addEventListener("keydown", this.handleKeyDown)
332
+ this.chessboard.view.svg.addEventListener("focus", this.handleFocus)
333
+ this.chessboard.view.svg.addEventListener("blur", this.handleBlur)
334
+
335
+ // Register extension points
336
+ extension.registerExtensionPoint(EXTENSION_POINT.afterRedrawBoard, () => {
337
+ this.redrawFocusIndicator()
338
+ })
339
+ extension.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
340
+ this.destroy()
341
+ })
342
+ extension.registerExtensionPoint(EXTENSION_POINT.moveInputToggled, () => {
343
+ // Reset move selection when input is toggled
344
+ this.fromSquare = null
345
+ this.redrawFocusIndicator()
346
+ })
347
+ }
348
+
349
+ handleFocus() {
350
+ this.redrawFocusIndicator()
351
+ this.announceCurrentSquare()
352
+ }
353
+
354
+ handleBlur() {
355
+ this.clearFocusIndicator()
356
+ }
357
+
358
+ handleKeyDown(event) {
359
+ const orientation = this.chessboard.state.orientation
360
+
361
+ switch (event.key) {
362
+ case "ArrowRight":
363
+ event.preventDefault()
364
+ if (orientation === COLOR.white) {
365
+ this.focusedFile = Math.min(7, this.focusedFile + 1)
366
+ } else {
367
+ this.focusedFile = Math.max(0, this.focusedFile - 1)
368
+ }
369
+ this.redrawFocusIndicator()
370
+ this.announceCurrentSquare()
371
+ break
372
+
373
+ case "ArrowLeft":
374
+ event.preventDefault()
375
+ if (orientation === COLOR.white) {
376
+ this.focusedFile = Math.max(0, this.focusedFile - 1)
377
+ } else {
378
+ this.focusedFile = Math.min(7, this.focusedFile + 1)
379
+ }
380
+ this.redrawFocusIndicator()
381
+ this.announceCurrentSquare()
382
+ break
383
+
384
+ case "ArrowUp":
385
+ event.preventDefault()
386
+ if (orientation === COLOR.white) {
387
+ this.focusedRank = Math.min(7, this.focusedRank + 1)
388
+ } else {
389
+ this.focusedRank = Math.max(0, this.focusedRank - 1)
390
+ }
391
+ this.redrawFocusIndicator()
392
+ this.announceCurrentSquare()
393
+ break
394
+
395
+ case "ArrowDown":
396
+ event.preventDefault()
397
+ if (orientation === COLOR.white) {
398
+ this.focusedRank = Math.max(0, this.focusedRank - 1)
399
+ } else {
400
+ this.focusedRank = Math.min(7, this.focusedRank + 1)
401
+ }
402
+ this.redrawFocusIndicator()
403
+ this.announceCurrentSquare()
404
+ break
405
+
406
+ case "Enter":
407
+ case " ":
408
+ event.preventDefault()
409
+ this.selectSquare()
410
+ break
411
+
412
+ case "Escape":
413
+ event.preventDefault()
414
+ if (this.fromSquare) {
415
+ this.fromSquare = null
416
+ this.redrawFocusIndicator()
417
+ this.announce(this.t.move_canceled)
418
+ }
419
+ break
420
+ }
421
+ }
422
+
423
+ selectSquare() {
424
+ if (!this.chessboard.state.inputWhiteEnabled && !this.chessboard.state.inputBlackEnabled) {
425
+ return // Input not enabled
426
+ }
427
+
428
+ const square = this.getCurrentSquare()
429
+ const piece = this.chessboard.getPiece(square)
430
+
431
+ if (!this.fromSquare) {
432
+ // Selecting "from" square
433
+ if (piece) {
434
+ const pieceColor = piece.charAt(0)
435
+ // Check if the piece color matches enabled input
436
+ if ((pieceColor === "w" && this.chessboard.state.inputWhiteEnabled) ||
437
+ (pieceColor === "b" && this.chessboard.state.inputBlackEnabled)) {
438
+ // Trigger moveInputStarted callback
439
+ const startResult = this.chessboard.state.moveInputCallback({
440
+ chessboard: this.chessboard,
441
+ type: INPUT_EVENT_TYPE.moveInputStarted,
442
+ square: square,
443
+ squareFrom: square,
444
+ piece: piece
445
+ })
446
+ if (startResult) {
447
+ this.fromSquare = square
448
+ this.redrawFocusIndicator()
449
+ this.announce(this.t.move_from + " " + square)
450
+ }
451
+ }
452
+ }
453
+ } else {
454
+ // Selecting "to" square
455
+ if (square === this.fromSquare) {
456
+ // Clicking same square cancels
457
+ this.fromSquare = null
458
+ this.redrawFocusIndicator()
459
+ this.announce(this.t.move_canceled)
460
+ } else {
461
+ // Try to make the move
462
+ const fromPiece = this.chessboard.getPiece(this.fromSquare)
463
+ const result = this.chessboard.state.moveInputCallback({
464
+ chessboard: this.chessboard,
465
+ type: INPUT_EVENT_TYPE.validateMoveInput,
466
+ squareFrom: this.fromSquare,
467
+ squareTo: square,
468
+ piece: fromPiece
469
+ })
470
+ if (result) {
471
+ const fromSquare = this.fromSquare
472
+ this.fromSquare = null
473
+ this.chessboard.movePiece(fromSquare, square, true).then(() => {
474
+ this.redrawFocusIndicator()
475
+ })
476
+ this.announce(this.t.move_to + " " + square)
477
+ } else {
478
+ // Invalid move - check if clicking on own piece to start new move
479
+ if (piece) {
480
+ const pieceColor = piece.charAt(0)
481
+ if ((pieceColor === "w" && this.chessboard.state.inputWhiteEnabled) ||
482
+ (pieceColor === "b" && this.chessboard.state.inputBlackEnabled)) {
483
+ const startResult = this.chessboard.state.moveInputCallback({
484
+ chessboard: this.chessboard,
485
+ type: INPUT_EVENT_TYPE.moveInputStarted,
486
+ square: square,
487
+ squareFrom: square,
488
+ piece: piece
489
+ })
490
+ if (startResult) {
491
+ this.fromSquare = square
492
+ this.redrawFocusIndicator()
493
+ this.announce(this.t.move_from + " " + square)
494
+ }
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
500
+ }
501
+
502
+ getCurrentSquare() {
503
+ const file = String.fromCharCode(97 + this.focusedFile) // 'a' + file
504
+ const rank = this.focusedRank + 1
505
+ return `${file}${rank}`
506
+ }
507
+
508
+ announceCurrentSquare() {
509
+ const square = this.getCurrentSquare()
510
+ const piece = this.chessboard.getPiece(square)
511
+ let announcement = square
512
+ if (piece) {
513
+ const pieceType = piece.charAt(1)
514
+ const pieceColor = piece.charAt(0)
515
+ announcement += " " + renderPieceTitle(this.extension.lang, pieceType, pieceColor)
516
+ } else {
517
+ announcement += " " + this.t.empty_square
518
+ }
519
+ if (this.fromSquare) {
520
+ announcement += " (" + this.t.move_from + " " + this.fromSquare + ")"
521
+ }
522
+ this.announce(announcement)
523
+ }
524
+
525
+ announce(message) {
526
+ this.liveRegion.textContent = ""
527
+ setTimeout(() => {
528
+ this.liveRegion.textContent = message
529
+ }, 50)
530
+ }
531
+
532
+ redrawFocusIndicator() {
533
+ this.clearFocusIndicator()
534
+
535
+ // Only show if SVG is focused
536
+ if (document.activeElement !== this.chessboard.view.svg) {
537
+ return
538
+ }
539
+
540
+ const square = this.getCurrentSquare()
541
+ const point = this.chessboard.view.squareToPoint(square)
542
+ const squareWidth = this.chessboard.view.squareWidth
543
+ const squareHeight = this.chessboard.view.squareHeight
544
+
545
+ // Draw focus indicator
546
+ Svg.addElement(this.focusIndicatorGroup, "rect", {
547
+ x: point.x,
548
+ y: point.y,
549
+ width: squareWidth,
550
+ height: squareHeight,
551
+ class: "keyboard-focus"
552
+ })
553
+
554
+ // If we have a from square selected, also highlight it
555
+ if (this.fromSquare) {
556
+ const fromPoint = this.chessboard.view.squareToPoint(this.fromSquare)
557
+ Svg.addElement(this.focusIndicatorGroup, "rect", {
558
+ x: fromPoint.x,
559
+ y: fromPoint.y,
560
+ width: squareWidth,
561
+ height: squareHeight,
562
+ class: "keyboard-from-square"
563
+ })
564
+ }
565
+ }
566
+
567
+ clearFocusIndicator() {
568
+ while (this.focusIndicatorGroup.firstChild) {
569
+ this.focusIndicatorGroup.removeChild(this.focusIndicatorGroup.firstChild)
570
+ }
571
+ }
572
+
573
+ destroy() {
574
+ this.chessboard.view.svg.removeEventListener("keydown", this.handleKeyDown)
575
+ this.chessboard.view.svg.removeEventListener("focus", this.handleFocus)
576
+ this.chessboard.view.svg.removeEventListener("blur", this.handleBlur)
577
+ if (this.liveRegion && this.liveRegion.parentNode) {
578
+ this.liveRegion.parentNode.removeChild(this.liveRegion)
579
+ }
580
+ this.clearFocusIndicator()
581
+ }
582
+ }
@@ -25,6 +25,9 @@ export class Arrows extends Extension {
25
25
  this.registerExtensionPoint(EXTENSION_POINT.afterRedrawBoard, () => {
26
26
  this.onRedrawBoard()
27
27
  })
28
+ this.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
29
+ this.onDestroy()
30
+ })
28
31
  this.props = {
29
32
  sprite: "extensions/arrows/arrows.svg",
30
33
  slice: "arrowDefault",
@@ -43,6 +46,16 @@ export class Arrows extends Extension {
43
46
  this.arrows = []
44
47
  }
45
48
 
49
+ onDestroy() {
50
+ this.arrows.length = 0
51
+ if (this.arrowGroup && this.arrowGroup.parentNode) {
52
+ this.arrowGroup.parentNode.removeChild(this.arrowGroup)
53
+ }
54
+ delete this.chessboard.addArrow
55
+ delete this.chessboard.getArrows
56
+ delete this.chessboard.removeArrows
57
+ }
58
+
46
59
  onRedrawBoard() {
47
60
  while (this.arrowGroup.firstChild) {
48
61
  this.arrowGroup.removeChild(this.arrowGroup.firstChild)
@@ -112,7 +125,7 @@ export class Arrows extends Extension {
112
125
 
113
126
  addArrow(type, from, to) {
114
127
  this.arrows.push(new Arrow(from, to, type))
115
- this.chessboard.view.redrawBoard()
128
+ this.onRedrawBoard()
116
129
  }
117
130
 
118
131
  getArrows(type = undefined, from = undefined, to = undefined) {
@@ -127,7 +140,7 @@ export class Arrows extends Extension {
127
140
 
128
141
  removeArrows(type = undefined, from = undefined, to = undefined) {
129
142
  this.arrows = this.arrows.filter((arrow) => !arrow.matches(from, to, type))
130
- this.chessboard.view.redrawBoard()
143
+ this.onRedrawBoard()
131
144
  }
132
145
 
133
146
  getSpriteUrl() {
@@ -8,12 +8,16 @@ import {Extension, EXTENSION_POINT} from "../../model/Extension.js"
8
8
  export class AutoBorderNone extends Extension {
9
9
  constructor(chessboard, props = {}) {
10
10
  super(chessboard)
11
+ this.originalBorderType = chessboard.props.style.borderType
11
12
  this.props = {
12
13
  chessboardBorderType: chessboard.props.style.borderType,
13
14
  borderNoneBelow: 540 // pixels width of the board, where the border is set to none
14
15
  }
15
16
  Object.assign(this.props, props)
16
17
  this.registerExtensionPoint(EXTENSION_POINT.beforeRedrawBoard, this.extensionPointBeforeRedrawBoard.bind(this))
18
+ this.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
19
+ this.chessboard.props.style.borderType = this.originalBorderType
20
+ })
17
21
  }
18
22
  extensionPointBeforeRedrawBoard() {
19
23
  let newBorderType
@@ -3,14 +3,18 @@
3
3
  * Repository: https://github.com/shaack/cm-chessboard
4
4
  * License: MIT, see file 'LICENSE'
5
5
  */
6
- import {Extension} from "../../model/Extension.js"
6
+ import {Extension, EXTENSION_POINT} from "../../model/Extension.js"
7
7
 
8
8
  export class HtmlLayer extends Extension {
9
9
  /** @constructor */
10
10
  constructor(chessboard) {
11
11
  super(chessboard)
12
+ this.layers = []
12
13
  chessboard.addHtmlLayer = this.addHtmlLayer.bind(this)
13
14
  chessboard.removeHtmlLayer = this.removeHtmlLayer.bind(this)
15
+ this.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
16
+ this.onDestroy()
17
+ })
14
18
  }
15
19
  addHtmlLayer(html) {
16
20
  const layer = document.createElement("div")
@@ -23,9 +27,26 @@ export class HtmlLayer extends Extension {
23
27
  layer.style.bottom = "0"
24
28
  layer.style.right = "0"
25
29
  layer.innerHTML = html
30
+ this.layers.push(layer)
26
31
  return layer
27
32
  }
28
33
  removeHtmlLayer(layer) {
29
- this.chessboard.context.removeChild(layer)
34
+ const index = this.layers.indexOf(layer)
35
+ if (index !== -1) {
36
+ this.layers.splice(index, 1)
37
+ }
38
+ if (layer && layer.parentNode === this.chessboard.context) {
39
+ this.chessboard.context.removeChild(layer)
40
+ }
41
+ }
42
+ onDestroy() {
43
+ for (const layer of this.layers) {
44
+ if (layer.parentNode) {
45
+ layer.parentNode.removeChild(layer)
46
+ }
47
+ }
48
+ this.layers.length = 0
49
+ delete this.chessboard.addHtmlLayer
50
+ delete this.chessboard.removeHtmlLayer
30
51
  }
31
52
  }
@@ -29,6 +29,9 @@ export class Markers extends Extension {
29
29
  this.registerExtensionPoint(EXTENSION_POINT.afterRedrawBoard, () => {
30
30
  this.onRedrawBoard()
31
31
  })
32
+ this.registerExtensionPoint(EXTENSION_POINT.destroy, () => {
33
+ this.onDestroy()
34
+ })
32
35
  this.props = {
33
36
  autoMarkers: MARKER_TYPE.frame, // set to `null` to disable autoMarkers
34
37
  sprite: "extensions/markers/markers.svg" // the sprite file of the markers
@@ -53,6 +56,21 @@ export class Markers extends Extension {
53
56
  }
54
57
  }
55
58
 
59
+ onDestroy() {
60
+ this.markers.length = 0
61
+ if (this.markerGroupDown && this.markerGroupDown.parentNode) {
62
+ this.markerGroupDown.parentNode.removeChild(this.markerGroupDown)
63
+ }
64
+ if (this.markerGroupUp && this.markerGroupUp.parentNode) {
65
+ this.markerGroupUp.parentNode.removeChild(this.markerGroupUp)
66
+ }
67
+ delete this.chessboard.addMarker
68
+ delete this.chessboard.getMarkers
69
+ delete this.chessboard.removeMarkers
70
+ delete this.chessboard.addLegalMovesMarkers
71
+ delete this.chessboard.removeLegalMovesMarkers
72
+ }
73
+
56
74
  drawAutoMarkers(event) {
57
75
  if(event.type !== INPUT_EVENT_TYPE.moveInputFinished) {
58
76
  this.removeMarkers(this.props.autoMarkers)
@@ -86,21 +104,33 @@ export class Markers extends Extension {
86
104
  }
87
105
 
88
106
  addLegalMovesMarkers(moves) {
89
- for (const move of moves) {
90
- if (move.promotion && move.promotion !== "q") {
91
- continue
92
- }
93
- if (this.chessboard.getPiece(move.to)) {
94
- this.chessboard.addMarker(MARKER_TYPE.bevel, move.to)
95
- } else {
96
- this.chessboard.addMarker(MARKER_TYPE.dot, move.to)
107
+ this.batchUpdate = true
108
+ try {
109
+ for (const move of moves) {
110
+ if (move.promotion && move.promotion !== "q") {
111
+ continue
112
+ }
113
+ if (this.chessboard.getPiece(move.to)) {
114
+ this.chessboard.addMarker(MARKER_TYPE.bevel, move.to)
115
+ } else {
116
+ this.chessboard.addMarker(MARKER_TYPE.dot, move.to)
117
+ }
97
118
  }
119
+ } finally {
120
+ this.batchUpdate = false
121
+ this.onRedrawBoard()
98
122
  }
99
123
  }
100
124
 
101
125
  removeLegalMovesMarkers() {
102
- this.chessboard.removeMarkers(MARKER_TYPE.bevel)
103
- this.chessboard.removeMarkers(MARKER_TYPE.dot)
126
+ this.batchUpdate = true
127
+ try {
128
+ this.chessboard.removeMarkers(MARKER_TYPE.bevel)
129
+ this.chessboard.removeMarkers(MARKER_TYPE.dot)
130
+ } finally {
131
+ this.batchUpdate = false
132
+ this.onRedrawBoard()
133
+ }
104
134
  }
105
135
 
106
136
  drawMarker(marker) {
@@ -130,7 +160,9 @@ export class Markers extends Extension {
130
160
  return
131
161
  }
132
162
  this.markers.push(new Marker(square, type))
133
- this.onRedrawBoard()
163
+ if (!this.batchUpdate) {
164
+ this.onRedrawBoard()
165
+ }
134
166
  }
135
167
 
136
168
  getMarkers(type = undefined, square = undefined) {
@@ -153,7 +185,9 @@ export class Markers extends Extension {
153
185
  return
154
186
  }
155
187
  this.markers = this.markers.filter((marker) => !marker.matches(square, type))
156
- this.onRedrawBoard()
188
+ if (!this.batchUpdate) {
189
+ this.onRedrawBoard()
190
+ }
157
191
  }
158
192
 
159
193
  getSpriteUrl() {
@@ -91,14 +91,16 @@ export class PromotionDialog extends Extension {
91
91
  this.state.dialogParams.color = color
92
92
  this.state.callback = callback
93
93
  this.setDisplayState(DISPLAY_STATE.displayRequested)
94
- setTimeout(() => {
95
- this.chessboard.view.positionsAnimationTask.then(() => {
96
- this.setDisplayState(DISPLAY_STATE.shown)
97
- this.announce(this.t.choosePromotion + ": " +
98
- this.pieceOrder.map(p => this.t.pieces[p]).join(", "))
99
- })
100
- }
101
- )
94
+ this.showTimeoutId = setTimeout(() => {
95
+ this.showTimeoutId = null
96
+ if (!this.chessboard.view) return // destroyed before timeout fired
97
+ this.chessboard.view.positionsAnimationTask.then(() => {
98
+ if (this.state.displayState !== DISPLAY_STATE.displayRequested) return
99
+ this.setDisplayState(DISPLAY_STATE.shown)
100
+ this.announce(this.t.choosePromotion + ": " +
101
+ this.pieceOrder.map(p => this.t.pieces[p]).join(", "))
102
+ })
103
+ })
102
104
  }
103
105
 
104
106
  // public (chessboard.isPromotionDialogShown)
@@ -144,12 +146,13 @@ export class PromotionDialog extends Extension {
144
146
  const squareCenterPoint = this.chessboard.view.squareToPoint(this.state.dialogParams.square)
145
147
  squareCenterPoint.x = squareCenterPoint.x + squareWidth / 2
146
148
  squareCenterPoint.y = squareCenterPoint.y + squareHeight / 2
147
- let turned = false
149
+ this.turned = false
148
150
  const rank = parseInt(this.state.dialogParams.square.charAt(1), 10)
149
151
  if (this.chessboard.getOrientation() === COLOR.white && rank < 5 ||
150
152
  this.chessboard.getOrientation() === COLOR.black && rank >= 5) {
151
- turned = true
153
+ this.turned = true
152
154
  }
155
+ const turned = this.turned
153
156
  const offsetY = turned ? -4 * squareHeight : 0
154
157
  const offsetX = squareCenterPoint.x + squareWidth > this.chessboard.view.width ? -squareWidth : 0
155
158
  Svg.addElement(this.promotionDialogGroup,
@@ -247,6 +250,7 @@ export class PromotionDialog extends Extension {
247
250
  }
248
251
 
249
252
  setDisplayState(displayState) {
253
+ const prevState = this.state.displayState
250
254
  this.state.displayState = displayState
251
255
  if (displayState === DISPLAY_STATE.shown) {
252
256
  this.clickDelegate = Utils.delegate(this.chessboard.view.svg,
@@ -258,19 +262,27 @@ export class PromotionDialog extends Extension {
258
262
  // Add keyboard listener
259
263
  document.addEventListener("keydown", this.handleKeyDown)
260
264
  } else if (displayState === DISPLAY_STATE.hidden) {
261
- this.clickDelegate.remove()
262
- this.chessboard.view.svg.removeEventListener("contextmenu", this.contextMenuListener)
265
+ if (this.clickDelegate) {
266
+ this.clickDelegate.remove()
267
+ this.clickDelegate = null
268
+ }
269
+ if (this.contextMenuListener && this.chessboard.view) {
270
+ this.chessboard.view.svg.removeEventListener("contextmenu", this.contextMenuListener)
271
+ this.contextMenuListener = null
272
+ }
263
273
  // Remove keyboard listener
264
274
  document.removeEventListener("keydown", this.handleKeyDown)
265
- // Restore focus
266
- if (this.previouslyFocusedElement && this.previouslyFocusedElement.focus) {
275
+ // Restore focus (only if the dialog was actually shown before)
276
+ if (prevState === DISPLAY_STATE.shown &&
277
+ this.previouslyFocusedElement && this.previouslyFocusedElement.focus) {
267
278
  this.previouslyFocusedElement.focus()
268
279
  }
269
280
  }
270
281
  this.redrawDialog()
271
282
  // Focus first button after redraw when shown
272
283
  if (displayState === DISPLAY_STATE.shown) {
273
- setTimeout(() => {
284
+ this.focusTimeoutId = setTimeout(() => {
285
+ this.focusTimeoutId = null
274
286
  this.focusButton(0)
275
287
  }, 0)
276
288
  }
@@ -282,12 +294,28 @@ export class PromotionDialog extends Extension {
282
294
  }
283
295
  switch (event.key) {
284
296
  case "ArrowDown":
297
+ event.preventDefault()
298
+ if (this.turned) {
299
+ this.focusedIndex = (this.focusedIndex - 1 + 4) % 4
300
+ } else {
301
+ this.focusedIndex = (this.focusedIndex + 1) % 4
302
+ }
303
+ this.focusButton(this.focusedIndex)
304
+ break
285
305
  case "ArrowRight":
286
306
  event.preventDefault()
287
307
  this.focusedIndex = (this.focusedIndex + 1) % 4
288
308
  this.focusButton(this.focusedIndex)
289
309
  break
290
310
  case "ArrowUp":
311
+ event.preventDefault()
312
+ if (this.turned) {
313
+ this.focusedIndex = (this.focusedIndex + 1) % 4
314
+ } else {
315
+ this.focusedIndex = (this.focusedIndex - 1 + 4) % 4
316
+ }
317
+ this.focusButton(this.focusedIndex)
318
+ break
291
319
  case "ArrowLeft":
292
320
  event.preventDefault()
293
321
  this.focusedIndex = (this.focusedIndex - 1 + 4) % 4
@@ -335,18 +363,45 @@ export class PromotionDialog extends Extension {
335
363
  }
336
364
 
337
365
  announce(message) {
366
+ if (!this.liveRegion) return
338
367
  this.liveRegion.textContent = ""
368
+ if (this.announceTimeoutId) {
369
+ clearTimeout(this.announceTimeoutId)
370
+ }
339
371
  // Small delay to ensure screen readers pick up the change
340
- setTimeout(() => {
341
- this.liveRegion.textContent = message
372
+ this.announceTimeoutId = setTimeout(() => {
373
+ this.announceTimeoutId = null
374
+ if (this.liveRegion) {
375
+ this.liveRegion.textContent = message
376
+ }
342
377
  }, 50)
343
378
  }
344
379
 
345
380
  destroy() {
381
+ // Close the dialog first so its listeners are removed
382
+ if (this.state.displayState === DISPLAY_STATE.shown) {
383
+ this.setDisplayState(DISPLAY_STATE.hidden)
384
+ }
385
+ // Cancel any pending timeouts so callbacks don't fire on a destroyed board
386
+ if (this.showTimeoutId) {
387
+ clearTimeout(this.showTimeoutId)
388
+ this.showTimeoutId = null
389
+ }
390
+ if (this.focusTimeoutId) {
391
+ clearTimeout(this.focusTimeoutId)
392
+ this.focusTimeoutId = null
393
+ }
394
+ if (this.announceTimeoutId) {
395
+ clearTimeout(this.announceTimeoutId)
396
+ this.announceTimeoutId = null
397
+ }
346
398
  document.removeEventListener("keydown", this.handleKeyDown)
347
399
  if (this.liveRegion && this.liveRegion.parentNode) {
348
400
  this.liveRegion.parentNode.removeChild(this.liveRegion)
401
+ this.liveRegion = null
349
402
  }
403
+ delete this.chessboard.showPromotionDialog
404
+ delete this.chessboard.isPromotionDialogShown
350
405
  }
351
406
 
352
407
  }
@@ -143,7 +143,7 @@ export class Position {
143
143
  }
144
144
 
145
145
  clone() {
146
- const cloned = new Position()
146
+ const cloned = Object.create(Position.prototype)
147
147
  cloned.squares = this.squares.slice(0)
148
148
  return cloned
149
149
  }
@@ -49,7 +49,12 @@ export class ChessboardView {
49
49
  if (chessboard.props.responsive) {
50
50
  if (typeof ResizeObserver !== "undefined") {
51
51
  this.resizeObserver = new ResizeObserver(() => {
52
- setTimeout(() => { // prevents "ResizeObserver loop completed with undelivered notifications."
52
+ // Defer via setTimeout to avoid "ResizeObserver loop
53
+ // completed with undelivered notifications." The timeout
54
+ // id is tracked so destroy() can cancel a pending call
55
+ // and avoid running handleResize on a destroyed board.
56
+ this.resizeTimeout = setTimeout(() => {
57
+ this.resizeTimeout = null
53
58
  this.handleResize()
54
59
  })
55
60
  })
@@ -76,6 +81,13 @@ export class ChessboardView {
76
81
  if (this.resizeObserver) {
77
82
  this.resizeObserver.unobserve(this.chessboard.context)
78
83
  }
84
+ // Cancel any pending handleResize that the ResizeObserver callback
85
+ // has already scheduled via setTimeout. unobserve() stops new
86
+ // notifications but does not clear timeouts we scheduled ourselves.
87
+ if (this.resizeTimeout) {
88
+ clearTimeout(this.resizeTimeout)
89
+ this.resizeTimeout = null
90
+ }
79
91
  if (this.resizeListener) {
80
92
  window.removeEventListener("resize", this.resizeListener)
81
93
  }
@@ -145,6 +157,12 @@ export class ChessboardView {
145
157
  }
146
158
 
147
159
  handleResize() {
160
+ // Skip if the board has already been destroyed. The resizeObserver
161
+ // or window resize listener may fire a callback whose deferred work
162
+ // is still pending when destroy() runs.
163
+ if (!this.chessboard || !this.chessboard.state) {
164
+ return
165
+ }
148
166
  this.container.style.width = (this.chessboard.context.clientWidth) + "px"
149
167
  this.container.style.height = (this.chessboard.context.clientWidth * this.chessboard.props.style.aspectRatio) + "px"
150
168
  if (this.container.clientWidth !== this.width || this.container.clientHeight !== this.height) {
@@ -69,4 +69,42 @@ describe("TestChessboard", () => {
69
69
  chessboard.destroy()
70
70
  })
71
71
 
72
+ // Regression for a production crash: the ResizeObserver callback defers
73
+ // handleResize() via setTimeout to avoid "ResizeObserver loop completed"
74
+ // warnings. If destroy() is called between the observer firing and the
75
+ // setTimeout running, handleResize -> redrawBoard hits
76
+ // `this.chessboard.state.invokeExtensionPoints(...)` where `state` is
77
+ // now undefined, throwing TypeError.
78
+ //
79
+ // We invalidate view.width by zeroing it so that the size-changed branch
80
+ // of handleResize is guaranteed to enter updateMetrics + redrawBoard.
81
+ // Otherwise a stable container size would short-circuit the branch and
82
+ // the bug path wouldn't be exercised.
83
+ it("should not crash when destroyed while a resize handler is pending", async () => {
84
+ const chessboard = new Chessboard(document.getElementById("TestBoard"), {
85
+ assetsUrl: "../assets/",
86
+ position: FEN.start
87
+ })
88
+ const view = chessboard.view
89
+ // Force the size-changed branch to trigger on the next handleResize
90
+ view.width = 0
91
+ view.height = 0
92
+ // Simulate what the ResizeObserver callback does: defer handleResize
93
+ // via setTimeout. This path is deterministic.
94
+ const pending = new Promise((resolve) => {
95
+ setTimeout(() => {
96
+ try {
97
+ view.handleResize()
98
+ resolve(null)
99
+ } catch (e) {
100
+ resolve(e)
101
+ }
102
+ })
103
+ })
104
+ // Force the race: destroy the board before the setTimeout fires.
105
+ chessboard.destroy()
106
+ const error = await pending
107
+ assert.equal(error, null, error && error.message)
108
+ })
109
+
72
110
  })
package/CLAUDE.md DELETED
@@ -1,207 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- cm-chessboard is a lightweight, dependency-free JavaScript chessboard library. It's ES6 module-based, responsive, SVG-rendered, and used in production on chessmail.eu/chessmail.de.
8
-
9
- **Key Philosophy:** The core is intentionally minimal. Functionality is extended through an extension system rather than adding features to the core.
10
-
11
- ## Development Commands
12
-
13
- ### Running Tests
14
-
15
- ```bash
16
- # Tests use the Teevi framework and must be run in a browser
17
- # Open test/index.html directly to run the test suite
18
- ```
19
-
20
- ### No Build Step
21
- This project has no build process. It uses native ES6 modules that run directly in modern browsers.
22
-
23
- ## Architecture
24
-
25
- ### Core Components (MVC Pattern)
26
-
27
- The codebase follows a clean MVC architecture:
28
-
29
- 1. **Model Layer** (`src/model/`)
30
- - `Position.js` - Board state representation using FEN notation. Contains 64-square array and FEN parsing/serialization
31
- - `ChessboardState.js` - Manages application state (orientation, input flags, extension points)
32
- - `Extension.js` - Base class for all extensions. Defines EXTENSION_POINT constants
33
-
34
- 2. **View Layer** (`src/view/`)
35
- - `ChessboardView.js` - SVG rendering, board drawing, piece rendering, coordinate display
36
- - `VisualMoveInput.js` - Handles user interaction (drag & drop, click to move)
37
- - `PositionAnimationsQueue.js` - Queues and executes piece animations
38
-
39
- 3. **Controller** (`src/Chessboard.js`)
40
- - Main API class that coordinates model and view
41
- - Entry point for all public methods
42
- - Instantiates extensions from props
43
-
44
- 4. **Utilities** (`src/lib/`)
45
- - `Svg.js` - SVG DOM manipulation helpers
46
- - `Utils.js` - General utilities (URL handling, object merging)
47
-
48
- ### Data Flow
49
-
50
- 1. User calls API method on `Chessboard` instance
51
- 2. `Chessboard` updates `Position` in `ChessboardState`
52
- 3. Extension points are invoked via `state.invokeExtensionPoints()`
53
- 4. Animation is queued in `PositionAnimationsQueue`
54
- 5. `ChessboardView` renders the changes to SVG
55
-
56
- ### Extension System
57
-
58
- Extensions are the primary way to add functionality. They:
59
- - Extend the `Extension` base class
60
- - Register callbacks at extension points using `registerExtensionPoint(EXTENSION_POINT.*, callback)`
61
- - Can add methods directly to the chessboard instance (e.g., `chessboard.addMarker = this.addMarker.bind(this)`)
62
-
63
- **Available Extension Points** (defined in `Extension.js`):
64
- - `positionChanged` - Piece positions changed
65
- - `boardChanged` - Board orientation changed
66
- - `boardResized` - Board was resized
67
- - `moveInputToggled` - Move input enabled/disabled
68
- - `moveInput` - Move events (started, validating, canceled, finished)
69
- - `beforeRedrawBoard` / `afterRedrawBoard` - Board redraw lifecycle
70
- - `animation` - Animation lifecycle hooks
71
- - `destroy` - Cleanup before board destruction
72
-
73
- **Included Extensions** (`src/extensions/`):
74
- - `markers/` - Visual markers on squares (frames, circles, dots)
75
- - `arrows/` - Drawing arrows between squares
76
- - `right-click-annotator/` - Right-click UI for annotations (uses markers + arrows)
77
- - `accessibility/` - Screen reader support, braille notation, keyboard input
78
- - `promotion-dialog/` - UI dialog for pawn promotion
79
- - `persistence/` - Save/restore board state to localStorage
80
- - `html-layer/` - Overlay HTML content on the board
81
- - `auto-border-none/` - Utility extension
82
-
83
- ## Important Implementation Details
84
-
85
- ### Coordinate System
86
- - Squares are represented as strings: "a1", "h8", etc.
87
- - Internally uses 0-63 index (Position.squareToIndex / indexToSquare)
88
- - Orientation affects rendering but not internal representation
89
-
90
- ### FEN Handling
91
- - Position class only stores the piece placement (first part of full FEN)
92
- - Full FEN format is supported in setPosition() but only piece positions are stored
93
- - Use `FEN.start` and `FEN.empty` constants
94
-
95
- ### Piece Naming
96
- - Format: `{color}{type}` - e.g., "wp" (white pawn), "bk" (black king)
97
- - Color: "w" or "b"
98
- - Type: "p", "n", "b", "r", "q", "k"
99
-
100
- ### SVG Rendering
101
- - Pieces are rendered from SVG sprites (40x40px tiles)
102
- - Sprite can be cached in a hidden div for performance
103
- - Board uses multiple SVG groups as layers (board, coordinates, markers, pieces, etc.)
104
- - Responsive sizing via ResizeObserver or window resize events
105
-
106
- ### Move Input System
107
- - Implemented in `VisualMoveInput.js`
108
- - Supports both drag-and-drop and click-to-move
109
- - Callback receives event objects with types from `INPUT_EVENT_TYPE`
110
- - Returns true/false to validate moves
111
- - Move input can be enabled per color (white/black/both)
112
-
113
- ### Animation Queue
114
- - All position changes are queued to prevent conflicts
115
- - Animations return Promises that resolve when complete
116
- - Queue ensures sequential execution even with rapid API calls
117
-
118
- ## Writing Extensions
119
-
120
- Template for a new extension:
121
-
122
- ```javascript
123
- import {Extension, EXTENSION_POINT} from "../../model/Extension.js"
124
-
125
- export class MyExtension extends Extension {
126
- constructor(chessboard, props = {}) {
127
- super(chessboard)
128
-
129
- // Set default props
130
- this.props = {
131
- myOption: true
132
- }
133
- Object.assign(this.props, props)
134
-
135
- // Register extension points
136
- this.registerExtensionPoint(EXTENSION_POINT.positionChanged, (data) => {
137
- // React to position changes
138
- })
139
-
140
- // Add methods to chessboard instance
141
- chessboard.myMethod = this.myMethod.bind(this)
142
- }
143
-
144
- myMethod() {
145
- // Implementation
146
- }
147
- }
148
- ```
149
-
150
- Enable extension in chessboard props:
151
- ```javascript
152
- extensions: [{class: MyExtension, props: {myOption: false}}]
153
- ```
154
-
155
- ## Common Patterns
156
-
157
- ### Adding a piece
158
- ```javascript
159
- board.setPiece("e4", "wn", true) // animated
160
- ```
161
-
162
- ### Moving pieces
163
- ```javascript
164
- board.movePiece("e2", "e4", true) // animated
165
- ```
166
-
167
- ### Setting entire position
168
- ```javascript
169
- board.setPosition(FEN.start, true) // animated
170
- ```
171
-
172
- ### Enabling move input
173
- ```javascript
174
- board.enableMoveInput((event) => {
175
- if (event.type === INPUT_EVENT_TYPE.validateMoveInput) {
176
- // Validate the move
177
- return isLegalMove(event.squareFrom, event.squareTo)
178
- }
179
- }, COLOR.white) // Optional: restrict to one side
180
- ```
181
-
182
- ### Creating markers (requires Markers extension)
183
- ```javascript
184
- board.addMarker(MARKER_TYPE.dot, "e4")
185
- board.removeMarkers(MARKER_TYPE.frame) // Remove by type
186
- board.removeMarkers() // Remove all
187
- ```
188
-
189
- ## Assets Structure
190
-
191
- - `assets/pieces/` - SVG sprite files for piece sets (standard.svg, staunty.svg)
192
- - `assets/extensions/` - Assets needed by extensions (markers SVG, arrow SVG, etc.)
193
- - `assets/chessboard.css` - Core styles
194
- - Extension-specific CSS must be included separately when using extensions
195
-
196
- ## Examples
197
-
198
- The `examples/` directory contains working examples demonstrating:
199
- - Simple board creation
200
- - Move input and validation
201
- - Different visual styles
202
- - All included extensions
203
- - Animation examples
204
- - Responsive boards
205
- - Multiple boards on one page
206
-
207
- Reference examples when implementing new features or debugging issues.