@jgengine/react 0.26.0 → 0.28.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.
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { createTipRotation } from "@jgengine/core/ui/loadingTips";
4
+ import { menuIntentForKey } from "@jgengine/core/ui/menuNav";
5
+ import { hudSpace, hudType } from "./hudTokens.js";
6
+ import { useMenuNav } from "./menuNav.js";
7
+ import { MenuScreen } from "./menuScreen.js";
8
+ const column = {
9
+ display: "flex",
10
+ flexDirection: "column",
11
+ gap: hudSpace("md"),
12
+ width: "min(92vw, 520px)",
13
+ };
14
+ function useTip(rotation, intervalMs, active) {
15
+ const [, bump] = useState(0);
16
+ useEffect(() => {
17
+ if (!active || intervalMs <= 0)
18
+ return;
19
+ const timer = setInterval(() => {
20
+ rotation.next();
21
+ bump((value) => value + 1);
22
+ }, intervalMs);
23
+ return () => clearInterval(timer);
24
+ }, [rotation, intervalMs, active]);
25
+ return rotation.current();
26
+ }
27
+ /**
28
+ * A loading screen with a progress bar and a rotating tip. Progress is a real 0–1 value from the
29
+ * game's loader (or indeterminate), tips come from the game and cycle deterministically, and a
30
+ * `ready` screen accepts any key, a gamepad A/Cross or a click. It reads HudTheme tokens and keeps a
31
+ * live scene visible behind a blur, so loading a level looks like the game rather than a blank page.
32
+ *
33
+ * @capability loading-screen loading block with progress, rotating tips, a ready gate, and a scene backdrop
34
+ */
35
+ export function LoadingScreen({ progress, title = "Loading", tips, tipIntervalMs = 6000, ready = false, onReady, onSkip, backdrop, scrim = "blur", children, open = true, reducedMotion, onClosed, className, style, contentClassName, contentStyle, }) {
36
+ const rotation = useMemo(() => {
37
+ if (tips === undefined)
38
+ return createTipRotation({ tips: [] });
39
+ return Array.isArray(tips) ? createTipRotation({ tips }) : tips;
40
+ }, [tips]);
41
+ const tip = useTip(rotation, tipIntervalMs, open && !ready);
42
+ const nav = useMenuNav({
43
+ ids: ready ? ["continue"] : [],
44
+ enabled: ready,
45
+ onConfirm: () => onReady?.(),
46
+ ...(onSkip === undefined ? {} : { onBack: onSkip }),
47
+ });
48
+ useEffect(() => {
49
+ if (!ready || onReady === undefined)
50
+ return;
51
+ const onKey = (event) => {
52
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey)
53
+ return;
54
+ if (event.code === "Tab" || menuIntentForKey(event.code) !== null)
55
+ return;
56
+ onReady();
57
+ };
58
+ window.addEventListener("keydown", onKey);
59
+ return () => window.removeEventListener("keydown", onKey);
60
+ }, [ready, onReady]);
61
+ const clamped = progress === undefined ? undefined : Math.max(0, Math.min(1, progress));
62
+ const percent = clamped === undefined ? null : `${Math.round(clamped * 100)}%`;
63
+ return (_jsx(MenuScreen, { open: open, transition: "fade", scrim: scrim, label: typeof title === "string" ? title : "Loading", ...(backdrop === undefined ? {} : { backdrop }), ...(reducedMotion === undefined ? {} : { reducedMotion }), ...(onClosed === undefined ? {} : { onClosed }), className: className, style: style, contentClassName: contentClassName, contentStyle: contentStyle, children: _jsxs("div", { "data-loading-screen": "", style: column, ...nav.containerProps, children: [_jsx("div", { "data-loading-title": "", style: {
64
+ fontFamily: "var(--jg-font-display, inherit)",
65
+ fontSize: hudType("xl"),
66
+ fontWeight: 700,
67
+ letterSpacing: "0.08em",
68
+ textTransform: "uppercase",
69
+ color: "var(--jg-text, #f5f7fa)",
70
+ }, children: title }), _jsx("div", { "data-loading-bar": "", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, ...(clamped === undefined ? {} : { "aria-valuenow": Math.round(clamped * 100) }), "aria-label": "Loading progress", style: {
71
+ position: "relative",
72
+ height: 6,
73
+ borderRadius: 999,
74
+ overflow: "hidden",
75
+ background: "var(--jg-surface-deep, rgba(255,255,255,0.12))",
76
+ border: "1px solid var(--jg-edge, rgba(255,255,255,0.14))",
77
+ }, children: _jsx("div", { "data-loading-fill": "", style: {
78
+ height: "100%",
79
+ transformOrigin: "left center",
80
+ transform: `scaleX(${clamped ?? 0.18})`,
81
+ background: "linear-gradient(90deg, var(--jg-accent-deep, #2f9e6b), var(--jg-accent, #6ee7a8))",
82
+ transition: "transform var(--jg-motion-slow, 320ms) var(--jg-ease-out, cubic-bezier(0.16, 1, 0.3, 1))",
83
+ ...(clamped === undefined
84
+ ? {
85
+ animation: "jg-sheen 1.1s linear infinite",
86
+ backgroundImage: "linear-gradient(90deg, var(--jg-accent-deep, #2f9e6b), var(--jg-accent, #6ee7a8), var(--jg-accent-deep, #2f9e6b))",
87
+ backgroundSize: "260px 100%",
88
+ }
89
+ : {}),
90
+ } }) }), percent === null ? null : (_jsx("span", { "data-loading-percent": "", style: { fontSize: hudType("xs"), color: "var(--jg-text-dim, #94a3a0)", fontVariantNumeric: "tabular-nums" }, children: percent })), tip === null ? null : (_jsxs("p", { "data-loading-tip": "", "aria-live": "polite", style: {
91
+ margin: 0,
92
+ minHeight: "2.6em",
93
+ fontSize: hudType("sm"),
94
+ lineHeight: 1.4,
95
+ color: "var(--jg-text-dim, #94a3a0)",
96
+ }, children: [_jsx("span", { style: { color: "var(--jg-accent, #6ee7a8)", fontWeight: 700, letterSpacing: "0.14em", marginRight: 8 }, children: "TIP" }), tip] })), children, ready ? (_jsx("button", { type: "button", "data-loading-continue": "", ...nav.itemProps("continue"), onClick: () => onReady?.(), style: {
97
+ alignSelf: "flex-start",
98
+ marginTop: hudSpace("sm"),
99
+ padding: "8px 14px",
100
+ borderRadius: 8,
101
+ border: "1px solid var(--jg-edge-bright, rgba(255,255,255,0.22))",
102
+ background: "var(--jg-surface, rgba(20,24,32,0.8))",
103
+ color: "var(--jg-text, #f5f7fa)",
104
+ fontSize: hudType("sm"),
105
+ fontWeight: 600,
106
+ cursor: "pointer",
107
+ }, children: "Press any key to continue" })) : null] }) }));
108
+ }
@@ -0,0 +1,63 @@
1
+ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefCallback } from "react";
2
+ import { type MenuNavDirection, type MenuNavRepeat } from "@jgengine/core/ui/menuNav";
3
+ /** Props a menu item needs so {@link useMenuNav} can focus it and track pointer/focus. */
4
+ export interface MenuNavItemProps {
5
+ "data-jg-menu-item": string;
6
+ tabIndex: number;
7
+ "aria-current"?: "true";
8
+ ref: RefCallback<HTMLElement>;
9
+ onPointerEnter: () => void;
10
+ onFocus: () => void;
11
+ }
12
+ /** The container props that make a menu navigable. */
13
+ export interface MenuNavContainerProps {
14
+ "data-jg-menu-nav": "";
15
+ onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
16
+ }
17
+ /** The live navigation controller returned by {@link useMenuNav}. */
18
+ export interface MenuNavController {
19
+ /** Id of the focused item, or `null` when the menu has no items. */
20
+ activeId: string | null;
21
+ /** Focus an item by id (pointer hover or a game call). Does not steal DOM focus. */
22
+ setActive: (id: string | null) => void;
23
+ /** Move focus by one direction. */
24
+ move: (direction: MenuNavDirection) => void;
25
+ /** Props for one item — spread them onto the item's focusable element. */
26
+ itemProps: (id: string) => MenuNavItemProps;
27
+ /** Props for the container that wraps the items. */
28
+ containerProps: MenuNavContainerProps;
29
+ }
30
+ /** How {@link useMenuNav} is wired. */
31
+ export interface UseMenuNavOptions {
32
+ /** Item ids in focus order. Changing the list keeps focus on the same id when it still exists. */
33
+ ids: readonly string[];
34
+ /** Items per row (see {@link MenuNavLayout}). Default 1 — a vertical list. */
35
+ columns?: number;
36
+ /** Wrap at the ends. Default true. */
37
+ wrap?: boolean;
38
+ /** Ids that are skipped by navigation (disabled rows). */
39
+ disabled?: readonly string[];
40
+ /** Item focused on mount. Default the first enabled item. */
41
+ initialId?: string;
42
+ /** Turn keyboard/gamepad handling off (for example while a rebind capture is armed). */
43
+ enabled?: boolean;
44
+ /** Confirm (Enter/Space/A). Called with the focused id. */
45
+ onConfirm?: (id: string) => void;
46
+ /** Back (Escape/Backspace/B/Start). */
47
+ onBack?: () => void;
48
+ /** Poll a connected gamepad for menu intents. Default true. */
49
+ gamepad?: boolean;
50
+ /** Hold-to-repeat timing for d-pad/stick navigation. */
51
+ repeat?: MenuNavRepeat;
52
+ }
53
+ /**
54
+ * Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a
55
+ * roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser,
56
+ * so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure
57
+ * state + listeners — it unmounts with the screen, so it costs nothing during play.
58
+ *
59
+ * @capability use-menu-nav keyboard and gamepad focus navigation for a menu — roving focus order, confirm/back, disabled-item skipping, no focus trap
60
+ */
61
+ export declare function useMenuNav({ ids, columns, wrap, disabled, initialId, enabled, onConfirm, onBack, gamepad, repeat, }: UseMenuNavOptions): MenuNavController;
62
+ /** Render prop for a menu whose items come from {@link useMenuNav}. */
63
+ export type MenuNavRender = (nav: MenuNavController) => ReactNode;
@@ -0,0 +1,257 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
2
+ import { menuIntentForPadAxis, menuIntentForPadButton, menuIntentForKey, menuNavIdAt, menuNavIndex, menuNavMove, shouldRepeatMenuNav, } from "@jgengine/core/ui/menuNav";
3
+ function isTextEntry(target) {
4
+ const element = target;
5
+ if (element === null || typeof element.closest !== "function")
6
+ return false;
7
+ return element.closest("input, textarea, select, [contenteditable]:not([contenteditable=false])") !== null;
8
+ }
9
+ /**
10
+ * Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a
11
+ * roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser,
12
+ * so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure
13
+ * state + listeners — it unmounts with the screen, so it costs nothing during play.
14
+ *
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
+ */
17
+ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }) {
18
+ const disabledIndices = useMemo(() => {
19
+ if (disabled === undefined || disabled.length === 0)
20
+ return undefined;
21
+ return disabled.map((id) => ids.indexOf(id)).filter((index) => index >= 0);
22
+ }, [disabled, ids]);
23
+ const firstEnabled = useMemo(() => {
24
+ for (let index = 0; index < ids.length; index += 1) {
25
+ if (disabledIndices === undefined || !disabledIndices.includes(index))
26
+ return index;
27
+ }
28
+ return -1;
29
+ }, [ids, disabledIndices]);
30
+ const [activeIndex, setActiveIndex] = useState(() => {
31
+ const initial = initialId === undefined ? -1 : ids.indexOf(initialId);
32
+ return initial >= 0 ? initial : firstEnabled;
33
+ });
34
+ const activeId = menuNavIdAt(ids, activeIndex);
35
+ const idsRef = useRef(ids);
36
+ idsRef.current = ids;
37
+ const activeIndexRef = useRef(activeIndex);
38
+ activeIndexRef.current = activeIndex;
39
+ const callbacks = useRef({ onConfirm, onBack });
40
+ callbacks.current = { onConfirm, onBack };
41
+ const elements = useRef(new Map());
42
+ const pendingFocus = useRef(true);
43
+ const focusActive = useCallback(() => {
44
+ const id = menuNavIdAt(idsRef.current, activeIndexRef.current);
45
+ if (id === null)
46
+ return;
47
+ elements.current.get(id)?.focus({ preventScroll: true });
48
+ }, []);
49
+ const move = useCallback((direction) => {
50
+ const layout = {
51
+ count: idsRef.current.length,
52
+ columns,
53
+ wrap,
54
+ ...(disabledIndices === undefined ? {} : { disabled: disabledIndices }),
55
+ };
56
+ const next = menuNavMove(activeIndexRef.current, direction, layout);
57
+ if (next < 0 || next === activeIndexRef.current)
58
+ return;
59
+ pendingFocus.current = true;
60
+ setActiveIndex(next);
61
+ }, [columns, disabledIndices, wrap]);
62
+ const setActive = useCallback((id) => {
63
+ const index = menuNavIndex(idsRef.current, id);
64
+ if (index < 0)
65
+ return;
66
+ pendingFocus.current = false;
67
+ setActiveIndex(index);
68
+ }, []);
69
+ const activate = useCallback((id) => {
70
+ if (id !== null)
71
+ callbacks.current.onConfirm?.(id);
72
+ }, []);
73
+ const runIntent = useCallback((intent) => {
74
+ if (intent === "confirm")
75
+ activate(menuNavIdAt(idsRef.current, activeIndexRef.current));
76
+ else if (intent === "back")
77
+ callbacks.current.onBack?.();
78
+ else
79
+ move(intent);
80
+ }, [activate, move]);
81
+ const moveRef = useRef(move);
82
+ moveRef.current = move;
83
+ const runIntentRef = useRef(runIntent);
84
+ runIntentRef.current = runIntent;
85
+ const repeatRef = useRef(repeat);
86
+ repeatRef.current = repeat;
87
+ useEffect(() => {
88
+ if (!pendingFocus.current)
89
+ return;
90
+ pendingFocus.current = false;
91
+ focusActive();
92
+ }, [activeIndex, focusActive]);
93
+ useEffect(() => {
94
+ if (!enabled)
95
+ return;
96
+ pendingFocus.current = true;
97
+ focusActive();
98
+ }, [enabled, focusActive]);
99
+ useEffect(() => {
100
+ const index = ids.indexOf(activeId ?? "");
101
+ if (activeId !== null && index >= 0)
102
+ return;
103
+ setActiveIndex(firstEnabled);
104
+ }, [ids, activeId, firstEnabled]);
105
+ const onKeyDown = useCallback((event) => {
106
+ if (!enabled || event.defaultPrevented || isTextEntry(event.target))
107
+ return;
108
+ const intent = menuIntentForKey(event.code);
109
+ if (intent === null)
110
+ return;
111
+ const target = event.target;
112
+ const nativeActivate = intent === "confirm" &&
113
+ typeof target.closest === "function" &&
114
+ target.closest("button, a[href], [role='button'], summary") !== null;
115
+ if (intent === "back") {
116
+ if (callbacks.current.onBack === undefined)
117
+ return;
118
+ event.preventDefault();
119
+ callbacks.current.onBack();
120
+ return;
121
+ }
122
+ if (nativeActivate)
123
+ return;
124
+ event.preventDefault();
125
+ runIntent(intent);
126
+ }, [enabled, runIntent]);
127
+ useEffect(() => {
128
+ if (!enabled)
129
+ return;
130
+ if (typeof window === "undefined")
131
+ return;
132
+ const onWindowKeyDown = (event) => {
133
+ if (event.defaultPrevented || isTextEntry(event.target))
134
+ return;
135
+ const target = event.target;
136
+ if (target !== null && typeof target.closest === "function" && target.closest("[data-jg-menu-nav]") !== null)
137
+ return;
138
+ const intent = menuIntentForKey(event.code);
139
+ if (intent === null)
140
+ return;
141
+ if (intent === "back") {
142
+ if (callbacks.current.onBack === undefined)
143
+ return;
144
+ event.preventDefault();
145
+ callbacks.current.onBack();
146
+ return;
147
+ }
148
+ event.preventDefault();
149
+ runIntent(intent);
150
+ };
151
+ window.addEventListener("keydown", onWindowKeyDown);
152
+ return () => window.removeEventListener("keydown", onWindowKeyDown);
153
+ }, [enabled, runIntent]);
154
+ useEffect(() => {
155
+ if (!enabled || !gamepad)
156
+ return;
157
+ if (typeof navigator === "undefined" || typeof navigator.getGamepads !== "function")
158
+ return;
159
+ if (typeof requestAnimationFrame !== "function")
160
+ return;
161
+ const held = new Map();
162
+ let stick = null;
163
+ let previousPressed = new Set();
164
+ let frame = 0;
165
+ const poll = () => {
166
+ frame = requestAnimationFrame(poll);
167
+ if (typeof document !== "undefined" && document.visibilityState === "hidden")
168
+ return;
169
+ if (isTextEntry(document.activeElement))
170
+ return;
171
+ const pads = navigator.getGamepads();
172
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
173
+ let pad = null;
174
+ for (const candidate of pads) {
175
+ if (candidate !== null && candidate.connected) {
176
+ pad = candidate;
177
+ break;
178
+ }
179
+ }
180
+ if (pad === null) {
181
+ held.clear();
182
+ previousPressed = new Set();
183
+ stick = null;
184
+ return;
185
+ }
186
+ const pressed = new Set();
187
+ for (let button = 0; button < pad.buttons.length; button += 1) {
188
+ if (pad.buttons[button]?.pressed === true)
189
+ pressed.add(button);
190
+ }
191
+ for (const button of pressed) {
192
+ const intent = menuIntentForPadButton(button);
193
+ if (intent === null)
194
+ continue;
195
+ const isNew = !previousPressed.has(button);
196
+ const record = held.get(button);
197
+ if (intent === "confirm" || intent === "back") {
198
+ if (isNew)
199
+ runIntent(intent);
200
+ continue;
201
+ }
202
+ if (isNew) {
203
+ held.set(button, { pressedAt: now, lastFiredAt: now });
204
+ moveRef.current(intent);
205
+ continue;
206
+ }
207
+ if (record !== undefined && shouldRepeatMenuNav(now, record, repeatRef.current)) {
208
+ record.lastFiredAt = now;
209
+ moveRef.current(intent);
210
+ }
211
+ }
212
+ for (const button of [...held.keys()])
213
+ if (!pressed.has(button))
214
+ held.delete(button);
215
+ previousPressed = pressed;
216
+ const horizontal = menuIntentForPadAxis(0, pad.axes[0] ?? 0);
217
+ const vertical = menuIntentForPadAxis(1, pad.axes[1] ?? 0);
218
+ const axisIntent = horizontal ?? vertical;
219
+ if (axisIntent === null) {
220
+ stick = null;
221
+ return;
222
+ }
223
+ if (stick === null || stick.intent !== axisIntent) {
224
+ stick = { intent: axisIntent, pressedAt: now, lastFiredAt: now };
225
+ moveRef.current(axisIntent);
226
+ return;
227
+ }
228
+ if (shouldRepeatMenuNav(now, stick, repeatRef.current)) {
229
+ stick.lastFiredAt = now;
230
+ moveRef.current(axisIntent);
231
+ }
232
+ };
233
+ frame = requestAnimationFrame(poll);
234
+ return () => cancelAnimationFrame(frame);
235
+ }, [enabled, gamepad]);
236
+ const itemProps = useCallback((id) => {
237
+ const active = id === activeId;
238
+ return {
239
+ "data-jg-menu-item": id,
240
+ tabIndex: active ? 0 : -1,
241
+ ...(active ? { "aria-current": "true" } : {}),
242
+ ref: (node) => {
243
+ if (node === null)
244
+ elements.current.delete(id);
245
+ else
246
+ elements.current.set(id, node);
247
+ },
248
+ onPointerEnter: () => setActive(id),
249
+ onFocus: () => setActive(id),
250
+ };
251
+ }, [activeId, setActive]);
252
+ const containerProps = useMemo(() => ({
253
+ "data-jg-menu-nav": "",
254
+ onKeyDown,
255
+ }), [onKeyDown]);
256
+ return { activeId, setActive, move, itemProps, containerProps };
257
+ }
@@ -0,0 +1,43 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ /** Which motion a {@link MenuScreen} uses when it enters and leaves. */
3
+ export type MenuScreenTransition = "fade" | "slide" | "scale" | "none";
4
+ /** How much a {@link MenuScreen} dims the scene behind it. `none` keeps a live 3D scene fully visible. */
5
+ export type MenuScreenScrim = "none" | "dim" | "blur";
6
+ /** Props for {@link MenuScreen}. */
7
+ export interface MenuScreenProps {
8
+ /** Open state the game owns. Closing runs the exit animation, then unmounts. Default true. */
9
+ open?: boolean;
10
+ /** Enter/exit motion. Default `"fade"`. */
11
+ transition?: MenuScreenTransition;
12
+ /** Dim/blur treatment over the scene behind. Default `"dim"`. */
13
+ scrim?: MenuScreenScrim;
14
+ /** Enter duration in ms. Default 220. */
15
+ enterMs?: number;
16
+ /** Exit duration in ms. Default 160. */
17
+ exitMs?: number;
18
+ /** Force motion off (reduced motion is also read from the accessibility preference and the OS). */
19
+ reducedMotion?: boolean;
20
+ /** Accessible name of the screen. */
21
+ label?: string;
22
+ /** `role` for the screen element. Default `"dialog"`. */
23
+ role?: string;
24
+ /** Full-bleed layer painted behind the menu content — a live canvas, a poster, or a preview. */
25
+ backdrop?: ReactNode;
26
+ className?: string;
27
+ style?: CSSProperties;
28
+ contentClassName?: string;
29
+ contentStyle?: CSSProperties;
30
+ children?: ReactNode;
31
+ /** Called once the exit animation finished and the screen unmounted. */
32
+ onClosed?: () => void;
33
+ }
34
+ /**
35
+ * One animated screen of a game front-end: full-bleed, laid out by the game, with an enter/exit
36
+ * animation that runs on transform/opacity only (no layout, no frame hitch) and unmounts when the
37
+ * exit finishes. The scrim is translucent and the optional `backdrop` slot paints behind the content,
38
+ * so a live 3D scene stays visible behind a title, pause or settings screen. Durations come from the
39
+ * caller or collapse to zero under reduced motion.
40
+ *
41
+ * @capability menu-screen animated enter/exit menu screen over a live scene, with translucent scrim, backdrop slot, reduced-motion support and exit unmount
42
+ */
43
+ export declare function MenuScreen({ open, transition, scrim, enterMs, exitMs, reducedMotion, label, role, backdrop, className, style, contentClassName, contentStyle, children, onClosed, }: MenuScreenProps): ReactNode;
@@ -0,0 +1,98 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useSyncExternalStore } from "react";
3
+ import { createMenuTransition, menuTransitionDurations } from "@jgengine/core/ui/menuTransition";
4
+ import { useReducedMotion } from "./accessibility.js";
5
+ const KEYFRAMES = {
6
+ fade: { enter: "jg-fade-in", exit: "jg-fade-out" },
7
+ slide: { enter: "jg-slide-up", exit: "jg-fade-out" },
8
+ scale: { enter: "jg-panel-in", exit: "jg-panel-out" },
9
+ };
10
+ const SCRIMS = {
11
+ none: {},
12
+ dim: { background: "radial-gradient(140% 130% at 50% 45%, rgba(0,0,0,0.34), rgba(0,0,0,0.78))" },
13
+ blur: {
14
+ background: "radial-gradient(140% 130% at 50% 45%, rgba(0,0,0,0.38), rgba(0,0,0,0.8))",
15
+ backdropFilter: "blur(4px)",
16
+ WebkitBackdropFilter: "blur(4px)",
17
+ },
18
+ };
19
+ function now() {
20
+ return typeof performance === "undefined" ? Date.now() : performance.now();
21
+ }
22
+ /**
23
+ * One animated screen of a game front-end: full-bleed, laid out by the game, with an enter/exit
24
+ * animation that runs on transform/opacity only (no layout, no frame hitch) and unmounts when the
25
+ * exit finishes. The scrim is translucent and the optional `backdrop` slot paints behind the content,
26
+ * so a live 3D scene stays visible behind a title, pause or settings screen. Durations come from the
27
+ * caller or collapse to zero under reduced motion.
28
+ *
29
+ * @capability menu-screen animated enter/exit menu screen over a live scene, with translucent scrim, backdrop slot, reduced-motion support and exit unmount
30
+ */
31
+ export function MenuScreen({ open = true, transition = "fade", scrim = "dim", enterMs = 220, exitMs = 160, reducedMotion, label, role = "dialog", backdrop, className, style, contentClassName, contentStyle, children, onClosed, }) {
32
+ const prefersReduced = useReducedMotion();
33
+ const motion = reducedMotion === undefined ? prefersReduced : reducedMotion || prefersReduced;
34
+ const machine = useMemo(() => {
35
+ const config = { enterMs, exitMs, reducedMotion: motion };
36
+ const initial = open
37
+ ? { phase: menuTransitionDurations(config).enter === 0 ? "open" : "enter", since: now() }
38
+ : undefined;
39
+ return createMenuTransition(config, initial);
40
+ // `open` is read once for the initial phase; later changes are driven by the effect below.
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ }, [enterMs, exitMs, motion]);
43
+ const phase = useSyncExternalStore(machine.subscribe, () => machine.state().phase, () => machine.state().phase);
44
+ useEffect(() => {
45
+ if (open)
46
+ machine.open(now());
47
+ else
48
+ machine.close(now());
49
+ }, [open, machine]);
50
+ useEffect(() => {
51
+ if (phase !== "enter" && phase !== "exit")
52
+ return;
53
+ if (typeof requestAnimationFrame !== "function") {
54
+ machine.tick(now());
55
+ return;
56
+ }
57
+ let frame = requestAnimationFrame(function tick() {
58
+ machine.tick(now());
59
+ frame = requestAnimationFrame(tick);
60
+ });
61
+ return () => cancelAnimationFrame(frame);
62
+ }, [phase, machine]);
63
+ useEffect(() => {
64
+ if (phase === "closed" && !open)
65
+ onClosed?.();
66
+ }, [phase, open, onClosed]);
67
+ if (phase === "closed")
68
+ return null;
69
+ const durations = menuTransitionDurations({ enterMs, exitMs, reducedMotion: motion });
70
+ const frames = transition === "none" ? null : KEYFRAMES[transition];
71
+ const contentAnimation = frames === null || phase === "open"
72
+ ? {}
73
+ : {
74
+ animationName: phase === "enter" ? frames.enter : frames.exit,
75
+ animationDuration: `${phase === "enter" ? durations.enter : durations.exit}ms`,
76
+ animationTimingFunction: "var(--jg-ease-out, cubic-bezier(0.16, 1, 0.3, 1))",
77
+ animationFillMode: "both",
78
+ };
79
+ const scrimAnimation = phase === "open"
80
+ ? {}
81
+ : {
82
+ animationName: phase === "enter" ? "jg-fade-in" : "jg-fade-out",
83
+ animationDuration: `${phase === "enter" ? durations.enter : durations.exit}ms`,
84
+ animationTimingFunction: "var(--jg-ease-out, cubic-bezier(0.16, 1, 0.3, 1))",
85
+ animationFillMode: "both",
86
+ };
87
+ return (_jsxs("div", { "data-jg-menu-screen": "", "data-phase": phase, "data-transition": transition, "data-scrim": scrim, role: role, "aria-label": label, "aria-modal": scrim === "none" ? undefined : true, className: className, style: {
88
+ position: "absolute",
89
+ inset: 0,
90
+ display: "flex",
91
+ alignItems: "center",
92
+ justifyContent: "center",
93
+ overflow: "hidden",
94
+ color: "var(--jg-text, #f5f7fa)",
95
+ fontFamily: "var(--jg-font-body, inherit)",
96
+ ...style,
97
+ }, children: [backdrop === undefined ? null : (_jsx("div", { "data-jg-menu-backdrop": "", "aria-hidden": "true", style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: backdrop })), _jsx("div", { "data-jg-menu-scrim": "", "aria-hidden": "true", style: { position: "absolute", inset: 0, pointerEvents: "none", ...SCRIMS[scrim], ...scrimAnimation } }), _jsx("div", { "data-jg-menu-content": "", className: contentClassName, style: { position: "relative", pointerEvents: "auto", ...contentAnimation, ...contentStyle }, children: children })] }));
98
+ }
package/dist/modals.js CHANGED
@@ -8,6 +8,7 @@ import { HudButton } from "./hudButton.js";
8
8
  import { useHudInteract } from "./hudInteract.js";
