@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.
Files changed (27) 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 +3 -0
  12. package/src/patterns/CircuitPuzzle/circuitPuzzleController.ts +49 -0
  13. package/src/patterns/LightMazePuzzle/LightMazePuzzle.astro +1 -0
  14. package/src/patterns/LightMazePuzzle/lightMazePuzzleController.ts +43 -56
  15. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.astro +1 -0
  16. package/src/patterns/MagneticFieldPuzzle/MagneticFieldPuzzle.css +2 -2
  17. package/src/patterns/MagneticFieldPuzzle/magneticFieldPuzzleController.ts +50 -64
  18. package/src/patterns/PuzzleGrid/PuzzleGrid.astro +2 -0
  19. package/src/patterns/PuzzleGrid/keyboard.ts +220 -0
  20. package/src/patterns/PuzzleGrid/movement.css +4 -0
  21. package/src/patterns/PuzzleGrid/movement.ts +67 -3
  22. package/src/patterns/PuzzleGrid/selectionDismissal.ts +2 -2
  23. package/src/patterns/SlidingPuzzle/SlidingPuzzle.astro +1 -0
  24. package/src/patterns/SlidingPuzzle/slidingPuzzleController.ts +71 -74
  25. package/src/patterns/Table/TableCell.astro +1 -1
  26. package/src/patterns/Table/TableCellValue.astro +6 -1
  27. package/src/styles/tokens.css +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -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
- return (
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 items={items} label={itemsLabel} size="medium" />
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
- max-inline-size: min(22rem, calc(100vw - var(--nox-space-4) * 2));
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.dataset.tooltipAlignTo === 'boundary'
129
- ? root.closest<HTMLElement>('[data-tooltip-boundary]')
130
- : null;
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?.left ?? viewport.left + viewportGap,
133
+ boundaryRect.left,
135
134
  );
136
135
  const horizontalRight = Math.min(
137
136
  viewport.right - viewportGap,
138
- boundaryRect?.right ?? viewport.right - viewportGap,
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.maxInlineSize = `${Math.floor(maximumInlineSize)}px`;
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 = boundaryRect ?? triggerRect;
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('max-inline-size');
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"
@@ -6,6 +6,11 @@
6
6
  overflow: hidden;
7
7
  }
8
8
 
9
+ html:has([data-embed-frame-scroll-locked]),
10
+ [data-site-scroll-root]:has([data-embed-frame-scroll-locked]) {
11
+ overflow: hidden;
12
+ }
13
+
9
14
  .embed-frame[data-aspect-ratio='16/9'] {
10
15
  aspect-ratio: 16 / 9;
11
16
  }
@@ -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
- requestAnimationFrame(() => iframe.focus({ preventScroll: true }));
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);
@@ -92,6 +92,7 @@ export type DisplayIconItem = {
92
92
  label: string;
93
93
  muted?: boolean;
94
94
  tone?: Tone;
95
+ tooltip?: string | DisplayTooltip;
95
96
  value?: DisplayTextValue;
96
97
  };
97
98
 
@@ -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
- '[data-light-maze-piece], [data-light-maze-destination]',
103
- ),
102
+ target.closest('[data-light-maze-piece]') ||
103
+ getPuzzleMoveTarget(panel.element, event),
104
104
  )
105
105
  );
106
106
  },
@@ -128,50 +128,14 @@ export function initLightMazePuzzle(root: HTMLElement): void {
128
128
  selectPiece(root, instance.selected === id ? null : id);
129
129
  return;
130
130
  }
131
- const destination = event.target.closest<HTMLButtonElement>(
132
- '[data-light-maze-destination]',
133
- );
134
- if (destination && !destination.disabled) {
135
- void move(
136
- root,
137
- destination.dataset.destinationPiece as LightMazePiece,
138
- destination.dataset.destinationDirection as LightMazeDirection,
139
- );
131
+ const movement = getPuzzleMoveTarget(active(instance).element, event);
132
+ if (movement) {
133
+ void move(root, movement.pieceId as LightMazePiece, movement.direction);
140
134
  } else selectPiece(root, null);
141
135
  },
142
136
  options,
143
137
  );
144
138
 
