@caperjs/core 0.2.2 → 0.2.4

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 (140) hide show
  1. package/build/index.mjs +2 -0
  2. package/build/plugins/viewport.mjs +52 -0
  3. package/lib/{CaptionsPlugin-DeGyPecB.js → CaptionsPlugin-WPAcfAkx.js} +13 -12
  4. package/lib/CaptionsPlugin-WPAcfAkx.js.map +1 -0
  5. package/lib/{DataAdapter-DeI2kIxC.js → DataAdapter-DS-twowe.js} +11 -11
  6. package/lib/{DataAdapter-DeI2kIxC.js.map → DataAdapter-DS-twowe.js.map} +1 -1
  7. package/lib/{DebugRenderer-wjV0tMwL.js → DebugRenderer-D1ryrRYx.js} +2 -2
  8. package/lib/{DebugRenderer-wjV0tMwL.js.map → DebugRenderer-D1ryrRYx.js.map} +1 -1
  9. package/lib/{DevToolsPlugin-D4iWQwkO.js → DevToolsPlugin-DH7zX7HU.js} +2 -2
  10. package/lib/{DevToolsPlugin-D4iWQwkO.js.map → DevToolsPlugin-DH7zX7HU.js.map} +1 -1
  11. package/lib/{GSAPPlugin-DQTyIwcC.js → GSAPPlugin-BBtRjUqe.js} +2 -2
  12. package/lib/{GSAPPlugin-DQTyIwcC.js.map → GSAPPlugin-BBtRjUqe.js.map} +1 -1
  13. package/lib/{LayoutPlugin-vR_h-BaG.js → LayoutPlugin-Co6vcoV4.js} +4 -4
  14. package/lib/{LayoutPlugin-vR_h-BaG.js.map → LayoutPlugin-Co6vcoV4.js.map} +1 -1
  15. package/lib/{SpinePlugin-DJ-PpxFj.js → SpinePlugin-uN641Hmk.js} +2 -2
  16. package/lib/{SpinePlugin-DJ-PpxFj.js.map → SpinePlugin-uN641Hmk.js.map} +1 -1
  17. package/lib/{StatsPlugin-BYNnKYOm.js → StatsPlugin-vZQRm6wh.js} +4 -4
  18. package/lib/{StatsPlugin-BYNnKYOm.js.map → StatsPlugin-vZQRm6wh.js.map} +1 -1
  19. package/lib/{VoiceOverPlugin-BLSbq3DU.js → VoiceOverPlugin-KlZdGj1G.js} +32 -32
  20. package/lib/{VoiceOverPlugin-BLSbq3DU.js.map → VoiceOverPlugin-KlZdGj1G.js.map} +1 -1
  21. package/lib/caper.mjs +2112 -353
  22. package/lib/caper.mjs.map +1 -1
  23. package/lib/core/Application.d.ts +7 -1
  24. package/lib/core/Application.d.ts.map +1 -1
  25. package/lib/core/interfaces/IApplicationOptions.d.ts +1 -1
  26. package/lib/core/interfaces/IApplicationOptions.d.ts.map +1 -1
  27. package/lib/core/interfaces/ICoreFunctions.d.ts +1 -0
  28. package/lib/core/interfaces/ICoreFunctions.d.ts.map +1 -1
  29. package/lib/display/AnimatedSprite.d.ts +1 -1
  30. package/lib/display/AnimatedSprite.d.ts.map +1 -1
  31. package/lib/display/Container.d.ts.map +1 -1
  32. package/lib/display/SpineAnimation.d.ts +1 -1
  33. package/lib/display/SpineAnimation.d.ts.map +1 -1
  34. package/lib/importOrder.containerFirst.test.d.ts +2 -0
  35. package/lib/importOrder.containerFirst.test.d.ts.map +1 -0
  36. package/lib/importOrder.displayFirst.test.d.ts +2 -0
  37. package/lib/importOrder.displayFirst.test.d.ts.map +1 -0
  38. package/lib/importOrder.mixinsFirst.test.d.ts +2 -0
  39. package/lib/importOrder.mixinsFirst.test.d.ts.map +1 -0
  40. package/lib/importOrder.sceneFirst.test.d.ts +2 -0
  41. package/lib/importOrder.sceneFirst.test.d.ts.map +1 -0
  42. package/lib/importOrder.toastFirst.test.d.ts +2 -0
  43. package/lib/importOrder.toastFirst.test.d.ts.map +1 -0
  44. package/lib/importOrder.uiFirst.test.d.ts +2 -0
  45. package/lib/importOrder.uiFirst.test.d.ts.map +1 -0
  46. package/lib/mixins/factory/Factory.d.ts +1 -1
  47. package/lib/mixins/factory/Factory.d.ts.map +1 -1
  48. package/lib/mixins/factory/Factory.test.d.ts +1 -0
  49. package/lib/mixins/factory/Factory.test.d.ts.map +1 -0
  50. package/lib/mixins/factory/const.d.ts +9 -2
  51. package/lib/mixins/factory/const.d.ts.map +1 -1
  52. package/lib/mixins/factory/defaults.d.ts +5 -0
  53. package/lib/mixins/factory/defaults.d.ts.map +1 -0
  54. package/lib/mixins/factory/methods.d.ts +1 -1
  55. package/lib/mixins/factory/methods.d.ts.map +1 -1
  56. package/lib/mixins/factory/props.d.ts +4 -2
  57. package/lib/mixins/factory/props.d.ts.map +1 -1
  58. package/lib/plugins/PopupManagerPlugin.d.ts +1 -1
  59. package/lib/plugins/PopupManagerPlugin.d.ts.map +1 -1
  60. package/lib/plugins/ResizerPlugin.d.ts +29 -0
  61. package/lib/plugins/ResizerPlugin.d.ts.map +1 -1
  62. package/lib/plugins/ResizerPlugin.test.d.ts +2 -0
  63. package/lib/plugins/ResizerPlugin.test.d.ts.map +1 -0
  64. package/lib/plugins/WebEventsPlugin.d.ts +5 -0
  65. package/lib/plugins/WebEventsPlugin.d.ts.map +1 -1
  66. package/lib/plugins/WebEventsPlugin.test.d.ts +2 -0
  67. package/lib/plugins/WebEventsPlugin.test.d.ts.map +1 -0
  68. package/lib/plugins/captions/CaptionsRenderer.d.ts +1 -1
  69. package/lib/plugins/captions/CaptionsRenderer.d.ts.map +1 -1
  70. package/lib/plugins/i18nPlugin.d.ts +29 -3
  71. package/lib/plugins/i18nPlugin.d.ts.map +1 -1
  72. package/lib/plugins/i18nPlugin.test.d.ts +2 -0
  73. package/lib/plugins/i18nPlugin.test.d.ts.map +1 -0
  74. package/lib/plugins/input/touch/VirtualControls.d.ts.map +1 -1
  75. package/lib/plugins/spine/pixi-spine/require-shim.d.ts +0 -1
  76. package/lib/plugins/spine/pixi-spine/require-shim.d.ts.map +1 -1
  77. package/lib/{registries-aN0y0Xha.js → registries-BLrxALZ0.js} +1124 -2801
  78. package/lib/registries-BLrxALZ0.js.map +1 -0
  79. package/lib/ui/Button.d.ts +9 -2
  80. package/lib/ui/Button.d.ts.map +1 -1
  81. package/lib/ui/Button.test.d.ts +2 -0
  82. package/lib/ui/Button.test.d.ts.map +1 -0
  83. package/lib/ui/FlexContainer.d.ts +1 -1
  84. package/lib/ui/FlexContainer.d.ts.map +1 -1
  85. package/lib/ui/Input.d.ts +2 -2
  86. package/lib/ui/Input.d.ts.map +1 -1
  87. package/lib/ui/Joystick.d.ts +1 -1
  88. package/lib/ui/Joystick.d.ts.map +1 -1
  89. package/lib/ui/Toast.d.ts +1 -1
  90. package/lib/ui/Toast.d.ts.map +1 -1
  91. package/lib/ui/UICanvas.d.ts +11 -1
  92. package/lib/ui/UICanvas.d.ts.map +1 -1
  93. package/lib/ui/UICanvas.test.d.ts +2 -0
  94. package/lib/ui/UICanvas.test.d.ts.map +1 -0
  95. package/lib/utils/types.d.ts +1 -2
  96. package/lib/utils/types.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/core/Application.ts +15 -2
  99. package/src/core/interfaces/IApplicationOptions.ts +1 -1
  100. package/src/core/interfaces/ICoreFunctions.ts +1 -0
  101. package/src/display/AnimatedSprite.ts +2 -1
  102. package/src/display/Container.ts +3 -1
  103. package/src/display/SpineAnimation.ts +3 -2
  104. package/src/importOrder.containerFirst.test.ts +32 -0
  105. package/src/importOrder.displayFirst.test.ts +19 -0
  106. package/src/importOrder.mixinsFirst.test.ts +18 -0
  107. package/src/importOrder.sceneFirst.test.ts +21 -0
  108. package/src/importOrder.toastFirst.test.ts +20 -0
  109. package/src/importOrder.uiFirst.test.ts +24 -0
  110. package/src/mixins/factory/Factory.test.ts +22 -0
  111. package/src/mixins/factory/Factory.ts +9 -2
  112. package/src/mixins/factory/const.ts +18 -11
  113. package/src/mixins/factory/defaults.ts +28 -0
  114. package/src/mixins/factory/methods.ts +1 -1
  115. package/src/mixins/factory/props.ts +5 -3
  116. package/src/plugins/LookupPlugin.ts +1 -1
  117. package/src/plugins/PopupManagerPlugin.ts +1 -1
  118. package/src/plugins/ResizerPlugin.test.ts +84 -0
  119. package/src/plugins/ResizerPlugin.ts +59 -1
  120. package/src/plugins/WebEventsPlugin.test.ts +75 -0
  121. package/src/plugins/WebEventsPlugin.ts +15 -0
  122. package/src/plugins/captions/CaptionsRenderer.ts +1 -1
  123. package/src/plugins/i18nPlugin.test.ts +124 -0
  124. package/src/plugins/i18nPlugin.ts +57 -18
  125. package/src/plugins/input/keyboard/KeyboardContols.ts +1 -1
  126. package/src/plugins/input/touch/VirtualControls.ts +2 -2
  127. package/src/plugins/spine/pixi-spine/require-shim.ts +3 -1
  128. package/src/ui/Button.test.ts +236 -0
  129. package/src/ui/Button.ts +46 -11
  130. package/src/ui/FlexContainer.ts +3 -2
  131. package/src/ui/Input.ts +5 -2
  132. package/src/ui/Joystick.ts +1 -1
  133. package/src/ui/Toast.ts +2 -1
  134. package/src/ui/Toaster.ts +1 -1
  135. package/src/ui/UICanvas.test.ts +35 -0
  136. package/src/ui/UICanvas.ts +46 -8
  137. package/src/utils/types.ts +1 -1
  138. package/src/version.ts +1 -1
  139. package/lib/CaptionsPlugin-DeGyPecB.js.map +0 -1
  140. package/lib/registries-aN0y0Xha.js.map +0 -1
