@dreamboard-games/ui-sdk 0.0.42 → 0.0.45

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.
Files changed (166) hide show
  1. package/dist/components/ActionButton.d.ts.map +1 -1
  2. package/dist/components/ActionButton.js +2 -1
  3. package/dist/components/Card.d.ts +1 -1
  4. package/dist/components/Card.d.ts.map +1 -1
  5. package/dist/components/DiceRoller.d.ts +3 -2
  6. package/dist/components/DiceRoller.d.ts.map +1 -1
  7. package/dist/components/DiceRoller.js +4 -13
  8. package/dist/components/ErrorBoundary.d.ts.map +1 -1
  9. package/dist/components/ErrorBoundary.js +94 -2
  10. package/dist/components/InteractionForm.d.ts +1 -1
  11. package/dist/components/InteractionForm.d.ts.map +1 -1
  12. package/dist/components/InteractionForm.js +29 -15
  13. package/dist/components/PrimaryActionButton.d.ts.map +1 -1
  14. package/dist/components/PrimaryActionButton.js +7 -6
  15. package/dist/components/ResourceCounter.d.ts +59 -25
  16. package/dist/components/ResourceCounter.d.ts.map +1 -1
  17. package/dist/components/ResourceCounter.js +106 -115
  18. package/dist/components/Toast.d.ts +13 -6
  19. package/dist/components/Toast.d.ts.map +1 -1
  20. package/dist/components/Toast.js +10 -5
  21. package/dist/components/board/HexGrid.js +6 -6
  22. package/dist/components/board/target-layer.d.ts +18 -2
  23. package/dist/components/board/target-layer.d.ts.map +1 -1
  24. package/dist/components/board/target-layer.js +20 -3
  25. package/dist/components/index.d.ts +3 -4
  26. package/dist/components/index.d.ts.map +1 -1
  27. package/dist/components/index.js +3 -4
  28. package/dist/components/surfaces/InboxSurface.d.ts.map +1 -1
  29. package/dist/components/surfaces/InboxSurface.js +2 -6
  30. package/dist/components/surfaces/PlayerCardsSurface.js +2 -2
  31. package/dist/components/surfaces/internal/CardZoneRoutedForm.d.ts +7 -0
  32. package/dist/components/surfaces/internal/CardZoneRoutedForm.d.ts.map +1 -0
  33. package/dist/components/surfaces/internal/CardZoneRoutedForm.js +9 -0
  34. package/dist/components/surfaces/internal/DefaultInteractionButton.d.ts.map +1 -1
  35. package/dist/components/surfaces/internal/DefaultInteractionButton.js +5 -8
  36. package/dist/components/surfaces/internal/useCardZoneInteractions.d.ts +2 -2
  37. package/dist/components/surfaces/internal/useCardZoneInteractions.d.ts.map +1 -1
  38. package/dist/components/surfaces/internal/useCardZoneInteractions.js +19 -43
  39. package/dist/context/InteractionDraftContext.d.ts +11 -2
  40. package/dist/context/InteractionDraftContext.d.ts.map +1 -1
  41. package/dist/context/InteractionDraftContext.js +41 -4
  42. package/dist/defaults/components.d.ts +0 -5
  43. package/dist/defaults/components.d.ts.map +1 -1
  44. package/dist/defaults/components.js +7 -11
  45. package/dist/hooks/useBoardInteractions.d.ts +35 -12
  46. package/dist/hooks/useBoardInteractions.d.ts.map +1 -1
  47. package/dist/hooks/useBoardInteractions.js +186 -82
  48. package/dist/hooks/useInteractionHandle.d.ts +1 -1
  49. package/dist/hooks/useInteractionHandle.d.ts.map +1 -1
  50. package/dist/hooks/useInteractionHandle.js +12 -27
  51. package/dist/index.d.ts +11 -17
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -14
  54. package/dist/primitives/board.d.ts +53 -3
  55. package/dist/primitives/board.d.ts.map +1 -1
  56. package/dist/primitives/board.js +65 -41
  57. package/dist/primitives/dialog-lifecycle.d.ts +17 -0
  58. package/dist/primitives/dialog-lifecycle.d.ts.map +1 -0
  59. package/dist/primitives/dialog-lifecycle.js +24 -0
  60. package/dist/primitives/dice.d.ts +31 -0
  61. package/dist/primitives/dice.d.ts.map +1 -0
  62. package/dist/primitives/dice.js +33 -0
  63. package/dist/primitives/game.d.ts +55 -0
  64. package/dist/primitives/game.d.ts.map +1 -0
  65. package/dist/primitives/game.js +101 -0
  66. package/dist/primitives/index.d.ts +7 -4
  67. package/dist/primitives/index.d.ts.map +1 -1
  68. package/dist/primitives/index.js +7 -4
  69. package/dist/primitives/interaction-form-binding.d.ts +12 -0
  70. package/dist/primitives/interaction-form-binding.d.ts.map +1 -0
  71. package/dist/primitives/interaction-form-binding.js +14 -0
  72. package/dist/primitives/interaction-submit.d.ts +23 -0
  73. package/dist/primitives/interaction-submit.d.ts.map +1 -0
  74. package/dist/primitives/interaction-submit.js +41 -0
  75. package/dist/primitives/interaction.d.ts +76 -6
  76. package/dist/primitives/interaction.d.ts.map +1 -1
  77. package/dist/primitives/interaction.js +210 -26
  78. package/dist/primitives/player-roster.d.ts +2 -1
  79. package/dist/primitives/player-roster.d.ts.map +1 -1
  80. package/dist/primitives/prompt.d.ts +36 -11
  81. package/dist/primitives/prompt.d.ts.map +1 -1
  82. package/dist/primitives/prompt.js +29 -17
  83. package/dist/primitives/ui.d.ts +9 -0
  84. package/dist/primitives/ui.d.ts.map +1 -0
  85. package/dist/primitives/ui.js +7 -0
  86. package/dist/primitives/zone.d.ts +111 -5
  87. package/dist/primitives/zone.d.ts.map +1 -1
  88. package/dist/primitives/zone.js +349 -9
  89. package/dist/reducer.d.ts +2 -14
  90. package/dist/reducer.d.ts.map +1 -1
  91. package/dist/reducer.js +1 -14
  92. package/dist/runtime/createPluginRuntimeAPI.js +1 -1
  93. package/dist/types/hex-color.d.ts +7 -0
  94. package/dist/types/hex-color.d.ts.map +1 -0
  95. package/dist/types/hex-color.js +13 -0
  96. package/dist/types/player-state.d.ts +28 -14
  97. package/dist/types/player-state.d.ts.map +1 -1
  98. package/dist/types/plugin-state.d.ts +9 -3
  99. package/dist/types/plugin-state.d.ts.map +1 -1
  100. package/dist/ui-contract.d.ts +119 -14
  101. package/dist/ui-contract.d.ts.map +1 -1
  102. package/dist/ui-contract.js +4 -3
  103. package/dist/ui-sdk.d.ts +1637 -1245
  104. package/dist/utils/interaction-inputs.d.ts +8 -5
  105. package/dist/utils/interaction-inputs.d.ts.map +1 -1
  106. package/dist/utils/interaction-inputs.js +82 -14
  107. package/dist/utils/interaction-router.d.ts +31 -0
  108. package/dist/utils/interaction-router.d.ts.map +1 -0
  109. package/dist/utils/interaction-router.js +114 -0
  110. package/package.json +2 -2
  111. package/src/components/ActionButton.tsx +2 -1
  112. package/src/components/Card.tsx +1 -1
  113. package/src/components/DiceRoller.tsx +13 -22
  114. package/src/components/ErrorBoundary.test.tsx +19 -0
  115. package/src/components/ErrorBoundary.tsx +113 -24
  116. package/src/components/InteractionForm.test.tsx +24 -0
  117. package/src/components/InteractionForm.tsx +48 -23
  118. package/src/components/PrimaryActionButton.tsx +19 -5
  119. package/src/components/ResourceCounter.test.tsx +13 -13
  120. package/src/components/ResourceCounter.tsx +238 -244
  121. package/src/components/Toast.tsx +23 -10
  122. package/src/components/__fixtures__/ResourceCounter.fixture.tsx +70 -169
  123. package/src/components/board/HexGrid.tsx +6 -6
  124. package/src/components/board/target-layer.ts +44 -5
  125. package/src/components/index.ts +17 -10
  126. package/src/components/surfaces/InboxSurface.tsx +7 -5
  127. package/src/components/surfaces/PlayerCardsSurface.tsx +6 -6
  128. package/src/components/surfaces/internal/CardZoneRoutedForm.tsx +35 -0
  129. package/src/components/surfaces/internal/DefaultInteractionButton.tsx +17 -7
  130. package/src/components/surfaces/internal/useCardZoneInteractions.ts +25 -67
  131. package/src/context/InteractionDraftContext.tsx +51 -5
  132. package/src/defaults/components.tsx +12 -50
  133. package/src/defaults/defaults.test.tsx +1 -50
  134. package/src/hooks/useBoardInteractions.test.tsx +240 -17
  135. package/src/hooks/useBoardInteractions.ts +330 -105
  136. package/src/hooks/useInteractionHandle.ts +23 -28
  137. package/src/index.test.ts +60 -40
  138. package/src/index.ts +30 -36
  139. package/src/primitives/board.test.tsx +73 -0
  140. package/src/primitives/board.tsx +191 -40
  141. package/src/primitives/dialog-lifecycle.ts +58 -0
  142. package/src/primitives/dice.test.tsx +47 -0
  143. package/src/primitives/dice.tsx +79 -0
  144. package/src/primitives/game.test.tsx +98 -0
  145. package/src/primitives/game.tsx +213 -0
  146. package/src/primitives/index.ts +84 -0
  147. package/src/primitives/interaction-form-binding.tsx +56 -0
  148. package/src/primitives/interaction-submit.ts +90 -0
  149. package/src/primitives/interaction.test.tsx +396 -0
  150. package/src/primitives/interaction.tsx +451 -31
  151. package/src/primitives/player-roster.tsx +2 -1
  152. package/src/primitives/prompt.test.tsx +94 -3
  153. package/src/primitives/prompt.tsx +87 -48
  154. package/src/primitives/ui.test.tsx +131 -0
  155. package/src/primitives/ui.tsx +13 -0
  156. package/src/primitives/zone.test.tsx +305 -0
  157. package/src/primitives/zone.tsx +660 -12
  158. package/src/reducer.ts +7 -20
  159. package/src/runtime/createPluginRuntimeAPI.ts +1 -1
  160. package/src/types/hex-color.ts +20 -0
  161. package/src/types/player-state.ts +36 -18
  162. package/src/types/plugin-state.ts +10 -3
  163. package/src/ui-contract.ts +253 -21
  164. package/src/utils/interaction-inputs.test.ts +400 -0
  165. package/src/utils/interaction-inputs.ts +113 -11
  166. package/src/utils/interaction-router.ts +200 -0
