@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.
- package/package.json +1 -1
- package/src/client/scroll.ts +14 -1
- package/src/components/data-display/IconList/IconList.astro +20 -2
- package/src/components/data-display/MetricTile/MetricTileContent.astro +6 -1
- package/src/components/feedback/Tooltip/Tooltip.css +9 -1
- package/src/components/feedback/Tooltip/tooltipController.ts +12 -9
- package/src/components/media/EmbedFrame/EmbedFrame.astro +9 -0
- package/src/components/media/EmbedFrame/EmbedFrame.css +5 -0
- package/src/components/media/EmbedFrame/embedFrameController.ts +84 -1
- package/src/display/contracts.ts +1 -0
- package/src/patterns/CircuitPuzzle/CircuitPuzzle.astro +25 -2
- package/src/patterns/CircuitPuzzle/CircuitPuzzle.css +24 -0
- package/src/patterns/CircuitPuzzle/circuitPuzzleController.ts +49 -0
- package/src/patterns/LightMazePuzzle/LightMazePuzzle.astro +1 -0
- package/src/patterns/LightMazePuzzle/LightMazePuzzle.css +9 -3
- package/src/patterns/LightMazePuzzle/lightMazePuzzleController.ts +43 -56
- package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.astro +65 -3
- package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +69 -0
- package/src/patterns/MagneticFieldPuzzle/contracts.ts +4 -0
- package/src/patterns/MagneticFieldPuzzle/magneticFieldPuzzleController.ts +50 -64
- package/src/patterns/PuzzleGrid/PuzzleGrid.astro +2 -0
- package/src/patterns/PuzzleGrid/keyboard.ts +220 -0
- package/src/patterns/PuzzleGrid/movement.css +4 -0
- package/src/patterns/PuzzleGrid/movement.ts +67 -3
- package/src/patterns/PuzzleGrid/selectionDismissal.ts +2 -2
- package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +9 -1
- package/src/patterns/SlidingPuzzle/SlidingPuzzle.css +13 -7
- package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +71 -74
- package/src/patterns/Table/TableCell.astro +1 -1
- 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
|
+
}
|
|
@@ -14,7 +14,60 @@ export type PuzzleDrag = {
|
|
|
14
14
|
moved: boolean;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
type
|
|
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:
|
|
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:
|
|
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
|
-
<
|
|
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
|
+
inline-size: 84%;
|
|
73
73
|
aspect-ratio: 1;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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]
|
|
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))
|
|
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
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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 (
|
|
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
|
-
|
|
386
|
+
signal.removeEventListener('abort', clearBusy);
|
|
390
387
|
if (instance.motion === motion) instance.motion = null;
|
|
391
|
-
if (!
|
|
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)
|
|
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.
|
|
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
|
-
|
|
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
|
|