145
- root.addEventListener(
146
- 'keydown',
147
- (event) => {
148
- if (instance.motion) {
149
- if (arrows[event.key] || ['Enter', ' ', 'Escape'].includes(event.key))
150
- event.preventDefault();
151
- return;
152
- }
153
- if (event.altKey || event.ctrlKey || event.metaKey || instance.drag)
154
- return;
155
- if (event.key === 'Escape') {
156
- selectPiece(root, null);
157
- return;
158
- }
159
- const direction = arrows[event.key];
160
- if (!direction) return;
161
- const focused =
162
- event.target instanceof Element
163
- ? event.target.closest<HTMLElement>('[data-light-maze-piece]')
164
- : null;
165
- const piece =
166
- (focused?.dataset.lightMazePiece as LightMazePiece | undefined) ??
167
- instance.selected;
168
- if (!piece) return;
169
- event.preventDefault();
170
- void move(root, piece, direction);
171
- },
172
- options,
173
- );
174
-
175
139
  root.addEventListener(
176
140
  'pointerdown',
177
141
  (event) => {
@@ -264,6 +228,30 @@ export function initLightMazePuzzle(root: HTMLElement): void {
264
228
  options,
265
229
  );
266
230
  render(root);
231
+ instance.keyboard = initPuzzleKeyboard(root, {
232
+ getPanel: () => active(instance).element,
233
+ getSelected: () =>
234
+ instance.selected
235
+ ? active(instance).element.querySelector<HTMLButtonElement>(
236
+ `[data-light-maze-piece="${instance.selected}"]`,
237
+ )
238
+ : null,
239
+ select: (piece) => {
240
+ if (!piece) cancelDrag(instance);
241
+ selectPiece(
242
+ root,
243
+ (piece?.dataset.lightMazePiece as LightMazePiece | undefined) ?? null,
244
+ );
245
+ },
246
+ move: (piece, direction) => {
247
+ void move(
248
+ root,
249
+ piece.dataset.lightMazePiece as LightMazePiece,
250
+ direction,
251
+ );
252
+ },
253
+ isLocked: () => instance.motion !== null || instance.drag !== null,
254
+ });
267
255
  }
268
256
 
269
257
  export function selectLightMazePuzzle(root: HTMLElement, index: number): void {
@@ -302,6 +290,7 @@ export function resetLightMazePuzzle(root: HTMLElement): void {
302
290
  piece.removeAttribute('data-piece-moved');
303
291
  }
304
292
  render(root);
293
+ instance.keyboard?.reset();
305
294
  emitChange(root, panel);
306
295
  }
307
296
 
@@ -339,6 +328,7 @@ export function destroyLightMazePuzzle(root: HTMLElement): void {
339
328
  cancelMotion(instance);
340
329
  instance.controller.abort();
341
330
  instance.dismiss();
331
+ instance.keyboard?.destroy();
342
332
  cancelDrag(instance);
343
333
  clearClickTimer(instance);
344
334
  for (const panel of instance.panels) {
@@ -360,7 +350,7 @@ function selectPiece(root: HTMLElement, piece: LightMazePiece | null): void {
360
350
  const instance = instances.get(root);
361
351
  if (
362
352
  !instance ||
363
- instance.motion ||
353
+ (instance.motion && piece !== null) ||
364
354
  active(instance).element.dataset.puzzleStatus === 'valid'
365
355
  )
366
356
  return;
@@ -570,8 +560,8 @@ function renderSelection(root: HTMLElement): void {
570
560
  if (movement.piece !== instance.selected) continue;
571
561
  const destination = findCell(panel, movement.to);
572
562
  if (!destination) continue;
573
- renderPathStream(panel.board, movement);
574
- destination.tabIndex = 0;
563
+ renderPathStream(panel.board, movement, movement.piece);
564
+ destination.tabIndex = -1;
575
565
  destination.setAttribute('data-light-maze-destination', '');
576
566
  destination.setAttribute('data-puzzle-cell-interactive', '');
577
567
  destination.dataset.destinationPiece = movement.piece;
@@ -587,10 +577,7 @@ function renderSelection(root: HTMLElement): void {
587
577
  }
588
578
 
589
579
  function clearPreviews(panel: Panel): void {
590
- for (const stream of panel.element.querySelectorAll(
591
- '[data-puzzle-path-stream]',
592
- ))
593
- stream.remove();
580
+ clearPuzzleMovementPreview(panel.element);
594
581
  for (const cell of panel.element.querySelectorAll<HTMLButtonElement>(
595
582
  '[data-light-maze-destination]',
596
583
  )) {
@@ -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-tone-info);
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-tone-danger);
113
+ color: var(--nox-source-magnetic-south);
114
114
  }
115
115
 
116
116
  .magnetic-field-puzzle__polarity-icon {