@@ -1,275 +1,269 @@
1
- /**
2
- * Displays resource counts with icons and animated updates.
3
- *
4
- * The chip frame (border, fill, shadow, padding, gap) is sourced from
5
- * the active {@link useTheme}. Authors can still override per-chip
6
- * colours via {@link ResourceDisplayConfig.bgColor} /
7
- * {@link ResourceDisplayConfig.iconColor} / {@link ResourceDisplayConfig.textColor}
8
- * (Tailwind class names) — those win over the theme defaults so existing
9
- * callers that pass game-specific palettes (Catan: wheat/wood/brick…)
10
- * keep their look.
11
- */
12
-
13
- import { motion, AnimatePresence } from "framer-motion";
14
- import { clsx } from "clsx";
15
- import { createElement, type ComponentType, type ReactNode } from "react";
1
+ import {
2
+ createContext,
3
+ createElement,
4
+ useContext,
5
+ useMemo,
6
+ type ComponentType,
7
+ type HTMLAttributes,
8
+ type ReactElement,
9
+ type ReactNode,
10
+ } from "react";
16
11
  import type { ResourceId } from "@dreamboard/manifest-contract";
17
12
  import {
18
- Tooltip,
19
- TooltipContent,
20
- TooltipProvider,
21
- TooltipTrigger,
22
- } from "../internal/ui/tooltip.js";
23
- import { useTheme } from "../theme/ThemeProvider.js";
24
-
25
- type CssVariableStyle = React.CSSProperties & {
26
- [K in `--${string}`]?: string | number;
27
- };
13
+ composeEventHandlers,
14
+ renderPrimitive,
15
+ type PrimitiveCommonProps,
16
+ } from "../primitives/index.js";
28
17
 
