@jgengine/react 0.29.0 → 0.31.0
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/CHANGELOG.md +53 -0
- package/dist/accessibility.d.ts +40 -7
- package/dist/accessibility.js +40 -12
- package/dist/hudLanes.d.ts +96 -0
- package/dist/hudLanes.js +148 -0
- package/dist/hudLanesPreview.d.ts +11 -0
- package/dist/hudLanesPreview.js +31 -0
- package/dist/hudLayout.d.ts +8 -1
- package/dist/hudLayout.js +236 -25
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/menuNav.d.ts +7 -1
- package/dist/menuNav.js +42 -12
- package/dist/previewFixtures.js +6 -0
- package/dist/settingsBlocks.d.ts +34 -8
- package/dist/settingsBlocks.js +109 -29
- package/package.json +2 -2
- package/skills/jgengine/api.md +3 -3
- package/skills/jgengine-ui/api.md +51 -16
- package/skills/jgengine-ui/capabilities.md +28 -2
- package/skills/jgengine-ui/reference.md +2 -2
package/dist/hudLayout.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
3
|
import { createPortal } from "react-dom";
|
|
4
4
|
import { HUD_ANCHOR_FRACTIONS, anchoredPlacement, createHudLayout, isPanelDraggable, registerActiveHudLayout, } from "@jgengine/core/ui/hudLayout";
|
|
5
5
|
import { resizePanelSize, resolvePanelResize, } from "@jgengine/core/ui/hudDocument";
|
|
@@ -9,6 +9,7 @@ import { useDisplayProfile } from "./display.js";
|
|
|
9
9
|
import { useEngineState } from "./engineStore.js";
|
|
10
10
|
import { useGameLayoutMode, useRegisterLayoutRegion } from "./gameViewport.js";
|
|
11
11
|
import { useOptionalGamePhase } from "./hooks.js";
|
|
12
|
+
import { formatHudOverlapReport, hudBlockOverlaps, resolveHudLanes } from "./hudLanes.js";
|
|
12
13
|
import { useHudLayoutPersist } from "./hudLayoutPersist.js";
|
|
13
14
|
import { useHudViewport } from "./hudViewport.js";
|
|
14
15
|
import { hudFont, hudType } from "./hudTokens.js";
|
|
@@ -34,13 +35,21 @@ const WINDOW_ATTR = "data-hud-window";
|
|
|
34
35
|
/**
|
|
35
36
|
* Dev-only, prod-safe. The on-screen boundary overlay must never reach players,
|
|
36
37
|
* so anything we cannot positively identify as a dev build resolves to `false`.
|
|
38
|
+
*
|
|
39
|
+
* `import.meta.env.DEV` is read as a literal member chain because Vite replaces
|
|
40
|
+
* exactly those words at transform time; reading `env` off a cast `import.meta`
|
|
41
|
+
* survives to the browser as `undefined` and every dev-only report stays dark.
|
|
37
42
|
*/
|
|
38
43
|
function isDevBuild() {
|
|
39
44
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
// Literal members on purpose: Vite replaces exactly `import.meta.env.DEV`.
|
|
46
|
+
// Inlined as plain `@ts-ignore` because a workspace-wide type program that
|
|
47
|
+
// includes `vite/client` would otherwise fail an unused-directive check.
|
|
48
|
+
// @ts-ignore -- `vite/client` types are not part of this package's own tsconfig
|
|
49
|
+
if (import.meta.env.DEV === true)
|
|
42
50
|
return true;
|
|
43
|
-
|
|
51
|
+
// @ts-ignore -- `vite/client` types are not part of this package's own tsconfig
|
|
52
|
+
if (import.meta.env.PROD === true)
|
|
44
53
|
return false;
|
|
45
54
|
}
|
|
46
55
|
catch {
|
|
@@ -108,6 +117,21 @@ function collectBoundarySurfaces(canvas) {
|
|
|
108
117
|
}
|
|
109
118
|
return out;
|
|
110
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The nearest ancestor that actually has a box. `HudThemeRoot` is
|
|
122
|
+
* `display: contents`, so `canvas.parentElement` can be a layout-free element
|
|
123
|
+
* whose rect is 0x0 — measuring it would silently disable every check below.
|
|
124
|
+
*/
|
|
125
|
+
function measurableHost(canvas) {
|
|
126
|
+
let element = canvas.parentElement;
|
|
127
|
+
while (element !== null) {
|
|
128
|
+
const rect = element.getBoundingClientRect();
|
|
129
|
+
if (rect.width > 0 && rect.height > 0)
|
|
130
|
+
return element;
|
|
131
|
+
element = element.parentElement;
|
|
132
|
+
}
|
|
133
|
+
return canvas;
|
|
134
|
+
}
|
|
111
135
|
function sizeStyle(panel) {
|
|
112
136
|
if (panel === undefined)
|
|
113
137
|
return {};
|
|
@@ -197,6 +221,7 @@ export function useHudLayout(options) {
|
|
|
197
221
|
}, [layout, storageKey, documentUi]);
|
|
198
222
|
return layout;
|
|
199
223
|
}
|
|
224
|
+
const EMPTY_LANE_RECTS = {};
|
|
200
225
|
const HudCanvasContext = createContext(null);
|
|
201
226
|
function HudEditBar({ layout }) {
|
|
202
227
|
return (_jsxs("div", { "data-hud-edit-bar": "", style: {
|
|
@@ -305,7 +330,82 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
|
|
|
305
330
|
}
|
|
306
331
|
return refs;
|
|
307
332
|
}, []);
|
|
308
|
-
const
|
|
333
|
+
const [laneLayer, setLaneLayer] = useState(null);
|
|
334
|
+
const [laneFrame, setLaneFrame] = useState(null);
|
|
335
|
+
const [laneRevision, setLaneRevision] = useState(0);
|
|
336
|
+
const laneSlots = useRef(new Map());
|
|
337
|
+
const laneSizes = useRef(new Map());
|
|
338
|
+
const declareLane = useCallback((slot) => {
|
|
339
|
+
const previous = laneSlots.current.get(slot.id);
|
|
340
|
+
if (previous !== undefined &&
|
|
341
|
+
previous.anchor === slot.anchor &&
|
|
342
|
+
previous.lane === slot.lane &&
|
|
343
|
+
previous.order === slot.order &&
|
|
344
|
+
previous.width === slot.width &&
|
|
345
|
+
previous.height === slot.height &&
|
|
346
|
+
previous.rect === slot.rect) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
laneSlots.current.set(slot.id, slot);
|
|
350
|
+
setLaneRevision((value) => value + 1);
|
|
351
|
+
}, []);
|
|
352
|
+
const removeLane = useCallback((id) => {
|
|
353
|
+
if (!laneSlots.current.delete(id))
|
|
354
|
+
return;
|
|
355
|
+
laneSizes.current.delete(id);
|
|
356
|
+
setLaneRevision((value) => value + 1);
|
|
357
|
+
}, []);
|
|
358
|
+
const reportLaneSize = useCallback((id, size) => {
|
|
359
|
+
const previous = laneSizes.current.get(id);
|
|
360
|
+
if (previous !== undefined &&
|
|
361
|
+
Math.abs(previous.width - size.width) < 0.5 &&
|
|
362
|
+
Math.abs(previous.height - size.height) < 0.5) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
laneSizes.current.set(id, size);
|
|
366
|
+
setLaneRevision((value) => value + 1);
|
|
367
|
+
}, []);
|
|
368
|
+
const laneRects = useMemo(() => {
|
|
369
|
+
if (laneFrame === null || laneSlots.current.size === 0)
|
|
370
|
+
return EMPTY_LANE_RECTS;
|
|
371
|
+
const slots = [];
|
|
372
|
+
for (const slot of laneSlots.current.values()) {
|
|
373
|
+
const measured = laneSizes.current.get(slot.id);
|
|
374
|
+
slots.push(measured === undefined
|
|
375
|
+
? slot
|
|
376
|
+
: { ...slot, width: slot.width ?? measured.width, height: slot.height ?? measured.height });
|
|
377
|
+
}
|
|
378
|
+
return resolveHudLanes(slots, laneFrame, { gap: REGION_GAP }).rects;
|
|
379
|
+
}, [laneFrame, laneRevision]);
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
if (laneLayer === null)
|
|
382
|
+
return;
|
|
383
|
+
const measure = () => {
|
|
384
|
+
const rect = laneLayer.getBoundingClientRect();
|
|
385
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
386
|
+
return;
|
|
387
|
+
setLaneFrame((prev) => prev !== null && Math.abs(prev.width - rect.width) < 0.5 && Math.abs(prev.height - rect.height) < 0.5
|
|
388
|
+
? prev
|
|
389
|
+
: { width: rect.width, height: rect.height });
|
|
390
|
+
};
|
|
391
|
+
measure();
|
|
392
|
+
if (typeof ResizeObserver === "undefined")
|
|
393
|
+
return;
|
|
394
|
+
const observer = new ResizeObserver(measure);
|
|
395
|
+
observer.observe(laneLayer);
|
|
396
|
+
return () => observer.disconnect();
|
|
397
|
+
}, [laneLayer]);
|
|
398
|
+
const value = useMemo(() => ({
|
|
399
|
+
layout,
|
|
400
|
+
canvasRef,
|
|
401
|
+
regions,
|
|
402
|
+
compact,
|
|
403
|
+
laneLayer,
|
|
404
|
+
laneRects,
|
|
405
|
+
declareLane,
|
|
406
|
+
removeLane,
|
|
407
|
+
reportLaneSize,
|
|
408
|
+
}), [layout, regions, compact, laneLayer, laneRects, declareLane, removeLane, reportLaneSize]);
|
|
309
409
|
const editing = useEngineState(layout).editing;
|
|
310
410
|
useEffect(() => registerActiveHudLayout(layout), [layout]);
|
|
311
411
|
const chordEnabled = editChord !== false;
|
|
@@ -350,7 +450,7 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
|
|
|
350
450
|
useEffect(() => {
|
|
351
451
|
if (!fitEnabled)
|
|
352
452
|
return;
|
|
353
|
-
const host = canvasRef.current
|
|
453
|
+
const host = canvasRef.current === null ? null : measurableHost(canvasRef.current);
|
|
354
454
|
if (host === undefined || host === null || typeof ResizeObserver === "undefined")
|
|
355
455
|
return;
|
|
356
456
|
const measure = () => {
|
|
@@ -365,13 +465,14 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
|
|
|
365
465
|
}, [fitEnabled]);
|
|
366
466
|
useEffect(() => {
|
|
367
467
|
const canvas = canvasRef.current;
|
|
368
|
-
|
|
369
|
-
if (canvas === null || canvas === undefined || host === undefined || host === null)
|
|
468
|
+
if (canvas === null)
|
|
370
469
|
return;
|
|
470
|
+
const host = measurableHost(canvas);
|
|
371
471
|
if (typeof requestAnimationFrame !== "function")
|
|
372
472
|
return;
|
|
373
473
|
let frame = 0;
|
|
374
474
|
let lastReport = "";
|
|
475
|
+
let lastOverlapReport = "";
|
|
375
476
|
const dev = isDevBuild();
|
|
376
477
|
const check = () => {
|
|
377
478
|
frame = 0;
|
|
@@ -398,21 +499,46 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
|
|
|
398
499
|
// cross an edge — a closed/parked/off-screen menu is never an offender.
|
|
399
500
|
const escaped = overflowingPanels(surfaces, viewportSize);
|
|
400
501
|
const report = escaped.length === 0 ? "" : JSON.stringify(escaped);
|
|
401
|
-
if (report
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
502
|
+
if (report !== lastReport) {
|
|
503
|
+
lastReport = report;
|
|
504
|
+
if (report === "") {
|
|
505
|
+
canvas.removeAttribute("data-hud-overflow");
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
canvas.setAttribute("data-hud-overflow", report);
|
|
509
|
+
console.warn(`[jgengine] HUD UI escapes the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
|
|
510
|
+
}
|
|
511
|
+
if (dev) {
|
|
512
|
+
setOffenders(escaped.map((overflow) => {
|
|
513
|
+
const rect = surfaces.find((s) => s.id === overflow.id)?.rect;
|
|
514
|
+
return { ...overflow, x: rect?.x ?? 0, y: rect?.y ?? 0, width: rect?.width ?? 0, height: rect?.height ?? 0 };
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
410
517
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
518
|
+
const blocks = [...canvas.querySelectorAll("[data-hud-panel]")]
|
|
519
|
+
.filter((el) => isSurfaceShown(el))
|
|
520
|
+
.map((el) => {
|
|
521
|
+
const rect = el.getBoundingClientRect();
|
|
522
|
+
return {
|
|
523
|
+
id: surfaceId(el),
|
|
524
|
+
rect: {
|
|
525
|
+
x: rect.left - hostRect.left,
|
|
526
|
+
y: rect.top - hostRect.top,
|
|
527
|
+
width: rect.width,
|
|
528
|
+
height: rect.height,
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
const overlapReport = dev ? formatHudOverlapReport(hudBlockOverlaps(blocks), viewportSize) : "";
|
|
533
|
+
if (overlapReport !== lastOverlapReport) {
|
|
534
|
+
lastOverlapReport = overlapReport;
|
|
535
|
+
if (overlapReport === "") {
|
|
536
|
+
canvas.removeAttribute("data-hud-overlap");
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
canvas.setAttribute("data-hud-overlap", overlapReport);
|
|
540
|
+
console.warn(`[jgengine] ${overlapReport}`);
|
|
541
|
+
}
|
|
416
542
|
}
|
|
417
543
|
};
|
|
418
544
|
const schedule = () => {
|
|
@@ -462,7 +588,14 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
|
|
|
462
588
|
pointerEvents: "none",
|
|
463
589
|
zIndex: 1,
|
|
464
590
|
...REGION_LAYOUT[anchor],
|
|
465
|
-
} }, anchor))),
|
|
591
|
+
} }, anchor))), _jsx("div", { "data-hud-lane-layer": "", style: {
|
|
592
|
+
position: "absolute",
|
|
593
|
+
inset: 0,
|
|
594
|
+
padding: `${PAD_TOP} ${PAD_RIGHT} ${PAD_BOTTOM} ${PAD_LEFT}`,
|
|
595
|
+
boxSizing: "border-box",
|
|
596
|
+
pointerEvents: "none",
|
|
597
|
+
zIndex: 1,
|
|
598
|
+
}, children: _jsx("div", { ref: setLaneLayer, style: { position: "relative", width: "100%", height: "100%" } }) }), children, offenders.length > 0 ? (_jsx("div", { "data-hud-overflow-overlay": "", style: { position: "absolute", inset: 0, pointerEvents: "none", zIndex: EDIT_BAR_Z - 1 }, children: offenders.map((o) => (_jsx("div", { style: {
|
|
466
599
|
position: "absolute",
|
|
467
600
|
left: o.x / scale,
|
|
468
601
|
top: o.y / scale,
|
|
@@ -537,13 +670,13 @@ function HudChip({ label, anchor, children, }) {
|
|
|
537
670
|
*
|
|
538
671
|
* @capability hud-panel anchored HUD slot — drop any widget in a named, player-movable panel
|
|
539
672
|
*/
|
|
540
|
-
export function HudPanel({ id, anchor = "top-left", order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }) {
|
|
673
|
+
export function HudPanel({ id, anchor = "top-left", lane, order, compact: compactMode = "keep", chip, interactive, inset, locked, showDuring, priority, mobileBehavior, allowOverlapWith, collisionGroup, region = true, width, height, type, className, style, children, }) {
|
|
541
674
|
const ctx = useContext(HudCanvasContext);
|
|
542
675
|
const phase = useOptionalGamePhase();
|
|
543
676
|
const layoutMode = useGameLayoutMode();
|
|
544
677
|
if (ctx === null)
|
|
545
678
|
throw new Error("HudPanel must be rendered inside a HudCanvas");
|
|
546
|
-
const { layout, canvasRef, regions, compact } = ctx;
|
|
679
|
+
const { layout, canvasRef, regions, compact, laneLayer, laneRects, declareLane, removeLane, reportLaneSize } = ctx;
|
|
547
680
|
const onMobile = isMobileMode(layoutMode);
|
|
548
681
|
const registeredOnRef = useRef(null);
|
|
549
682
|
if (registeredOnRef.current !== layout) {
|
|
@@ -582,6 +715,59 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
|
|
|
582
715
|
// A flowing panel portals into its anchor region, which HudCanvas only provides after its first commit.
|
|
583
716
|
const awaitingRegion = panel !== undefined && (compact || !panel.moved) && regions[compact ? anchor : panel.placement.anchor] === undefined;
|
|
584
717
|
useRegisterLayoutRegion(regionSpec, rootRef, region && !regionHidden && !awaitingRegion);
|
|
718
|
+
const laneActive = lane !== undefined && !regionHidden && !(panel !== undefined && panel.moved);
|
|
719
|
+
const laneRect = lane === undefined ? undefined : laneRects[id];
|
|
720
|
+
const [laneSize, setLaneSize] = useState(null);
|
|
721
|
+
useEffect(() => {
|
|
722
|
+
if (!laneActive)
|
|
723
|
+
return;
|
|
724
|
+
declareLane({
|
|
725
|
+
id,
|
|
726
|
+
anchor: panel?.placement.anchor ?? anchor,
|
|
727
|
+
lane,
|
|
728
|
+
order,
|
|
729
|
+
width: panel?.width ?? width,
|
|
730
|
+
height: panel?.height ?? height,
|
|
731
|
+
});
|
|
732
|
+
return () => removeLane(id);
|
|
733
|
+
}, [
|
|
734
|
+
laneActive,
|
|
735
|
+
declareLane,
|
|
736
|
+
removeLane,
|
|
737
|
+
id,
|
|
738
|
+
panel?.placement.anchor,
|
|
739
|
+
lane,
|
|
740
|
+
order,
|
|
741
|
+
panel?.width,
|
|
742
|
+
width,
|
|
743
|
+
panel?.height,
|
|
744
|
+
height,
|
|
745
|
+
]);
|
|
746
|
+
const laneMounted = laneRect !== undefined;
|
|
747
|
+
useEffect(() => {
|
|
748
|
+
if (!laneActive || !laneMounted)
|
|
749
|
+
return;
|
|
750
|
+
const element = rootRef.current;
|
|
751
|
+
if (element === null)
|
|
752
|
+
return;
|
|
753
|
+
const measure = () => {
|
|
754
|
+
const rect = element.getBoundingClientRect();
|
|
755
|
+
if (rect.width <= 0 && rect.height <= 0)
|
|
756
|
+
return;
|
|
757
|
+
setLaneSize((previous) => previous !== null &&
|
|
758
|
+
Math.abs(previous.width - rect.width) < 0.5 &&
|
|
759
|
+
Math.abs(previous.height - rect.height) < 0.5
|
|
760
|
+
? previous
|
|
761
|
+
: { width: rect.width, height: rect.height });
|
|
762
|
+
reportLaneSize(id, { width: rect.width, height: rect.height });
|
|
763
|
+
};
|
|
764
|
+
measure();
|
|
765
|
+
if (typeof ResizeObserver === "undefined")
|
|
766
|
+
return;
|
|
767
|
+
const observer = new ResizeObserver(measure);
|
|
768
|
+
observer.observe(element);
|
|
769
|
+
return () => observer.disconnect();
|
|
770
|
+
}, [laneActive, laneMounted, id, reportLaneSize]);
|
|
585
771
|
const onPointerDown = (event) => {
|
|
586
772
|
if (!draggable || event.button !== 0)
|
|
587
773
|
return;
|
|
@@ -712,6 +898,31 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
|
|
|
712
898
|
if (!hudVisibleInPhase(showDuring, phase))
|
|
713
899
|
return null;
|
|
714
900
|
const authoredSize = sizeStyle(panel);
|
|
901
|
+
if (laneActive) {
|
|
902
|
+
if (laneRect === undefined || laneLayer === null)
|
|
903
|
+
return null;
|
|
904
|
+
const declaredWidth = panel.width ?? width;
|
|
905
|
+
const declaredHeight = panel.height ?? height;
|
|
906
|
+
const settled = laneSize !== null || declaredWidth !== undefined || declaredHeight !== undefined;
|
|
907
|
+
const chipped = compact && compactMode === "chip";
|
|
908
|
+
const passThrough = interactive === false && !editing && !chipped;
|
|
909
|
+
return createPortal(_jsxs("div", { ref: rootRef, "data-hud-panel": id, "data-hud-lane": lane, "data-dragging": dragging ? "" : undefined, "data-hud-priority": priority, "data-hud-mobile-behavior": mobileBehavior, "data-hud-type": panel.type ?? type, className: className, onPointerDown: onPointerDown, onClickCapture: onClickCapture, style: {
|
|
910
|
+
position: "absolute",
|
|
911
|
+
left: laneRect.x,
|
|
912
|
+
top: laneRect.y,
|
|
913
|
+
...(declaredWidth === undefined ? {} : { width: declaredWidth }),
|
|
914
|
+
...(declaredHeight === undefined ? {} : { height: declaredHeight }),
|
|
915
|
+
visibility: settled ? undefined : "hidden",
|
|
916
|
+
pointerEvents: passThrough ? "none" : "auto",
|
|
917
|
+
touchAction: draggable ? "none" : undefined,
|
|
918
|
+
userSelect: editing || dragging ? "none" : undefined,
|
|
919
|
+
cursor: dragging ? "grabbing" : editing ? "grab" : undefined,
|
|
920
|
+
outline: editing ? "1px dashed rgba(255, 255, 255, 0.6)" : undefined,
|
|
921
|
+
outlineOffset: editing ? 2 : undefined,
|
|
922
|
+
boxSizing: "border-box",
|
|
923
|
+
...style,
|
|
924
|
+
}, children: [chipped ? (_jsx(HudChip, { label: chip ?? id, anchor: anchor, children: children })) : (children), editingChrome] }), laneLayer);
|
|
925
|
+
}
|
|
715
926
|
const flow = compact || !panel.moved;
|
|
716
927
|
if (flow) {
|
|
717
928
|
const regionEl = regions[compact ? anchor : panel.placement.anchor];
|
package/dist/index.d.ts
CHANGED
|
@@ -67,6 +67,7 @@ export * from "./cards.js";
|
|
|
67
67
|
export * from "./actionHud.js";
|
|
68
68
|
export * from "./selectionHud.js";
|
|
69
69
|
export * from "./hudLayout.js";
|
|
70
|
+
export * from "./hudLanes.js";
|
|
70
71
|
export * from "./hudLayoutPersist.js";
|
|
71
72
|
export * from "./hudViewport.js";
|
|
72
73
|
export * from "./gameViewport.js";
|
package/dist/index.js
CHANGED
|
@@ -67,6 +67,7 @@ export * from "./cards.js";
|
|
|
67
67
|
export * from "./actionHud.js";
|
|
68
68
|
export * from "./selectionHud.js";
|
|
69
69
|
export * from "./hudLayout.js";
|
|
70
|
+
export * from "./hudLanes.js";
|
|
70
71
|
export * from "./hudLayoutPersist.js";
|
|
71
72
|
export * from "./hudViewport.js";
|
|
72
73
|
export * from "./gameViewport.js";
|
package/dist/menuNav.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export interface UseMenuNavOptions {
|
|
|
43
43
|
enabled?: boolean;
|
|
44
44
|
/** Confirm (Enter/Space/A). Called with the focused id. */
|
|
45
45
|
onConfirm?: (id: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Left/right (arrow keys, d-pad, left stick) change the focused item's value by one step instead of
|
|
48
|
+
* moving focus. Give it to a menu whose items are settings rows; hold-to-repeat applies, so a held
|
|
49
|
+
* direction steps repeatedly. Up/down and confirm/back are unchanged.
|
|
50
|
+
*/
|
|
51
|
+
onAdjust?: (id: string, delta: -1 | 1) => void;
|
|
46
52
|
/** Back (Escape/Backspace/B/Start). */
|
|
47
53
|
onBack?: () => void;
|
|
48
54
|
/** Poll a connected gamepad for menu intents. Default true. */
|
|
@@ -58,6 +64,6 @@ export interface UseMenuNavOptions {
|
|
|
58
64
|
*
|
|
59
65
|
* @capability use-menu-nav keyboard and gamepad focus navigation for a menu — roving focus order, confirm/back, disabled-item skipping, no focus trap
|
|
60
66
|
*/
|
|
61
|
-
export declare function useMenuNav({ ids, columns, wrap, disabled, initialId, enabled, onConfirm, onBack, gamepad, repeat, }: UseMenuNavOptions): MenuNavController;
|
|
67
|
+
export declare function useMenuNav({ ids, columns, wrap, disabled, initialId, enabled, onConfirm, onBack, onAdjust, gamepad, repeat, }: UseMenuNavOptions): MenuNavController;
|
|
62
68
|
/** Render prop for a menu whose items come from {@link useMenuNav}. */
|
|
63
69
|
export type MenuNavRender = (nav: MenuNavController) => ReactNode;
|
package/dist/menuNav.js
CHANGED
|
@@ -14,7 +14,7 @@ function isTextEntry(target) {
|
|
|
14
14
|
*
|
|
15
15
|
* @capability use-menu-nav keyboard and gamepad focus navigation for a menu — roving focus order, confirm/back, disabled-item skipping, no focus trap
|
|
16
16
|
*/
|
|
17
|
-
export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }) {
|
|
17
|
+
export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }) {
|
|
18
18
|
const disabledIndices = useMemo(() => {
|
|
19
19
|
if (disabled === undefined || disabled.length === 0)
|
|
20
20
|
return undefined;
|
|
@@ -36,16 +36,28 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
36
36
|
idsRef.current = ids;
|
|
37
37
|
const activeIndexRef = useRef(activeIndex);
|
|
38
38
|
activeIndexRef.current = activeIndex;
|
|
39
|
-
const callbacks = useRef({ onConfirm, onBack });
|
|
40
|
-
callbacks.current = { onConfirm, onBack };
|
|
39
|
+
const callbacks = useRef({ onConfirm, onBack, onAdjust });
|
|
40
|
+
callbacks.current = { onConfirm, onBack, onAdjust };
|
|
41
41
|
const elements = useRef(new Map());
|
|
42
42
|
const pendingFocus = useRef(true);
|
|
43
|
+
// The first pass claims focus only when nothing else owns it; later moves focus the new item.
|
|
44
|
+
const claimed = useRef(false);
|
|
45
|
+
const firstEnablePass = useRef(true);
|
|
43
46
|
const focusActive = useCallback(() => {
|
|
44
47
|
const id = menuNavIdAt(idsRef.current, activeIndexRef.current);
|
|
45
48
|
if (id === null)
|
|
46
49
|
return;
|
|
47
50
|
elements.current.get(id)?.focus({ preventScroll: true });
|
|
48
51
|
}, []);
|
|
52
|
+
// A control or dialog that already owns focus keeps it; the first move brings focus into the menu. Without
|
|
53
|
+
// this a menu mounted inside a dialog takes focus from the dialog's own initial control, and the dialog's
|
|
54
|
+
// opener is never restored on close.
|
|
55
|
+
const claimFocus = useCallback(() => {
|
|
56
|
+
const active = typeof document === "undefined" ? null : document.activeElement;
|
|
57
|
+
if (active !== null && active !== document.body && active !== document.documentElement)
|
|
58
|
+
return;
|
|
59
|
+
focusActive();
|
|
60
|
+
}, [focusActive]);
|
|
49
61
|
const move = useCallback((direction) => {
|
|
50
62
|
const layout = {
|
|
51
63
|
count: idsRef.current.length,
|
|
@@ -75,11 +87,19 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
75
87
|
activate(menuNavIdAt(idsRef.current, activeIndexRef.current));
|
|
76
88
|
else if (intent === "back")
|
|
77
89
|
callbacks.current.onBack?.();
|
|
90
|
+
else if (intent === "left" || intent === "right") {
|
|
91
|
+
const adjust = callbacks.current.onAdjust;
|
|
92
|
+
if (adjust === undefined) {
|
|
93
|
+
move(intent);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const id = menuNavIdAt(idsRef.current, activeIndexRef.current);
|
|
97
|
+
if (id !== null)
|
|
98
|
+
adjust(id, intent === "right" ? 1 : -1);
|
|
99
|
+
}
|
|
78
100
|
else
|
|
79
101
|
move(intent);
|
|
80
102
|
}, [activate, move]);
|
|
81
|
-
const moveRef = useRef(move);
|
|
82
|
-
moveRef.current = move;
|
|
83
103
|
const runIntentRef = useRef(runIntent);
|
|
84
104
|
runIntentRef.current = runIntent;
|
|
85
105
|
const repeatRef = useRef(repeat);
|
|
@@ -88,14 +108,24 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
88
108
|
if (!pendingFocus.current)
|
|
89
109
|
return;
|
|
90
110
|
pendingFocus.current = false;
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
if (claimed.current) {
|
|
112
|
+
focusActive();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
claimed.current = true;
|
|
116
|
+
claimFocus();
|
|
117
|
+
}, [activeIndex, claimFocus, focusActive]);
|
|
93
118
|
useEffect(() => {
|
|
94
119
|
if (!enabled)
|
|
95
120
|
return;
|
|
96
121
|
pendingFocus.current = true;
|
|
122
|
+
if (firstEnablePass.current) {
|
|
123
|
+
firstEnablePass.current = false;
|
|
124
|
+
claimFocus();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
97
127
|
focusActive();
|
|
98
|
-
}, [enabled, focusActive]);
|
|
128
|
+
}, [enabled, claimFocus, focusActive]);
|
|
99
129
|
useEffect(() => {
|
|
100
130
|
const index = ids.indexOf(activeId ?? "");
|
|
101
131
|
if (activeId !== null && index >= 0)
|
|
@@ -201,12 +231,12 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
201
231
|
}
|
|
202
232
|
if (isNew) {
|
|
203
233
|
held.set(button, { pressedAt: now, lastFiredAt: now });
|
|
204
|
-
|
|
234
|
+
runIntentRef.current(intent);
|
|
205
235
|
continue;
|
|
206
236
|
}
|
|
207
237
|
if (record !== undefined && shouldRepeatMenuNav(now, record, repeatRef.current)) {
|
|
208
238
|
record.lastFiredAt = now;
|
|
209
|
-
|
|
239
|
+
runIntentRef.current(intent);
|
|
210
240
|
}
|
|
211
241
|
}
|
|
212
242
|
for (const button of [...held.keys()])
|
|
@@ -222,12 +252,12 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
|
|
|
222
252
|
}
|
|
223
253
|
if (stick === null || stick.intent !== axisIntent) {
|
|
224
254
|
stick = { intent: axisIntent, pressedAt: now, lastFiredAt: now };
|
|
225
|
-
|
|
255
|
+
runIntentRef.current(axisIntent);
|
|
226
256
|
return;
|
|
227
257
|
}
|
|
228
258
|
if (shouldRepeatMenuNav(now, stick, repeatRef.current)) {
|
|
229
259
|
stick.lastFiredAt = now;
|
|
230
|
-
|
|
260
|
+
runIntentRef.current(axisIntent);
|
|
231
261
|
}
|
|
232
262
|
};
|
|
233
263
|
frame = requestAnimationFrame(poll);
|
package/dist/previewFixtures.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BarsPreview } from "./barsPreview.js";
|
|
2
2
|
import { HudChromePreview } from "./hudChromePreview.js";
|
|
3
|
+
import { HudLanesPreview } from "./hudLanesPreview.js";
|
|
3
4
|
import { HudThemePreview } from "./hudThemePreview.js";
|
|
4
5
|
import { IconsPreview } from "./iconsPreview.js";
|
|
5
6
|
import { StreetGeometryPreview } from "./streetGeometryPreview.js";
|
|
@@ -20,6 +21,11 @@ import { ScrollRailPreview } from "./scrollRailPreview.js";
|
|
|
20
21
|
* declares.
|
|
21
22
|
*/
|
|
22
23
|
export const PREVIEW_FIXTURES = {
|
|
24
|
+
HudLanesPreview: {
|
|
25
|
+
name: "HudLanesPreview",
|
|
26
|
+
description: "Two HUD blocks stacked in one lane, a second lane beside it, and the mirrored right-hand lane.",
|
|
27
|
+
component: HudLanesPreview,
|
|
28
|
+
},
|
|
23
29
|
ScrollRailPreview: { name: "ScrollRailPreview", description: "Reachable caller-owned settings categories in shared scrolling chrome.", component: ScrollRailPreview },
|
|
24
30
|
HudChromePreview: {
|
|
25
31
|
name: "HudChromePreview",
|
package/dist/settingsBlocks.d.ts
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { SettingValue } from "@jgengine/core/settings/settingsModel";
|
|
3
|
+
import { type MenuNavItemProps } from "./menuNav.js";
|
|
2
4
|
import type { SettingsCategoryView, SettingsKeybindRow, SettingsRow } from "./settings.js";
|
|
5
|
+
/**
|
|
6
|
+
* One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a
|
|
7
|
+
* select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the
|
|
8
|
+
* kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
|
|
9
|
+
*
|
|
10
|
+
* @capability adjust-setting-row step a settings row value once, for keyboard and gamepad alike
|
|
11
|
+
*/
|
|
12
|
+
export declare function adjustSettingRow(row: SettingsRow, delta: -1 | 1): SettingValue | undefined;
|
|
3
13
|
/**
|
|
4
14
|
* One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a
|
|
5
15
|
* drag never storms the settings store or the graphics rebuild it drives.
|
|
6
16
|
*
|
|
7
17
|
* @capability settings-slider a settings slider row that mirrors locally and persists on release
|
|
8
18
|
*/
|
|
9
|
-
export declare function SettingsSlider({ row }: {
|
|
19
|
+
export declare function SettingsSlider({ row, itemProps }: {
|
|
10
20
|
row: SettingsRow;
|
|
21
|
+
itemProps?: MenuNavItemProps;
|
|
11
22
|
}): ReactNode;
|
|
12
23
|
/** One on/off row from a {@link SettingsRow}. */
|
|
13
|
-
export declare function SettingsToggle({ row }: {
|
|
24
|
+
export declare function SettingsToggle({ row, itemProps }: {
|
|
14
25
|
row: SettingsRow;
|
|
26
|
+
itemProps?: MenuNavItemProps;
|
|
15
27
|
}): ReactNode;
|
|
16
28
|
/** One choice row from a {@link SettingsRow}, rendered as a pressed-state option group. */
|
|
17
|
-
export declare function SettingsSelect({ row }: {
|
|
29
|
+
export declare function SettingsSelect({ row, itemProps }: {
|
|
18
30
|
row: SettingsRow;
|
|
31
|
+
itemProps?: MenuNavItemProps;
|
|
19
32
|
}): ReactNode;
|
|
20
|
-
/** Render the control a {@link SettingsRow} kind asks for. */
|
|
21
|
-
export declare function SettingsRowControl({ row }: {
|
|
33
|
+
/** Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}. */
|
|
34
|
+
export declare function SettingsRowControl({ row, itemProps }: {
|
|
22
35
|
row: SettingsRow;
|
|
36
|
+
itemProps?: MenuNavItemProps;
|
|
23
37
|
}): ReactNode;
|
|
24
38
|
/** One rebindable action row: current key glyph, armed-capture state and a reset button. */
|
|
25
39
|
export interface SettingsKeybindControlProps {
|
|
@@ -30,6 +44,8 @@ export interface SettingsKeybindControlProps {
|
|
|
30
44
|
onArm?: () => void;
|
|
31
45
|
/** Props the caller can spread onto the binding button for focus tracking. */
|
|
32
46
|
buttonProps?: Record<string, unknown>;
|
|
47
|
+
/** Props for the row element (a `useMenuNav` item). */
|
|
48
|
+
rowProps?: MenuNavItemProps;
|
|
33
49
|
}
|
|
34
50
|
/**
|
|
35
51
|
* A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state
|
|
@@ -37,7 +53,7 @@ export interface SettingsKeybindControlProps {
|
|
|
37
53
|
*
|
|
38
54
|
* @capability settings-keybind-row one rebindable controls row — key glyph, armed state and reset
|
|
39
55
|
*/
|
|
40
|
-
export declare function SettingsKeybindControl({ row, armed, onArm, buttonProps }: SettingsKeybindControlProps): ReactNode;
|
|
56
|
+
export declare function SettingsKeybindControl({ row, armed, onArm, buttonProps, rowProps }: SettingsKeybindControlProps): ReactNode;
|
|
41
57
|
/** How a {@link SettingsSection} handles a rebind capture. */
|
|
42
58
|
export interface SettingsSectionProps {
|
|
43
59
|
/** The category to render: its rows and its rebindable actions. */
|
|
@@ -46,6 +62,10 @@ export interface SettingsSectionProps {
|
|
|
46
62
|
heading?: boolean;
|
|
47
63
|
/** Allow click-to-rebind on the controls rows. Default true. */
|
|
48
64
|
capture?: boolean;
|
|
65
|
+
/** Take arrow keys, the d-pad and the left stick for row navigation and value edits. Default true. */
|
|
66
|
+
enabled?: boolean;
|
|
67
|
+
/** Row focused on mount. Default the first row. */
|
|
68
|
+
initialId?: string;
|
|
49
69
|
className?: string;
|
|
50
70
|
style?: CSSProperties;
|
|
51
71
|
/** Called with the armed action id (or `null`) so a game can suspend its own keys. */
|
|
@@ -56,9 +76,15 @@ export interface SettingsSectionProps {
|
|
|
56
76
|
* game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the
|
|
57
77
|
* settings controller hands it, and writes every change straight back to that controller's store.
|
|
58
78
|
*
|
|
59
|
-
*
|
|
79
|
+
* The rows are one `useMenuNav` menu: up/down (or the d-pad/stick) move between them, left/right step
|
|
80
|
+
* the focused value — a slider by its step, a toggle flipped, a select to the next/previous option —
|
|
81
|
+
* and Enter/Space/A confirms, which flips a toggle, steps a select forward or arms a keybind.
|
|
82
|
+
* Keyboard and gamepad share that one mapping, so a held direction repeats at the same rate for both,
|
|
83
|
+
* and no row carries its own key handling.
|
|
84
|
+
*
|
|
85
|
+
* @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
|
|
60
86
|
*/
|
|
61
|
-
export declare function SettingsSection({ category, heading, capture, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode;
|
|
87
|
+
export declare function SettingsSection({ category, heading, capture, enabled, initialId, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode;
|
|
62
88
|
/** The live category tabs for a settings screen: a scrollable rail with pressed state. */
|
|
63
89
|
export interface SettingsTabsProps {
|
|
64
90
|
/** The live categories from the settings controller. */
|