@astronox/core 0.5.8 → 0.5.10

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.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/client/scroll.ts +14 -1
  3. package/src/components/data-display/IconList/IconList.astro +20 -2
  4. package/src/components/data-display/MetricTile/MetricTileContent.astro +6 -1
  5. package/src/components/feedback/Tooltip/Tooltip.css +9 -1
  6. package/src/components/feedback/Tooltip/tooltipController.ts +12 -9
  7. package/src/components/media/EmbedFrame/EmbedFrame.astro +9 -0
  8. package/src/components/media/EmbedFrame/EmbedFrame.css +5 -0
  9. package/src/components/media/EmbedFrame/embedFrameController.ts +84 -1
  10. package/src/display/contracts.ts +1 -0
  11. package/src/patterns/CircuitPuzzle/CircuitPuzzle.astro +25 -2
  12. package/src/patterns/CircuitPuzzle/CircuitPuzzle.css +24 -0
  13. package/src/patterns/CircuitPuzzle/circuitPuzzleController.ts +49 -0
  14. package/src/patterns/LightMazePuzzle/LightMazePuzzle.astro +1 -0
  15. package/src/patterns/LightMazePuzzle/LightMazePuzzle.css +9 -3
  16. package/src/patterns/LightMazePuzzle/lightMazePuzzleController.ts +43 -56
  17. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.astro +65 -3
  18. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +69 -0
  19. package/src/patterns/MagneticFieldPuzzle/contracts.ts +4 -0
  20. package/src/patterns/MagneticFieldPuzzle/magneticFieldPuzzleController.ts +50 -64
  21. package/src/patterns/PuzzleGrid/PuzzleGrid.astro +2 -0
  22. package/src/patterns/PuzzleGrid/keyboard.ts +220 -0
  23. package/src/patterns/PuzzleGrid/movement.css +4 -0
  24. package/src/patterns/PuzzleGrid/movement.ts +67 -3
  25. package/src/patterns/PuzzleGrid/selectionDismissal.ts +2 -2
  26. package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +9 -1
  27. package/src/patterns/SlidingPuzzle/SlidingPuzzle.css +13 -7
  28. package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +71 -74
  29. package/src/patterns/Table/TableCell.astro +1 -1
  30. package/src/patterns/Table/TableCellValue.astro +6 -1
@@ -1,13 +1,19 @@
1
1
  import {
2
2
  animatePieceMovement,
3
3
  cancelDragFrame,
4
+ clearPuzzleMovementPreview,
4
5
  findDirectionalDropDestination,
6
+ getPuzzleMoveTarget,
5
7
  hasLeftOriginCell,
6
8
  renderPathStream,
7
9
  scheduleDragFrame,
8
10
  type PuzzleDrag,
9
11
  } from '../PuzzleGrid/movement';
10
12
  import { initPuzzleSelectionDismissal } from '../PuzzleGrid/selectionDismissal';
13
+ import {
14
+ initPuzzleKeyboard,
15
+ type PuzzleKeyboard,
16
+ } from '../PuzzleGrid/keyboard';
11
17
  import { presentLightMazeCell } from './presentation';
