@jgengine/react 0.19.0 → 0.21.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 +346 -0
- package/dist/actionHud.js +1 -1
- package/dist/bars.d.ts +1 -1
- package/dist/bars.js +1 -1
- package/dist/barsPreview.d.ts +1 -1
- package/dist/barsPreview.js +9 -2
- package/dist/browserLifecycle.d.ts +26 -0
- package/dist/browserLifecycle.js +42 -0
- package/dist/dialogBehavior.d.ts +22 -0
- package/dist/dialogBehavior.js +109 -0
- package/dist/dragLayer.d.ts +7 -0
- package/dist/dragLayer.js +156 -100
- package/dist/hooks.d.ts +5 -1
- package/dist/hooks.js +3 -1
- package/dist/hudTheme.d.ts +1 -1
- package/dist/hudTheme.js +3 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/map.js +106 -42
- package/dist/modals.js +8 -52
- package/dist/panels.js +45 -14
- package/dist/previewFixtures.js +3 -1
- package/dist/scrollRail.d.ts +18 -0
- package/dist/scrollRail.js +55 -0
- package/dist/scrollRailPreview.d.ts +4 -0
- package/dist/scrollRailPreview.js +8 -0
- package/dist/voice.js +120 -13
- package/package.json +2 -2
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/jgengine/SKILL.md +22 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-ui/SKILL.md +3 -1
- package/skills/jgengine-ui/api.md +204 -67
- package/skills/jgengine-ui/capabilities.md +115 -1
- package/skills/jgengine-ui/reference-primitives.md +93 -3
- package/skills/jgengine-ui/reference.md +2 -0
package/dist/map.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
3
|
import { DEFAULT_MARKER_KINDS, markerKindStyle, } from "@jgengine/core/world/markers";
|
|
4
4
|
import { bearingToCardinal, clampToMinimapEdge, compassBearing, headingToBearing, projectToMinimap, relativeBearing, unprojectFromMinimap, } from "@jgengine/core/world/minimap";
|
|
5
5
|
import { mapLayerColor, } from "@jgengine/core/world/mapLayers";
|
|
@@ -457,6 +457,15 @@ export function WorldMap({ markers, bounds, player, facingYaw = 0, fog, backgrou
|
|
|
457
457
|
function clampScale(value, min, max) {
|
|
458
458
|
return Math.max(min, Math.min(max, value));
|
|
459
459
|
}
|
|
460
|
+
function mapControlEvent(event) {
|
|
461
|
+
for (const target of event.nativeEvent.composedPath()) {
|
|
462
|
+
if (target === event.currentTarget)
|
|
463
|
+
break;
|
|
464
|
+
if (target instanceof Element && target.matches('button, a[href], input, textarea, select, label, [role="button"], [role="link"], [role="slider"], [role="switch"], [role="checkbox"], [contenteditable]:not([contenteditable=false])'))
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
460
469
|
/**
|
|
461
470
|
* Fullscreen pan/zoom world-map overlay — the enlarged "press M" map. Wheel to
|
|
462
471
|
* zoom (toward the cursor), drag to pan, and click to place (wire `onWorldClick`
|
|
@@ -477,8 +486,28 @@ export function FullscreenMap({ open = true, bounds, contentWidth = 900, minScal
|
|
|
477
486
|
const moved = useRef(false);
|
|
478
487
|
const draftRef = useRef(null);
|
|
479
488
|
const [draft, setDraft] = useState(null);
|
|
489
|
+
const [panning, setPanning] = useState(false);
|
|
490
|
+
const ownerRef = useRef(null);
|
|
491
|
+
const gestureRef = useRef(null);
|
|
480
492
|
const drawing = tool === "draw";
|
|
481
493
|
const minSpacing = (worldW + worldD) / 400;
|
|
494
|
+
const retireGesture = (notify = true) => {
|
|
495
|
+
const owner = ownerRef.current;
|
|
496
|
+
ownerRef.current = null;
|
|
497
|
+
owner?.detach();
|
|
498
|
+
drag.current = null;
|
|
499
|
+
draftRef.current = null;
|
|
500
|
+
if (notify) {
|
|
501
|
+
setDraft(null);
|
|
502
|
+
setPanning(false);
|
|
503
|
+
}
|
|
504
|
+
if (owner?.source.hasPointerCapture(owner.pointerId))
|
|
505
|
+
owner.source.releasePointerCapture(owner.pointerId);
|
|
506
|
+
};
|
|
507
|
+
const cancelGesture = () => { if (ownerRef.current !== null)
|
|
508
|
+
moved.current = true; retireGesture(); };
|
|
509
|
+
useEffect(() => { cancelGesture(); }, [open, tool]);
|
|
510
|
+
useEffect(() => () => retireGesture(false), []);
|
|
482
511
|
if (!open)
|
|
483
512
|
return null;
|
|
484
513
|
const zoomBy = (factor, sx, sy) => {
|
|
@@ -508,12 +537,43 @@ export function FullscreenMap({ open = true, bounds, contentWidth = 900, minScal
|
|
|
508
537
|
draftRef.current = [...points, point];
|
|
509
538
|
setDraft(draftRef.current);
|
|
510
539
|
};
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
540
|
+
gestureRef.current = {
|
|
541
|
+
move: (event) => {
|
|
542
|
+
const owner = ownerRef.current;
|
|
543
|
+
if (owner === null || event.pointerId !== owner.pointerId)
|
|
544
|
+
return;
|
|
545
|
+
if (!owner.source.isConnected) {
|
|
546
|
+
cancelGesture();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (owner.tool === "draw") {
|
|
550
|
+
if (draftRef.current !== null)
|
|
551
|
+
pushDraftPoint(screenToWorld({ currentTarget: owner.source, clientX: event.clientX, clientY: event.clientY }));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const state = drag.current;
|
|
555
|
+
if (state === null)
|
|
556
|
+
return;
|
|
557
|
+
const rect = owner.source.getBoundingClientRect();
|
|
558
|
+
const dx = (event.clientX - state.x) * contentWidth / rect.width;
|
|
559
|
+
const dy = (event.clientY - state.y) * contentHeight / rect.height;
|
|
560
|
+
if (Math.abs(dx) + Math.abs(dy) > 3)
|
|
561
|
+
moved.current = true;
|
|
562
|
+
setViewport({ scale: state.vp.scale, tx: state.vp.tx + dx, ty: state.vp.ty + dy });
|
|
563
|
+
},
|
|
564
|
+
finish: (event) => {
|
|
565
|
+
const owner = ownerRef.current;
|
|
566
|
+
if (owner === null || event.pointerId !== owner.pointerId)
|
|
567
|
+
return;
|
|
568
|
+
if (!owner.source.isConnected) {
|
|
569
|
+
cancelGesture();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const points = owner.tool === "draw" ? draftRef.current : null;
|
|
573
|
+
retireGesture();
|
|
574
|
+
if (points !== null && points.length >= 2)
|
|
575
|
+
onStrokeComplete?.(points);
|
|
576
|
+
},
|
|
517
577
|
};
|
|
518
578
|
const draftRoute = draft !== null && draft.length >= 2 ? { id: "__draft-stroke", points: draft, tone: drawTone, width: drawWidth } : null;
|
|
519
579
|
const forwardedRoutes = draftRoute === null ? surfaceProps.routes : [...(surfaceProps.routes ?? []), draftRoute];
|
|
@@ -551,55 +611,59 @@ export function FullscreenMap({ open = true, bounds, contentWidth = 900, minScal
|
|
|
551
611
|
minHeight: 0,
|
|
552
612
|
overflow: "hidden",
|
|
553
613
|
touchAction: "none",
|
|
554
|
-
cursor: drawing ? "crosshair" :
|
|
614
|
+
cursor: drawing ? "crosshair" : panning ? "grabbing" : "grab",
|
|
555
615
|
}, onWheel: (event) => {
|
|
556
616
|
const at = canvasPoint(event);
|
|
557
617
|
zoomBy(event.deltaY < 0 ? 1.15 : 1 / 1.15, at.x, at.y);
|
|
558
618
|
}, onPointerDown: (event) => {
|
|
559
|
-
if (event.button !== 0)
|
|
619
|
+
if (event.button !== 0 || !event.isPrimary || ownerRef.current !== null || mapControlEvent(event))
|
|
560
620
|
return;
|
|
561
|
-
event.currentTarget
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
setDraft(draftRef.current);
|
|
621
|
+
const source = event.currentTarget;
|
|
622
|
+
const view = source.ownerDocument.defaultView;
|
|
623
|
+
if (view === null)
|
|
565
624
|
return;
|
|
625
|
+
const onMove = (move) => gestureRef.current?.move(move);
|
|
626
|
+
const onUp = (up) => { if (up.button === 0)
|
|
627
|
+
gestureRef.current?.finish({ pointerId: event.pointerId }); };
|
|
628
|
+
const onCancel = (cancel) => { if (cancel.pointerId === ownerRef.current?.pointerId)
|
|
629
|
+
cancelGesture(); };
|
|
630
|
+
const mouse = event.pointerType === "mouse";
|
|
631
|
+
ownerRef.current = { pointerId: event.pointerId, pointerType: event.pointerType, source, tool, detach: () => {
|
|
632
|
+
if (!mouse)
|
|
633
|
+
return;
|
|
634
|
+
view.removeEventListener("pointermove", onMove);
|
|
635
|
+
view.removeEventListener("mouseup", onUp);
|
|
636
|
+
view.removeEventListener("pointercancel", onCancel);
|
|
637
|
+
} };
|
|
638
|
+
if (mouse) {
|
|
639
|
+
// Mouse capture can be dropped on an auxiliary release while the primary button is still held.
|
|
640
|
+
view.addEventListener("pointermove", onMove);
|
|
641
|
+
view.addEventListener("mouseup", onUp);
|
|
642
|
+
view.addEventListener("pointercancel", onCancel);
|
|
566
643
|
}
|
|
567
|
-
|
|
644
|
+
else
|
|
645
|
+
source.setPointerCapture(event.pointerId);
|
|
568
646
|
moved.current = false;
|
|
569
|
-
}, onPointerMove: (event) => {
|
|
570
647
|
if (drawing) {
|
|
571
|
-
|
|
572
|
-
|
|
648
|
+
draftRef.current = [screenToWorld(event)];
|
|
649
|
+
setDraft(draftRef.current);
|
|
573
650
|
return;
|
|
574
651
|
}
|
|
575
|
-
|
|
576
|
-
|
|
652
|
+
drag.current = { x: event.clientX, y: event.clientY, vp: viewport };
|
|
653
|
+
setPanning(true);
|
|
654
|
+
}, onPointerMove: (event) => { if (ownerRef.current?.pointerType !== "mouse")
|
|
655
|
+
gestureRef.current?.move(event); }, onPointerUp: (event) => { if (ownerRef.current?.pointerType !== "mouse")
|
|
656
|
+
gestureRef.current?.finish(event); }, onPointerCancel: (event) => { if (event.pointerId === ownerRef.current?.pointerId)
|
|
657
|
+
cancelGesture(); }, onLostPointerCapture: (event) => { if (event.pointerId === ownerRef.current?.pointerId)
|
|
658
|
+
cancelGesture(); }, onClick: (event) => {
|
|
659
|
+
if (ownerRef.current !== null || onWorldClick === undefined || drawing || mapControlEvent(event))
|
|
577
660
|
return;
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const ky = contentHeight / rect.height;
|
|
581
|
-
const dx = (event.clientX - state.x) * kx;
|
|
582
|
-
const dy = (event.clientY - state.y) * ky;
|
|
583
|
-
if (Math.abs(dx) + Math.abs(dy) > 3)
|
|
584
|
-
moved.current = true;
|
|
585
|
-
setViewport({ scale: state.vp.scale, tx: state.vp.tx + dx, ty: state.vp.ty + dy });
|
|
586
|
-
}, onPointerUp: (event) => {
|
|
587
|
-
if (event.currentTarget.hasPointerCapture(event.pointerId))
|
|
588
|
-
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
589
|
-
if (drawing) {
|
|
590
|
-
commitDraft();
|
|
661
|
+
if (moved.current) {
|
|
662
|
+
moved.current = false;
|
|
591
663
|
return;
|
|
592
664
|
}
|
|
593
|
-
|
|
594
|
-
}, children: [_jsx(WorldMapSurface, { ...surfaceProps, routes: forwardedRoutes, bounds: bounds, width: contentWidth, height: contentHeight, canvasWidth: contentWidth, canvasHeight: contentHeight, viewport: viewport, style: { width: "100%", height: "100%", borderRadius: 0, display: "block"
|
|
595
|
-
? undefined
|
|
596
|
-
: (world) => {
|
|
597
|
-
if (moved.current) {
|
|
598
|
-
moved.current = false;
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
onWorldClick(world);
|
|
602
|
-
} }), children] })] }));
|
|
665
|
+
onWorldClick(screenToWorld(event));
|
|
666
|
+
}, children: [_jsx(WorldMapSurface, { ...surfaceProps, routes: forwardedRoutes, bounds: bounds, width: contentWidth, height: contentHeight, canvasWidth: contentWidth, canvasHeight: contentHeight, viewport: viewport, style: { width: "100%", height: "100%", borderRadius: 0, display: "block", ...(onWorldClick === undefined || drawing ? {} : { cursor: "crosshair" }) } }), children] })] }));
|
|
603
667
|
}
|
|
604
668
|
/**
|
|
605
669
|
* Marker-kind key for a map/minimap — glyph + color swatch per kind, labelled.
|
package/dist/modals.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect, useReducer,
|
|
2
|
+
import { useCallback, useEffect, useReducer, } from "react";
|
|
3
3
|
import { MODAL_CANCEL, MODAL_CONFIRM, } from "@jgengine/core/ui/modalStack";
|
|
4
4
|
import { HudFrame } from "./hudFrame.js";
|
|
5
|
+
import { useDialogBehavior } from "./dialogBehavior.js";
|
|
5
6
|
/**
|
|
6
7
|
* Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
|
|
7
8
|
*
|
|
@@ -12,11 +13,6 @@ export function useModalStack(stack) {
|
|
|
12
13
|
useEffect(() => stack.subscribe(bump), [stack]);
|
|
13
14
|
return { top: stack.top(), depth: stack.depth() };
|
|
14
15
|
}
|
|
15
|
-
/** @internal Focusable descendants of `root`, in DOM order, skipping disabled/hidden ones. */
|
|
16
|
-
function focusableWithin(root) {
|
|
17
|
-
const selector = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
18
|
-
return Array.from(root.querySelectorAll(selector)).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
19
|
-
}
|
|
20
16
|
/**
|
|
21
17
|
* Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed
|
|
22
18
|
* backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into
|
|
@@ -28,60 +24,20 @@ function focusableWithin(root) {
|
|
|
28
24
|
*/
|
|
29
25
|
export function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, className, style, }) {
|
|
30
26
|
const { top } = useModalStack(stack);
|
|
31
|
-
const panelRef = useRef(null);
|
|
32
|
-
const restoreRef = useRef(null);
|
|
33
|
-
const stepId = top?.id ?? null;
|
|
34
27
|
const controls = {
|
|
35
28
|
resolve: useCallback((result, payload) => stack.resolve(result, payload !== undefined ? { payload } : undefined), [stack]),
|
|
36
29
|
confirm: useCallback((payload) => stack.resolve(MODAL_CONFIRM, payload !== undefined ? { payload } : undefined), [stack]),
|
|
37
30
|
cancel: useCallback(() => stack.resolve(MODAL_CANCEL), [stack]),
|
|
38
31
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const panel = panelRef.current;
|
|
45
|
-
if (panel !== null) {
|
|
46
|
-
const first = focusableWithin(panel)[0] ?? panel;
|
|
47
|
-
first.focus();
|
|
48
|
-
}
|
|
49
|
-
return () => {
|
|
50
|
-
restoreRef.current?.focus?.();
|
|
51
|
-
};
|
|
52
|
-
}, [stepId]);
|
|
32
|
+
const panelRef = useDialogBehavior({
|
|
33
|
+
open: top !== null,
|
|
34
|
+
focusKey: top?.id,
|
|
35
|
+
onClose: closeOnEsc ? controls.cancel : undefined,
|
|
36
|
+
});
|
|
53
37
|
if (top === null)
|
|
54
38
|
return null;
|
|
55
39
|
const dim = backdrop ?? "var(--jg-backdrop, rgba(4,7,12,0.62))";
|
|
56
|
-
|
|
57
|
-
if (closeOnEsc && event.key === "Escape") {
|
|
58
|
-
event.stopPropagation();
|
|
59
|
-
controls.cancel();
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (event.key !== "Tab")
|
|
63
|
-
return;
|
|
64
|
-
const panel = panelRef.current;
|
|
65
|
-
if (panel === null)
|
|
66
|
-
return;
|
|
67
|
-
const focusable = focusableWithin(panel);
|
|
68
|
-
if (focusable.length === 0) {
|
|
69
|
-
event.preventDefault();
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
const first = focusable[0];
|
|
73
|
-
const last = focusable[focusable.length - 1];
|
|
74
|
-
const active = document.activeElement;
|
|
75
|
-
if (event.shiftKey && active === first) {
|
|
76
|
-
event.preventDefault();
|
|
77
|
-
last.focus();
|
|
78
|
-
}
|
|
79
|
-
else if (!event.shiftKey && active === last) {
|
|
80
|
-
event.preventDefault();
|
|
81
|
-
first.focus();
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
return (_jsxs("div", { "data-modal-host": "", className: className, onKeyDown: onKeyDown, style: {
|
|
40
|
+
return (_jsxs("div", { "data-modal-host": "", className: className, style: {
|
|
85
41
|
position: "fixed",
|
|
86
42
|
inset: 0,
|
|
87
43
|
zIndex: zIndexBase,
|
package/dist/panels.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState, } from "react";
|
|
3
3
|
import { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen as isPanelOpen, movePanel, openPanel, orderedOpen, panelByHotkey, } from "@jgengine/core/ui/panelModel";
|
|
4
4
|
import { HudFrame } from "./hudFrame.js";
|
|
5
|
+
import { observeBrowserSuspension } from "./browserLifecycle.js";
|
|
5
6
|
/**
|
|
6
7
|
* Pure keybind resolver for a panel set: ESC resolves to `closeTop` when a closable window is open, and
|
|
7
8
|
* any other key routes through {@link panelByHotkey} (trying `code` then `key`) to a `toggle`. Returns
|
|
@@ -116,24 +117,51 @@ function topClosableOpen(state) {
|
|
|
116
117
|
return null;
|
|
117
118
|
}
|
|
118
119
|
// ---- Window chrome ---------------------------------------------------------
|
|
119
|
-
/** @internal
|
|
120
|
-
function beginWindowDrag(event, origin, onMove) {
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
/** @internal Own a title-bar pointer until release or interruption, streaming positions to `onMove`. */
|
|
121
|
+
function beginWindowDrag(event, origin, onMove, onRetire) {
|
|
122
|
+
const source = event.currentTarget;
|
|
123
|
+
const view = source.ownerDocument.defaultView;
|
|
124
|
+
if (view === null)
|
|
125
|
+
return null;
|
|
126
|
+
const pointerId = event.pointerId;
|
|
123
127
|
const startX = event.clientX;
|
|
124
128
|
const startY = event.clientY;
|
|
125
129
|
const base = { x: origin.x, y: origin.y };
|
|
130
|
+
let active = true;
|
|
131
|
+
const retire = () => {
|
|
132
|
+
if (!active)
|
|
133
|
+
return;
|
|
134
|
+
active = false;
|
|
135
|
+
view.removeEventListener("pointermove", onPointerMove);
|
|
136
|
+
view.removeEventListener("pointerup", onEnd);
|
|
137
|
+
view.removeEventListener("pointercancel", onEnd);
|
|
138
|
+
detachSuspension();
|
|
139
|
+
source.removeEventListener("lostpointercapture", onEnd);
|
|
140
|
+
onRetire();
|
|
141
|
+
if (source.hasPointerCapture(pointerId))
|
|
142
|
+
source.releasePointerCapture(pointerId);
|
|
143
|
+
};
|
|
126
144
|
const onPointerMove = (moveEvent) => {
|
|
145
|
+
if (moveEvent.pointerId !== pointerId)
|
|
146
|
+
return;
|
|
147
|
+
if (!source.isConnected) {
|
|
148
|
+
retire();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
127
151
|
onMove({ x: base.x + (moveEvent.clientX - startX), y: base.y + (moveEvent.clientY - startY) });
|
|
128
152
|
};
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
153
|
+
const onEnd = (endEvent) => {
|
|
154
|
+
if (endEvent.pointerId !== pointerId || (endEvent.type === "pointerup" && endEvent.button !== 0))
|
|
155
|
+
return;
|
|
156
|
+
retire();
|
|
133
157
|
};
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
view.addEventListener("pointermove", onPointerMove);
|
|
159
|
+
view.addEventListener("pointerup", onEnd);
|
|
160
|
+
view.addEventListener("pointercancel", onEnd);
|
|
161
|
+
const detachSuspension = observeBrowserSuspension(retire, { hidden: false, pointerLockLost: false }, { window: view, document: source.ownerDocument });
|
|
162
|
+
source.addEventListener("lostpointercapture", onEnd);
|
|
163
|
+
source.setPointerCapture(pointerId);
|
|
164
|
+
return retire;
|
|
137
165
|
}
|
|
138
166
|
const TITLEBAR_STYLE = {
|
|
139
167
|
display: "flex",
|
|
@@ -167,12 +195,15 @@ const CLOSE_BUTTON_STYLE = {
|
|
|
167
195
|
};
|
|
168
196
|
/** @internal Shared, fully-controlled window view — title bar (drag + close) plus a HudFrame body. */
|
|
169
197
|
function WindowView({ title, ariaLabel, closable, onClose, onFocus, onMove, pos, z, width, variation, shape, className, style, bodyStyle, children, }) {
|
|
198
|
+
const retireDrag = useRef(null);
|
|
199
|
+
useEffect(() => () => retireDrag.current?.(), []);
|
|
170
200
|
return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: _jsxs(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: [_jsxs("div", { "data-jg-window-titlebar": "", onPointerDown: (event) => {
|
|
171
|
-
if (event.button !== 0)
|
|
201
|
+
if (event.button !== 0 || !event.isPrimary || retireDrag.current !== null)
|
|
172
202
|
return;
|
|
173
203
|
onFocus?.();
|
|
174
|
-
if (onMove !== undefined)
|
|
175
|
-
beginWindowDrag(event, pos, onMove);
|
|
204
|
+
if (onMove !== undefined) {
|
|
205
|
+
retireDrag.current = beginWindowDrag(event, pos, onMove, () => { retireDrag.current = null; });
|
|
206
|
+
}
|
|
176
207
|
}, style: TITLEBAR_STYLE, children: [_jsx("span", { "data-jg-window-title": "", children: title }), closable ? (_jsx("button", { type: "button", "data-jg-window-close": "", "aria-label": "Close", onClick: onClose, onPointerDown: (event) => event.stopPropagation(), style: CLOSE_BUTTON_STYLE, children: "\u2715" })) : null] }), _jsx("div", { "data-jg-window-body": "", style: { padding: 10, ...bodyStyle }, children: children })] }) }));
|
|
177
208
|
}
|
|
178
209
|
/** @internal Fallback cascade position for a window whose state has no override yet. */
|
package/dist/previewFixtures.js
CHANGED
|
@@ -5,6 +5,7 @@ import { StreetGeometryPreview } from "./streetGeometryPreview.js";
|
|
|
5
5
|
import { SpriteClipPreview } from "./spriteClipPreview.js";
|
|
6
6
|
import { TileLayerPreview } from "./tileLayerPreview.js";
|
|
7
7
|
import { HudSkinPreview } from "./hudSkinPreview.js";
|
|
8
|
+
import { ScrollRailPreview } from "./scrollRailPreview.js";
|
|
8
9
|
/**
|
|
9
10
|
* Registry of deterministic, engine-level preview fixtures — the *real* exported
|
|
10
11
|
* `@jgengine/react` components (`HudThemePreview`, `BarsPreview`, `IconsPreview`, …) that render
|
|
@@ -18,6 +19,7 @@ import { HudSkinPreview } from "./hudSkinPreview.js";
|
|
|
18
19
|
* declares.
|
|
19
20
|
*/
|
|
20
21
|
export const PREVIEW_FIXTURES = {
|
|
22
|
+
ScrollRailPreview: { name: "ScrollRailPreview", description: "Reachable caller-owned settings categories in shared scrolling chrome.", component: ScrollRailPreview },
|
|
21
23
|
HudThemePreview: {
|
|
22
24
|
name: "HudThemePreview",
|
|
23
25
|
description: "Every HudTheme genre preset × atomic bars + a themed frame + a slot row.",
|
|
@@ -25,7 +27,7 @@ export const PREVIEW_FIXTURES = {
|
|
|
25
27
|
},
|
|
26
28
|
BarsPreview: {
|
|
27
29
|
name: "BarsPreview",
|
|
28
|
-
description: "
|
|
30
|
+
description: "Atomic vitals under BarTokens blocks, explicit fills, skins, and nested theme resets.",
|
|
29
31
|
component: BarsPreview,
|
|
30
32
|
},
|
|
31
33
|
IconsPreview: {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
/** Props for caller-composed horizontally scrolling controls. */
|
|
3
|
+
export interface ScrollRailProps {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Accessible name for the rail and its overflow navigation. */
|
|
6
|
+
label: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
style?: CSSProperties;
|
|
9
|
+
/** Spacing between caller-owned items. Default 6 pixels. */
|
|
10
|
+
gap?: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Native horizontal scrolling with overflow-only navigation and automatic focus reveal.
|
|
14
|
+
* Callers own item markup, selection, keyboard semantics, and skin through HudTheme or style.
|
|
15
|
+
*
|
|
16
|
+
* @capability scroll-rail reachable horizontal controls with visible overflow navigation and keyboard focus reveal
|
|
17
|
+
*/
|
|
18
|
+
export declare function ScrollRail({ children, label, className, style, gap }: ScrollRailProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Native horizontal scrolling with overflow-only navigation and automatic focus reveal.
|
|
5
|
+
* Callers own item markup, selection, keyboard semantics, and skin through HudTheme or style.
|
|
6
|
+
*
|
|
7
|
+
* @capability scroll-rail reachable horizontal controls with visible overflow navigation and keyboard focus reveal
|
|
8
|
+
*/
|
|
9
|
+
export function ScrollRail({ children, label, className, style, gap = 6 }) {
|
|
10
|
+
const id = useId();
|
|
11
|
+
const ref = useRef(null);
|
|
12
|
+
const [edges, setEdges] = useState({ start: true, end: true });
|
|
13
|
+
const measure = () => {
|
|
14
|
+
const rail = ref.current;
|
|
15
|
+
if (rail === null)
|
|
16
|
+
return;
|
|
17
|
+
const next = { start: rail.scrollLeft <= 1, end: rail.scrollWidth - rail.clientWidth - rail.scrollLeft <= 1 };
|
|
18
|
+
setEdges(current => current.start === next.start && current.end === next.end ? current : next);
|
|
19
|
+
};
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const rail = ref.current;
|
|
22
|
+
if (rail === null)
|
|
23
|
+
return;
|
|
24
|
+
const observer = new ResizeObserver(measure);
|
|
25
|
+
observer.observe(rail);
|
|
26
|
+
for (const child of rail.children)
|
|
27
|
+
observer.observe(child);
|
|
28
|
+
measure();
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}, [children]);
|
|
31
|
+
const scroll = (direction) => {
|
|
32
|
+
const rail = ref.current;
|
|
33
|
+
if (rail === null)
|
|
34
|
+
return;
|
|
35
|
+
rail.scrollBy({ left: direction * Math.max(44, rail.clientWidth * 0.8), behavior: "instant" });
|
|
36
|
+
measure();
|
|
37
|
+
};
|
|
38
|
+
const overflow = !edges.start || !edges.end;
|
|
39
|
+
const buttonStyle = {
|
|
40
|
+
flex: "0 0 44px", minHeight: 44, border: "1px solid var(--jg-edge, #454550)",
|
|
41
|
+
borderRadius: "var(--jg-slot-radius, 6px)", background: "var(--jg-surface-deep, #15151d)",
|
|
42
|
+
color: "var(--jg-text, #ececef)", font: "inherit", cursor: "pointer",
|
|
43
|
+
};
|
|
44
|
+
return _jsxs("div", { className: className, "data-scroll-rail": "", style: { display: "flex", minWidth: 0, flexShrink: 0, gap, ...style }, children: [overflow && _jsx("button", { type: "button", "aria-label": `Show earlier ${label}`, "aria-controls": id, disabled: edges.start, onClick: () => scroll(-1), style: { ...buttonStyle, opacity: edges.start ? 0.45 : 1 }, children: "\u2190" }), _jsx("div", { ref: ref, id: id, role: "group", "aria-label": label, onScroll: measure, onFocusCapture: event => {
|
|
45
|
+
const rail = ref.current;
|
|
46
|
+
if (rail === null || event.target === rail)
|
|
47
|
+
return;
|
|
48
|
+
const bounds = rail.getBoundingClientRect(), target = event.target.getBoundingClientRect();
|
|
49
|
+
if (target.left < bounds.left)
|
|
50
|
+
rail.scrollLeft += target.left - bounds.left;
|
|
51
|
+
else if (target.right > bounds.right)
|
|
52
|
+
rail.scrollLeft += target.right - bounds.right;
|
|
53
|
+
measure();
|
|
54
|
+
}, style: { display: "flex", flex: "1 1 auto", minWidth: 0, overflowX: "auto", gap }, children: children }), overflow && _jsx("button", { type: "button", "aria-label": `Show later ${label}`, "aria-controls": id, disabled: edges.end, onClick: () => scroll(1), style: { ...buttonStyle, opacity: edges.end ? 0.45 : 1 }, children: "\u2192" })] });
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { ScrollRail } from "./scrollRail.js";
|
|
4
|
+
/** Deterministic preview of caller-owned controls in the shared scrolling chrome. @internal */
|
|
5
|
+
export function ScrollRailPreview({ className }) {
|
|
6
|
+
const [selected, setSelected] = useState("Game");
|
|
7
|
+
return _jsxs("div", { className: className, style: { padding: 24, boxSizing: "border-box", background: "#18252b", color: "#f2e6c9", font: "16px system-ui" }, children: [_jsx("h1", { style: { fontSize: 20 }, children: "Scrolling controls" }), _jsx("div", { style: { width: "min(100%, 340px)" }, children: _jsx(ScrollRail, { label: "settings categories", children: ["Game", "Graphics", "Audio", "Accessibility", "Controls"].map(label => _jsx("button", { type: "button", onClick: () => setSelected(label), "aria-pressed": selected === label, style: { flexShrink: 0, minHeight: 44, padding: "0 16px", font: "inherit", color: "inherit", border: "1px solid #b2a27e", borderRadius: 2, background: selected === label ? "#526951" : "#20363c" }, children: label }, label)) }) }), _jsxs("p", { "aria-live": "polite", children: ["Selected: ", selected] })] });
|
|
8
|
+
}
|