@caperjs/core 0.2.3 → 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.
@@ -104,6 +104,7 @@ export declare class Button extends _Button implements IButton {
104
104
  private _isDownListenerAdded;
105
105
  private _pointerId?;
106
106
  private _textLabel?;
107
+ private _lastClickEvent?;
107
108
  /**
108
109
  * The text label of the button.
109
110
  * @returns {Text | HTMLText | BitmapText | undefined} The text label instance.
@@ -120,6 +121,12 @@ export declare class Button extends _Button implements IButton {
120
121
  * @param {boolean} enabled - Whether the button is enabled.
121
122
  */
122
123
  set enabled(enabled: boolean);
124
+ /**
125
+ * Clears any in-progress press: removes the window-level release listener and
126
+ * resets press tracking. Called when the button is disabled, removed, or
127
+ * destroyed mid-press so a stale pointerId can't block future presses.
128
+ */
129
+ private _resetPressState;
123
130
  addLabel<T extends Text | HTMLText | BitmapText>(config: (Partial<TextProps | HTMLTextProps> & {
124
131
  type?: 'text' | 'html' | 'bitmap';
125
132
  }) | T): T;
@@ -149,7 +156,7 @@ export declare class Button extends _Button implements IButton {
149
156
  * Removes the keyup event listener and emits the onPress and onUp events.
150
157
  */
151
158
  protected handlePointerUp(e: FederatedEvent): void;
152
- protected handleClick(): void;
159
+ protected handleClick(e?: FederatedEvent): void;
153
160
  /**
154
161
  * Handles the pointer up event.
155
162
  */
@@ -1 +1 @@
1
- {"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../src/ui/Button.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,MAAM,EACN,cAAc,EACd,cAAc,EACd,qBAAqB,EACrB,QAAQ,EACR,MAAM,EACN,IAAI,EACL,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAG5C,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAIxE,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACpC,OAAO,EAAE,UAAU,EAAkB,KAAK,gBAAgB,EAAE,KAAK,YAAY,EAAE,MAAM,UAAU,CAAC;AAEhG,MAAM,MAAM,cAAc,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;AAClE,MAAM,MAAM,YAAY,GAAG;IAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC;AAE/D,MAAM,MAAM,sBAAsB,GAAG,YAAY,GAAG,cAAc,CAAC;AAEnE,KAAK,eAAe,GAAG,SAAS,GAAG,OAAO,GAAG,QAAQ,GAAG,UAAU,CAAC;AAEnE,MAAM,WAAW,OAAO;IAEtB,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IACzB,WAAW,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAChC,KAAK,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC5B,SAAS,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC9B,UAAU,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC,MAAM,IAAI,CAAC,CAAC;IAC9B,eAAe,EAAE,MAAM,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC,CAAC;IAE/C,IAAI,EAAE,MAAM,CAAC;IAEb,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAEhB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE;QACR,OAAO,EAAE,YAAY,CAAC;QACtB,KAAK,CAAC,EAAE,YAAY,CAAC;QACrB,MAAM,CAAC,EAAE,YAAY,CAAC;QACtB,QAAQ,CAAC,EAAE,YAAY,CAAC;KACzB,CAAC;IACF,MAAM,CAAC,EAAE;QACP,KAAK,CAAC,EAAE,UAAU,CAAC;QACnB,GAAG,CAAC,EAAE,UAAU,CAAC;QACjB,IAAI,CAAC,EAAE,UAAU,CAAC;QAClB,KAAK,CAAC,EAAE,UAAU,CAAC;KACpB,CAAC;IACF,OAAO,CAAC,EAAE;QACR,KAAK,CAAC,EAAE,sBAAsB,CAAC;QAC/B,GAAG,CAAC,EAAE,sBAAsB,CAAC;QAC7B,IAAI,CAAC,EAAE,sBAAsB,CAAC;QAC9B,KAAK,CAAC,EAAE,sBAAsB,CAAC;KAChC,CAAC;IACF,SAAS,CAAC,EAAE,OAAO,CAAC,SAAS,GAAG,aAAa,CAAC,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;KAAE,CAAC;IACvF,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,gBAAgB,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,GAAG,IAAI,GAAG,OAAO,CAAC;CACzD,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,CAAC,MAAM,YAAY,CAAC,EAUlD,CAAC;AAGF,QAAA,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;uKAAiD,CAAC;AAE/D;;;;GAIG;AACH,qBAAa,MAAO,SAAQ,OAAQ,YAAW,OAAO;IAE7C,MAAM,eAAoB,IAAI,EAAI;IAClC,IAAI,eAAoB,IAAI,EAAI;IAChC,WAAW,eAAoB,IAAI,EAAI;IACvC,KAAK,eAAoB,IAAI,EAAI;IACjC,MAAM,eAAoB,IAAI,EAAI;IAClC,OAAO,eAAoB,IAAI,EAAI;IACnC,SAAS,eAAoB,IAAI,EAAI;IACrC,UAAU,eAAoB,IAAI,EAAI;IACtC,eAAe,eAAoB,MAAM,KAAK,IAAI,EAAI;IACtD,SAAS,eAAoB,IAAI,EAAI;IACrC,EAAE,EAAE,MAAM,CAAC;IAEX,IAAI,EAAE,MAAM,CAAC;IAEb,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAEvB,SAAS,CAAC,MAAM,EAAE,YAAY,CAAC;IAE/B,SAAS,CAAC,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC,CAAa;IAChE,OAAO,CAAC,oBAAoB,CAAkB;IAC9C,OAAO,CAAC,UAAU,CAAC,CAAS;IAC5B,OAAO,CAAC,UAAU,CAAC,CAA+B;IAElD;;;OAGG;IACH,IAAI,SAAS,6CAEZ;IAED;;;OAGG;gBACS,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC;IA2DzC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC;IAE5B;;;OAGG;IACH,IAAW,OAAO,CAAC,OAAO,EAAE,OAAO,EAoBlC;IAEM,QAAQ,CAAC,CAAC,SAAS,IAAI,GAAG,QAAQ,GAAG,UAAU,EACpD,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,GAAG,aAAa,CAAC,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;KAAE,CAAC,GAAG,CAAC,GACvF,CAAC;IAyBG,aAAa;IAMpB,IAAW,OAAO,IAvDU,OAAO,CAyDlC;IAED,IAAI,GAAG,IAAI,YAAY,CAEtB;IAED,OAAO,CAAC,OAAO,CAAC,EAAE,cAAc;IAKhC,QAAQ;IAMR,IAAI;IAMG,YAAY;IAInB,iBAAiB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI;IAK1D,oBAAoB,CAAC,UAAU,EAAE,MAAM;IAIvC,UAAU,CAAC,SAAS,EAAE,eAAe,EAAE,OAAO,EAAE,YAAY;IAY5D;;OAEG;IACH,SAAS,CAAC,iBAAiB;IAuB3B;;OAEG;IACH,SAAS,CAAC,gBAAgB;IAgB1B;;OAEG;IACH,SAAS,CAAC,iBAAiB,CAAC,CAAC,EAAE,cAAc;IA4B7C;;OAEG;IACH,SAAS,CAAC,eAAe,CAAC,CAAC,EAAE,cAAc;IAY3C,SAAS,CAAC,WAAW;IAcrB;;OAEG;IACH,SAAS,CAAC,sBAAsB,CAAC,CAAC,EAAE,YAAY,GAAG,qBAAqB;IAcxE,OAAO,CAAC,SAAS;IAWjB,OAAO,CAAC,qBAAqB;IAW7B,OAAO,CAAC,sBAAsB;CAO/B"}
1
+ {"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../src/ui/Button.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=Button.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Button.test.d.ts","sourceRoot":"","sources":["../../src/ui/Button.test.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caperjs/core",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "title": "Caper",
5
5
  "description": "An opinionated HTML game framework built on PixiJS v8",
6
6
  "bin": {
@@ -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
@@ -121,6 +121,7 @@ export class Button extends _Button implements IButton {
121
121
  private _isDownListenerAdded: boolean = false;
122
122
  private _pointerId?: number;
123
123
  private _textLabel?: Text | HTMLText | BitmapText;
124
+ private _lastClickEvent?: FederatedEvent;
124
125
 
125
126
  /**
126
127
  * The text label of the button.
@@ -190,6 +191,8 @@ export class Button extends _Button implements IButton {
190
191
  this.onInteraction('tap').connect(this.handleClick, -1),
191
192
  this.onInteraction('pointerdown').connect(this.handlePointerDown, -1),
192
193
  );
194
+
195
+ this.on('removed', this._resetPressState);
193
196
  }
194
197
 
195
198
  // enabled state
@@ -213,6 +216,7 @@ export class Button extends _Button implements IButton {
213
216
  });
214
217
  this.onEnabled.emit();
215
218
  } else {
219
+ this._resetPressState();
216
220
  this.view.texture = this.make.texture({
217
221
  asset: this.config.textures.disabled || this.config.textures.default,
218
222
  sheet: this.config.sheet ?? undefined,
@@ -221,6 +225,18 @@ export class Button extends _Button implements IButton {
221
225
  }
222
226
  }
223
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
+
224
240
  public addLabel<T extends Text | HTMLText | BitmapText>(
225
241
  config: (Partial<TextProps | HTMLTextProps> & { type?: 'text' | 'html' | 'bitmap' }) | T,
226
242
  ): T {
@@ -264,6 +280,11 @@ export class Button extends _Button implements IButton {
264
280
 
265
281
  destroy(options?: DestroyOptions) {
266
282
  this.onDestroy.emit();
283
+ this._resetPressState();
284
+ if (this._isDownListenerAdded) {
285
+ this.app.ticker.remove(this._handleIsDownCallbacks);
286
+ this._isDownListenerAdded = false;
287
+ }
267
288
  super.destroy(options);
268
289
  }
269
290
 
@@ -290,6 +311,7 @@ export class Button extends _Button implements IButton {
290
311
 
291
312
  removeIsDownCallback(callbackId: string) {
292
313
  this._isDownCallbacks.delete(callbackId);
314
+ this._checkIsDownCallbacks();
293
315
  }
294
316
 
295
317
  setTexture(textureId: ButtonTextureId, texture: TextureAsset) {
@@ -395,10 +417,20 @@ export class Button extends _Button implements IButton {
395
417
  this._pointerId = undefined;
396
418
  }
397
419
 
398
- protected handleClick() {
420
+ protected handleClick(e?: FederatedEvent) {
399
421
  if (!this._enabled) {
400
422
  return;
401
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;
402
434
  this.isDown = false;
403
435
  this.onClick.emit();
404
436
  if (this.config.sounds?.click) {
@@ -413,17 +445,16 @@ export class Button extends _Button implements IButton {
413
445
  * Handles the pointer up event.
414
446
  */
415
447
  protected handlePointerUpOutside(e: PointerEvent | FederatedPointerEvent) {
416
- if (!this._enabled || e.pointerId !== this._pointerId) {
448
+ if (e.pointerId !== this._pointerId) {
417
449
  return;
418
450
  }
419
- window.removeEventListener('pointerup', this.handlePointerUpOutside);
420
- this.off('pointerupoutside', this.handlePointerUpOutside);
421
- this.view.texture = this.make.texture({ asset: this.config.textures.default, sheet: this.config.sheet });
422
- this.isDown = false;
451
+ const wasEnabled = this._enabled;
452
+ this._resetPressState();
423
453
  this.isOver = false;
424
- this.onUpOutside.emit();
425
-
426
- 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
+ }
427
458
  }
428
459
 
429
460
  private _doAction(action: ButtonActionOrCallback) {
@@ -442,7 +473,7 @@ export class Button extends _Button implements IButton {
442
473
  if (!this._isDownListenerAdded && this._isDownCallbacks.size > 0) {
443
474
  this._isDownListenerAdded = true;
444
475
  this.app.ticker.add(this._handleIsDownCallbacks);
445
- } else {
476
+ } else if (this._isDownListenerAdded && this._isDownCallbacks.size === 0) {
446
477
  this.app.ticker.remove(this._handleIsDownCallbacks);
447
478
  this._isDownListenerAdded = false;
448
479
  }
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // This file is auto-generated during the build process.
2
- export const version: string = '0.2.3';
2
+ export const version: string = '0.2.4';
3
3
  export const pixiVersion: string = '8.19.0';