@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
@@ -0,0 +1,220 @@
1
+ import {
2
+ getPuzzleMoveTarget,
3
+ puzzleArrowDirections,
4
+ type PuzzleDirection,
5
+ } from './movement';
6
+
7
+ type PuzzleKeyboardOptions = {
8
+ getPanel: () => HTMLElement | undefined;
9
+ getSelected: () => HTMLButtonElement | null;
10
+ select: (control: HTMLButtonElement | null) => void;
11
+ move: (control: HTMLButtonElement, direction: PuzzleDirection) => void;
12
+ isLocked?: () => boolean;
13
+ };
14
+
15
+ export type PuzzleKeyboard = {
16
+ reset: () => void;
17
+ destroy: () => void;
18
+ };
19
+
20
+ export function initPuzzleKeyboard(
21
+ root: HTMLElement,
22
+ {
23
+ getPanel,
24
+ getSelected,
25
+ select,
26
+ move,
27
+ isLocked = () => false,
28
+ }: PuzzleKeyboardOptions,
29
+ ): PuzzleKeyboard {
30
+ const controller = new AbortController();
31
+ const options = { signal: controller.signal };
32
+ let engaged = false;
33
+ let released = false;
34
+ let pointerFocus = false;
35
+ let focusing = false;
36
+ let entry: HTMLButtonElement | null = null;
37
+ const getBoard = (): HTMLElement | null =>
38
+ getPanel()?.querySelector<HTMLElement>('[data-puzzle-grid]') ?? null;
39
+ const isComplete = (): boolean =>
40
+ getPanel()?.dataset.puzzleStatus === 'valid';
41
+ const getControls = (): HTMLButtonElement[] => [
42
+ ...(getBoard()?.querySelectorAll<HTMLButtonElement>(
43
+ '[data-puzzle-control]:not(:disabled)',
44
+ ) ?? []),
45
+ ];
46
+
47
+ const sync = (): void => {
48
+ const controls = getControls();
49
+ const selected = getSelected();
50
+ entry =
51
+ (selected && controls.includes(selected) ? selected : null) ??
52
+ (entry && controls.includes(entry) ? entry : null) ??
53
+ controls[0] ??
54
+ null;
55
+ if (!selected || isComplete()) engaged = false;
56
+ const board = getBoard();
57
+ if (board) board.tabIndex = released && !isComplete() ? 0 : -1;
58
+ for (const control of controls) {
59
+ control.tabIndex =
60
+ !released && !isComplete() && control === entry ? 0 : -1;
61
+ control.setAttribute(
62
+ 'aria-keyshortcuts',
63
+ 'ArrowUp ArrowRight ArrowDown ArrowLeft Tab Shift+Tab Escape Enter Space',
64
+ );
65
+ }
66
+ };
67
+
68
+ const focus = (control: HTMLElement): void => {
69
+ focusing = true;
70
+ control.focus({ preventScroll: true });
71
+ focusing = false;
72
+ };
73
+
74
+ const activate = (control: HTMLButtonElement): void => {
75
+ released = false;
76
+ entry = control;
77
+ if (getSelected() !== control) select(control);
78
+ sync();
79
+ engaged = true;
80
+ focus(control);
81
+ };
82
+
83
+ root.addEventListener(
84
+ 'pointerdown',
85
+ () => {
86
+ pointerFocus = true;
87
+ },
88
+ { ...options, capture: true },
89
+ );
90
+ root.addEventListener(
91
+ 'pointercancel',
92
+ () => {
93
+ pointerFocus = false;
94
+ },
95
+ options,
96
+ );
97
+ root.addEventListener(
98
+ 'focusin',
99
+ (event) => {
100
+ if (focusing || pointerFocus || isLocked() || isComplete()) return;
101
+ if (!(event.target instanceof HTMLButtonElement)) return;
102
+ if (getControls().includes(event.target)) activate(event.target);
103
+ },
104
+ options,
105
+ );
106
+ root.addEventListener(
107
+ 'focusout',
108
+ (event) => {
109
+ if (
110
+ event.relatedTarget instanceof Node &&
111
+ getBoard()?.contains(event.relatedTarget)
112
+ )
113
+ return;
114
+ engaged = false;
115
+ released = false;
116
+ pointerFocus = false;
117
+ select(null);
118
+ sync();
119
+ },
120
+ options,
121
+ );
122
+ root.addEventListener(
123
+ 'click',
124
+ (event) => {
125
+ if (!(event.target instanceof Element) || isLocked()) return;
126
+ const selected = getSelected();
127
+ if (selected && getPuzzleMoveTarget(getPanel(), event)) focus(selected);
128
+ },
129
+ { ...options, capture: true },
130
+ );
131
+ root.addEventListener(
132
+ 'click',
133
+ (event) => {
134
+ pointerFocus = false;
135
+ if (
136
+ !(event.target instanceof Node) ||
137
+ !getBoard()?.contains(event.target)
138
+ )
139
+ return;
140
+ const selected = getSelected();
141
+ if (selected && !isComplete()) {
142
+ released = false;
143
+ engaged = true;
144
+ sync();
145
+ focus(selected);
146
+ } else if (getBoard()?.contains(document.activeElement)) {
147
+ released = true;
148
+ sync();
149
+ const board = getBoard();
150
+ if (board) focus(board);
151
+ } else sync();
152
+ },
153
+ options,
154
+ );
155
+ root.addEventListener(
156
+ 'keydown',
157
+ (event) => {
158
+ pointerFocus = false;
159
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
160
+ const board = getBoard();
161
+ if (!(event.target instanceof Node) || !board?.contains(event.target))
162
+ return;
163
+ if (event.key === 'Escape') {
164
+ event.preventDefault();
165
+ event.stopPropagation();
166
+ engaged = false;
167
+ released = true;
168
+ select(null);
169
+ sync();
170
+ focus(board);
171
+ return;
172
+ }
173
+ if (isComplete()) return;
174
+ const direction = puzzleArrowDirections[event.key];
175
+ if (event.key === 'Tab') {
176
+ if (!engaged) return;
177
+ event.preventDefault();
178
+ if (isLocked()) return;
179
+ const controls = getControls();
180
+ if (!controls.length) return;
181
+ const index = controls.indexOf(getSelected() ?? entry ?? controls[0]);
182
+ const next =
183
+ (index + (event.shiftKey ? -1 : 1) + controls.length) %
184
+ controls.length;
185
+ activate(controls[next]);
186
+ } else if (isLocked()) {
187
+ if (direction || event.key === 'Enter' || event.key === ' ')
188
+ event.preventDefault();
189
+ } else if (direction) {
190
+ event.preventDefault();
191
+ const selected = getSelected() ?? entry ?? getControls()[0];
192
+ if (!selected) return;
193
+ activate(selected);
194
+ move(selected, direction);
195
+ sync();
196
+ } else if (
197
+ event.target === board &&
198
+ (event.key === 'Enter' || event.key === ' ')
199
+ ) {
200
+ event.preventDefault();
201
+ const selected = entry ?? getControls()[0];
202
+ if (selected) activate(selected);
203
+ }
204
+ },
205
+ options,
206
+ );
207
+ root.addEventListener('puzzle:change', sync, options);
208
+ root.addEventListener('puzzle:complete', sync, options);
209
+ sync();
210
+ return {
211
+ reset: () => {
212
+ engaged = false;
213
+ released = false;
214
+ entry = null;
215
+ pointerFocus = false;
216
+ sync();
217
+ },
218
+ destroy: () => controller.abort(),
219
+ };
220
+ }
@@ -1,3 +1,7 @@
1
+ .puzzle-grid__cell[data-puzzle-move-direction]:not(:disabled) {
2
+ cursor: pointer;
3
+ }
4
+
1
5
  .puzzle-grid__path-stream {
2
6
  --puzzle-path-rotation: 0deg;
3
7
 
@@ -14,7 +14,60 @@ export type PuzzleDrag = {
14
14
  moved: boolean;
15
15
  };
16
16
 
17
- type Direction = 'north' | 'east' | 'south' | 'west';
17
+ export type PuzzleDirection = 'north' | 'east' | 'south' | 'west';
18
+
19
+ export const puzzleArrowDirections: Readonly<
20
+ Partial<Record<string, PuzzleDirection>>
21
+ > = {
22
+ ArrowUp: 'north',
23
+ ArrowRight: 'east',
24
+ ArrowDown: 'south',
25
+ ArrowLeft: 'west',
26
+ };
27
+
28
+ export function getPuzzleMoveTarget(
29
+ panel: HTMLElement | undefined,
30
+ event: MouseEvent,
31
+ ): { pieceId: string; direction: PuzzleDirection } | null {
32
+ const target = event.target;
33
+ if (!(target instanceof Element) || !panel?.contains(target)) return null;
34
+ let cell = target.closest<HTMLButtonElement>('[data-puzzle-move-direction]');
35
+ if (
36
+ !cell &&
37
+ target instanceof HTMLElement &&
38
+ target.matches('[data-puzzle-grid]')
39
+ ) {
40
+ const bounds = target.getBoundingClientRect();
41
+ const x = event.clientX - bounds.left - target.clientLeft;
42
+ const y = event.clientY - bounds.top - target.clientTop;
43
+ if (x < 0 || y < 0 || x >= target.clientWidth || y >= target.clientHeight)
44
+ return null;
45
+ const column = Math.floor(
46
+ (x * Number(target.dataset.puzzleColumns)) / target.clientWidth,
47
+ );
48
+ const row = Math.floor(
49
+ (y * Number(target.dataset.puzzleRows)) / target.clientHeight,
50
+ );
51
+ cell = target.querySelector<HTMLButtonElement>(
52
+ `[data-puzzle-move-direction][data-cell-row="${row}"][data-cell-column="${column}"]`,
53
+ );
54
+ }
55
+ if (!cell || cell.disabled) return null;
56
+ const pieceId = cell.dataset.puzzleMovePiece;
57
+ const direction = cell.dataset.puzzleMoveDirection as PuzzleDirection;
58
+ return pieceId ? { pieceId, direction } : null;
59
+ }
60
+
61
+ export function clearPuzzleMovementPreview(panel: HTMLElement): void {
62
+ for (const stream of panel.querySelectorAll('[data-puzzle-path-stream]'))
63
+ stream.remove();
64
+ for (const cell of panel.querySelectorAll<HTMLElement>(
65
+ '[data-puzzle-move-direction]',
66
+ )) {
67
+ delete cell.dataset.puzzleMoveDirection;
68
+ delete cell.dataset.puzzleMovePiece;
69
+ }
70
+ }
18
71
 
19
72
  const pathChevronsPerCell = 3;
20
73
  const pathDurationPerCellMs = 2100;
@@ -78,7 +131,7 @@ export function findDirectionalDropDestination(
78
131
  deltaX: number,
79
132
  deltaY: number,
80
133
  ): HTMLButtonElement | null {
81
- let direction: Direction;
134
+ let direction: PuzzleDirection;
82
135
  if (Math.abs(deltaX) >= Math.abs(deltaY)) {
83
136
  direction = deltaX >= 0 ? 'east' : 'west';
84
137
  } else {
@@ -99,12 +152,23 @@ export function renderPathStream(
99
152
  movement: {
100
153
  from: { row: number; column: number };
101
154
  to: { row: number; column: number };
102
- direction: Direction;
155
+ direction: PuzzleDirection;
103
156
  },
157
+ pieceId: string,
104
158
  ): void {
105
159
  const rowDistance = movement.to.row - movement.from.row;
106
160
  const columnDistance = movement.to.column - movement.from.column;
107
161
  const cellDistance = Math.abs(rowDistance) + Math.abs(columnDistance);
162
+ for (let step = 1; step <= cellDistance; step += 1) {
163
+ const row = movement.from.row + Math.sign(rowDistance) * step;
164
+ const column = movement.from.column + Math.sign(columnDistance) * step;
165
+ const cell = board.querySelector<HTMLButtonElement>(
166
+ `[data-puzzle-cell][data-cell-row="${row}"][data-cell-column="${column}"]`,
167
+ );
168
+ if (!cell || cell.disabled) continue;
169
+ cell.dataset.puzzleMoveDirection = movement.direction;
170
+ cell.dataset.puzzleMovePiece = pieceId;
171
+ }
108
172
  const chevronCount = pathChevronsPerCell * cellDistance;
109
173
  const stream = document.createElement('span');
110
174
  stream.className = 'puzzle-grid__path-stream';
@@ -1,6 +1,6 @@
1
1
  type PuzzleSelectionDismissalOptions = {
2
2
  dismiss: () => void;
3
- isSelectionTarget: (target: Element) => boolean;
3
+ isSelectionTarget: (target: Element, event: PointerEvent) => boolean;
4
4
  };
5
5
 
6
6
  export function initPuzzleSelectionDismissal(
@@ -15,7 +15,7 @@ export function initPuzzleSelectionDismissal(
15
15
  const target = event.target;
16
16
  if (!(target instanceof Node)) return;
17
17
  const element = target instanceof Element ? target : target.parentElement;
18
- if (element && isSelectionTarget(element)) return;
18
+ if (element && isSelectionTarget(element, event)) return;
19
19
 
20
20
  dismiss();
21
21
  const focused = document.activeElement;
@@ -147,7 +147,14 @@ const {
147
147
  </svg>
148
148
  )}
149
149
  {hole && (
150
- <span class="sliding-puzzle__hole" aria-hidden="true" />
150
+ <svg
151
+ class="sliding-puzzle__hole"
152
+ viewBox="0 0 100 100"
153
+ aria-hidden="true"
154
+ focusable="false"
155
+ >
156
+ <circle cx="50" cy="50" r="46.5" />
157
+ </svg>
151
158
  )}
152
159
  {target && (
153
160
  <span class="sliding-puzzle__target" aria-hidden="true">
@@ -163,6 +170,7 @@ const {
163
170
  type="button"
164
171
  aria-label={labels.piece}
165
172
  aria-pressed="false"
173
+ data-puzzle-control
166
174
  data-sliding-piece
167
175
  style={`--piece-row: ${puzzle.start.row + 1}; --piece-column: ${puzzle.start.column + 1};`}
168
176
  >
@@ -69,11 +69,11 @@
69
69
 
70
70
  .sliding-puzzle__hole {
71
71
  display: none;
72
- inline-size: 72%;
72
+ inline-size: 84%;
73
73
  aspect-ratio: 1;
74
- border: calc(var(--nox-border-width) * 2) solid var(--sliding-transfer-color);
75
- border-radius: var(--nox-radius-pill);
76
- background: color-mix(
74
+ stroke: var(--sliding-transfer-color);
75
+ stroke-width: 2.5;
76
+ fill: color-mix(
77
77
  in srgb,
78
78
  var(--nox-source-tone-neutral) 20%,
79
79
  var(--nox-mix-dark)
@@ -99,7 +99,7 @@
99
99
 
100
100
  .sliding-puzzle__target {
101
101
  display: grid;
102
- inline-size: 90%;
102
+ inline-size: 84%;
103
103
  aspect-ratio: 1;
104
104
  pointer-events: none;
105
105
  }
@@ -115,7 +115,7 @@
115
115
  margin: var(--nox-space-1);
116
116
  padding: 0;
117
117
  place-items: center;
118
- border: 0;
118
+ border: var(--nox-border-width) solid transparent;
119
119
  border-radius: var(--nox-radius-sm);
120
120
  background: transparent;
121
121
  cursor: grab;
@@ -125,7 +125,7 @@
125
125
 
126
126
  .sliding-puzzle__piece-media {
127
127
  display: grid;
128
- inline-size: 80%;
128
+ inline-size: 84%;
129
129
  aspect-ratio: 1;
130
130
  pointer-events: none;
131
131
  }
@@ -169,6 +169,12 @@
169
169
  cursor: default;
170
170
  }
171
171
 
172
+ @media (width < 34rem) {
173
+ .sliding-puzzle__piece {
174
+ margin: 2px;
175
+ }
176
+ }
177
+
172
178
  @media (prefers-reduced-motion: no-preference) {
173
179
  .sliding-puzzle__piece[data-piece-moved] {
174
180
  animation: sliding-puzzle-move 260ms var(--nox-motion-easing-standard);
@@ -3,10 +3,16 @@ import {
3
3
  setPuzzleGridState,
4
4
  } from '../PuzzleGrid/state';
5
5
  import { initPuzzleSelectionDismissal } from '../PuzzleGrid/selectionDismissal';
6
+ import {
7
+ initPuzzleKeyboard,
8
+ type PuzzleKeyboard,
9
+ } from '../PuzzleGrid/keyboard';
6
10
  import {
7
11
  animatePieceMovement,
8
12
  cancelDragFrame,
13
+ clearPuzzleMovementPreview,
9
14
  findDirectionalDropDestination,
15
+ getPuzzleMoveTarget,
10
16
  hasLeftOriginCell,
11
17
  renderPathStream,
12
18
  scheduleDragFrame,
@@ -35,19 +41,17 @@ type Instance = {
35
41
  selected: boolean;
36
42
  drag: PuzzleDrag | null;
37
43
  terrainFrame: number | null;
38
- motion: AbortController | null;
44
+ motion: {
45
+ controller: AbortController;
46
+ blocking: boolean;
47
+ } | null;
39
48
  ignoreNextClick: boolean;
40
49
  abort: AbortController;
41
50
  dismiss: () => void;
51
+ keyboard?: PuzzleKeyboard;
42
52
  };
43
53
 
44
54
  const instances = new WeakMap<HTMLElement, Instance>();
45
- const directions: Readonly<Partial<Record<string, SlidingDirection>>> = {
46
- ArrowUp: 'north',
47
- ArrowRight: 'east',
48
- ArrowDown: 'south',
49
- ArrowLeft: 'west',
50
- };
51
55
 
52
56
  export function initSlidingPuzzle(root: HTMLElement): void {
53
57
  if (instances.has(root)) return;
@@ -68,13 +72,14 @@ export function initSlidingPuzzle(root: HTMLElement): void {
68
72
  const options = { signal: instance.abort.signal };
69
73
  instance.dismiss = initPuzzleSelectionDismissal(root, {
70
74
  dismiss: () => select(root, false),
71
- isSelectionTarget: (target) => {
75
+ isSelectionTarget: (target, event) => {
72
76
  const panel = activePanel(root);
73
77
  return Boolean(
74
78
  panel &&
75
79
  panel.dataset.puzzleStatus !== 'valid' &&
76
80
  panel.contains(target) &&
77
- target.closest('[data-sliding-piece], [data-sliding-destination]'),
81
+ (target.closest('[data-sliding-piece]') ||
82
+ getPuzzleMoveTarget(panel, event)),
78
83
  );
79
84
  },
80
85
  });
@@ -82,7 +87,8 @@ export function initSlidingPuzzle(root: HTMLElement): void {
82
87
  root.addEventListener(
83
88
  'click',
84
89
  (event) => {
85
- if (!(event.target instanceof Element)) return;
90
+ if (!(event.target instanceof Element) || instance.motion?.blocking)
91
+ return;
86
92
  if (instance.ignoreNextClick) {
87
93
  instance.ignoreNextClick = false;
88
94
  return;
@@ -91,37 +97,13 @@ export function initSlidingPuzzle(root: HTMLElement): void {
91
97
  select(root, !instance.selected);
92
98
  return;
93
99
  }
94
- const destination = event.target.closest<HTMLButtonElement>(
95
- '[data-sliding-destination]',
96
- );
97
- const direction = destination?.dataset.destinationDirection as
98
- SlidingDirection | undefined;
99
- if (destination && !destination.disabled && direction)
100
- void move(root, direction);
100
+ const movement = getPuzzleMoveTarget(activePanel(root), event);
101
+ if (movement) void move(root, movement.direction);
101
102
  else select(root, false);
102
103
  },
103
104
  options,
104
105
  );
105
106
 
106
- root.addEventListener(
107
- 'keydown',
108
- (event) => {
109
- if (event.altKey || event.ctrlKey || event.metaKey) return;
110
- if (event.key === 'Escape') {
111
- cancelDrag(instance);
112
- select(root, false);
113
- return;
114
- }
115
- const direction = directions[event.key];
116
- if (!direction || !(event.target instanceof Element)) return;
117
- if (!instance.selected && !event.target.closest('[data-sliding-piece]'))
118
- return;
119
- event.preventDefault();
120
- void move(root, direction);
121
- },
122
- options,
123
- );
124
-
125
107
  root.addEventListener(
126
108
  'dragstart',
127
109
  (event) => event.preventDefault(),
@@ -231,6 +213,24 @@ export function initSlidingPuzzle(root: HTMLElement): void {
231
213
  () => cancelDrag(instance),
232
214
  options,
233
215
  );
216
+ instance.keyboard = initPuzzleKeyboard(root, {
217
+ getPanel: () => activePanel(root),
218
+ getSelected: () =>
219
+ instance.selected
220
+ ? (activePanel(root)?.querySelector<HTMLButtonElement>(
221
+ '[data-sliding-piece]',
222
+ ) ?? null)
223
+ : null,
224
+ select: (piece) => {
225
+ if (!piece) cancelDrag(instance);
226
+ select(root, piece !== null);
227
+ },
228
+ move: (_piece, direction) => {
229
+ void move(root, direction);
230
+ },
231
+ isLocked: () =>
232
+ instance.motion?.blocking === true || instance.drag !== null,
233
+ });
234
234
  }
235
235
 
236
236
  export function destroySlidingPuzzle(root: HTMLElement): void {
@@ -240,6 +240,7 @@ export function destroySlidingPuzzle(root: HTMLElement): void {
240
240
  cancelMotion(instance);
241
241
  cancelTerrainFrame(instance);
242
242
  instance.dismiss();
243
+ instance.keyboard?.destroy();
243
244
  instance.abort.abort();
244
245
  instances.delete(root);
245
246
  }
@@ -270,6 +271,7 @@ export function resetSlidingPuzzle(root: HTMLElement): void {
270
271
  setSlidingPuzzleStatus(root, panel.dataset.puzzleId ?? '', 'incomplete');
271
272
  render(root);
272
273
  previews(root);
274
+ instance.keyboard?.reset();
273
275
  emitChange(root);
274
276
  }
275
277
 
@@ -311,7 +313,7 @@ function select(root: HTMLElement, selected: boolean): void {
311
313
  if (
312
314
  !instance ||
313
315
  !panel ||
314
- instance.motion ||
316
+ (instance.motion?.blocking && selected) ||
315
317
  panel.dataset.puzzleStatus === 'valid'
316
318
  )
317
319
  return;
@@ -332,63 +334,58 @@ async function move(
332
334
  !panel ||
333
335
  !piece ||
334
336
  instance.drag ||
335
- instance.motion ||
336
- isMoving(piece) ||
337
+ instance.motion?.blocking ||
337
338
  panel.dataset.puzzleStatus === 'valid'
338
339
  )
339
340
  return;
340
341
  const puzzle = model(panel);
341
342
  const movement = simulateSlidingMove(puzzle, instance.state, direction);
342
343
  if (!movement) return;
343
- const motion = new AbortController();
344
+ const bounds = piece.getBoundingClientRect();
345
+ cancelMotion(instance);
346
+ const complete = isSlidingPuzzleComplete(puzzle, movement.state);
347
+ const motion = {
348
+ controller: new AbortController(),
349
+ blocking: movement.teleported || complete,
350
+ };
351
+ const { signal } = motion.controller;
344
352
  instance.motion = motion;
345
353
  piece.setAttribute('aria-busy', 'true');
346
354
  const clearBusy = (): void => piece.removeAttribute('aria-busy');
347
- motion.signal.addEventListener('abort', clearBusy, { once: true });
355
+ signal.addEventListener('abort', clearBusy, { once: true });
348
356
  cancelTerrainFrame(instance);
349
357
  const previousState = instance.state;
350
358
  renderTerrain(panel, previousState);
351
- const bounds = piece.getBoundingClientRect();
352
359
  instance.state = movement.state;
353
- instance.selected =
354
- retainSelection && !isSlidingPuzzleComplete(puzzle, movement.state);
360
+ instance.selected = retainSelection && !complete;
355
361
  render(root, false, movement.slideTo);
356
362
  if (!movement.teleported || !instance.selected) previews(root);
357
363
  try {
358
- if (
359
- !(await animateSlide(
360
- panel,
361
- piece,
362
- instance,
363
- movement,
364
- previousState,
365
- bounds,
366
- motion.signal,
367
- ))
368
- )
369
- return;
364
+ const animation = animateSlide(
365
+ panel,
366
+ piece,
367
+ instance,
368
+ movement,
369
+ previousState,
370
+ bounds,
371
+ signal,
372
+ );
373
+ if (!motion.blocking) emitChange(root);
374
+ if (!(await animation)) return;
370
375
  if (movement.teleported) {
371
376
  clearPreviews(panel);
372
- if (
373
- !(await animateHoleTransfer(
374
- root,
375
- piece,
376
- puzzle,
377
- movement,
378
- motion.signal,
379
- ))
380
- )
377
+ if (!(await animateHoleTransfer(root, piece, puzzle, movement, signal)))
381
378
  return;
382
379
  }
383
- if (motion.signal.aborted) return;
380
+ if (signal.aborted) return;
384
381
  instance.motion = null;
385
382
  render(root);
386
383
  if (movement.teleported) previews(root);
387
- emitChange(root);
384
+ if (motion.blocking) emitChange(root);
388
385
  } finally {
389
- motion.signal.removeEventListener('abort', clearBusy);
386
+ signal.removeEventListener('abort', clearBusy);
390
387
  if (instance.motion === motion) instance.motion = null;
391
- if (!motion.signal.aborted) clearBusy();
388
+ if (!signal.aborted) clearBusy();
392
389
  }
393
390
  }
394
391
 
@@ -693,10 +690,11 @@ function previews(root: HTMLElement): void {
693
690
  ) as SlidingPuzzleLabels;
694
691
  const previewPuzzle = { ...model(panel), holes: [] };
695
692
  for (const movement of getSlidingMoves(previewPuzzle, instance.state)) {
696
- if (board) renderPathStream(board, { ...movement, to: movement.slideTo });
693
+ if (board)
694
+ renderPathStream(board, { ...movement, to: movement.slideTo }, 'piece');
697
695
  const destination = cell(panel, movement.slideTo);
698
696
  if (!destination) continue;
699
- destination.removeAttribute('tabindex');
697
+ destination.tabIndex = -1;
700
698
  destination.setAttribute('data-sliding-destination', '');
701
699
  destination.setAttribute('data-puzzle-cell-interactive', '');
702
700
  destination.dataset.destinationPiece = 'piece';
@@ -711,8 +709,7 @@ function previews(root: HTMLElement): void {
711
709
  }
712
710
 
713
711
  function clearPreviews(panel: HTMLElement): void {
714
- for (const stream of panel.querySelectorAll('[data-puzzle-path-stream]'))
715
- stream.remove();
712
+ clearPuzzleMovementPreview(panel);
716
713
  for (const destination of panel.querySelectorAll<HTMLButtonElement>(
717
714
  '[data-sliding-destination]',
718
715
  )) {
@@ -746,7 +743,7 @@ function cancelTerrainFrame(instance: Instance): void {
746
743
  }
747
744
 
748
745
  function cancelMotion(instance: Instance): void {
749
- instance.motion?.abort();
746
+ instance.motion?.controller.abort();
750
747
  instance.motion = null;
751
748
  }
752
749