@astronox/core 0.5.9 → 0.5.11
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 +3 -0
- package/src/patterns/CircuitPuzzle/circuitPuzzleController.ts +49 -0
- package/src/patterns/LightMazePuzzle/LightMazePuzzle.astro +1 -0
- package/src/patterns/LightMazePuzzle/lightMazePuzzleController.ts +43 -56
- package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.astro +1 -0
- package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +2 -2
- 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 +1 -0
- package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +71 -74
- package/src/patterns/Table/TableCell.astro +1 -1
- package/src/patterns/Table/TableCellValue.astro +6 -1
- package/src/styles/tokens.css +4 -0
package/package.json
CHANGED
package/src/client/scroll.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
type JumpToElementOptions = {
|
|
2
|
-
block?: ScrollLogicalPosition;
|
|
2
|
+
block?: ScrollLogicalPosition | 'auto';
|
|
3
3
|
inline?: ScrollLogicalPosition;
|
|
4
4
|
};
|
|
5
5
|
|
|
@@ -7,6 +7,19 @@ export function jumpToElement(
|
|
|
7
7
|
element: Element,
|
|
8
8
|
{ block = 'center', inline = 'nearest' }: JumpToElementOptions = {},
|
|
9
9
|
): void {
|
|
10
|
+
if (block === 'auto') {
|
|
11
|
+
const header = document.querySelector<HTMLElement>('[data-site-header]');
|
|
12
|
+
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
|
13
|
+
const availableHeight = Math.max(
|
|
14
|
+
0,
|
|
15
|
+
viewportHeight - (header?.getBoundingClientRect().height ?? 0),
|
|
16
|
+
);
|
|
17
|
+
block =
|
|
18
|
+
element.getBoundingClientRect().height <= availableHeight
|
|
19
|
+
? 'center'
|
|
20
|
+
: 'start';
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
element.scrollIntoView({
|
|
11
24
|
behavior: 'auto',
|
|
12
25
|
block,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { HTMLAttributes } from 'astro/types';
|
|
3
3
|
import type { DisplayIconItem } from '../../../display/contracts';
|
|
4
4
|
import type { Tone } from '../../../theme/contracts';
|
|
5
|
+
import Tooltip from '../../feedback/Tooltip/Tooltip.astro';
|
|
5
6
|
|
|
6
7
|
type Props = HTMLAttributes<'div'> & {
|
|
7
8
|
'data-tone'?: Tone;
|
|
@@ -9,6 +10,7 @@ type Props = HTMLAttributes<'div'> & {
|
|
|
9
10
|
label?: string;
|
|
10
11
|
size?: 'small' | 'medium';
|
|
11
12
|
tone?: Tone;
|
|
13
|
+
tooltips?: boolean;
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
const {
|
|
@@ -18,6 +20,7 @@ const {
|
|
|
18
20
|
label,
|
|
19
21
|
size = 'small',
|
|
20
22
|
tone,
|
|
23
|
+
tooltips = true,
|
|
21
24
|
...attrs
|
|
22
25
|
}: Props = Astro.props;
|
|
23
26
|
const resolvedTone = tone ?? dataTone;
|
|
@@ -34,6 +37,11 @@ const resolvedTone = tone ?? dataTone;
|
|
|
34
37
|
{
|
|
35
38
|
items.map((item) => {
|
|
36
39
|
const Icon = item.icon;
|
|
40
|
+
const tooltip = tooltips
|
|
41
|
+
? typeof item.tooltip === 'string'
|
|
42
|
+
? item.tooltip.trim()
|
|
43
|
+
: item.tooltip
|
|
44
|
+
: undefined;
|
|
37
45
|
const icon = (
|
|
38
46
|
<span class="icon-list__icon">
|
|
39
47
|
<Icon />
|
|
@@ -44,14 +52,16 @@ const resolvedTone = tone ?? dataTone;
|
|
|
44
52
|
<span class="icon-list__value">{item.value}</span>
|
|
45
53
|
) : null;
|
|
46
54
|
|
|
47
|
-
|
|
55
|
+
const content = (
|
|
48
56
|
<span
|
|
49
57
|
class="icon-list__item"
|
|
50
58
|
data-muted={item.muted ? 'true' : undefined}
|
|
51
59
|
data-tone={item.tone}
|
|
52
60
|
role="group"
|
|
53
61
|
aria-label={item.label}
|
|
54
|
-
title={item.label}
|
|
62
|
+
title={tooltips && !tooltip ? item.label : undefined}
|
|
63
|
+
tabindex={tooltip ? '0' : undefined}
|
|
64
|
+
data-tooltip-trigger={tooltip ? true : undefined}
|
|
55
65
|
>
|
|
56
66
|
<span class="icon-list__visible" aria-hidden="true">
|
|
57
67
|
{item.iconPosition === 'end' ? value : icon}
|
|
@@ -59,6 +69,14 @@ const resolvedTone = tone ?? dataTone;
|
|
|
59
69
|
</span>
|
|
60
70
|
</span>
|
|
61
71
|
);
|
|
72
|
+
|
|
73
|
+
return tooltip ? (
|
|
74
|
+
<Tooltip content={tooltip} activation="hover">
|
|
75
|
+
{content}
|
|
76
|
+
</Tooltip>
|
|
77
|
+
) : (
|
|
78
|
+
content
|
|
79
|
+
);
|
|
62
80
|
})
|
|
63
81
|
}
|
|
64
82
|
</div>
|
|
@@ -42,7 +42,12 @@ const ValueIcon = icon?.component;
|
|
|
42
42
|
<dd class="metric-tile__value">
|
|
43
43
|
{
|
|
44
44
|
items ? (
|
|
45
|
-
<IconList
|
|
45
|
+
<IconList
|
|
46
|
+
items={items}
|
|
47
|
+
label={itemsLabel}
|
|
48
|
+
size="medium"
|
|
49
|
+
tooltips={!hint}
|
|
50
|
+
/>
|
|
46
51
|
) : (
|
|
47
52
|
<>
|
|
48
53
|
<strong data-metric-value>{value}</strong>
|
|
@@ -26,8 +26,16 @@
|
|
|
26
26
|
position: fixed;
|
|
27
27
|
z-index: 100;
|
|
28
28
|
inset: auto;
|
|
29
|
+
box-sizing: border-box;
|
|
29
30
|
inline-size: max-content;
|
|
30
|
-
|
|
31
|
+
min-inline-size: 0;
|
|
32
|
+
max-inline-size: min(
|
|
33
|
+
26rem,
|
|
34
|
+
var(
|
|
35
|
+
--tooltip-available-inline-size,
|
|
36
|
+
calc(100vw - var(--tooltip-viewport-gap) * 2)
|
|
37
|
+
)
|
|
38
|
+
);
|
|
31
39
|
max-block-size: calc(100dvh - var(--nox-space-4) * 2);
|
|
32
40
|
margin: 0;
|
|
33
41
|
padding: var(--nox-space-3) var(--nox-space-4);
|
|
@@ -125,17 +125,16 @@ export function initTooltip(root: HTMLElement): () => void {
|
|
|
125
125
|
const triggerGap = getCssLength(panel, '--tooltip-trigger-gap', 8);
|
|
126
126
|
const triggerRect = trigger.getBoundingClientRect();
|
|
127
127
|
const boundary =
|
|
128
|
-
root.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const boundaryRect = boundary?.getBoundingClientRect();
|
|
128
|
+
root.closest<HTMLElement>('[data-tooltip-boundary], main, dialog') ??
|
|
129
|
+
document.body;
|
|
130
|
+
const boundaryRect = boundary.getBoundingClientRect();
|
|
132
131
|
const horizontalLeft = Math.max(
|
|
133
132
|
viewport.left + viewportGap,
|
|
134
|
-
boundaryRect
|
|
133
|
+
boundaryRect.left,
|
|
135
134
|
);
|
|
136
135
|
const horizontalRight = Math.min(
|
|
137
136
|
viewport.right - viewportGap,
|
|
138
|
-
boundaryRect
|
|
137
|
+
boundaryRect.right,
|
|
139
138
|
);
|
|
140
139
|
const maximumInlineSize = Math.max(0, horizontalRight - horizontalLeft);
|
|
141
140
|
const maximumBlockSize = Math.max(
|
|
@@ -143,7 +142,10 @@ export function initTooltip(root: HTMLElement): () => void {
|
|
|
143
142
|
viewport.bottom - viewport.top - viewportGap * 2,
|
|
144
143
|
);
|
|
145
144
|
|
|
146
|
-
panel.style.
|
|
145
|
+
panel.style.setProperty(
|
|
146
|
+
'--tooltip-available-inline-size',
|
|
147
|
+
`${Math.floor(maximumInlineSize)}px`,
|
|
148
|
+
);
|
|
147
149
|
panel.style.maxBlockSize = `${Math.floor(maximumBlockSize)}px`;
|
|
148
150
|
panel.style.visibility = 'hidden';
|
|
149
151
|
panel.style.left = `${Math.round(horizontalLeft)}px`;
|
|
@@ -165,7 +167,8 @@ export function initTooltip(root: HTMLElement): () => void {
|
|
|
165
167
|
|
|
166
168
|
panel.style.maxBlockSize = `${Math.floor(availableBlockSize)}px`;
|
|
167
169
|
const panelRect = panel.getBoundingClientRect();
|
|
168
|
-
const horizontalAnchor =
|
|
170
|
+
const horizontalAnchor =
|
|
171
|
+
root.dataset.tooltipAlignTo === 'boundary' ? boundaryRect : triggerRect;
|
|
169
172
|
const centeredLeft =
|
|
170
173
|
horizontalAnchor.left + (horizontalAnchor.width - panelRect.width) / 2;
|
|
171
174
|
const left = Math.min(
|
|
@@ -199,7 +202,7 @@ export function initTooltip(root: HTMLElement): () => void {
|
|
|
199
202
|
panel.hidden = true;
|
|
200
203
|
panel.style.removeProperty('left');
|
|
201
204
|
panel.style.removeProperty('top');
|
|
202
|
-
panel.style.removeProperty('
|
|
205
|
+
panel.style.removeProperty('--tooltip-available-inline-size');
|
|
203
206
|
panel.style.removeProperty('max-block-size');
|
|
204
207
|
panel.style.removeProperty('visibility');
|
|
205
208
|
};
|
|
@@ -21,6 +21,9 @@ type Props = Omit<
|
|
|
21
21
|
loading?: EmbedLoading;
|
|
22
22
|
loadingIndicator?: EmbedLoadingIndicator;
|
|
23
23
|
loadingLabel: string;
|
|
24
|
+
pageScroll?: 'auto' | 'lock-while-interacting';
|
|
25
|
+
scrollOnActivate?: boolean;
|
|
26
|
+
scrollTargetId?: string;
|
|
24
27
|
src: string;
|
|
25
28
|
title: string;
|
|
26
29
|
tone?: Tone;
|
|
@@ -45,6 +48,9 @@ const {
|
|
|
45
48
|
loading = 'lazy',
|
|
46
49
|
loadingIndicator = 'spinner',
|
|
47
50
|
loadingLabel,
|
|
51
|
+
pageScroll = 'auto',
|
|
52
|
+
scrollOnActivate = false,
|
|
53
|
+
scrollTargetId,
|
|
48
54
|
src,
|
|
49
55
|
title,
|
|
50
56
|
tone,
|
|
@@ -59,6 +65,9 @@ const resolvedTone = tone ?? dataTone;
|
|
|
59
65
|
class="embed-frame"
|
|
60
66
|
data-embed-frame-activation={activation}
|
|
61
67
|
data-embed-frame-source={activation === 'manual' ? src : undefined}
|
|
68
|
+
data-embed-frame-page-scroll={pageScroll}
|
|
69
|
+
data-embed-frame-scroll-on-activate={scrollOnActivate ? 'true' : undefined}
|
|
70
|
+
data-embed-frame-scroll-target={scrollTargetId}
|
|
62
71
|
data-aspect-ratio={aspectRatio}
|
|
63
72
|
data-fullscreen-region={fullscreen ? true : undefined}
|
|
64
73
|
data-widget="embed-frame"
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { jumpToElement } from '../../../client/scroll';
|
|
2
|
+
|
|
1
3
|
const activateEvent = 'nox:embed-frame:activate';
|
|
2
4
|
|
|
3
5
|
export function activateEmbedFrame(
|
|
@@ -27,6 +29,55 @@ export const initEmbedFrame = (root: HTMLElement): (() => void) => {
|
|
|
27
29
|
let activated =
|
|
28
30
|
root.dataset.embedFrameActivation !== 'manual' ||
|
|
29
31
|
root.dataset.embedFrameActive === 'true';
|
|
32
|
+
let activationFocusFrame = 0;
|
|
33
|
+
let focusCheckFrame = 0;
|
|
34
|
+
let interactionDismissed = false;
|
|
35
|
+
const lockPageScroll =
|
|
36
|
+
root.dataset.embedFramePageScroll === 'lock-while-interacting';
|
|
37
|
+
|
|
38
|
+
const setScrollLock = (locked: boolean) => {
|
|
39
|
+
root.toggleAttribute(
|
|
40
|
+
'data-embed-frame-scroll-locked',
|
|
41
|
+
lockPageScroll && activated && locked,
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const releaseScroll = () => {
|
|
46
|
+
cancelAnimationFrame(activationFocusFrame);
|
|
47
|
+
cancelAnimationFrame(focusCheckFrame);
|
|
48
|
+
activationFocusFrame = 0;
|
|
49
|
+
focusCheckFrame = 0;
|
|
50
|
+
interactionDismissed = true;
|
|
51
|
+
setScrollLock(false);
|
|
52
|
+
};
|
|
53
|
+
const enterFrame = () => {
|
|
54
|
+
cancelAnimationFrame(focusCheckFrame);
|
|
55
|
+
focusCheckFrame = 0;
|
|
56
|
+
interactionDismissed = false;
|
|
57
|
+
setScrollLock(true);
|
|
58
|
+
};
|
|
59
|
+
const leaveFrame = (event: PointerEvent) => {
|
|
60
|
+
if (event.pointerType !== 'touch') releaseScroll();
|
|
61
|
+
};
|
|
62
|
+
const updateFocus = () => {
|
|
63
|
+
cancelAnimationFrame(focusCheckFrame);
|
|
64
|
+
focusCheckFrame = requestAnimationFrame(() => {
|
|
65
|
+
focusCheckFrame = 0;
|
|
66
|
+
setScrollLock(!interactionDismissed && document.activeElement === iframe);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const interactOutside = (event: Event) => {
|
|
70
|
+
if (event.target instanceof Node && !root.contains(event.target)) {
|
|
71
|
+
releaseScroll();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const scrollToFrame = () => {
|
|
76
|
+
if (root.dataset.embedFrameScrollOnActivate !== 'true') return;
|
|
77
|
+
const targetId = root.dataset.embedFrameScrollTarget;
|
|
78
|
+
const target = (targetId && document.getElementById(targetId)) || root;
|
|
79
|
+
jumpToElement(target, { block: 'auto' });
|
|
80
|
+
};
|
|
30
81
|
|
|
31
82
|
const markLoaded = () => {
|
|
32
83
|
if (!activated) return;
|
|
@@ -46,19 +97,51 @@ export const initEmbedFrame = (root: HTMLElement): (() => void) => {
|
|
|
46
97
|
root.setAttribute('aria-busy', loading ? 'true' : 'false');
|
|
47
98
|
root.setAttribute('data-embed-frame-active', 'true');
|
|
48
99
|
iframe.src = src;
|
|
100
|
+
scrollToFrame();
|
|
49
101
|
};
|
|
50
102
|
|
|
51
103
|
const activateFromControl = () => {
|
|
52
104
|
activate();
|
|
53
|
-
|
|
105
|
+
cancelAnimationFrame(activationFocusFrame);
|
|
106
|
+
interactionDismissed = false;
|
|
107
|
+
activationFocusFrame = requestAnimationFrame(() => {
|
|
108
|
+
activationFocusFrame = 0;
|
|
109
|
+
iframe.focus({ preventScroll: true });
|
|
110
|
+
setScrollLock(document.activeElement === iframe);
|
|
111
|
+
});
|
|
54
112
|
};
|
|
55
113
|
|
|
56
114
|
if (activated && activationLayer) activationLayer.hidden = true;
|
|
57
115
|
iframe.addEventListener('load', markLoaded);
|
|
58
116
|
root.addEventListener(activateEvent, activate);
|
|
59
117
|
activationControl?.addEventListener('click', activateFromControl);
|
|
118
|
+
if (lockPageScroll) {
|
|
119
|
+
root.addEventListener('pointerenter', enterFrame);
|
|
120
|
+
root.addEventListener('pointerleave', leaveFrame);
|
|
121
|
+
iframe.addEventListener('focus', enterFrame);
|
|
122
|
+
iframe.addEventListener('blur', releaseScroll);
|
|
123
|
+
window.addEventListener('blur', updateFocus);
|
|
124
|
+
window.addEventListener('focus', updateFocus);
|
|
125
|
+
window.addEventListener('pagehide', releaseScroll);
|
|
126
|
+
document.addEventListener('focusin', interactOutside);
|
|
127
|
+
document.addEventListener('pointerdown', interactOutside, true);
|
|
128
|
+
document.addEventListener('wheel', interactOutside, { passive: true });
|
|
129
|
+
document.addEventListener('touchstart', interactOutside, { passive: true });
|
|
130
|
+
}
|
|
60
131
|
|
|
61
132
|
return () => {
|
|
133
|
+
releaseScroll();
|
|
134
|
+
root.removeEventListener('pointerenter', enterFrame);
|
|
135
|
+
root.removeEventListener('pointerleave', leaveFrame);
|
|
136
|
+
iframe.removeEventListener('focus', enterFrame);
|
|
137
|
+
iframe.removeEventListener('blur', releaseScroll);
|
|
138
|
+
window.removeEventListener('blur', updateFocus);
|
|
139
|
+
window.removeEventListener('focus', updateFocus);
|
|
140
|
+
window.removeEventListener('pagehide', releaseScroll);
|
|
141
|
+
document.removeEventListener('focusin', interactOutside);
|
|
142
|
+
document.removeEventListener('pointerdown', interactOutside, true);
|
|
143
|
+
document.removeEventListener('wheel', interactOutside);
|
|
144
|
+
document.removeEventListener('touchstart', interactOutside);
|
|
62
145
|
iframe.removeEventListener('load', markLoaded);
|
|
63
146
|
root.removeEventListener(activateEvent, activate);
|
|
64
147
|
activationControl?.removeEventListener('click', activateFromControl);
|
package/src/display/contracts.ts
CHANGED
|
@@ -58,6 +58,9 @@ const {
|
|
|
58
58
|
data-puzzle-cell-interactive={cell.fixed ? undefined : ''}
|
|
59
59
|
data-puzzle-cell-disabled={cell.fixed ? '' : undefined}
|
|
60
60
|
data-circuit-cell={cell.id}
|
|
61
|
+
data-puzzle-control={cell.fixed ? undefined : ''}
|
|
62
|
+
data-cell-row={cell.row}
|
|
63
|
+
data-cell-column={cell.column}
|
|
61
64
|
data-cell-kind={cell.kind}
|
|
62
65
|
data-cell-label={cellLabel}
|
|
63
66
|
data-orientation-label={labels.orientation}
|
|
@@ -3,6 +3,11 @@ 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';
|
|
10
|
+
import type { PuzzleDirection } from '../PuzzleGrid/movement';
|
|
6
11
|
import type {
|
|
7
12
|
CircuitPuzzleChangeDetail,
|
|
8
13
|
CircuitPuzzleEvaluation,
|
|
@@ -11,6 +16,7 @@ import type {
|
|
|
11
16
|
|
|
12
17
|
type CircuitInstance = {
|
|
13
18
|
activeIndex: number;
|
|
19
|
+
keyboard?: PuzzleKeyboard;
|
|
14
20
|
};
|
|
15
21
|
|
|
16
22
|
const rotations: readonly CircuitRotation[] = [0, 90, 180, 270];
|
|
@@ -60,6 +66,21 @@ export function initCircuitPuzzle(root: HTMLElement): void {
|
|
|
60
66
|
setRotation(cell, rotations[nextIndex], visualRotation + 90);
|
|
61
67
|
emitChange(root);
|
|
62
68
|
});
|
|
69
|
+
instances.get(root)!.keyboard = initPuzzleKeyboard(root, {
|
|
70
|
+
getPanel: () => getActivePanel(root),
|
|
71
|
+
getSelected: () =>
|
|
72
|
+
getActivePanel(root)?.querySelector<HTMLButtonElement>(
|
|
73
|
+
'[data-circuit-cell][data-puzzle-cell-selected]',
|
|
74
|
+
) ?? null,
|
|
75
|
+
select: (cell) => {
|
|
76
|
+
const panel = getActivePanel(root);
|
|
77
|
+
if (panel) selectCell(panel, cell);
|
|
78
|
+
},
|
|
79
|
+
move: (cell, direction) => {
|
|
80
|
+
const panel = getActivePanel(root);
|
|
81
|
+
if (panel) moveCellFocus(panel, cell, direction);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
export function selectCircuitPuzzle(root: HTMLElement, index: number): void {
|
|
@@ -100,6 +121,7 @@ export function resetCircuitPuzzle(root: HTMLElement): void {
|
|
|
100
121
|
panel.removeAttribute('data-puzzle-resetting');
|
|
101
122
|
});
|
|
102
123
|
});
|
|
124
|
+
instances.get(root)?.keyboard?.reset();
|
|
103
125
|
emitChange(root);
|
|
104
126
|
}
|
|
105
127
|
|
|
@@ -132,6 +154,7 @@ export function setCircuitPuzzleEvaluation(
|
|
|
132
154
|
if (grid) {
|
|
133
155
|
setPuzzleGridState(grid, evaluation.status === 'valid' ? 'valid' : null);
|
|
134
156
|
}
|
|
157
|
+
if (evaluation.status === 'valid') selectCell(panel, null);
|
|
135
158
|
|
|
136
159
|
if (evaluation.status === 'valid' && previous !== 'valid') {
|
|
137
160
|
root.dispatchEvent(
|
|
@@ -170,6 +193,32 @@ function selectCell(
|
|
|
170
193
|
}
|
|
171
194
|
}
|
|
172
195
|
|
|
196
|
+
function moveCellFocus(
|
|
197
|
+
panel: HTMLElement,
|
|
198
|
+
origin: HTMLButtonElement,
|
|
199
|
+
direction: PuzzleDirection,
|
|
200
|
+
): void {
|
|
201
|
+
const vertical = direction === 'north' || direction === 'south';
|
|
202
|
+
const sign = direction === 'north' || direction === 'west' ? -1 : 1;
|
|
203
|
+
const row = Number(origin.dataset.cellRow);
|
|
204
|
+
const column = Number(origin.dataset.cellColumn);
|
|
205
|
+
let nearest: HTMLButtonElement | null = null;
|
|
206
|
+
let nearestDistance = Infinity;
|
|
207
|
+
for (const cell of panel.querySelectorAll<HTMLButtonElement>(
|
|
208
|
+
'[data-puzzle-control]:not(:disabled)',
|
|
209
|
+
)) {
|
|
210
|
+
const rowDistance = Number(cell.dataset.cellRow) - row;
|
|
211
|
+
const columnDistance = Number(cell.dataset.cellColumn) - column;
|
|
212
|
+
if (vertical ? columnDistance !== 0 : rowDistance !== 0) continue;
|
|
213
|
+
const distance = (vertical ? rowDistance : columnDistance) * sign;
|
|
214
|
+
if (distance > 0 && distance < nearestDistance) {
|
|
215
|
+
nearest = cell;
|
|
216
|
+
nearestDistance = distance;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
nearest?.focus({ preventScroll: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
173
222
|
function emitChange(root: HTMLElement): void {
|
|
174
223
|
const panel = getActivePanel(root);
|
|
175
224
|
if (!panel?.dataset.puzzleId) return;
|
|
@@ -188,6 +188,7 @@ if (puzzles.some((puzzle) => puzzle.activators?.length) && !media.mechanism)
|
|
|
188
188
|
aria-label={labels[piece]}
|
|
189
189
|
aria-pressed="false"
|
|
190
190
|
data-light-maze-piece={piece}
|
|
191
|
+
data-puzzle-control
|
|
191
192
|
style={`--piece-row: ${initial[piece].row + 1}; --piece-column: ${initial[piece].column + 1};`}
|
|
192
193
|
>
|
|
193
194
|
<span class="light-maze-puzzle__piece-media" aria-hidden="true">
|
|
@@ -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
|
-
|
|
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
|
|
132
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
)) {
|
|
@@ -214,6 +214,7 @@ if (
|
|
|
214
214
|
aria-label={`${piece.label} — ${labels.polarities[piece.polarity]}`}
|
|
215
215
|
aria-pressed="false"
|
|
216
216
|
data-field-piece={piece.id}
|
|
217
|
+
data-puzzle-control
|
|
217
218
|
data-field-piece-media={media ? '' : undefined}
|
|
218
219
|
data-piece-label={piece.label}
|
|
219
220
|
data-piece-short-label={piece.shortLabel}
|
|
@@ -106,11 +106,11 @@
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
.magnetic-field-puzzle__identity[data-polarity='north'] {
|
|
109
|
-
color: var(--nox-source-
|
|
109
|
+
color: var(--nox-source-magnetic-north);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
.magnetic-field-puzzle__identity[data-polarity='south'] {
|
|
113
|
-
color: var(--nox-source-
|
|
113
|
+
color: var(--nox-source-magnetic-south);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
.magnetic-field-puzzle__polarity-icon {
|