@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
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
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
import type { DisplayMedia } from '../../display/contracts';
|
|
4
|
+
import Media from '../../primitives/media/Media/Media.astro';
|
|
3
5
|
import PuzzleGrid from '../PuzzleGrid/PuzzleGrid.astro';
|
|
4
6
|
import type { CircuitPuzzleLabels, CircuitPuzzleModel } from './contracts';
|
|
5
7
|
|
|
6
8
|
type Props = HTMLAttributes<'section'> & {
|
|
7
9
|
label: string;
|
|
8
10
|
labels: CircuitPuzzleLabels;
|
|
11
|
+
coreMedia?: DisplayMedia;
|
|
9
12
|
puzzles: readonly CircuitPuzzleModel[];
|
|
10
13
|
};
|
|
11
14
|
|
|
@@ -13,6 +16,7 @@ const {
|
|
|
13
16
|
class: className,
|
|
14
17
|
label,
|
|
15
18
|
labels,
|
|
19
|
+
coreMedia,
|
|
16
20
|
puzzles,
|
|
17
21
|
...attrs
|
|
18
22
|
}: Props = Astro.props;
|
|
@@ -54,6 +58,9 @@ const {
|
|
|
54
58
|
data-puzzle-cell-interactive={cell.fixed ? undefined : ''}
|
|
55
59
|
data-puzzle-cell-disabled={cell.fixed ? '' : undefined}
|
|
56
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}
|
|
57
64
|
data-cell-kind={cell.kind}
|
|
58
65
|
data-cell-label={cellLabel}
|
|
59
66
|
data-orientation-label={labels.orientation}
|
|
@@ -64,7 +71,16 @@ const {
|
|
|
64
71
|
disabled={cell.fixed ? true : undefined}
|
|
65
72
|
style={`--cell-row: ${cell.row + 1}; --cell-column: ${cell.column + 1}; --cell-rotation: ${cell.initialRotation}deg; --cell-index: ${cellIndex};`}
|
|
66
73
|
>
|
|
67
|
-
<span
|
|
74
|
+
<span
|
|
75
|
+
class:list={[
|
|
76
|
+
'circuit-puzzle__piece',
|
|
77
|
+
{
|
|
78
|
+
'circuit-puzzle__piece--with-media':
|
|
79
|
+
cell.kind === 'core' && coreMedia,
|
|
80
|
+
},
|
|
81
|
+
]}
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
>
|
|
68
84
|
{cell.connections.map((direction) => (
|
|
69
85
|
<span
|
|
70
86
|
class:list={[
|
|
@@ -73,8 +89,15 @@ const {
|
|
|
73
89
|
]}
|
|
74
90
|
/>
|
|
75
91
|
))}
|
|
76
|
-
|
|
92
|
+
{!(cell.kind === 'core' && coreMedia) && (
|
|
93
|
+
<span class="circuit-puzzle__node" />
|
|
94
|
+
)}
|
|
77
95
|
</span>
|
|
96
|
+
{cell.kind === 'core' && coreMedia && (
|
|
97
|
+
<span class="circuit-puzzle__core-media" aria-hidden="true">
|
|
98
|
+
<Media media={coreMedia} size="fill" />
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
78
101
|
</button>
|
|
79
102
|
);
|
|
80
103
|
})}
|
|
@@ -36,6 +36,15 @@
|
|
|
36
36
|
--circuit-puzzle-symbol-color: var(--nox-source-tone-danger);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
.circuit-puzzle__instance[data-puzzle-status='valid']
|
|
40
|
+
.circuit-puzzle__cell[data-cell-kind='core'] {
|
|
41
|
+
--puzzle-cell-state-background: color-mix(
|
|
42
|
+
in srgb,
|
|
43
|
+
var(--nox-source-tone-success) 14%,
|
|
44
|
+
var(--nox-surface)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
.circuit-puzzle__piece {
|
|
40
49
|
position: relative;
|
|
41
50
|
display: grid;
|
|
@@ -46,6 +55,21 @@
|
|
|
46
55
|
color: var(--circuit-puzzle-symbol-color);
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
.circuit-puzzle__piece--with-media {
|
|
59
|
+
grid-area: 1 / 1;
|
|
60
|
+
inline-size: 100%;
|
|
61
|
+
block-size: 100%;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.circuit-puzzle__core-media {
|
|
65
|
+
z-index: 1;
|
|
66
|
+
grid-area: 1 / 1;
|
|
67
|
+
display: grid;
|
|
68
|
+
inline-size: 84%;
|
|
69
|
+
aspect-ratio: 1;
|
|
70
|
+
pointer-events: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
.circuit-puzzle__node {
|
|
50
74
|
z-index: 1;
|
|
51
75
|
inline-size: 32%;
|
|
@@ -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">
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
.light-maze-puzzle__activator {
|
|
35
35
|
position: absolute;
|
|
36
|
-
inset:
|
|
36
|
+
inset: 8%;
|
|
37
37
|
display: grid;
|
|
38
38
|
pointer-events: none;
|
|
39
39
|
}
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
margin: var(--nox-space-1);
|
|
111
111
|
padding: 0;
|
|
112
112
|
place-items: center;
|
|
113
|
-
border:
|
|
113
|
+
border: var(--nox-border-width) solid transparent;
|
|
114
114
|
border-radius: var(--nox-radius-sm);
|
|
115
115
|
background: transparent;
|
|
116
116
|
cursor: grab;
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
|
|
121
121
|
.light-maze-puzzle__piece-media {
|
|
122
122
|
display: grid;
|
|
123
|
-
inline-size:
|
|
123
|
+
inline-size: 84%;
|
|
124
124
|
aspect-ratio: 1;
|
|
125
125
|
pointer-events: none;
|
|
126
126
|
}
|
|
@@ -173,6 +173,12 @@
|
|
|
173
173
|
--puzzle-cell-ring-width: 0px;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
@media (width < 34rem) {
|
|
177
|
+
.light-maze-puzzle__piece {
|
|
178
|
+
margin: 2px;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
176
182
|
@media (prefers-reduced-motion: no-preference) {
|
|
177
183
|
.light-maze-puzzle__piece[data-piece-moved] {
|
|
178
184
|
animation: light-maze-puzzle-move 260ms var(--nox-motion-easing-standard);
|