@@ -17,6 +17,17 @@ export type LocaleKey = AppTypeOverrides extends { LocaleKeys: infer K extends s
17
17
  ? K | (string & {})
18
18
  : string;
19
19
 
20
+ /**
21
+ * Base keys that have plural leaves (i.e. a `<base>.other` entry exists in the
22
+ * reference locale). These are the keys `tCount()` accepts.
23
+ *
24
+ * `(string & {})` preserves autocomplete for known keys while still
25
+ * accepting dynamic strings.
26
+ */
27
+ export type PluralLocaleKey = AppTypeOverrides extends { LocaleKeys: infer K extends string }
28
+ ? (K extends `${infer B}.other` ? B : never) | (string & {})
29
+ : string;
30
+
20
31
  /**
21
32
  * Type definition for i18n dictionary.
22
33
  */
@@ -73,6 +84,8 @@ export interface Ii18nPlugin extends IPlugin {
73
84
 
74
85
  t(key: LocaleKey, params?: i18nTParams, locale?: string): string;
75
86
 
87
+ tCount(key: PluralLocaleKey, count: number, params?: i18nTParams, locale?: string): string;
88
+
76
89
  parse(input: string, locale?: string): string;
77
90
  }
78
91
 
@@ -152,10 +165,13 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
152
165
 
153
166
  /**
154
167
  * Translates a key into a string.
155
- * If the key is not found, it will return an empty string.
168
+ * If the key is not found (or no dictionary is loaded), it returns the key itself, so a typo
169
+ * renders visibly on screen instead of blanking the UI.
156
170
  * If the key is found, it will replace any placeholders in the string with the values from the params object.
157
- * If the key contains a variant, it will select a random variant if the variant param is set to 'random'.
158
- * If the key contains a number variant, it will select the variant based on the variant param.
171
+ * Every `[a|b|c]` group in the string is resolved, whether or not params were passed:
172
+ * `variant: 'random'` picks a random item per group, `variant: <number>` picks that index for every
173
+ * group (clamped to the group's last item), and with no variant param index 0 is used. Brackets never
174
+ * survive into the output.
159
175
  * @param key The key to translate.
160
176
  * @param params The parameters to replace in the string.
161
177
  * @param locale The locale to use for translation.
@@ -166,7 +182,7 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
166
182
  const dict = this._dicts[locale];
167
183
  if (!dict) {
168
184
  Logger.error(`i18n:: No dictionary loaded for current locale: ${locale}`);
169
- return '';
185
+ return key as string;
170
186
  }
171
187
  // Dot-path resolution: `t('obj.nested')` walks the dict tree. Flat keys
172
188
  // still work (including keys that literally contain dots, via the
@@ -176,24 +192,26 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
176
192
 
177
193
  if (!str) {
178
194
  Logger.error(`i18n:: No result found for the key ${key} in the locale: ${this._locale}`);
179
- return '';
195
+ return key as string;
180
196
  }
181
197
 
182
- if (params) {
183
- if (typeof params.variant === 'number' || params.variant === 'random') {
184
- const match = /\[(.*?)\]/.exec(str);
185
- if (match) {
186
- // Split the string by the "|" character to get an array of variations.
187
- const items = match[1].split('|');
188
-
189
- // Get the selected variant based on the "variation" param.
190
- const num = params.variant === 'random' ? Math.floor(Math.random() * items.length) : params.variant;
198
+ // Resolve every `[a|b|c]` group, whether or not params were passed, so brackets
199
+ // never leak into the rendered string.
200
+ const variant = params?.variant;
201
+ str = str.replace(/\[(.*?)\]/g, (_match, group: string) => {
202
+ // Split the group by the "|" character to get an array of variations.
203
+ const items = group.split('|');
191
204
 
192
- // Replace the original string with the selected variant
193
- str = str.replace(match[0], items[num]);
194
- }
205
+ if (variant === 'random') {
206
+ return items[Math.floor(Math.random() * items.length)];
195
207
  }
196
208
 
209
+ // A number picks that index for every group, clamped to the group's last item.
210
+ const num = typeof variant === 'number' ? Math.min(Math.max(variant, 0), items.length - 1) : 0;
211
+ return items[num];
212
+ });
213
+
214
+ if (params) {
197
215
  // Iterate over all params to replace placeholders in the string.
198
216
  for (const f in params) {
199
217
  // Create a regular expression to match the placeholder for the current param.
@@ -221,6 +239,27 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
221
239
  return this.t(key, params, locale);
222
240
  }
223
241
 
242
+ /**
243
+ * Translates a pluralised key using the locale's own plural rules.
244
+ * The CLDR plural category for `count` (`one`, `other`, `few`, ...) is resolved against
245
+ * `<key>.<category>`, falling back to `<key>.other` when that leaf does not exist.
246
+ * `count` is passed through as a param, so `{count}` interpolates and variants still apply.
247
+ * @param key The base key holding the plural leaves.
248
+ * @param count The count used to pick the plural category.
249
+ * @param params Additional parameters to replace in the string.
250
+ * @param locale The locale to use for translation.
251
+ * @returns The translated string.
252
+ */
253
+ tCount(key: PluralLocaleKey, count: number, params?: i18nTParams, locale: string = this._locale): string {
254
+ const category = new Intl.PluralRules(locale).select(count);
255
+ const dict = this._dicts[locale];
256
+ let pluralKey = `${key}.${category}`;
257
+ if (!dict || typeof resolveLocalePath(dict, pluralKey) !== 'string') {
258
+ pluralKey = `${key}.other`;
259
+ }
260
+ return this.t(pluralKey, { count, ...params }, locale);
261
+ }
262
+
224
263
  /**
225
264
  * Parses the input string and replaces anything in between {} braces, assuming it is a key in the dictionary.
226
265
  * @param {string} input
@@ -263,7 +302,7 @@ export class i18nPlugin extends Plugin<i18nOptions> implements Ii18nPlugin {
263
302
  }
264
303
 
265
304
  protected getCoreFunctions(): string[] {
266
- return ['t', 'translate', 'setLocale'];
305
+ return ['t', 'translate', 'tCount', 'setLocale'];
267
306
  }
268
307
 
269
308
  protected getCoreSignals(): string[] {
@@ -1,5 +1,5 @@
1
1
  import { Application } from '../../../core/Application';
2
- import { WithSignals } from '../../../mixins';
2
+ import { WithSignals } from '../../../mixins/signals';
3
3
  import { bindAllMethods } from '../../../utils';
4
4
  import type { Action } from '../../actions';
5
5
  import { normalizeKey, type KeyboardEventDetail } from '../../KeyboardPlugin';
@@ -3,8 +3,8 @@ import type { ControlsActionMap, JoystickControlsScheme, TouchControlsMap, Touch
3
3
 
4
4
  import { JoystickDirection } from '..';
5
5
  import { Application } from '../../../core/Application';
6
- import { WithSignals } from '../../../mixins';
7
- import { type IButton, type IJoystick } from '../../../ui';
6
+ import { WithSignals } from '../../../mixins/signals';
7
+ import type { IButton, IJoystick } from '../../../ui';
8
8
  import type { Action } from '../../actions';
9
9
  import { AbstractControls } from '../AbstractControls';
10
10
 
@@ -28,7 +28,9 @@
28
28
  *****************************************************************************/
29
29
 
30
30
  declare global {
31
- var require: any;
31
+ // @types/node (present via typeRoots) already declares `require: Require`;
32
+ // redeclaring it as `any` is a TS2403 conflict, so declare only what node
33
+ // doesn't, and keep the shim's loose typing at the use sites below.
32
34
  var PIXI: any;
33
35
  }
34
36
 
@@ -0,0 +1,236 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // Button transitively imports Application → Pixi display graph. Stub it — these
4
+ // tests drive the press-state machine directly and never touch the app singleton.
5
+ vi.mock('../core/Application', () => ({
6
+ Application: { getInstance: () => ({}) },
7
+ }));
8
+
9
+ import { Button } from './Button';
10
+ import { Signal } from '../signals';
11
+
12
+ /**
13
+ * Builds a Button instance without running the real constructor (which drags in
14
+ * Factory/Interactive/Focusable mixin construction and a real Pixi Sprite). Only
15
+ * the fields the handlers under test actually touch are hand-initialized.
16
+ */
17
+ function makeButton() {
18
+ const button = Object.create(Button.prototype) as Button;
19
+
20
+ // Pixi's Container (via eventemitter3) normally initializes these in its own
21
+ // constructor, which this hand-rolled instance never runs; `on`/`off` throw
22
+ // without them.
23
+ (button as any)._events = Object.create(null);
24
+ (button as any)._eventsCount = 0;
25
+
26
+ (button as any).config = {
27
+ id: 'button',
28
+ textures: { default: '' },
29
+ sheet: undefined,
30
+ enabled: true,
31
+ cursor: 'default',
32
+ disabledCursor: 'not-allowed',
33
+ };
34
+
35
+ (button as any)._enabled = true;
36
+ button.isDown = false;
37
+ button.isOver = false;
38
+ (button as any)._pointerId = undefined;
39
+ (button as any).isKeyDown = false;
40
+ (button as any).cursor = 'default';
41
+ (button as any).focusEnabled = true;
42
+
43
+ (button as any).view = { texture: undefined };
44
+ (button as any).make = { texture: vi.fn(() => 'texture') };
45
+ (button as any)._isDownCallbacks = new Map();
46
+ (button as any)._isDownListenerAdded = false;
47
+
48
+ button.onDown = new Signal();
49
+ button.onUp = new Signal();
50
+ button.onUpOutside = new Signal();
51
+ button.onOut = new Signal();
52
+ button.onOver = new Signal();
53
+ button.onClick = new Signal();
54
+ button.onEnabled = new Signal();
55
+ button.onDisabled = new Signal();
56
+ button.onKeyboardEvent = new Signal();
57
+ button.onDestroy = new Signal();
58
+
59
+ return button;
60
+ }
61
+
62
+ describe('Button press-state machine', () => {
63
+ it('recovers from a press that is disabled mid-press', () => {
64
+ const button = makeButton();
65
+ const onDown = vi.fn();
66
+ button.onDown.connect(onDown);
67
+
68
+ (button as any).handlePointerDown({ pointerId: 1 });
69
+ expect(button.isDown).toBe(true);
70
+
71
+ button.enabled = false;
72
+ expect(button.isDown).toBe(false);
73
+ expect((button as any)._pointerId).toBeUndefined();
74
+
75
+ button.enabled = true;
76
+ (button as any).handlePointerDown({ pointerId: 2 });
77
+
78
+ expect(onDown).toHaveBeenCalledTimes(2);
79
+ expect(button.isDown).toBe(true);
80
+ });
81
+
82
+ it('clears press state on pointerupoutside while disabled and does not emit onUpOutside', () => {
83
+ const button = makeButton();
84
+ const onUpOutside = vi.fn();
85
+ button.onUpOutside.connect(onUpOutside);
86
+
87
+ // Arm a stale press, then disable without going through the enabled setter,
88
+ // to exercise the handler's own cleanup path directly.
89
+ button.isDown = true;
90
+ (button as any)._pointerId = 1;
91
+ (button as any)._enabled = false;
92
+
93
+ (button as any).handlePointerUpOutside({ pointerId: 1 });
94
+
95
+ expect(button.isDown).toBe(false);
96
+ expect((button as any)._pointerId).toBeUndefined();
97
+ expect(onUpOutside).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it('removes the window pointerup listener on destroy', () => {
101
+ const button = makeButton();
102
+ (button as any).handlePointerDown({ pointerId: 1 });
103
+
104
+ // Stub out the real mixin chain's destroy() (Focusable → Interactive →
105
+ // WithSignals → Factory → real Pixi Container.destroy) so this test isolates
106
+ // Button.destroy()'s own added cleanup without needing a fully-constructed
107
+ // Container underneath.
108
+ const superDestroy = vi
109
+ .spyOn(Object.getPrototypeOf(Button.prototype), 'destroy')
110
+ .mockImplementation(() => {});
111
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
112
+
113
+ button.destroy();
114
+
115
+ expect(removeSpy).toHaveBeenCalledWith('pointerup', (button as any).handlePointerUpOutside);
116
+ expect(superDestroy).toHaveBeenCalled();
117
+ expect(button.isDown).toBe(false);
118
+ expect((button as any)._pointerId).toBeUndefined();
119
+
120
+ removeSpy.mockRestore();
121
+ superDestroy.mockRestore();
122
+ });
123
+
124
+ it('does not click after a cancelled press (disabled then re-enabled)', () => {
125
+ const button = makeButton();
126
+ const onClick = vi.fn();
127
+ button.onClick.connect(onClick);
128
+
129
+ (button as any).handlePointerDown({ pointerId: 1 });
130
+ button.enabled = false;
131
+ button.enabled = true;
132
+
133
+ // A real pointer-driven click event carries the same pointerId as the press.
134
+ (button as any).handleClick({ pointerId: 1 });
135
+
136
+ expect(onClick).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('clicks normally after a press', () => {
140
+ const button = makeButton();
141
+ const onClick = vi.fn();
142
+ button.onClick.connect(onClick);
143
+
144
+ (button as any).handlePointerDown({ pointerId: 1 });
145
+ (button as any).handleClick({ pointerId: 1 });
146
+
147
+ expect(onClick).toHaveBeenCalledTimes(1);
148
+ });
149
+
150
+ it('still clicks for a synthetic keyboard activation (no pointerId)', () => {
151
+ const button = makeButton();
152
+ const onClick = vi.fn();
153
+ button.onClick.connect(onClick);
154
+
155
+ // Mimics FocusManagerPlugin's synthetic pointerdown on Enter/Space keydown.
156
+ (button as any).handlePointerDown({ type: 'pointerdown' });
157
+ (button as any).handleClick();
158
+
159
+ expect(onClick).toHaveBeenCalledTimes(1);
160
+ });
161
+
162
+ it('fires onClick exactly once for a single accessibility activation delivered as click+tap', () => {
163
+ const button = makeButton();
164
+ const onClick = vi.fn();
165
+ button.onClick.connect(onClick);
166
+
167
+ // AccessibilitySystem dispatches ONE shared FederatedEvent instance as both
168
+ // 'click' and 'tap' — Button connects both to handleClick.
169
+ const a11yEvent = { type: 'click' };
170
+ (button as any).handleClick(a11yEvent);
171
+ (button as any).handleClick(a11yEvent);
172
+
173
+ expect(onClick).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it('suppresses a cancelled pointer-driven click even without a prior press in this test', () => {
177
+ const button = makeButton();
178
+ const onClick = vi.fn();
179
+ button.onClick.connect(onClick);
180
+
181
+ // isDown is false (no press) — a click event carrying a pointerId must be treated
182
+ // as a cancelled press and suppressed.
183
+ (button as any).handleClick({ pointerId: 5 });
184
+
185
+ expect(onClick).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it('fires onClick for each of two distinct accessibility activations', () => {
189
+ const button = makeButton();
190
+ const onClick = vi.fn();
191
+ button.onClick.connect(onClick);
192
+
193
+ (button as any).handleClick({ type: 'click' });
194
+ (button as any).handleClick({ type: 'click' });
195
+
196
+ expect(onClick).toHaveBeenCalledTimes(2);
197
+ });
198
+
199
+ it('toggles the ticker listener on for the first isDown callback and off when the last is removed', () => {
200
+ const button = makeButton();
201
+ const add = vi.fn();
202
+ const remove = vi.fn();
203
+ Object.defineProperty(button, 'app', { value: { ticker: { add, remove } }, configurable: true });
204
+
205
+ button.addIsDownCallback('a', () => {});
206
+ button.addIsDownCallback('b', () => {});
207
+
208
+ expect(add).toHaveBeenCalledTimes(1);
209
+ expect(remove).not.toHaveBeenCalled();
210
+
211
+ button.removeIsDownCallback('a');
212
+ expect(remove).not.toHaveBeenCalled();
213
+
214
+ button.removeIsDownCallback('b');
215
+ expect(remove).toHaveBeenCalledTimes(1);
216
+ });
217
+
218
+ it('removes the ticker listener on destroy when one is active', () => {
219
+ const button = makeButton();
220
+ const add = vi.fn();
221
+ const remove = vi.fn();
222
+ Object.defineProperty(button, 'app', { value: { ticker: { add, remove } }, configurable: true });
223
+
224
+ button.addIsDownCallback('a', () => {});
225
+
226
+ const superDestroy = vi
227
+ .spyOn(Object.getPrototypeOf(Button.prototype), 'destroy')
228
+ .mockImplementation(() => {});
229
+
230
+ button.destroy();
231
+
232
+ expect(remove).toHaveBeenCalledTimes(1);
233
+
234
+ superDestroy.mockRestore();
235
+ });
236
+ });
package/src/ui/Button.ts CHANGED
@@ -12,7 +12,11 @@ import {
12
12
  import { type LayoutOptions } from '@pixi/layout';
13
13
  import type { IApplication } from '../core';
14
14
  import { Application } from '../core/Application';
15
- import { Factory, Focusable, HTMLTextProps, Interactive, type TextProps, WithSignals } from '../mixins';
15
+ import { Factory } from '../mixins/factory/Factory';
16
+ import type { HTMLTextProps, TextProps } from '../mixins/factory/props';
17
+ import { Focusable } from '../mixins/focus';
18
+ import { Interactive } from '../mixins/interaction';
19
+ import { WithSignals } from '../mixins/signals';
16
20
  import { Signal } from '../signals';
17
21
  import { AudioAsset, bindAllMethods, type SpritesheetAsset, type TextureAsset } from '../utils';
18
22
 
@@ -117,6 +121,7 @@ export class Button extends _Button implements IButton {
117
121
  private _isDownListenerAdded: boolean = false;
118
122
  private _pointerId?: number;
119
123
  private _textLabel?: Text | HTMLText | BitmapText;
124
+ private _lastClickEvent?: FederatedEvent;
120
125
 
121
126
  /**
122
127
  * The text label of the button.
@@ -186,6 +191,8 @@ export class Button extends _Button implements IButton {
186
191
  this.onInteraction('tap').connect(this.handleClick, -1),
187
192
  this.onInteraction('pointerdown').connect(this.handlePointerDown, -1),
188
193
  );
194
+
195
+ this.on('removed', this._resetPressState);
189
196
  }
190
197
 
191
198
  // enabled state
@@ -209,6 +216,7 @@ export class Button extends _Button implements IButton {
209
216
  });
210
217
  this.onEnabled.emit();
211
218
  } else {
219
+ this._resetPressState();
212
220
  this.view.texture = this.make.texture({
213
221
  asset: this.config.textures.disabled || this.config.textures.default,
214
222
  sheet: this.config.sheet ?? undefined,
@@ -217,6 +225,18 @@ export class Button extends _Button implements IButton {
217
225
  }
218
226
  }
219
227
 
228
+ /**
229
+ * Clears any in-progress press: removes the window-level release listener and
230
+ * resets press tracking. Called when the button is disabled, removed, or
231
+ * destroyed mid-press so a stale pointerId can't block future presses.
232
+ */
233
+ private _resetPressState() {
234
+ window.removeEventListener('pointerup', this.handlePointerUpOutside);
235
+ this.off('pointerupoutside', this.handlePointerUpOutside);
236
+ this.isDown = false;
237
+ this._pointerId = undefined;
238
+ }
239
+
220
240
  public addLabel<T extends Text | HTMLText | BitmapText>(
221
241
  config: (Partial<TextProps | HTMLTextProps> & { type?: 'text' | 'html' | 'bitmap' }) | T,
222
242
  ): T {
@@ -260,6 +280,11 @@ export class Button extends _Button implements IButton {
260
280
 
261
281
  destroy(options?: DestroyOptions) {
262
282
  this.onDestroy.emit();
283
+ this._resetPressState();
284
+ if (this._isDownListenerAdded) {
285
+ this.app.ticker.remove(this._handleIsDownCallbacks);
286
+ this._isDownListenerAdded = false;
287
+ }
263
288
  super.destroy(options);
264
289
  }
265
290
 
@@ -286,6 +311,7 @@ export class Button extends _Button implements IButton {
286
311
 
287
312
  removeIsDownCallback(callbackId: string) {
288
313
  this._isDownCallbacks.delete(callbackId);
314
+ this._checkIsDownCallbacks();
289
315
  }
290
316
 
291
317
  setTexture(textureId: ButtonTextureId, texture: TextureAsset) {
@@ -391,10 +417,20 @@ export class Button extends _Button implements IButton {
391
417
  this._pointerId = undefined;
392
418
  }
393
419
 
394
- protected handleClick() {
420
+ protected handleClick(e?: FederatedEvent) {
395
421
  if (!this._enabled) {
396
422
  return;
397
423
  }
424
+ const pointerId = (e as FederatedPointerEvent | undefined)?.pointerId;
425
+ if (pointerId !== undefined && !this.isDown) {
426
+ // a pointer press that was cancelled (e.g. disabled mid-press) must not click
427
+ return;
428
+ }
429
+ if (e !== undefined && e === this._lastClickEvent) {
430
+ // accessibility dispatches one event instance as both 'click' and 'tap'
431
+ return;
432
+ }
433
+ this._lastClickEvent = e;
398
434
  this.isDown = false;
399
435
  this.onClick.emit();
400
436
  if (this.config.sounds?.click) {
@@ -409,17 +445,16 @@ export class Button extends _Button implements IButton {
409
445
  * Handles the pointer up event.
410
446
  */
411
447
  protected handlePointerUpOutside(e: PointerEvent | FederatedPointerEvent) {
412
- if (!this._enabled || e.pointerId !== this._pointerId) {
448
+ if (e.pointerId !== this._pointerId) {
413
449
  return;
414
450
  }
415
- window.removeEventListener('pointerup', this.handlePointerUpOutside);
416
- this.off('pointerupoutside', this.handlePointerUpOutside);
417
- this.view.texture = this.make.texture({ asset: this.config.textures.default, sheet: this.config.sheet });
418
- this.isDown = false;
451
+ const wasEnabled = this._enabled;
452
+ this._resetPressState();
419
453
  this.isOver = false;
420
- this.onUpOutside.emit();
421
-
422
- this._pointerId = undefined;
454
+ if (wasEnabled) {
455
+ this.view.texture = this.make.texture({ asset: this.config.textures.default, sheet: this.config.sheet });
456
+ this.onUpOutside.emit();
457
+ }
423
458
  }
424
459
 
425
460
  private _doAction(action: ButtonActionOrCallback) {
@@ -438,7 +473,7 @@ export class Button extends _Button implements IButton {
438
473
  if (!this._isDownListenerAdded && this._isDownCallbacks.size > 0) {
439
474
  this._isDownListenerAdded = true;
440
475
  this.app.ticker.add(this._handleIsDownCallbacks);
441
- } else {
476
+ } else if (this._isDownListenerAdded && this._isDownCallbacks.size === 0) {
442
477
  this.app.ticker.remove(this._handleIsDownCallbacks);
443
478
  this._isDownListenerAdded = false;
444
479
  }
@@ -1,8 +1,9 @@
1
1
  import type { LayoutOptions, NumberValue } from '@pixi/layout';
2
2
  import { BitmapText, Graphics, Container as PIXIContainer, Text } from 'pixi.js';
3
3
  import { Application } from '../core/Application';
4
- import { Container } from '../display';
5
- import { Factory, WithSignals } from '../mixins';
4
+ import { Container } from '../display/Container';
5
+ import { Factory } from '../mixins/factory/Factory';
6
+ import { WithSignals } from '../mixins/signals';
6
7
  import { Signal } from '../signals';
7
8
  import {
8
9
  AppTypeOverrides,
package/src/ui/Input.ts CHANGED
@@ -11,7 +11,10 @@ import {
11
11
  Texture,
12
12
  } from 'pixi.js';
13
13
 
14
- import { Focusable, Interactive, TextProps, WithSignals } from '../mixins';
14
+ import type { TextProps } from '../mixins/factory/props';
15
+ import { Focusable } from '../mixins/focus';
16
+ import { Interactive } from '../mixins/interaction';
17
+ import { WithSignals } from '../mixins/signals';
15
18
 
16
19
  import {
17
20
  EaseString,
@@ -27,7 +30,7 @@ import {
27
30
  } from '../utils';
28
31
 
29
32
  import { gsap } from 'gsap';
30
- import { Container } from '../display';
33
+ import { Container } from '../display/Container';
31
34
  import { Signal } from '../signals';
32
35
 
33
36
  /**
@@ -4,7 +4,7 @@
4
4
  * ported to TypeScript and adapted for Caper
5
5
  */
6
6
  import { DestroyOptions, FederatedPointerEvent, Graphics, Point, Sprite } from 'pixi.js';
7
- import { Container } from '../display';
7
+ import { Container } from '../display/Container';
8
8
  import { JoystickDirection } from '../plugins';
9
9
  import { Signal } from '../signals';
10
10
 
package/src/ui/Toast.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { gsap } from 'gsap';
2
2
  import { Graphics, Text } from 'pixi.js';
3
3
  import { Container } from '../display/Container';
4
- import { type TextStyle, WithSignals } from '../mixins';
4
+ import type { TextStyle } from '../mixins/factory/props';
5
+ import { WithSignals } from '../mixins/signals';
5
6
  import { Signal } from '../signals';
6
7
  import { Button } from './Button';
7
8
  import { Toaster } from './Toaster';
package/src/ui/Toaster.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { gsap } from 'gsap';
2
2
  import { Container } from '../display/Container';
3
- import { WithSignals } from '../mixins';
3
+ import { WithSignals } from '../mixins/signals';
4
4
  import { Signal } from '../signals';
5
5
  import { defaultToastConfig, Toast, ToastConfig } from './Toast';
6
6
  import { UICanvasEdge } from './UICanvas';
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ // UICanvas transitively imports Application → Pixi display graph. Stub it — the
4
+ // pure padding helper needs none of it.
5
+ vi.mock('../core/Application', () => ({
6
+ Application: { getInstance: () => ({}) },
7
+ }));
8
+
9
+ import { computeEffectivePadding } from './UICanvas';
10
+
11
+ const zero = { top: 0, right: 0, bottom: 0, left: 0 };
12
+
13
+ describe('computeEffectivePadding', () => {
14
+ it('adds the safe area to the configured padding', () => {
15
+ expect(
16
+ computeEffectivePadding({ top: 10, right: 10, bottom: 10, left: 10 }, { ...zero, top: 44, bottom: 34 }),
17
+ ).toEqual({ top: 54, right: 10, bottom: 44, left: 10 });
18
+ });
19
+
20
+ it('returns the configured padding when there is no safe area', () => {
21
+ expect(computeEffectivePadding({ top: 10, right: 20, bottom: 30, left: 40 }, zero)).toEqual({
22
+ top: 10,
23
+ right: 20,
24
+ bottom: 30,
25
+ left: 40,
26
+ });
27
+ });
28
+
29
+ it('does not compound when applied repeatedly to the same base padding', () => {
30
+ const base = { top: 10, right: 10, bottom: 10, left: 10 };
31
+ const safeArea = { ...zero, top: 44 };
32
+ computeEffectivePadding(base, safeArea);
33
+ expect(computeEffectivePadding(base, safeArea).top).toBe(54);
34
+ });
35
+ });