@jgengine/react 0.9.0 → 0.11.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 +34 -3
- package/LICENSE +203 -661
- package/README.md +1 -1
- package/dist/actionHud.d.ts +174 -0
- package/dist/actionHud.js +292 -0
- package/dist/cards.d.ts +59 -0
- package/dist/cards.js +116 -0
- package/dist/chat.d.ts +6 -4
- package/dist/chat.js +16 -7
- package/dist/chatBubbles.js +2 -1
- package/dist/components.d.ts +20 -0
- package/dist/components.js +29 -1
- package/dist/display.d.ts +1 -0
- package/dist/display.js +1 -0
- package/dist/engineStore.d.ts +3 -0
- package/dist/engineStore.js +3 -0
- package/dist/fogOverlay.d.ts +3 -0
- package/dist/fogOverlay.js +3 -0
- package/dist/gameViewport.d.ts +54 -0
- package/dist/gameViewport.js +356 -0
- package/dist/hooks.d.ts +22 -0
- package/dist/hooks.js +58 -12
- package/dist/hud.d.ts +87 -0
- package/dist/hud.js +144 -0
- package/dist/hudLayout.d.ts +39 -4
- package/dist/hudLayout.js +152 -11
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/keyHint.d.ts +30 -0
- package/dist/keyHint.js +31 -0
- package/dist/map.d.ts +69 -1
- package/dist/map.js +54 -1
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +4 -0
- package/dist/rotateDevice.d.ts +24 -0
- package/dist/rotateDevice.js +83 -0
- package/dist/save.d.ts +25 -0
- package/dist/save.js +37 -0
- package/dist/selectSnapshot.d.ts +2 -0
- package/dist/selectSnapshot.js +2 -0
- package/dist/selectionHud.d.ts +133 -0
- package/dist/selectionHud.js +182 -0
- package/dist/settings.d.ts +3 -0
- package/dist/skillCheckPaint.d.ts +2 -0
- package/dist/skillCheckPaint.js +2 -0
- package/dist/social.d.ts +3 -3
- package/dist/social.js +31 -10
- package/dist/startScreen.d.ts +62 -0
- package/dist/startScreen.js +62 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.js +9 -0
- package/package.json +4 -4
- package/skills/jgengine/SKILL.md +51 -0
- package/skills/jgengine/api.md +434 -0
- package/skills/jgengine/capabilities.md +29 -0
- package/skills/jgengine/reference-cartridge.md +72 -0
- package/skills/jgengine-ui/SKILL.md +36 -0
- package/skills/jgengine-ui/api.md +1852 -0
- package/skills/jgengine-ui/capabilities.md +142 -0
- package/skills/jgengine-ui/reference.md +500 -0
- package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
- package/llms.txt +0 -1633
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# @jgengine/react
|
|
2
2
|
|
|
3
|
-
React UI layer for [JGengine](https://github.com/Noisemaker111/jgengine): `GameProvider`, hooks, and headless primitives over `@jgengine/core`.
|
|
3
|
+
React UI layer for [JGengine](https://github.com/Noisemaker111/jgengine): `GameProvider`, hooks, and headless primitives over `@jgengine/core`. Apache-2.0.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { AbilitySlotSnapshot } from "@jgengine/core/combat/abilityKit";
|
|
3
|
+
import { type ActionDef, type ResolvedAction } from "@jgengine/core/ui/actionModel";
|
|
4
|
+
import { type PopoverSide, type TooltipContent } from "@jgengine/core/ui/tooltipModel";
|
|
5
|
+
/**
|
|
6
|
+
* Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
|
|
7
|
+
* the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a
|
|
8
|
+
* radial. `useActionBar` is the DATA/HOOK layer (resolution + focus/hover/keyboard/hotkey), `ActionBarChrome`
|
|
9
|
+
* is the composable RENDERER (swap `renderItem` for custom chrome, or ignore it and consume the model
|
|
10
|
+
* directly for a radial), and `ActionBar` is the batteries-included default. A game swaps renderer or
|
|
11
|
+
* chrome without forking the logic. Layout, dimensions, hotkeys, and catalog-id mapping stay caller-owned.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Adapt a combat {@link AbilitySlotSnapshot} into an {@link ActionDef} — the glue that binds an
|
|
15
|
+
* `AbilityKit` (via `useAbilitySlots`) to the action renderers without the game re-deriving cooldown
|
|
16
|
+
* and cost shapes. `extra` supplies the caller-owned bits the snapshot cannot know (label, icon,
|
|
17
|
+
* hotkey, description, resource name). Pure, so it is unit-testable.
|
|
18
|
+
*
|
|
19
|
+
* @capability action-from-ability-slot adapt a combat ability-kit snapshot into an action-bar action def
|
|
20
|
+
*/
|
|
21
|
+
export declare function actionFromAbilitySlot(slot: AbilitySlotSnapshot, extra?: Partial<ActionDef> & {
|
|
22
|
+
resourceId?: string;
|
|
23
|
+
}): ActionDef;
|
|
24
|
+
/** The intent a key press resolves to over an action bar: move focus, activate, or ignore. */
|
|
25
|
+
export interface ActionBarKeyResult {
|
|
26
|
+
handled: boolean;
|
|
27
|
+
focusId?: string;
|
|
28
|
+
activateId?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Pure keyboard/controller resolver for an action bar: arrows/Home/End move grid focus, Enter/Space
|
|
32
|
+
* activate the focused action, and any other single key routes through {@link actionByHotkey}. Returns
|
|
33
|
+
* the resulting intent without touching the DOM, so it unit-tests headless and `useActionBar` is a
|
|
34
|
+
* thin shell over it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function actionBarKeyAction(actions: readonly ResolvedAction[], focusedId: string | null, columns: number, key: string, options?: {
|
|
37
|
+
wrap?: boolean;
|
|
38
|
+
}): ActionBarKeyResult;
|
|
39
|
+
/** A minimal keyboard-event shape — a real `KeyboardEvent`/React synthetic satisfies it. */
|
|
40
|
+
export interface ActionKeyEvent {
|
|
41
|
+
key: string;
|
|
42
|
+
preventDefault(): void;
|
|
43
|
+
}
|
|
44
|
+
/** Options for {@link useActionBar}. */
|
|
45
|
+
export interface UseActionBarOptions {
|
|
46
|
+
/** Grid width for keyboard focus math and the default grid renderer (default: number of actions). */
|
|
47
|
+
columns?: number;
|
|
48
|
+
/** Wrap focus at the grid edges (default false). */
|
|
49
|
+
wrap?: boolean;
|
|
50
|
+
/** Fired when an action is activated by click, Enter/Space, or its hotkey. */
|
|
51
|
+
onActivate?: (id: string) => void;
|
|
52
|
+
/** Set false to ignore single-key hotkeys (default true). */
|
|
53
|
+
hotkeys?: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** The live action-bar model returned by {@link useActionBar}. */
|
|
56
|
+
export interface ActionBarModel {
|
|
57
|
+
actions: readonly ResolvedAction[];
|
|
58
|
+
columns: number;
|
|
59
|
+
focusedId: string | null;
|
|
60
|
+
hoveredId: string | null;
|
|
61
|
+
setFocus: (id: string | null) => void;
|
|
62
|
+
setHover: (id: string | null) => void;
|
|
63
|
+
activate: (id: string) => void;
|
|
64
|
+
onKeyDown: (event: ActionKeyEvent) => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The DATA/HOOK layer: resolve `defs` into a live view model with focus, hover, keyboard grid
|
|
68
|
+
* navigation, and hotkey activation. Rendering-agnostic — feed the returned model to
|
|
69
|
+
* {@link ActionBarChrome}, or read it directly to lay out a radial or a bespoke card.
|
|
70
|
+
*
|
|
71
|
+
* @capability action-bar-model headless action-collection model with focus, hover, keyboard, and hotkey routing
|
|
72
|
+
*/
|
|
73
|
+
export declare function useActionBar(defs: readonly ActionDef[], options?: UseActionBarOptions): ActionBarModel;
|
|
74
|
+
/** Render context passed to a custom {@link ActionBarChrome} item renderer. */
|
|
75
|
+
export interface ActionItemContext {
|
|
76
|
+
focused: boolean;
|
|
77
|
+
hovered: boolean;
|
|
78
|
+
model: ActionBarModel;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The default per-item chrome — a cooldown-aware action button with hotkey cap, cost line, radial
|
|
82
|
+
* cooldown wipe, and full rest/hover/focus/pressed/active/disabled states. Accessible: it is a real
|
|
83
|
+
* `<button>` with `aria-disabled`, `aria-pressed` for toggles, `aria-keyshortcuts`, and a native
|
|
84
|
+
* `title` describing why it is blocked. Swap it via `ActionBarChrome`'s `renderItem`, or drop it into
|
|
85
|
+
* a radial layout yourself.
|
|
86
|
+
*
|
|
87
|
+
* @capability action-button cooldown/cost/hotkey action button with complete interaction states
|
|
88
|
+
*/
|
|
89
|
+
export declare function ActionButton({ action, focused, hovered, size, showHotkey, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }: {
|
|
90
|
+
action: ResolvedAction;
|
|
91
|
+
focused?: boolean;
|
|
92
|
+
hovered?: boolean;
|
|
93
|
+
size?: number;
|
|
94
|
+
showHotkey?: boolean;
|
|
95
|
+
tabIndex?: number;
|
|
96
|
+
buttonRef?: (node: HTMLButtonElement | null) => void;
|
|
97
|
+
onActivate?: (id: string) => void;
|
|
98
|
+
onFocus?: (id: string) => void;
|
|
99
|
+
onHover?: (id: string | null) => void;
|
|
100
|
+
renderIcon?: (action: ResolvedAction) => ReactNode;
|
|
101
|
+
className?: string;
|
|
102
|
+
style?: CSSProperties;
|
|
103
|
+
}): import("react").JSX.Element;
|
|
104
|
+
/**
|
|
105
|
+
* The composable RENDERER: lay the model's actions out as a `grid` or `list` toolbar with roving
|
|
106
|
+
* tabindex, arrow-key navigation, and a moved DOM focus. `renderItem` swaps per-item chrome; omit it
|
|
107
|
+
* for {@link ActionButton}. A radial or bespoke UI can skip this entirely and read the model. Purely
|
|
108
|
+
* a wiring/behavior layer — no panel skin — so a game art-directs freely.
|
|
109
|
+
*
|
|
110
|
+
* @capability action-bar-chrome accessible grid/list toolbar renderer over an action-bar model with swappable item chrome
|
|
111
|
+
*/
|
|
112
|
+
export declare function ActionBarChrome({ model, layout, itemSize, gap, ariaLabel, renderItem, className, style, }: {
|
|
113
|
+
model: ActionBarModel;
|
|
114
|
+
layout?: "grid" | "list";
|
|
115
|
+
itemSize?: number;
|
|
116
|
+
gap?: number;
|
|
117
|
+
ariaLabel?: string;
|
|
118
|
+
renderItem?: (action: ResolvedAction, ctx: ActionItemContext) => ReactNode;
|
|
119
|
+
className?: string;
|
|
120
|
+
style?: CSSProperties;
|
|
121
|
+
}): import("react").JSX.Element;
|
|
122
|
+
/**
|
|
123
|
+
* The batteries-included default: a skinned glass panel wrapping {@link useActionBar} +
|
|
124
|
+
* {@link ActionBarChrome}. Pass `defs`, choose `grid`/`list`, and hand it `onActivate`. The renderer
|
|
125
|
+
* and item chrome are still swappable via `renderItem`; a game that wants a different frame composes
|
|
126
|
+
* the hook and chrome directly.
|
|
127
|
+
*
|
|
128
|
+
* @capability action-bar drop-in cooldown/cost/hotkey action bar over the headless action model
|
|
129
|
+
*/
|
|
130
|
+
export declare function ActionBar({ defs, columns, layout, wrap, hotkeys, itemSize, ariaLabel, onActivate, renderItem, className, style, }: {
|
|
131
|
+
defs: readonly ActionDef[];
|
|
132
|
+
columns?: number;
|
|
133
|
+
layout?: "grid" | "list";
|
|
134
|
+
wrap?: boolean;
|
|
135
|
+
hotkeys?: boolean;
|
|
136
|
+
itemSize?: number;
|
|
137
|
+
ariaLabel?: string;
|
|
138
|
+
onActivate?: (id: string) => void;
|
|
139
|
+
renderItem?: (action: ResolvedAction, ctx: ActionItemContext) => ReactNode;
|
|
140
|
+
className?: string;
|
|
141
|
+
style?: CSSProperties;
|
|
142
|
+
}): import("react").JSX.Element;
|
|
143
|
+
/**
|
|
144
|
+
* An accessible popover shell positioned by the pure {@link placePopover} math — flips and clamps
|
|
145
|
+
* against the viewport, renders nothing while closed, and defaults to `role="tooltip"`. Reuse it for
|
|
146
|
+
* an action's hover/focus description or any anchored panel.
|
|
147
|
+
*
|
|
148
|
+
* @capability popover viewport-flipping, viewport-clamped anchored popover/tooltip shell
|
|
149
|
+
*/
|
|
150
|
+
export declare function Popover({ open, anchorRef, side, gap, role, id, children, className, style, }: {
|
|
151
|
+
open: boolean;
|
|
152
|
+
anchorRef: {
|
|
153
|
+
current: HTMLElement | null;
|
|
154
|
+
};
|
|
155
|
+
side?: PopoverSide;
|
|
156
|
+
gap?: number;
|
|
157
|
+
role?: string;
|
|
158
|
+
id?: string;
|
|
159
|
+
children: ReactNode;
|
|
160
|
+
className?: string;
|
|
161
|
+
style?: CSSProperties;
|
|
162
|
+
}): import("react").JSX.Element | null;
|
|
163
|
+
/**
|
|
164
|
+
* Render {@link TooltipContent} (from `actionTooltip`) as a styled block: title, subtitle, body,
|
|
165
|
+
* hotkey, cost lines, cooldown, and a warning footer of blocking notes. Drop it inside a
|
|
166
|
+
* {@link Popover} for an action's hover/focus description.
|
|
167
|
+
*
|
|
168
|
+
* @capability action-tooltip structured action tooltip body (title, cost, cooldown, blocking notes)
|
|
169
|
+
*/
|
|
170
|
+
export declare function ActionTooltip({ content, className, style, }: {
|
|
171
|
+
content: TooltipContent;
|
|
172
|
+
className?: string;
|
|
173
|
+
style?: CSSProperties;
|
|
174
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
3
|
+
import { actionByHotkey, actionCooldownFromFraction, actionCost, moveGridFocus, resolveActionCollection, } from "@jgengine/core/ui/actionModel";
|
|
4
|
+
import { actionTooltip, placePopover } from "@jgengine/core/ui/tooltipModel";
|
|
5
|
+
import { Keycap } from "./keyHint.js";
|
|
6
|
+
/**
|
|
7
|
+
* Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
|
|
8
|
+
* the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a
|
|
9
|
+
* radial. `useActionBar` is the DATA/HOOK layer (resolution + focus/hover/keyboard/hotkey), `ActionBarChrome`
|
|
10
|
+
* is the composable RENDERER (swap `renderItem` for custom chrome, or ignore it and consume the model
|
|
11
|
+
* directly for a radial), and `ActionBar` is the batteries-included default. A game swaps renderer or
|
|
12
|
+
* chrome without forking the logic. Layout, dimensions, hotkeys, and catalog-id mapping stay caller-owned.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Adapt a combat {@link AbilitySlotSnapshot} into an {@link ActionDef} — the glue that binds an
|
|
16
|
+
* `AbilityKit` (via `useAbilitySlots`) to the action renderers without the game re-deriving cooldown
|
|
17
|
+
* and cost shapes. `extra` supplies the caller-owned bits the snapshot cannot know (label, icon,
|
|
18
|
+
* hotkey, description, resource name). Pure, so it is unit-testable.
|
|
19
|
+
*
|
|
20
|
+
* @capability action-from-ability-slot adapt a combat ability-kit snapshot into an action-bar action def
|
|
21
|
+
*/
|
|
22
|
+
export function actionFromAbilitySlot(slot, extra) {
|
|
23
|
+
const def = { id: slot.id };
|
|
24
|
+
if (slot.cooldownRemainingMs > 0) {
|
|
25
|
+
def.cooldown = actionCooldownFromFraction(slot.cooldownRemainingMs, slot.cooldownFraction);
|
|
26
|
+
}
|
|
27
|
+
if (slot.resourceCost > 0) {
|
|
28
|
+
const available = slot.state === "no-resource" ? 0 : slot.resourceCost;
|
|
29
|
+
def.costs = [actionCost(extra?.resourceId ?? "resource", slot.resourceCost, available)];
|
|
30
|
+
}
|
|
31
|
+
if (extra?.label !== undefined)
|
|
32
|
+
def.label = extra.label;
|
|
33
|
+
if (extra?.description !== undefined)
|
|
34
|
+
def.description = extra.description;
|
|
35
|
+
if (extra?.icon !== undefined)
|
|
36
|
+
def.icon = extra.icon;
|
|
37
|
+
if (extra?.hotkey !== undefined)
|
|
38
|
+
def.hotkey = extra.hotkey;
|
|
39
|
+
if (extra?.group !== undefined)
|
|
40
|
+
def.group = extra.group;
|
|
41
|
+
if (extra?.active !== undefined)
|
|
42
|
+
def.active = extra.active;
|
|
43
|
+
if (extra?.disabled !== undefined)
|
|
44
|
+
def.disabled = extra.disabled;
|
|
45
|
+
if (extra?.reasons !== undefined)
|
|
46
|
+
def.reasons = extra.reasons;
|
|
47
|
+
return def;
|
|
48
|
+
}
|
|
49
|
+
const ARROW_DIRECTIONS = {
|
|
50
|
+
ArrowRight: "right",
|
|
51
|
+
ArrowLeft: "left",
|
|
52
|
+
ArrowUp: "up",
|
|
53
|
+
ArrowDown: "down",
|
|
54
|
+
Home: "first",
|
|
55
|
+
End: "last",
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Pure keyboard/controller resolver for an action bar: arrows/Home/End move grid focus, Enter/Space
|
|
59
|
+
* activate the focused action, and any other single key routes through {@link actionByHotkey}. Returns
|
|
60
|
+
* the resulting intent without touching the DOM, so it unit-tests headless and `useActionBar` is a
|
|
61
|
+
* thin shell over it.
|
|
62
|
+
*/
|
|
63
|
+
export function actionBarKeyAction(actions, focusedId, columns, key, options) {
|
|
64
|
+
const index = focusedId === null ? -1 : actions.findIndex((a) => a.id === focusedId);
|
|
65
|
+
const dir = ARROW_DIRECTIONS[key];
|
|
66
|
+
if (dir !== undefined) {
|
|
67
|
+
const next = moveGridFocus(index, actions.length, columns, dir, { wrap: options?.wrap === true });
|
|
68
|
+
const nextId = actions[next]?.id;
|
|
69
|
+
return nextId === undefined ? { handled: false } : { handled: true, focusId: nextId };
|
|
70
|
+
}
|
|
71
|
+
if (key === "Enter" || key === " " || key === "Spacebar") {
|
|
72
|
+
return focusedId === null ? { handled: false } : { handled: true, activateId: focusedId };
|
|
73
|
+
}
|
|
74
|
+
const hotkeyId = actionByHotkey(actions, key);
|
|
75
|
+
return hotkeyId === null ? { handled: false } : { handled: true, focusId: hotkeyId, activateId: hotkeyId };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The DATA/HOOK layer: resolve `defs` into a live view model with focus, hover, keyboard grid
|
|
79
|
+
* navigation, and hotkey activation. Rendering-agnostic — feed the returned model to
|
|
80
|
+
* {@link ActionBarChrome}, or read it directly to lay out a radial or a bespoke card.
|
|
81
|
+
*
|
|
82
|
+
* @capability action-bar-model headless action-collection model with focus, hover, keyboard, and hotkey routing
|
|
83
|
+
*/
|
|
84
|
+
export function useActionBar(defs, options) {
|
|
85
|
+
const [focusedId, setFocusedId] = useState(null);
|
|
86
|
+
const [hoveredId, setHoveredId] = useState(null);
|
|
87
|
+
const actions = useMemo(() => resolveActionCollection(defs), [defs]);
|
|
88
|
+
const columns = options?.columns ?? Math.max(1, actions.length);
|
|
89
|
+
const activate = useCallback((id) => options?.onActivate?.(id), [options]);
|
|
90
|
+
const onKeyDown = useCallback((event) => {
|
|
91
|
+
const result = actionBarKeyAction(actions, focusedId, columns, event.key, { wrap: options?.wrap === true });
|
|
92
|
+
if (!result.handled)
|
|
93
|
+
return;
|
|
94
|
+
if (result.activateId !== undefined && options?.hotkeys === false && result.focusId === result.activateId) {
|
|
95
|
+
// hotkey routing suppressed
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
if (result.focusId !== undefined)
|
|
99
|
+
setFocusedId(result.focusId);
|
|
100
|
+
if (result.activateId !== undefined)
|
|
101
|
+
activate(result.activateId);
|
|
102
|
+
}
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
}, [actions, focusedId, columns, options, activate]);
|
|
105
|
+
return {
|
|
106
|
+
actions,
|
|
107
|
+
columns,
|
|
108
|
+
focusedId,
|
|
109
|
+
hoveredId,
|
|
110
|
+
setFocus: setFocusedId,
|
|
111
|
+
setHover: setHoveredId,
|
|
112
|
+
activate,
|
|
113
|
+
onKeyDown,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const ITEM_BASE = {
|
|
117
|
+
position: "relative",
|
|
118
|
+
boxSizing: "border-box",
|
|
119
|
+
display: "flex",
|
|
120
|
+
flexDirection: "column",
|
|
121
|
+
alignItems: "center",
|
|
122
|
+
justifyContent: "center",
|
|
123
|
+
gap: 2,
|
|
124
|
+
border: "1px solid rgba(255,255,255,0.14)",
|
|
125
|
+
borderRadius: 8,
|
|
126
|
+
background: "rgba(14,17,22,0.72)",
|
|
127
|
+
color: "#eef2f8",
|
|
128
|
+
font: "600 12px/1.1 ui-sans-serif, system-ui, sans-serif",
|
|
129
|
+
cursor: "pointer",
|
|
130
|
+
userSelect: "none",
|
|
131
|
+
overflow: "hidden",
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* The default per-item chrome — a cooldown-aware action button with hotkey cap, cost line, radial
|
|
135
|
+
* cooldown wipe, and full rest/hover/focus/pressed/active/disabled states. Accessible: it is a real
|
|
136
|
+
* `<button>` with `aria-disabled`, `aria-pressed` for toggles, `aria-keyshortcuts`, and a native
|
|
137
|
+
* `title` describing why it is blocked. Swap it via `ActionBarChrome`'s `renderItem`, or drop it into
|
|
138
|
+
* a radial layout yourself.
|
|
139
|
+
*
|
|
140
|
+
* @capability action-button cooldown/cost/hotkey action button with complete interaction states
|
|
141
|
+
*/
|
|
142
|
+
export function ActionButton({ action, focused = false, hovered = false, size = 52, showHotkey = true, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }) {
|
|
143
|
+
const tip = actionTooltip(action);
|
|
144
|
+
const title = [tip.title, ...(tip.notes ?? [])].join("\n");
|
|
145
|
+
const cooldown = action.cooldown;
|
|
146
|
+
const wipeDeg = cooldown !== null && !cooldown.ready ? Math.round(cooldown.fraction * 360) : 0;
|
|
147
|
+
return (_jsxs("button", { type: "button", ref: buttonRef, className: className, "data-action-id": action.id, "data-active": action.active ? "" : undefined, "data-enabled": action.enabled ? "" : undefined, "data-focused": focused ? "" : undefined, "aria-disabled": !action.enabled, "aria-pressed": action.active, "aria-keyshortcuts": action.hotkey, "aria-label": action.label, title: title, tabIndex: tabIndex, onClick: () => onActivate?.(action.id), onFocus: () => onFocus?.(action.id), onPointerEnter: () => onHover?.(action.id), onPointerLeave: () => onHover?.(null), style: {
|
|
148
|
+
...ITEM_BASE,
|
|
149
|
+
width: size,
|
|
150
|
+
height: size,
|
|
151
|
+
opacity: action.enabled ? 1 : 0.5,
|
|
152
|
+
borderColor: action.active
|
|
153
|
+
? "rgba(56,189,248,0.95)"
|
|
154
|
+
: focused
|
|
155
|
+
? "rgba(148,197,255,0.9)"
|
|
156
|
+
: hovered
|
|
157
|
+
? "rgba(255,255,255,0.3)"
|
|
158
|
+
: "rgba(255,255,255,0.14)",
|
|
159
|
+
boxShadow: focused
|
|
160
|
+
? "0 0 0 2px rgba(96,165,250,0.7)"
|
|
161
|
+
: action.active
|
|
162
|
+
? "0 0 0 1px rgba(56,189,248,0.6)"
|
|
163
|
+
: "none",
|
|
164
|
+
transform: hovered && action.enabled ? "translateY(-1px)" : "none",
|
|
165
|
+
transition: "transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease",
|
|
166
|
+
...style,
|
|
167
|
+
}, children: [showHotkey && action.hotkey !== undefined ? (_jsx("span", { style: { position: "absolute", top: 2, left: 3 }, "aria-hidden": true, children: _jsx(Keycap, { style: { fontSize: 9, padding: "0 3px", opacity: 0.85 }, children: action.hotkey }) })) : null, _jsx("span", { style: { fontSize: renderIcon ? undefined : 18, lineHeight: 1 }, "data-action-icon": true, "aria-hidden": true, children: renderIcon ? renderIcon(action) : (action.icon ?? action.label.slice(0, 2)) }), action.costs.length > 0 ? (_jsx("span", { "data-action-costs": true, style: { display: "flex", gap: 4, fontSize: 9 }, children: action.costs.map((cost) => (_jsx("span", { style: { color: cost.met ? "rgba(147,197,253,0.9)" : "#f87171" }, children: cost.amount }, cost.resourceId))) })) : null, wipeDeg > 0 ? (_jsx("span", { "data-action-cooldown": true, "aria-hidden": true, style: {
|
|
168
|
+
position: "absolute",
|
|
169
|
+
inset: 0,
|
|
170
|
+
pointerEvents: "none",
|
|
171
|
+
background: `conic-gradient(rgba(4,6,10,0.62) ${wipeDeg}deg, transparent 0deg)`,
|
|
172
|
+
}, children: _jsx("span", { style: {
|
|
173
|
+
position: "absolute",
|
|
174
|
+
inset: 0,
|
|
175
|
+
display: "flex",
|
|
176
|
+
alignItems: "center",
|
|
177
|
+
justifyContent: "center",
|
|
178
|
+
fontSize: 13,
|
|
179
|
+
fontWeight: 700,
|
|
180
|
+
fontVariantNumeric: "tabular-nums",
|
|
181
|
+
}, children: (action.cooldown.remainingMs / 1000).toFixed(action.cooldown.remainingMs < 10000 ? 1 : 0) }) })) : null] }));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The composable RENDERER: lay the model's actions out as a `grid` or `list` toolbar with roving
|
|
185
|
+
* tabindex, arrow-key navigation, and a moved DOM focus. `renderItem` swaps per-item chrome; omit it
|
|
186
|
+
* for {@link ActionButton}. A radial or bespoke UI can skip this entirely and read the model. Purely
|
|
187
|
+
* a wiring/behavior layer — no panel skin — so a game art-directs freely.
|
|
188
|
+
*
|
|
189
|
+
* @capability action-bar-chrome accessible grid/list toolbar renderer over an action-bar model with swappable item chrome
|
|
190
|
+
*/
|
|
191
|
+
export function ActionBarChrome({ model, layout = "grid", itemSize = 52, gap = 6, ariaLabel = "Actions", renderItem, className, style, }) {
|
|
192
|
+
const refs = useRef(new Map());
|
|
193
|
+
const { actions, focusedId, hoveredId, columns } = model;
|
|
194
|
+
const rovingId = focusedId ?? actions[0]?.id ?? null;
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (focusedId === null)
|
|
197
|
+
return;
|
|
198
|
+
refs.current.get(focusedId)?.focus();
|
|
199
|
+
}, [focusedId]);
|
|
200
|
+
const containerStyle = layout === "grid"
|
|
201
|
+
? { display: "grid", gridTemplateColumns: `repeat(${Math.max(1, columns)}, ${itemSize}px)`, gap }
|
|
202
|
+
: { display: "flex", flexDirection: "column", gap };
|
|
203
|
+
return (_jsx("div", { role: "toolbar", "aria-label": ariaLabel, "aria-orientation": layout === "list" ? "vertical" : "horizontal", className: className, "data-action-bar": "", onKeyDown: model.onKeyDown, style: { ...containerStyle, ...style }, children: actions.map((action) => {
|
|
204
|
+
const ctx = {
|
|
205
|
+
focused: focusedId === action.id,
|
|
206
|
+
hovered: hoveredId === action.id,
|
|
207
|
+
model,
|
|
208
|
+
};
|
|
209
|
+
if (renderItem !== undefined)
|
|
210
|
+
return _jsx("div", { children: renderItem(action, ctx) }, action.id);
|
|
211
|
+
return (_jsx(ActionButton, { action: action, focused: ctx.focused, hovered: ctx.hovered, size: itemSize, tabIndex: rovingId === action.id ? 0 : -1, buttonRef: (node) => {
|
|
212
|
+
if (node === null)
|
|
213
|
+
refs.current.delete(action.id);
|
|
214
|
+
else
|
|
215
|
+
refs.current.set(action.id, node);
|
|
216
|
+
}, onActivate: model.activate, onFocus: model.setFocus, onHover: model.setHover }, action.id));
|
|
217
|
+
}) }));
|
|
218
|
+
}
|
|
219
|
+
const PANEL = {
|
|
220
|
+
padding: 8,
|
|
221
|
+
borderRadius: 12,
|
|
222
|
+
background: "rgba(10,12,16,0.6)",
|
|
223
|
+
backdropFilter: "blur(6px)",
|
|
224
|
+
WebkitBackdropFilter: "blur(6px)",
|
|
225
|
+
border: "1px solid rgba(255,255,255,0.1)",
|
|
226
|
+
boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* The batteries-included default: a skinned glass panel wrapping {@link useActionBar} +
|
|
230
|
+
* {@link ActionBarChrome}. Pass `defs`, choose `grid`/`list`, and hand it `onActivate`. The renderer
|
|
231
|
+
* and item chrome are still swappable via `renderItem`; a game that wants a different frame composes
|
|
232
|
+
* the hook and chrome directly.
|
|
233
|
+
*
|
|
234
|
+
* @capability action-bar drop-in cooldown/cost/hotkey action bar over the headless action model
|
|
235
|
+
*/
|
|
236
|
+
export function ActionBar({ defs, columns, layout = "grid", wrap, hotkeys, itemSize, ariaLabel, onActivate, renderItem, className, style, }) {
|
|
237
|
+
const model = useActionBar(defs, { columns, wrap, hotkeys, onActivate });
|
|
238
|
+
return (_jsx("div", { className: className, "data-action-panel": "", style: { ...PANEL, display: "inline-block", ...style }, children: _jsx(ActionBarChrome, { model: model, layout: layout, itemSize: itemSize, ariaLabel: ariaLabel, renderItem: renderItem }) }));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* An accessible popover shell positioned by the pure {@link placePopover} math — flips and clamps
|
|
242
|
+
* against the viewport, renders nothing while closed, and defaults to `role="tooltip"`. Reuse it for
|
|
243
|
+
* an action's hover/focus description or any anchored panel.
|
|
244
|
+
*
|
|
245
|
+
* @capability popover viewport-flipping, viewport-clamped anchored popover/tooltip shell
|
|
246
|
+
*/
|
|
247
|
+
export function Popover({ open, anchorRef, side = "top", gap = 8, role = "tooltip", id, children, className, style, }) {
|
|
248
|
+
const ref = useRef(null);
|
|
249
|
+
const [pos, setPos] = useState(null);
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (!open || typeof window === "undefined")
|
|
252
|
+
return;
|
|
253
|
+
const anchor = anchorRef.current;
|
|
254
|
+
const node = ref.current;
|
|
255
|
+
if (anchor === null || node === null)
|
|
256
|
+
return;
|
|
257
|
+
const a = anchor.getBoundingClientRect();
|
|
258
|
+
const c = node.getBoundingClientRect();
|
|
259
|
+
const placement = placePopover({ x: a.left, y: a.top, width: a.width, height: a.height }, { width: c.width, height: c.height }, { width: window.innerWidth, height: window.innerHeight }, { preferred: side, gap });
|
|
260
|
+
setPos({ left: placement.left, top: placement.top });
|
|
261
|
+
}, [open, side, gap, anchorRef]);
|
|
262
|
+
if (!open)
|
|
263
|
+
return null;
|
|
264
|
+
return (_jsx("div", { ref: ref, role: role, id: id, "data-popover": "", className: className, style: {
|
|
265
|
+
position: "fixed",
|
|
266
|
+
left: pos?.left ?? -9999,
|
|
267
|
+
top: pos?.top ?? -9999,
|
|
268
|
+
zIndex: 1000,
|
|
269
|
+
pointerEvents: "none",
|
|
270
|
+
...style,
|
|
271
|
+
}, children: children }));
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Render {@link TooltipContent} (from `actionTooltip`) as a styled block: title, subtitle, body,
|
|
275
|
+
* hotkey, cost lines, cooldown, and a warning footer of blocking notes. Drop it inside a
|
|
276
|
+
* {@link Popover} for an action's hover/focus description.
|
|
277
|
+
*
|
|
278
|
+
* @capability action-tooltip structured action tooltip body (title, cost, cooldown, blocking notes)
|
|
279
|
+
*/
|
|
280
|
+
export function ActionTooltip({ content, className, style, }) {
|
|
281
|
+
return (_jsxs("div", { className: className, "data-action-tooltip": "", style: {
|
|
282
|
+
maxWidth: 240,
|
|
283
|
+
padding: "8px 10px",
|
|
284
|
+
borderRadius: 8,
|
|
285
|
+
background: "rgba(8,10,14,0.95)",
|
|
286
|
+
border: "1px solid rgba(255,255,255,0.14)",
|
|
287
|
+
color: "#eef2f8",
|
|
288
|
+
font: "500 12px/1.35 ui-sans-serif, system-ui, sans-serif",
|
|
289
|
+
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
|
|
290
|
+
...style,
|
|
291
|
+
}, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8 }, children: [_jsx("strong", { style: { fontSize: 13 }, children: content.title }), content.hotkey !== undefined ? (_jsx(Keycap, { style: { marginLeft: "auto", fontSize: 10 }, children: content.hotkey })) : null] }), content.subtitle !== undefined ? (_jsx("div", { style: { fontSize: 10, textTransform: "uppercase", letterSpacing: 0.4, opacity: 0.6 }, children: content.subtitle })) : null, content.body !== undefined ? _jsx("div", { style: { marginTop: 4, opacity: 0.9 }, children: content.body }) : null, content.costs !== undefined && content.costs.length > 0 ? (_jsx("div", { style: { marginTop: 4, display: "flex", gap: 8, fontSize: 11 }, children: content.costs.map((cost) => (_jsxs("span", { style: { color: cost.met ? "rgba(147,197,253,0.95)" : "#f87171" }, children: [cost.amount, " ", cost.resourceId] }, cost.resourceId))) })) : null, content.cooldown != null ? (_jsxs("div", { style: { marginTop: 4, fontSize: 11, opacity: 0.8 }, children: [(content.cooldown.remainingMs / 1000).toFixed(1), "s cooldown"] })) : null, content.notes !== undefined && content.notes.length > 0 ? (_jsx("div", { style: { marginTop: 6, paddingTop: 4, borderTop: "1px solid rgba(255,255,255,0.1)", fontSize: 11, color: "#fca5a5" }, children: content.notes.map((note) => (_jsx("div", { children: note }, note))) })) : null] }));
|
|
292
|
+
}
|
package/dist/cards.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import type { CardPileState } from "@jgengine/core/cards/cardPile";
|
|
3
|
+
import { type DragLayer } from "./dragLayer.js";
|
|
4
|
+
/** The four French-deck suits. */
|
|
5
|
+
export type Suit = "clubs" | "diamonds" | "hearts" | "spades";
|
|
6
|
+
/** A playing-card rank, ace through king. */
|
|
7
|
+
export type Rank = "A" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "J" | "Q" | "K";
|
|
8
|
+
/** Suits in canonical order (clubs, diamonds, hearts, spades). */
|
|
9
|
+
export declare const SUITS: readonly Suit[];
|
|
10
|
+
/** Ranks in ascending order, ace low. */
|
|
11
|
+
export declare const RANKS: readonly Rank[];
|
|
12
|
+
/** True for the red suits (hearts, diamonds). */
|
|
13
|
+
export declare function isRedSuit(suit: Suit): boolean;
|
|
14
|
+
/** A single card in a stack: an id plus its face and orientation. */
|
|
15
|
+
export interface PlayingCard {
|
|
16
|
+
id: string;
|
|
17
|
+
rank: Rank;
|
|
18
|
+
suit: Suit;
|
|
19
|
+
faceDown?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** A single playing-card face: rank + suit pips, or a patterned back when `faceDown`. */
|
|
22
|
+
export declare function CardFace({ rank, suit, faceDown, width, height, className, style, children, }: {
|
|
23
|
+
rank: Rank;
|
|
24
|
+
suit: Suit;
|
|
25
|
+
faceDown?: boolean;
|
|
26
|
+
width?: number;
|
|
27
|
+
height?: number;
|
|
28
|
+
className?: string;
|
|
29
|
+
style?: CSSProperties;
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
}): import("react").JSX.Element;
|
|
32
|
+
/**
|
|
33
|
+
* An overlapping fan of `CardFace`s. Plain-props: pass `cards`. Optional drag: pass a `layer`
|
|
34
|
+
* to make each card a `DraggableCard`, and `dropId` to wrap the pile in a `DropZone`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function StackedPile({ cards, faceDown, offsetX, offsetY, cardWidth, cardHeight, layer, dropId, draggableFrom, className, cardClassName, onCardClick, renderOverlay, }: {
|
|
37
|
+
cards: readonly PlayingCard[];
|
|
38
|
+
faceDown?: boolean;
|
|
39
|
+
offsetX?: number;
|
|
40
|
+
offsetY?: number;
|
|
41
|
+
cardWidth?: number;
|
|
42
|
+
cardHeight?: number;
|
|
43
|
+
layer?: DragLayer<PlayingCard>;
|
|
44
|
+
dropId?: string;
|
|
45
|
+
draggableFrom?: number;
|
|
46
|
+
className?: string;
|
|
47
|
+
cardClassName?: string;
|
|
48
|
+
onCardClick?: (card: PlayingCard, index: number) => void;
|
|
49
|
+
renderOverlay?: (card: PlayingCard, index: number) => ReactNode;
|
|
50
|
+
}): import("react").JSX.Element;
|
|
51
|
+
/**
|
|
52
|
+
* `StackedPile` bound to a headless `CardPileState` zone: reads the ordered card ids from
|
|
53
|
+
* `pile.zones[zone]` and resolves each to a `PlayingCard` via `cardOf`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function CardStack({ pile, zone, cardOf, ...rest }: {
|
|
56
|
+
pile: CardPileState;
|
|
57
|
+
zone: string;
|
|
58
|
+
cardOf: (id: string) => PlayingCard;
|
|
59
|
+
} & Omit<Parameters<typeof StackedPile>[0], "cards">): import("react").JSX.Element;
|
package/dist/cards.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { DraggableCard, DropZone } from "./dragLayer.js";
|
|
3
|
+
/** Suits in canonical order (clubs, diamonds, hearts, spades). */
|
|
4
|
+
export const SUITS = ["clubs", "diamonds", "hearts", "spades"];
|
|
5
|
+
/** Ranks in ascending order, ace low. */
|
|
6
|
+
export const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
|
7
|
+
/** True for the red suits (hearts, diamonds). */
|
|
8
|
+
export function isRedSuit(suit) {
|
|
9
|
+
return suit === "hearts" || suit === "diamonds";
|
|
10
|
+
}
|
|
11
|
+
const SUIT_PATHS = {
|
|
12
|
+
hearts: _jsx("path", { d: "M12 21C7 17 3 13.5 3 9.5A4.5 4.5 0 0112 7a4.5 4.5 0 019 2.5C21 13.5 17 17 12 21z" }),
|
|
13
|
+
diamonds: _jsx("path", { d: "M12 2l7.5 10L12 22 4.5 12z" }),
|
|
14
|
+
spades: (_jsx("path", { d: "M12 2C8.5 6 3.5 8.5 3.5 13A4 4 0 0011 15.7C10.7 18 9.7 19.3 8 21h8c-1.7-1.7-2.7-3-3-5.3A4 4 0 0020.5 13C20.5 8.5 15.5 6 12 2z" })),
|
|
15
|
+
clubs: (_jsx("path", { d: "M12 2a3.6 3.6 0 00-2.9 5.7A3.6 3.6 0 108.4 14.6 3.6 3.6 0 0011 15.3C10.8 18 9.8 19.4 8 21h8c-1.8-1.6-2.8-3-3-5.7a3.6 3.6 0 102.6-6.9A3.6 3.6 0 0012 2z" })),
|
|
16
|
+
};
|
|
17
|
+
function SuitGlyph({ suit, size = 16 }) {
|
|
18
|
+
return (_jsx("svg", { viewBox: "0 0 24 24", width: size, height: size, fill: "currentColor", "aria-hidden": "true", "data-suit-glyph": suit, style: { display: "block" }, children: SUIT_PATHS[suit] }));
|
|
19
|
+
}
|
|
20
|
+
const SUIT_LABEL = {
|
|
21
|
+
clubs: "clubs",
|
|
22
|
+
diamonds: "diamonds",
|
|
23
|
+
hearts: "hearts",
|
|
24
|
+
spades: "spades",
|
|
25
|
+
};
|
|
26
|
+
/** A single playing-card face: rank + suit pips, or a patterned back when `faceDown`. */
|
|
27
|
+
export function CardFace({ rank, suit, faceDown = false, width = 64, height = 90, className, style, children, }) {
|
|
28
|
+
const radius = Math.round(Math.min(width, height) * 0.11);
|
|
29
|
+
const base = {
|
|
30
|
+
width,
|
|
31
|
+
height,
|
|
32
|
+
borderRadius: radius,
|
|
33
|
+
boxSizing: "border-box",
|
|
34
|
+
position: "relative",
|
|
35
|
+
userSelect: "none",
|
|
36
|
+
...style,
|
|
37
|
+
};
|
|
38
|
+
if (faceDown) {
|
|
39
|
+
return (_jsxs("div", { className: className, "data-card-face": "", "data-face-down": "", role: "img", "aria-label": "Face-down card", style: {
|
|
40
|
+
...base,
|
|
41
|
+
border: "1px solid #1e2a52",
|
|
42
|
+
background: "repeating-linear-gradient(45deg, #2b3a72 0, #2b3a72 4px, #1b2650 4px, #1b2650 8px)",
|
|
43
|
+
}, children: [_jsx("div", { style: {
|
|
44
|
+
position: "absolute",
|
|
45
|
+
inset: Math.round(Math.min(width, height) * 0.09),
|
|
46
|
+
borderRadius: Math.max(2, radius - 3),
|
|
47
|
+
border: "1px solid rgba(255,255,255,0.35)",
|
|
48
|
+
} }), children] }));
|
|
49
|
+
}
|
|
50
|
+
const color = isRedSuit(suit) ? "#c02434" : "#16181d";
|
|
51
|
+
const cornerFont = Math.round(height * 0.16);
|
|
52
|
+
const corner = (rotated) => ({
|
|
53
|
+
position: "absolute",
|
|
54
|
+
display: "flex",
|
|
55
|
+
flexDirection: "column",
|
|
56
|
+
alignItems: "center",
|
|
57
|
+
lineHeight: 1,
|
|
58
|
+
color,
|
|
59
|
+
fontFamily: "Georgia, 'Times New Roman', serif",
|
|
60
|
+
fontWeight: 700,
|
|
61
|
+
fontSize: cornerFont,
|
|
62
|
+
...(rotated
|
|
63
|
+
? { right: Math.round(width * 0.06), bottom: Math.round(height * 0.05), transform: "rotate(180deg)" }
|
|
64
|
+
: { left: Math.round(width * 0.06), top: Math.round(height * 0.05) }),
|
|
65
|
+
});
|
|
66
|
+
return (_jsxs("div", { className: className, "data-card-face": "", "data-rank": rank, "data-suit": suit, role: "img", "aria-label": `${rank} of ${SUIT_LABEL[suit]}`, style: {
|
|
67
|
+
...base,
|
|
68
|
+
border: "1px solid #c8ccd6",
|
|
69
|
+
background: "#ffffff",
|
|
70
|
+
}, children: [_jsxs("span", { "data-card-corner": "top", style: corner(false), children: [_jsx("span", { "data-card-rank": true, children: rank }), _jsx(SuitGlyph, { suit: suit, size: cornerFont })] }), _jsx("div", { style: {
|
|
71
|
+
position: "absolute",
|
|
72
|
+
inset: 0,
|
|
73
|
+
display: "flex",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
justifyContent: "center",
|
|
76
|
+
color,
|
|
77
|
+
}, children: _jsx(SuitGlyph, { suit: suit, size: Math.round(Math.min(width, height) * 0.5) }) }), _jsxs("span", { "data-card-corner": "bottom", style: corner(true), children: [_jsx("span", { "data-card-rank": true, children: rank }), _jsx(SuitGlyph, { suit: suit, size: cornerFont })] }), children] }));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* An overlapping fan of `CardFace`s. Plain-props: pass `cards`. Optional drag: pass a `layer`
|
|
81
|
+
* to make each card a `DraggableCard`, and `dropId` to wrap the pile in a `DropZone`.
|
|
82
|
+
*/
|
|
83
|
+
export function StackedPile({ cards, faceDown, offsetX = 0, offsetY = 20, cardWidth = 64, cardHeight = 90, layer, dropId, draggableFrom = 0, className, cardClassName, onCardClick, renderOverlay, }) {
|
|
84
|
+
const last = Math.max(0, cards.length - 1);
|
|
85
|
+
const width = cardWidth + Math.abs(offsetX) * last;
|
|
86
|
+
const height = cardHeight + Math.abs(offsetY) * last;
|
|
87
|
+
const baseLeft = offsetX < 0 ? -offsetX * last : 0;
|
|
88
|
+
const baseTop = offsetY < 0 ? -offsetY * last : 0;
|
|
89
|
+
const body = (_jsx("div", { className: className, "data-card-stack": "", "data-count": cards.length, style: { position: "relative", width, height }, children: cards.map((card, index) => {
|
|
90
|
+
const positioned = {
|
|
91
|
+
position: "absolute",
|
|
92
|
+
left: baseLeft + offsetX * index,
|
|
93
|
+
top: baseTop + offsetY * index,
|
|
94
|
+
zIndex: index,
|
|
95
|
+
};
|
|
96
|
+
const face = (_jsx(CardFace, { rank: card.rank, suit: card.suit, faceDown: card.faceDown ?? faceDown, width: cardWidth, height: cardHeight, className: cardClassName, children: renderOverlay?.(card, index) }));
|
|
97
|
+
const clickable = onCardClick !== undefined;
|
|
98
|
+
if (layer !== undefined && index >= draggableFrom) {
|
|
99
|
+
return (_jsx("div", { style: positioned, children: _jsx(DraggableCard, { id: card.id, value: card, layer: layer, onRotate: false, children: face }) }, card.id));
|
|
100
|
+
}
|
|
101
|
+
return (_jsx("div", { "data-card-slot": index, style: { ...positioned, cursor: clickable ? "pointer" : undefined }, onClick: clickable ? () => onCardClick(card, index) : undefined, children: face }, card.id));
|
|
102
|
+
}) }));
|
|
103
|
+
if (layer !== undefined && dropId !== undefined) {
|
|
104
|
+
return (_jsx(DropZone, { id: dropId, layer: layer, children: body }));
|
|
105
|
+
}
|
|
106
|
+
return body;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `StackedPile` bound to a headless `CardPileState` zone: reads the ordered card ids from
|
|
110
|
+
* `pile.zones[zone]` and resolves each to a `PlayingCard` via `cardOf`.
|
|
111
|
+
*/
|
|
112
|
+
export function CardStack({ pile, zone, cardOf, ...rest }) {
|
|
113
|
+
const ids = pile.zones[zone] ?? [];
|
|
114
|
+
const cards = ids.map(cardOf);
|
|
115
|
+
return _jsx(StackedPile, { cards: cards, ...rest });
|
|
116
|
+
}
|