29
- export interface ResourceDisplayConfig {
30
- type: ResourceId;
18
+ export interface ResourceDisplayConfig<Resource extends string = ResourceId> {
19
+ type: Resource;
31
20
  label: string;
32
21
  icon:
33
22
  | ReactNode
34
23
  | ComponentType<{
35
24
  className?: string;
36
25
  strokeWidth?: number;
37
- "aria-hidden"?: string;
26
+ "aria-hidden"?: boolean | "true" | "false";
38
27
  }>;
39
28
  iconColor?: string;
40
29
  bgColor?: string;
41
30
  textColor?: string;
42
31
  }
43
32
 
44
- export interface ResourceCounterProps {
45
- resources: ResourceDisplayConfig[];
46
- counts: Record<ResourceId, number>;
47
- layout?: "row" | "grid" | "compact";
48
- /** Number of columns for grid layout */
49
- columns?: number;
50
- showZero?: boolean;
51
- size?: "sm" | "md" | "lg";
52
- onResourceClick?: (resourceType: ResourceId) => void;
33
+ export interface ResourceCounterItemState<
34
+ Resource extends string = ResourceId,
35
+ > {
36
+ type: Resource;
37
+ label: string;
38
+ icon: ResourceDisplayConfig<Resource>["icon"];
39
+ iconColor?: string;
40
+ bgColor?: string;
41
+ textColor?: string;
42
+ count: number;
43
+ isZero: boolean;
44
+ interactive: boolean;
45
+ select: () => void;
46
+ renderIcon: (props?: ResourceIconProps) => ReactNode;
47
+ dataAttributes: {
48
+ "data-resource-id": Resource;
49
+ "data-resource-count": number;
50
+ "data-resource-zero": boolean | undefined;
51
+ "data-interactive": boolean | undefined;
52
+ };
53
+ }
54
+
55
+ export interface ResourceIconProps {
53
56
  className?: string;
57
+ strokeWidth?: number;
58
+ "aria-hidden"?: boolean | "true" | "false";
54
59
  }
55
60
 
56
- export function ResourceCounter({
57
- resources,
58
- counts,
59
- layout = "row",
60
- columns = 5,
61
- showZero = true,
62
- size = "md",
63
- onResourceClick,
64
- className,
65
- }: ResourceCounterProps) {
66
- const theme = useTheme();
67
- const reducedMotion = theme.motion.reducedMotion === "true";
61
+ export type ResourceCounterRootProps<Resource extends string = ResourceId> =
62
+ Omit<PrimitiveCommonProps, "children"> &
63
+ Omit<HTMLAttributes<HTMLElement>, "children"> & {
64
+ resources: ReadonlyArray<ResourceDisplayConfig<Resource>>;
65
+ counts: Partial<Record<Resource, number>>;
66
+ zero?: "show" | "hide";
67
+ onResourceClick?: (resourceType: Resource) => void;
68
+ children: ReactNode;
69
+ };
68
70
 
69
- const sizeConfig = {
70
- sm: {
71
- icon: "w-4 h-4",
72
- fontSize: theme.typography.fontSize.sm,
73
- paddingBlock: theme.space[1],
74
- paddingInline: theme.space[2],
75
- gap: theme.space[1],
76
- },
77
- md: {
78
- icon: "w-5 h-5",
79
- fontSize: theme.typography.fontSize.md,
80
- paddingBlock: theme.space[1.5],
81
- paddingInline: theme.space[3],
82
- gap: theme.space[1.5],
83
- },
84
- lg: {
85
- icon: "w-6 h-6",
86
- fontSize: theme.typography.fontSize.lg,
87
- paddingBlock: theme.space[2],
88
- paddingInline: theme.space[4],
89
- gap: theme.space[2],
90
- },
91
- } as const;
71
+ export type BoundResourceCounterRootProps<
72
+ Resource extends string = ResourceId,
73
+ > = Omit<ResourceCounterRootProps<Resource>, "resources">;
92
74
 
93
- const styles = sizeConfig[size];
75
+ export type ResourceCounterProps<Resource extends string = ResourceId> =
76
+ ResourceCounterRootProps<Resource>;
94
77
 
95
- const filteredResources = showZero
96
- ? resources
97
- : resources.filter((r) => (counts[r.type] ?? 0) > 0);
78
+ export type ResourceCounterPartProps<Resource extends string = ResourceId> =
79
+ Omit<PrimitiveCommonProps, "children"> &
80
+ Omit<HTMLAttributes<HTMLElement>, "children"> & {
81
+ children?:
82
+ | ReactNode
83
+ | ((resource: ResourceCounterItemState<Resource>) => ReactNode);
84
+ };
98
85
 
99
- // Base chip styling pulled from the theme. Caller-supplied
100
- // `bgColor`/`textColor` (Tailwind classes) override these inline
101
- // defaults via the `style` cascade — when a Tailwind class sets
102
- // `background-color`, it wins because we don't pass `background`
103
- // when the caller provided a `bgColor` class.
104
- const baseChipStyle = (
105
- bgColorClass: string | undefined,
106
- ): React.CSSProperties => ({
107
- display: "inline-flex",
108
- alignItems: "center",
109
- gap: styles.gap,
110
- paddingBlock: styles.paddingBlock,
111
- paddingInline: styles.paddingInline,
112
- borderRadius: theme.radius.md,
113
- border: `2px solid ${theme.semantic.border.default}`,
114
- background: bgColorClass ? undefined : theme.semantic.surface.card,
115
- boxShadow: theme.elevation.rest,
116
- fontFamily: theme.typography.fontFamily.body,
117
- transition: `transform ${theme.motion.duration.fast} ${theme.motion.easing.out}, box-shadow ${theme.motion.duration.normal} ${theme.motion.easing.out}`,
118
- });
86
+ const ResourceCounterItemContext =
87
+ createContext<ResourceCounterItemState<string> | null>(null);
88
+
89
+ function useResourceCounterItemContext<Resource extends string>() {
90
+ const value = useContext(ResourceCounterItemContext);
91
+ if (!value) {
92
+ throw new Error(
93
+ "ResourceCounter item primitives must be rendered inside <ResourceCounter.Item>.",
94
+ );
95
+ }
96
+ return value as ResourceCounterItemState<Resource>;
97
+ }
119
98
 
99
+ function renderResourceIcon(
100
+ icon: ResourceDisplayConfig<string>["icon"],
101
+ props: ResourceIconProps = {},
102
+ ) {
103
+ if (typeof icon === "function") {
104
+ return createElement(icon, {
105
+ "aria-hidden": true,
106
+ strokeWidth: 2.5,
107
+ ...props,
108
+ });
109
+ }
110
+ const {
111
+ strokeWidth: _strokeWidth,
112
+ "aria-hidden": ariaHidden,
113
+ ...spanProps
114
+ } = props;
120
115
  return (
121
- <div
122
- className={clsx(
123
- layout === "grid" && "grid",
124
- layout === "row" && "flex flex-wrap",
125
- layout === "compact" && "flex flex-wrap",
126
- layout === "row" && "gap-3 sm:gap-4",
127
- layout === "compact" && "gap-2",
128
- layout === "grid" && "gap-3 sm:gap-4",
129
- className,
130
- )}
131
- style={
132
- layout === "grid"
133
- ? {
134
- gridTemplateColumns: `repeat(${columns}, 1fr)`,
135
- fontFamily: theme.typography.fontFamily.body,
136
- }
137
- : { fontFamily: theme.typography.fontFamily.body }
138
- }
139
- role="list"
140
- aria-label="Resource counts"
116
+ <span
117
+ aria-hidden={ariaHidden === undefined ? true : ariaHidden !== "false"}
118
+ {...spanProps}
141
119
  >
142
- <TooltipProvider delayDuration={200}>
143
- <AnimatePresence mode="popLayout">
144
- {filteredResources.map(
145
- ({ type, label, icon, iconColor, bgColor, textColor }) => {
146
- const count = counts[type] ?? 0;
147
- const iconNode =
148
- typeof icon === "function" ? (
149
- createElement(icon, {
150
- className: clsx(styles.icon, iconColor),
151
- strokeWidth: 2.5,
152
- "aria-hidden": "true",
153
- })
154
- ) : (
155
- <span
156
- className={clsx(styles.icon, iconColor)}
157
- aria-hidden="true"
158
- style={{
159
- display: "inline-flex",
160
- alignItems: "center",
161
- justifyContent: "center",
162
- fontSize: "1.1em",
163
- }}
164
- >
165
- {icon}
166
- </span>
167
- );
168
- const content = (
169
- <>
170
- {iconNode}
171
- <motion.span
172
- key={count}
173
- initial={reducedMotion ? { scale: 1 } : { scale: 1.5 }}
174
- animate={{ scale: 1 }}
175
- className={clsx("font-bold", textColor)}
176
- style={{
177
- fontSize: styles.fontSize,
178
- fontFamily: theme.typography.fontFamily.tabular,
179
- fontWeight: theme.typography.fontWeight.bold,
180
- color: textColor
181
- ? undefined
182
- : theme.semantic.text.primary,
183
- }}
184
- >
185
- {count}
186
- </motion.span>
187
- </>
188
- );
120
+ {icon}
121
+ </span>
122
+ );
123
+ }
189
124
 
190
- const sharedClassName = clsx(
191
- "flex items-center focus-visible:outline-none",
192
- bgColor,
193
- onResourceClick
194
- ? "cursor-pointer focus-visible:ring-2 focus-visible:ring-offset-2"
195
- : "cursor-help",
196
- );
125
+ function resolveResourceChildren<Resource extends string>(
126
+ children: ResourceCounterPartProps<Resource>["children"],
127
+ resource: ResourceCounterItemState<Resource>,
128
+ ) {
129
+ return typeof children === "function" ? children(resource) : children;
130
+ }
197
131
 
198
- const sharedStyle: React.CSSProperties = {
199
- ...baseChipStyle(bgColor),
200
- color: textColor ? undefined : theme.semantic.text.primary,
201
- };
132
+ export function ResourceCounterRoot<Resource extends string = ResourceId>({
133
+ resources,
134
+ counts,
135
+ zero = "show",
136
+ onResourceClick,
137
+ children,
138
+ "aria-label": ariaLabel,
139
+ ...props
140
+ }: ResourceCounterRootProps<Resource>) {
141
+ const items = useMemo(
142
+ () =>
143
+ resources
144
+ .map((resource) => {
145
+ const count = counts[resource.type] ?? 0;
146
+ return {
147
+ ...resource,
148
+ count,
149
+ isZero: count === 0,
150
+ interactive: Boolean(onResourceClick),
151
+ select: () => onResourceClick?.(resource.type),
152
+ renderIcon: (iconProps) =>
153
+ renderResourceIcon(resource.icon, iconProps),
154
+ dataAttributes: {
155
+ "data-resource-id": resource.type,
156
+ "data-resource-count": count,
157
+ "data-resource-zero": count === 0 || undefined,
158
+ "data-interactive": onResourceClick ? true : undefined,
159
+ },
160
+ } satisfies ResourceCounterItemState<Resource>;
161
+ })
162
+ .filter((resource) => zero === "show" || !resource.isZero),
163
+ [counts, onResourceClick, resources, zero],
164
+ );
202
165
 
203
- const focusVisibleStyle: CssVariableStyle = onResourceClick
204
- ? ({
205
- // Theming the focus ring requires a CSS variable hop
206
- // because Tailwind's focus-visible:ring-offset uses
207
- // `--tw-ring-offset-color`. We expose the theme
208
- // border.focus token here so consumers without our
209
- // Tailwind preset still get a visible ring.
210
- "--tw-ring-color": theme.semantic.border.focus,
211
- "--tw-ring-offset-color": theme.semantic.surface.app,
212
- } satisfies CssVariableStyle)
213
- : {};
166
+ return renderPrimitive("div", {
167
+ role: "list",
168
+ "aria-label": ariaLabel ?? "Resource counts",
169
+ "data-dreamboard-resource-counter": "",
170
+ ...props,
171
+ children: items.map((resource) => (
172
+ <ResourceCounterItemContext.Provider key={resource.type} value={resource}>
173
+ {children}
174
+ </ResourceCounterItemContext.Provider>
175
+ )),
176
+ });
177
+ }
214
178
 
215
- const chip = onResourceClick ? (
216
- <button
217
- type="button"
218
- onClick={() => onResourceClick(type)}
219
- className={sharedClassName}
220
- style={{ ...sharedStyle, ...focusVisibleStyle }}
221
- aria-label={`${label}: ${count}`}
222
- >
223
- {content}
224
- </button>
225
- ) : (
226
- <div
227
- className={sharedClassName}
228
- style={sharedStyle}
229
- aria-label={`${label}: ${count}`}
230
- >
231
- {content}
232
- </div>
233
- );
179
+ export function ResourceCounterItem<Resource extends string = ResourceId>({
180
+ children,
181
+ onClick,
182
+ "aria-label": ariaLabel,
183
+ ...props
184
+ }: ResourceCounterPartProps<Resource>) {
185
+ const resource = useResourceCounterItemContext<Resource>();
186
+ return renderPrimitive("span", {
187
+ role: "listitem",
188
+ "aria-label": ariaLabel ?? `${resource.label}: ${resource.count}`,
189
+ ...resource.dataAttributes,
190
+ ...props,
191
+ onClick: composeEventHandlers(
192
+ onClick,
193
+ resource.interactive ? resource.select : undefined,
194
+ ),
195
+ children: resolveResourceChildren(children, resource),
196
+ });
197
+ }
234
198
 
235
- return (
236
- <motion.div
237
- key={type}
238
- layout={!reducedMotion}
239
- initial={
240
- reducedMotion
241
- ? { opacity: 0, scale: 1 }
242
- : { opacity: 0, scale: 0.8 }
243
- }
244
- animate={{ opacity: 1, scale: 1 }}
245
- exit={{ opacity: 0, scale: 0.8 }}
246
- whileHover={
247
- onResourceClick && !reducedMotion
248
- ? { scale: 1.05 }
249
- : undefined
250
- }
251
- whileTap={
252
- onResourceClick && !reducedMotion
253
- ? { scale: 0.95 }
254
- : undefined
255
- }
256
- role="listitem"
257
- >
258
- <Tooltip>
259
- <TooltipTrigger asChild>{chip}</TooltipTrigger>
260
- <TooltipContent side="top" sideOffset={6}>
261
- <p className="font-sans text-sm font-bold">{label}</p>
262
- <p className="font-sans text-xs font-normal opacity-90">
263
- Quantity: {count}
264
- </p>
265
- </TooltipContent>
266
- </Tooltip>
267
- </motion.div>
268
- );
269
- },
270
- )}
271
- </AnimatePresence>
272
- </TooltipProvider>
273
- </div>
274
- );
199
+ export function ResourceCounterIcon<Resource extends string = ResourceId>({
200
+ className,
201
+ strokeWidth,
202
+ "aria-hidden": ariaHidden,
203
+ }: ResourceIconProps): ReactNode {
204
+ const resource = useResourceCounterItemContext<Resource>();
205
+ return resource.renderIcon({
206
+ className,
207
+ strokeWidth,
208
+ "aria-hidden": ariaHidden,
209
+ });
210
+ }
211
+
212
+ export function ResourceCounterCount<Resource extends string = ResourceId>({
213
+ children,
214
+ ...props
215
+ }: ResourceCounterPartProps<Resource>) {
216
+ const resource = useResourceCounterItemContext<Resource>();
217
+ return renderPrimitive("span", {
218
+ ...props,
219
+ "data-dreamboard-resource-count": "",
220
+ children: resolveResourceChildren(children ?? resource.count, resource),
221
+ });
222
+ }
223
+
224
+ export function ResourceCounterLabel<Resource extends string = ResourceId>({
225
+ children,
226
+ ...props
227
+ }: ResourceCounterPartProps<Resource>) {
228
+ const resource = useResourceCounterItemContext<Resource>();
229
+ return renderPrimitive("span", {
230
+ ...props,
231
+ "data-dreamboard-resource-label": "",
232
+ children: resolveResourceChildren(children ?? resource.label, resource),
233
+ });
234
+ }
235
+
236
+ export interface ResourceCounterComponents<
237
+ Resource extends string = ResourceId,
238
+ > {
239
+ Root(props: BoundResourceCounterRootProps<Resource>): ReactElement;
240
+ Item(props: ResourceCounterPartProps<Resource>): ReactElement;
241
+ Icon(props: ResourceIconProps): ReactNode;
242
+ Count(props: ResourceCounterPartProps<Resource>): ReactElement;
243
+ Label(props: ResourceCounterPartProps<Resource>): ReactElement;
244
+ }
245
+
246
+ export function createResourceCounter<Resource extends string>(
247
+ resources: ReadonlyArray<ResourceDisplayConfig<Resource>>,
248
+ ): ResourceCounterComponents<Resource> {
249
+ return {
250
+ Root(props) {
251
+ return createElement(ResourceCounterRoot<Resource>, {
252
+ ...props,
253
+ resources,
254
+ });
255
+ },
256
+ Item: ResourceCounterItem,
257
+ Icon: ResourceCounterIcon,
258
+ Count: ResourceCounterCount,
259
+ Label: ResourceCounterLabel,
260
+ } satisfies ResourceCounterComponents<Resource>;
275
261
  }
262
+
263
+ export const ResourceCounter = {
264
+ Root: ResourceCounterRoot,
265
+ Item: ResourceCounterItem,
266
+ Icon: ResourceCounterIcon,
267
+ Count: ResourceCounterCount,
268
+ Label: ResourceCounterLabel,
269
+ };
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Plugin-internal toast notification system.
3
3
  *
4
- * `<ToastProvider>` exposes a `useToast()` API that game authors call
5
- * imperatively to surface their own game-specific feedback ("Resource
6
- * gained", "Card discarded", "Tip: rotate the board with R", …). It
7
- * is intentionally NOT wired to the host notification stream:
4
+ * `<ToastProvider>` exposes `<Toast.Actions>` for game-specific feedback
5
+ * ("Resource gained", "Card discarded", "Tip: rotate the board with R", …).
6
+ * It is intentionally NOT wired to the host notification stream:
8
7
  * `YOUR_TURN`, `PROMPT_OPENED` and `ACTION_REJECTED` events are owned
9
8
  * by `@dreamboard/ui-host-runtime`'s `<HostFeedbackToaster>` and must
10
9
  * not be mirrored from inside the plugin tree.
@@ -27,7 +26,7 @@ import { ThemedButton } from "./ThemedButton.js";
27
26
 
28
27
  export type ToastType = "success" | "error" | "info" | "warning";
29
28
 
30
- export interface Toast {
29
+ export interface ToastNotification {
31
30
  id: string;
32
31
  type: ToastType;
33
32
  message: string;
@@ -35,7 +34,7 @@ export interface Toast {
35
34
  }
36
35
 
37
36
  interface ToastContextValue {
38
- toasts: Toast[];
37
+ toasts: ToastNotification[];
39
38
  show: (message: string, type?: ToastType, duration?: number) => void;
40
39
  dismiss: (id: string) => void;
41
40
  success: (message: string, duration?: number) => void;
@@ -46,17 +45,19 @@ interface ToastContextValue {
46
45
 
47
46
  const ToastContext = createContext<ToastContextValue | null>(null);
48
47
 
48
+ export type ToastActionsValue = ToastContextValue;
49
+
49
50
  export interface ToastProviderProps {
50
51
  children: ReactNode;
51
52
  }
52
53
 
53
54
  export function ToastProvider({ children }: ToastProviderProps) {
54
- const [toasts, setToasts] = useState<Toast[]>([]);
55
+ const [toasts, setToasts] = useState<ToastNotification[]>([]);
55
56
 
56
57
  const show = useCallback(
57
58
  (message: string, type: ToastType = "info", duration = 3000) => {
58
59
  const id = `toast-${Date.now()}-${Math.random()}`;
59
- const toast: Toast = { id, type, message, duration };
60
+ const toast: ToastNotification = { id, type, message, duration };
60
61
 
61
62
  setToasts((prev) => {
62
63
  // Dedup by `(type, message)` so a fast burst of identical
@@ -120,11 +121,23 @@ export function useToast() {
120
121
  return context;
121
122
  }
122
123
 
124
+ export interface ToastActionsProps {
125
+ children: (actions: ToastActionsValue) => ReactNode;
126
+ }
127
+
128
+ export function ToastActions({ children }: ToastActionsProps) {
129
+ return <>{children(useToast())}</>;
130
+ }
131
+
132
+ export const Toast = {
133
+ Actions: ToastActions,
134
+ } as const;
135
+
123
136
  function ToastContainer({
124
137
  toasts,
125
138
  onDismiss,
126
139
  }: {
127
- toasts: Toast[];
140
+ toasts: ToastNotification[];
128
141
  onDismiss: (id: string) => void;
129
142
  }) {
130
143
  return (
@@ -184,7 +197,7 @@ function ToastItem({
184
197
  toast,
185
198
  onDismiss,
186
199
  }: {
187
- toast: Toast;
200
+ toast: ToastNotification;
188
201
  onDismiss: (id: string) => void;
189
202
  }) {
190
203
  const theme = useTheme();