9
9
  import { HudSurface } from "./hudSurface.js";
10
10
  import { hudType } from "./hudTokens.js";
11
+ import { useMenuNav } from "./menuNav.js";
11
12
  import { usePresence } from "./presence.js";
12
13
  /**
13
14
  * Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
@@ -110,5 +111,16 @@ const PAUSE_DANGER_ITEM = { ...PAUSE_ITEM, color: "var(--jg-danger, #e5484d)", b
110
111
  * @capability pause-menu reskinnable pause-menu building block — Resume plus a game-filled Settings/Quit slot list, HudTheme-driven
111
112
  */
112
113
  export function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }) {
113
- return (_jsxs(HudSurface, { elevation: "modal", width: width, padding: 20, className: className, style: style, children: [_jsx("div", { "data-pause-title": "", style: PAUSE_TITLE, children: title }), _jsx(HudButton, { "data-pause-resume": "", cue: "resume", variant: "primary", size: "lg", block: true, onClick: onResume, children: resumeLabel }), items.map((item) => (_jsx(HudButton, { "data-pause-item": item.id, cue: `pause:${item.id}`, size: "lg", block: true, onClick: item.onSelect, disabled: item.disabled, style: item.danger ? PAUSE_DANGER_ITEM : PAUSE_ITEM, children: item.label }, item.id)))] }));
114
+ const nav = useMenuNav({
115
+ ids: ["resume", ...items.map((item) => item.id)],
116
+ disabled: items.filter((item) => item.disabled === true).map((item) => item.id),
117
+ onBack: onResume,
118
+ onConfirm: (id) => {
119
+ if (id === "resume")
120
+ onResume();
121
+ else
122
+ items.find((item) => item.id === id)?.onSelect();
123
+ },
124
+ });
125
+ return (_jsxs(HudSurface, { elevation: "modal", width: width, padding: 20, className: className, style: style, ...nav.containerProps, children: [_jsx("div", { "data-pause-title": "", style: PAUSE_TITLE, children: title }), _jsx(HudButton, { ...nav.itemProps("resume"), "data-pause-resume": "", cue: "resume", variant: "primary", size: "lg", block: true, onClick: onResume, children: resumeLabel }), items.map((item) => (_jsx(HudButton, { ...nav.itemProps(item.id), "data-pause-item": item.id, cue: `pause:${item.id}`, size: "lg", block: true, onClick: item.onSelect, disabled: item.disabled, style: item.danger ? PAUSE_DANGER_ITEM : PAUSE_ITEM, children: item.label }, item.id)))] }));
114
126
  }