12
18
  import {
13
19
  setPuzzleGridCellState,
@@ -48,15 +54,10 @@ type Instance = {
48
54
  clickTimer: number | null;
49
55
  controller: AbortController;
50
56
  dismiss: () => void;
57
+ keyboard?: PuzzleKeyboard;
51
58
  };
52
59
 
53
60
  const instances = new WeakMap<HTMLElement, Instance>();
54
- const arrows: Record<string, LightMazeDirection> = {
55
- ArrowUp: 'north',
56
- ArrowRight: 'east',
57
- ArrowDown: 'south',
58
- ArrowLeft: 'west',
59
- };
60
61
 
61
62
  export function initLightMazePuzzle(root: HTMLElement): void {
62
63
  if (instances.has(root)) return;
@@ -92,15 +93,14 @@ export function initLightMazePuzzle(root: HTMLElement): void {
92
93
  instances.set(root, instance);
93
94
  instance.dismiss = initPuzzleSelectionDismissal(root, {
94
95
  dismiss: () => selectPiece(root, null),
95
- isSelectionTarget: (target) => {
96
+ isSelectionTarget: (target, event) => {
96
97
  const panel = active(instance);
97
98
  return (
98
99
  panel.element.dataset.puzzleStatus !== 'valid' &&
99
100
  panel.element.contains(target) &&
100
101
  Boolean(
101
- target.closest(
102
- '[data-light-maze-piece], [data-light-maze-destination]',
103
- ),
102
+ target.closest('[data-light-maze-piece]') ||
103
+ getPuzzleMoveTarget(panel.element, event),
104
104
  )
105
105
  );
106
106
  },
@@ -128,50 +128,14 @@ export function initLightMazePuzzle(root: HTMLElement): void {
128
128
  selectPiece(root, instance.selected === id ? null : id);
129
129
  return;
130
130
  }
131
- const destination = event.target.closest<HTMLButtonElement>(
132
- '[data-light-maze-destination]',
133
- );
134
- if (destination && !destination.disabled) {
135
- void move(
136
- root,
137
- destination.dataset.destinationPiece as LightMazePiece,
138
- destination.dataset.destinationDirection as LightMazeDirection,
139
- );
131
+ const movement = getPuzzleMoveTarget(active(instance).element, event);
132
+ if (movement) {
133
+ void move(root, movement.pieceId as LightMazePiece, movement.direction);
140
134
  } else selectPiece(root, null);
141
135
  },
142
136
  options,
143
137
  );
144
138
 
145
- root.addEventListener(
146
- 'keydown',
147
- (event) => {
148
- if (instance.motion) {
149
- if (arrows[event.key] || ['Enter', ' ', 'Escape'].includes(event.key))
150
- event.preventDefault();
151
- return;
152
- }
153
- if (event.altKey || event.ctrlKey || event.metaKey || instance.drag)
154
- return;
155
- if (event.key === 'Escape') {
156
- selectPiece(root, null);
157
- return;
158
- }
159
- const direction = arrows[event.key];
160
- if (!direction) return;
161
- const focused =
162
- event.target instanceof Element
163
- ? event.target.closest<HTMLElement>('[data-light-maze-piece]')
164
- : null;
165
- const piece =
166
- (focused?.dataset.lightMazePiece as LightMazePiece | undefined) ??
167
- instance.selected;
168
- if (!piece) return;
169
- event.preventDefault();
170
- void move(root, piece, direction);
171
- },
172
- options,
173
- );
174
-
175
139
  root.addEventListener(
176
140
  'pointerdown',
177
141
  (event) => {
@@ -264,6 +228,30 @@ export function initLightMazePuzzle(root: HTMLElement): void {
264
228
  options,
265
229
  );
266
230
  render(root);
231
+ instance.keyboard = initPuzzleKeyboard(root, {
232
+ getPanel: () => active(instance).element,
233
+ getSelected: () =>
234
+ instance.selected
235
+ ? active(instance).element.querySelector<HTMLButtonElement>(
236
+ `[data-light-maze-piece="${instance.selected}"]`,
237
+ )
238
+ : null,
239
+ select: (piece) => {
240
+ if (!piece) cancelDrag(instance);
241
+ selectPiece(
242
+ root,
243
+ (piece?.dataset.lightMazePiece as LightMazePiece | undefined) ?? null,
244
+ );
245
+ },
246
+ move: (piece, direction) => {
247
+ void move(
248
+ root,
249
+ piece.dataset.lightMazePiece as LightMazePiece,
250
+ direction,
251
+ );
252
+ },
253
+ isLocked: () => instance.motion !== null || instance.drag !== null,
254
+ });
267
255
  }
268
256
 
269
257
  export function selectLightMazePuzzle(root: HTMLElement, index: number): void {
@@ -302,6 +290,7 @@ export function resetLightMazePuzzle(root: HTMLElement): void {
302
290
  piece.removeAttribute('data-piece-moved');
303
291
  }
304
292
  render(root);
293
+ instance.keyboard?.reset();
305
294
  emitChange(root, panel);
306
295
  }
307
296
 
@@ -339,6 +328,7 @@ export function destroyLightMazePuzzle(root: HTMLElement): void {
339
328
  cancelMotion(instance);
340
329
  instance.controller.abort();
341
330
  instance.dismiss();
331
+ instance.keyboard?.destroy();
342
332
  cancelDrag(instance);
343
333
  clearClickTimer(instance);
344
334
  for (const panel of instance.panels) {
@@ -360,7 +350,7 @@ function selectPiece(root: HTMLElement, piece: LightMazePiece | null): void {
360
350
  const instance = instances.get(root);
361
351
  if (
362
352
  !instance ||
363
- instance.motion ||
353
+ (instance.motion && piece !== null) ||
364
354
  active(instance).element.dataset.puzzleStatus === 'valid'
365
355
  )
366
356
  return;
@@ -570,8 +560,8 @@ function renderSelection(root: HTMLElement): void {
570
560
  if (movement.piece !== instance.selected) continue;
571
561
  const destination = findCell(panel, movement.to);
572
562
  if (!destination) continue;
573
- renderPathStream(panel.board, movement);
574
- destination.tabIndex = 0;
563
+ renderPathStream(panel.board, movement, movement.piece);
564
+ destination.tabIndex = -1;
575
565
  destination.setAttribute('data-light-maze-destination', '');
576
566
  destination.setAttribute('data-puzzle-cell-interactive', '');
577
567
  destination.dataset.destinationPiece = movement.piece;
@@ -587,10 +577,7 @@ function renderSelection(root: HTMLElement): void {
587
577
  }
588
578
 
589
579
  function clearPreviews(panel: Panel): void {
590
- for (const stream of panel.element.querySelectorAll(
591
- '[data-puzzle-path-stream]',
592
- ))
593
- stream.remove();
580
+ clearPuzzleMovementPreview(panel.element);
594
581
  for (const cell of panel.element.querySelectorAll<HTMLButtonElement>(
595
582
  '[data-light-maze-destination]',
596
583
  )) {
@@ -4,14 +4,17 @@ import MinusIcon from '../../components/icons/MinusIcon.astro';
4
4
  import PlusIcon from '../../components/icons/PlusIcon.astro';
5
5
  import SlashIcon from '../../components/icons/SlashIcon.astro';
6
6
  import PuzzleGrid from '../PuzzleGrid/PuzzleGrid.astro';
7
+ import Media from '../../primitives/media/Media/Media.astro';
7
8
  import type {
8
9
  MagneticFieldPuzzleLabels,
9
10
  MagneticFieldPuzzleModel,
11
+ MagneticFieldPuzzleMedia,
10
12
  } from './contracts';
11
13
 
12
14
  type Props = HTMLAttributes<'section'> & {
13
15
  label: string;
14
16
  labels: MagneticFieldPuzzleLabels;
17
+ media?: MagneticFieldPuzzleMedia;
15
18
  puzzles: readonly MagneticFieldPuzzleModel[];
16
19
  };
17
20
 
@@ -19,9 +22,16 @@ const {
19
22
  class: className,
20
23
  label,
21
24
  labels,
25
+ media,
22
26
  puzzles,
23
27
  ...attrs
24
28
  }: Props = Astro.props;
29
+
30
+ if (
31
+ media &&
32
+ puzzles.some((puzzle) => puzzle.pieces.some((piece) => !media[piece.id]))
33
+ )
34
+ throw new Error('Magnetic field puzzle media must cover every piece.');
25
35
  ---
26
36
 
27
37
  <section
@@ -46,7 +56,53 @@ const {
46
56
  data-puzzle-id={puzzle.id}
47
57
  data-puzzle-status="incomplete"
48
58
  hidden={puzzleIndex !== 0}
59
+ style={
60
+ media
61
+ ? `--piece-selection-filter: url(#magnetic-outline-${encodeURIComponent(puzzle.id)}); --piece-hover-filter: url(#magnetic-hover-${encodeURIComponent(puzzle.id)});`
62
+ : undefined
63
+ }
49
64
  >
65
+ {media && (
66
+ <svg
67
+ class="magnetic-field-puzzle__filters"
68
+ aria-hidden="true"
69
+ focusable="false"
70
+ >
71
+ <defs>
72
+ <filter
73
+ id={`magnetic-hover-${encodeURIComponent(puzzle.id)}`}
74
+ color-interpolation-filters="sRGB"
75
+ >
76
+ <feFlood
77
+ class="magnetic-field-puzzle__hover-color"
78
+ flood-opacity="0.1"
79
+ />
80
+ <feComposite operator="in" in2="SourceAlpha" />
81
+ <feMerge>
82
+ <feMergeNode in="SourceGraphic" />
83
+ <feMergeNode />
84
+ </feMerge>
85
+ </filter>
86
+ <filter
87
+ id={`magnetic-outline-${encodeURIComponent(puzzle.id)}`}
88
+ x="-25%"
89
+ y="-25%"
90
+ width="150%"
91
+ height="150%"
92
+ color-interpolation-filters="sRGB"
93
+ >
94
+ <feMorphology in="SourceAlpha" operator="dilate" radius="2" />
95
+ <feGaussianBlur stdDeviation="0.5" result="outline" />
96
+ <feFlood class="magnetic-field-puzzle__ring-color" />
97
+ <feComposite operator="in" in2="outline" />
98
+ <feMerge>
99
+ <feMergeNode />
100
+ <feMergeNode in="SourceGraphic" />
101
+ </feMerge>
102
+ </filter>
103
+ </defs>
104
+ </svg>
105
+ )}
50
106
  <PuzzleGrid
51
107
  class="magnetic-field-puzzle__board"
52
108
  label={puzzle.label}
@@ -158,6 +214,8 @@ const {
158
214
  aria-label={`${piece.label} — ${labels.polarities[piece.polarity]}`}
159
215
  aria-pressed="false"
160
216
  data-field-piece={piece.id}
217
+ data-puzzle-control
218
+ data-field-piece-media={media ? '' : undefined}
161
219
  data-piece-label={piece.label}
162
220
  data-piece-short-label={piece.shortLabel}
163
221
  data-piece-row={piece.row}
@@ -175,9 +233,13 @@ const {
175
233
  class="magnetic-field-puzzle__piece-core"
176
234
  aria-hidden="true"
177
235
  >
178
- <span class="magnetic-field-puzzle__piece-iris">
179
- <span class="magnetic-field-puzzle__piece-pupil" />
180
- </span>
236
+ {media ? (
237
+ <Media media={media[piece.id]!} size="fill" />
238
+ ) : (
239
+ <span class="magnetic-field-puzzle__piece-iris">
240
+ <span class="magnetic-field-puzzle__piece-pupil" />
241
+ </span>
242
+ )}
181
243
  <span class="magnetic-field-puzzle__piece-identity-region">
182
244
  <span
183
245
  class="magnetic-field-puzzle__identity"
@@ -19,6 +19,21 @@
19
19
  display: none;
20
20
  }
21
21
 
22
+ .magnetic-field-puzzle__filters {
23
+ position: absolute;
24
+ inline-size: 0;
25
+ block-size: 0;
26
+ pointer-events: none;
27
+ }
28
+
29
+ .magnetic-field-puzzle__ring-color {
30
+ flood-color: var(--nox-source-tone-info);
31
+ }
32
+
33
+ .magnetic-field-puzzle__hover-color {
34
+ flood-color: var(--nox-tone-color);
35
+ }
36
+
22
37
  .magnetic-field-puzzle__board {
23
38
  isolation: isolate;
24
39
  touch-action: manipulation;
@@ -287,6 +302,60 @@
287
302
  );
288
303
  }
289
304
 
305
+ .magnetic-field-puzzle__piece[data-field-piece-media] {
306
+ border: var(--nox-border-width) solid transparent;
307
+ }
308
+
309
+ .magnetic-field-puzzle__piece[data-field-piece-media]
310
+ .magnetic-field-puzzle__piece-core {
311
+ inline-size: 84%;
312
+ border: 0;
313
+ border-radius: 0;
314
+ background: none;
315
+ box-shadow: none;
316
+ container-type: inline-size;
317
+ pointer-events: none;
318
+ }
319
+
320
+ .magnetic-field-puzzle__piece[data-field-piece-media]
321
+ .magnetic-field-puzzle__piece-identity-region {
322
+ inset-block: 68% 14%;
323
+ }
324
+
325
+ .magnetic-field-puzzle__piece[data-field-piece-media]
326
+ .magnetic-field-puzzle__identity {
327
+ font-size: clamp(0.625rem, 22cqi, var(--nox-font-size-md));
328
+ }
329
+
330
+ .magnetic-field-puzzle__piece[data-field-piece-media]:is(
331
+ [data-piece-selected],
332
+ :focus-visible
333
+ )
334
+ .magnetic-field-puzzle__piece-core {
335
+ filter: var(--piece-selection-filter);
336
+ }
337
+
338
+ @media (hover: hover) {
339
+ .magnetic-field-puzzle__piece[data-field-piece-media]:hover
340
+ .magnetic-field-puzzle__piece-core {
341
+ filter: var(--piece-hover-filter);
342
+ }
343
+
344
+ .magnetic-field-puzzle__piece[data-field-piece-media]:is(
345
+ [data-piece-selected],
346
+ :focus-visible
347
+ ):hover
348
+ .magnetic-field-puzzle__piece-core {
349
+ filter: var(--piece-hover-filter) var(--piece-selection-filter);
350
+ }
351
+ }
352
+
353
+ @media (width < 34rem) {
354
+ .magnetic-field-puzzle__piece[data-field-piece-media] {
355
+ margin: 2px;
356
+ }
357
+ }
358
+
290
359
  @media (prefers-reduced-motion: no-preference) {
291
360
  .magnetic-field-puzzle__piece[data-piece-moved] {
292
361
  animation: magnetic-field-slide 260ms var(--nox-motion-easing-standard);
@@ -1,3 +1,7 @@
1
+ import type { DisplayMedia } from '../../display/contracts';
2
+
3
+ export type MagneticFieldPuzzleMedia = Readonly<Record<string, DisplayMedia>>;
4
+
1
5
  export type MagneticFieldDirection = 'north' | 'east' | 'south' | 'west';
2
6
  export type MagneticFieldPolarity = 'north' | 'south';
3
7
  export type MagneticFieldPupilPosition = 'upper-end' | 'center' | 'lower-start';
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  animatePieceMovement,
3
3
  cancelDragFrame,
4
+ clearPuzzleMovementPreview,
4
5
  findDirectionalDropDestination,
6
+ getPuzzleMoveTarget,
5
7
  hasLeftOriginCell,
6
8
  renderPathStream,
7
9
  scheduleDragFrame,
@@ -12,6 +14,10 @@ import {
12
14
  setPuzzleGridState,
13
15
  } from '../PuzzleGrid/state';
14
16
  import { initPuzzleSelectionDismissal } from '../PuzzleGrid/selectionDismissal';
17
+ import {
18
+ initPuzzleKeyboard,
19
+ type PuzzleKeyboard,
20
+ } from '../PuzzleGrid/keyboard';
15
21
  import type {
16
22
  MagneticFieldDirection,
17
23
  MagneticFieldPolarity,
@@ -33,6 +39,7 @@ type MagneticFieldInstance = {
33
39
  selectedPieceId: string | null;
34
40
  drag: PuzzleDrag | null;
35
41
  ignoreNextClick: boolean;
42
+ keyboard?: PuzzleKeyboard;
36
43
  };
37
44
 
38
45
  const dragThreshold = 12;
@@ -48,19 +55,14 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
48
55
  });
49
56
  initPuzzleSelectionDismissal(root, {
50
57
  dismiss: () => selectPiece(root, null),
51
- isSelectionTarget: (target) => {
58
+ isSelectionTarget: (target, event) => {
52
59
  const panel = getActivePanel(root);
53
60
  if (!panel || panel.dataset.puzzleStatus === 'valid') return false;
54
61
 
55
62
  const piece = target.closest<HTMLElement>('[data-field-piece]');
56
63
  if (piece && panel.contains(piece)) return true;
57
64
 
58
- const destination = target.closest<HTMLButtonElement>(
59
- '[data-field-destination]',
60
- );
61
- return Boolean(
62
- destination && panel.contains(destination) && !destination.disabled,
63
- );
65
+ return Boolean(getPuzzleMoveTarget(panel, event));
64
66
  },
65
67
  });
66
68
 
@@ -79,14 +81,9 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
79
81
  return;
80
82
  }
81
83
 
82
- const destination = target.closest<HTMLButtonElement>(
83
- '[data-field-destination]',
84
- );
85
- if (destination && !destination.disabled) {
86
- const pieceId = destination.dataset.destinationPiece;
87
- const direction = destination.dataset.destinationDirection as
88
- MagneticFieldDirection | undefined;
89
- if (pieceId && direction) movePiece(root, pieceId, direction);
84
+ const movement = getPuzzleMoveTarget(getActivePanel(root), event);
85
+ if (movement) {
86
+ movePiece(root, movement.pieceId, movement.direction);
90
87
  return;
91
88
  }
92
89
 
@@ -102,12 +99,11 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
102
99
  const pieceId = piece?.dataset.fieldPiece;
103
100
  if (!piece || !pieceId || !instance) return;
104
101
  const panel = getActivePanel(root);
105
- const originCell = panel
106
- ? findCell(panel, {
107
- row: Number(piece.dataset.pieceRow),
108
- column: Number(piece.dataset.pieceColumn),
109
- })
110
- : null;
102
+ if (!panel || panel.dataset.puzzleStatus === 'valid') return;
103
+ const originCell = findCell(panel, {
104
+ row: Number(piece.dataset.pieceRow),
105
+ column: Number(piece.dataset.pieceColumn),
106
+ });
111
107
  if (!originCell) return;
112
108
  const pieceBounds = piece.getBoundingClientRect();
113
109
 
@@ -155,24 +151,20 @@ export function initMagneticFieldPuzzle(root: HTMLElement): void {
155
151
  finishDrag(root, event, false);
156
152
  });
157
153
 
158
- root.addEventListener('keydown', (event) => {
159
- if (event.altKey || event.ctrlKey || event.metaKey) return;
160
- const direction = arrowDirections[event.key.toLowerCase()];
161
- if (!direction) {
162
- if (event.key === 'Escape') selectPiece(root, null);
163
- return;
164
- }
165
-
166
- const piece = (event.target as Element | null)?.closest<HTMLElement>(
167
- '[data-field-piece]',
168
- );
169
- const pieceId =
170
- piece?.dataset.fieldPiece ??
171
- instances.get(root)?.selectedPieceId ??
172
- undefined;
173
- if (!pieceId) return;
174
- event.preventDefault();
175
- movePiece(root, pieceId, direction);
154
+ const instance = instances.get(root)!;
155
+ instance.keyboard = initPuzzleKeyboard(root, {
156
+ getPanel: () => getActivePanel(root),
157
+ getSelected: () =>
158
+ getActivePanel(root)?.querySelector<HTMLButtonElement>(
159
+ '[data-field-piece][data-piece-selected]',
160
+ ) ?? null,
161
+ select: (piece) => {
162
+ if (!piece) cancelDrag(instance);
163
+ selectPiece(root, piece?.dataset.fieldPiece ?? null);
164
+ },
165
+ move: (piece, direction) =>
166
+ movePiece(root, piece.dataset.fieldPiece!, direction),
167
+ isLocked: () => instance.drag !== null,
176
168
  });
177
169
  }
178
170
 
@@ -286,11 +278,11 @@ function updateDestinations(root: HTMLElement): void {
286
278
  for (const movement of getMagneticFieldMoves(puzzle, state, pieceId)) {
287
279
  const directionLabel = readDirectionLabel(labels, movement.direction);
288
280
  const board = panel.querySelector<HTMLElement>('[data-field-board]');
289
- if (board) renderPathStream(board, movement);
281
+ if (board) renderPathStream(board, movement, pieceId);
290
282
 
291
283
  const destination = findCell(panel, movement.to);
292
284
  if (!destination) continue;
293
- destination.removeAttribute('tabindex');
285
+ destination.tabIndex = -1;
294
286
  destination.setAttribute('data-field-destination', '');
295
287
  destination.setAttribute('data-puzzle-cell-interactive', '');
296
288
  destination.dataset.destinationPiece = pieceId;
@@ -350,13 +342,15 @@ function initializeActivePuzzle(root: HTMLElement): void {
350
342
  const initial = createMagneticFieldInitialState(puzzle);
351
343
 
352
344
  instance.selectedPieceId = null;
353
- instance.drag = null;
345
+ cancelDrag(instance);
354
346
  instance.ignoreNextClick = false;
355
347
  panel.dataset.puzzleStatus = 'incomplete';
356
348
  const grid = panel.querySelector<HTMLElement>('[data-puzzle-grid]');
357
349
  if (grid) setPuzzleGridState(grid, null);
358
350
  renderState(panel, puzzle, initial);
351
+ syncPieceSelection(panel, null);
359
352
  hideMovePreviews(panel);
353
+ instance.keyboard?.reset();
360
354
  emitChange(root, puzzle.id, initial);
361
355
  }
362
356
 
@@ -458,14 +452,7 @@ function finishDrag(
458
452
  ? findDirectionalDropDestination(drag, deltaX, deltaY)
459
453
  : null;
460
454
 
461
- cancelDragFrame(drag);
462
- drag.element.removeAttribute('data-piece-dragging');
463
- drag.element.style.removeProperty('--piece-drag-x');
464
- drag.element.style.removeProperty('--piece-drag-y');
465
- if (drag.element.hasPointerCapture(event.pointerId)) {
466
- drag.element.releasePointerCapture(event.pointerId);
467
- }
468
- instance.drag = null;
455
+ cancelDrag(instance);
469
456
 
470
457
  if (commit && drag.moved) {
471
458
  instance.ignoreNextClick = true;
@@ -492,12 +479,20 @@ function getDropDestinations(root: HTMLElement): HTMLButtonElement[] {
492
479
  ];
493
480
  }
494
481
 
482
+ function cancelDrag(instance: MagneticFieldInstance): void {
483
+ const drag = instance.drag;
484
+ if (!drag) return;
485
+ instance.drag = null;
486
+ cancelDragFrame(drag);
487
+ drag.element.removeAttribute('data-piece-dragging');
488
+ drag.element.style.removeProperty('--piece-drag-x');
489
+ drag.element.style.removeProperty('--piece-drag-y');
490
+ if (drag.element.hasPointerCapture(drag.pointerId))
491
+ drag.element.releasePointerCapture(drag.pointerId);
492
+ }
493
+
495
494
  function hideMovePreviews(panel: HTMLElement): void {
496
- for (const stream of panel.querySelectorAll<HTMLElement>(
497
- '[data-puzzle-path-stream]',
498
- )) {
499
- stream.remove();
500
- }
495
+ clearPuzzleMovementPreview(panel);
501
496
 
502
497
  for (const destination of panel.querySelectorAll<HTMLButtonElement>(
503
498
  '[data-field-destination]',
@@ -657,12 +652,3 @@ function formatTemplate(
657
652
  function capitalize(value: string): string {
658
653
  return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
659
654
  }
660
-
661
- const arrowDirections: Readonly<
662
- Partial<Record<string, MagneticFieldDirection>>
663
- > = {
664
- arrowup: 'north',
665
- arrowright: 'east',
666
- arrowdown: 'south',
667
- arrowleft: 'west',
668
- };
@@ -16,6 +16,8 @@ const { class: className, label, rows, columns, ...attrs }: Props = Astro.props;
16
16
  role="group"
17
17
  aria-label={label}
18
18
  data-puzzle-grid
19
+ data-puzzle-rows={rows}
20
+ data-puzzle-columns={columns}
19
21
  style={`--puzzle-columns: ${columns}; --puzzle-rows: ${rows};`}
20
22
  >
21
23
  <slot />