@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/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
- const meta = import.meta;
41
- if (meta.env?.DEV === true)
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
- if (meta.env?.PROD === true)
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 value = useMemo(() => ({ layout, canvasRef, regions, compact }), [layout, regions, compact]);
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?.parentElement;
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
- const host = canvas?.parentElement;
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 === lastReport)
402
- return;
403
- lastReport = report;
404
- if (report === "") {
405
- canvas.removeAttribute("data-hud-overflow");
406
- }
407
- else {
408
- canvas.setAttribute("data-hud-overflow", report);
409
- console.warn(`[jgengine] HUD UI escapes the ${Math.round(hostRect.width)}x${Math.round(hostRect.height)} viewport: ${report}`);
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
- if (dev) {
412
- setOffenders(escaped.map((overflow) => {
413
- const rect = surfaces.find((s) => s.id === overflow.id)?.rect;
414
- return { ...overflow, x: rect?.x ?? 0, y: rect?.y ?? 0, width: rect?.width ?? 0, height: rect?.height ?? 0 };
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))), 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: {
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
- focusActive();
92
- }, [activeIndex, focusActive]);
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
- moveRef.current(intent);
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
- moveRef.current(intent);
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
- moveRef.current(axisIntent);
255
+ runIntentRef.current(axisIntent);
226
256
  return;
227
257
  }
228
258
  if (shouldRepeatMenuNav(now, stick, repeatRef.current)) {
229
259
  stick.lastFiredAt = now;
230
- moveRef.current(axisIntent);
260
+ runIntentRef.current(axisIntent);
231
261
  }
232
262
  };
233
263
  frame = requestAnimationFrame(poll);
@@ -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",
@@ -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
- * @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
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. */