@mantou/gem 2.2.9 → 2.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,311 @@
1
+ import type { GemGestureElement, SwipeEventDetail } from '../../elements/gesture';
2
+ import { html } from '../../lib/element';
3
+ import { aTimeout, expect, fixture } from '../utils';
4
+
5
+ import '../../elements/gesture';
6
+
7
+ type Sample = [timeStamp: number, clientX: number, clientY?: number];
8
+
9
+ function pointer(type: string, [timeStamp, clientX, clientY = 0]: Sample, pointerId = 1) {
10
+ const event = new PointerEvent(type, {
11
+ bubbles: true,
12
+ pointerType: 'touch',
13
+ isPrimary: pointerId === 1,
14
+ pointerId,
15
+ clientX,
16
+ clientY,
17
+ });
18
+ // timeStamp 是只读属性,PointerEventInit 不接受它。
19
+ Object.defineProperty(event, 'timeStamp', { value: timeStamp });
20
+ return event;
21
+ }
22
+
23
+ async function setupGesture(touchAction = '') {
24
+ const el: GemGestureElement = await fixture(html`<gem-gesture touch-action=${touchAction}></gem-gesture>`);
25
+ const captured = new Set<number>();
26
+ el.setPointerCapture = (id) => captured.add(id);
27
+ el.releasePointerCapture = (id) => captured.delete(id);
28
+ el.hasPointerCapture = (id) => captured.has(id);
29
+ const swipes: SwipeEventDetail[] = [];
30
+ const events: string[] = [];
31
+ el.addEventListener('swipe', (evt: CustomEvent<SwipeEventDetail>) => {
32
+ swipes.push(evt.detail);
33
+ events.push('swipe');
34
+ });
35
+ el.addEventListener('end', () => events.push('end'));
36
+ const dispatch = (type: string, sample: Sample, id = 1) => el.dispatchEvent(pointer(type, sample, id));
37
+ return { el, swipes, events, dispatch };
38
+ }
39
+
40
+ describe('GemGestureElement swipe', () => {
41
+ for (const [direction, x, y] of [
42
+ ['right', 60, 0],
43
+ ['left', -60, 0],
44
+ ['bottom', 0, 60],
45
+ ['top', 0, -60],
46
+ ] as const) {
47
+ it(`emits ${direction} with release speed before end`, async () => {
48
+ const { el, swipes, events, dispatch } = await setupGesture();
49
+ dispatch('pointerdown', [0, 100, 100]);
50
+ dispatch('pointermove', [50, 100 + x, 100 + y]);
51
+ let movesAtEnd = 0;
52
+ el.addEventListener('end', () => {
53
+ movesAtEnd = el.movesMap.get(1)!.length;
54
+ expect(el.grabbing).to.equal(false);
55
+ });
56
+ dispatch('pointerup', [60, 100 + x, 100 + y]);
57
+ await Promise.resolve();
58
+ expect(swipes).to.deep.equal([{ direction, speed: 1 }]);
59
+ expect(events).to.deep.equal(['swipe', 'end']);
60
+ expect(movesAtEnd).to.equal(1);
61
+ await Promise.resolve();
62
+ expect(el.movesMap.size).to.equal(0);
63
+ });
64
+ }
65
+
66
+ for (const frequency of [30, 60, 120, 240]) {
67
+ it(`samples the same release velocity at ${frequency} Hz`, async () => {
68
+ const { swipes, dispatch } = await setupGesture();
69
+ dispatch('pointerdown', [0, 0]);
70
+ for (let time = 1000 / frequency; time < 250; time += 1000 / frequency) {
71
+ dispatch('pointermove', [time, time <= 100 ? time * 0.2 : 20 + (time - 100) * 0.8]);
72
+ }
73
+ dispatch('pointerup', [250, 140]);
74
+ await Promise.resolve();
75
+ expect(swipes).to.have.lengthOf(1);
76
+ expect(swipes[0].direction).to.equal('right');
77
+ expect(swipes[0].speed).to.be.closeTo(0.8, 0.000001);
78
+ });
79
+ }
80
+
81
+ it('interpolates the window boundary instead of including stale movement', async () => {
82
+ const { swipes, dispatch } = await setupGesture();
83
+ dispatch('pointerdown', [0, 0]);
84
+ dispatch('pointermove', [50, 5]);
85
+ dispatch('pointermove', [150, 105]);
86
+ dispatch('pointerup', [180, 135]);
87
+ await Promise.resolve();
88
+ expect(swipes).to.deep.equal([{ direction: 'right', speed: 1 }]);
89
+ });
90
+
91
+ it('includes movement delivered only on pointerup', async () => {
92
+ const { swipes, dispatch } = await setupGesture();
93
+ dispatch('pointerdown', [0, 0]);
94
+ dispatch('pointerup', [50, 50]);
95
+ await Promise.resolve();
96
+ expect(swipes).to.deep.equal([{ direction: 'right', speed: 1 }]);
97
+ });
98
+
99
+ it('reduces velocity when the finger pauses briefly before release', async () => {
100
+ const { swipes, dispatch } = await setupGesture();
101
+ dispatch('pointerdown', [0, 0]);
102
+ dispatch('pointermove', [40, 40]);
103
+ dispatch('pointerup', [80, 40]);
104
+ await Promise.resolve();
105
+ expect(swipes).to.deep.equal([{ direction: 'right', speed: 0.5 }]);
106
+ });
107
+
108
+ for (const [name, samples] of [
109
+ [
110
+ 'slow drag',
111
+ [
112
+ [0, 0],
113
+ [150, 30],
114
+ [180, 35],
115
+ ],
116
+ ],
117
+ [
118
+ 'tap jitter',
119
+ [
120
+ [0, 0],
121
+ [10, 8],
122
+ [20, 7],
123
+ ],
124
+ ],
125
+ [
126
+ 'paused drag',
127
+ [
128
+ [0, 0],
129
+ [40, 80],
130
+ [140, 80],
131
+ ],
132
+ ],
133
+ [
134
+ 'diagonal without a dominant axis',
135
+ [
136
+ [0, 0],
137
+ [40, 40, 40],
138
+ [50, 50, 50],
139
+ ],
140
+ ],
141
+ [
142
+ 'zero duration',
143
+ [
144
+ [0, 0],
145
+ [0, 50],
146
+ [0, 80],
147
+ ],
148
+ ],
149
+ [
150
+ 'short reversal',
151
+ [
152
+ [0, 0],
153
+ [30, 100],
154
+ [60, 85],
155
+ ],
156
+ ],
157
+ ] satisfies [string, Sample[]][]) {
158
+ it(`does not swipe on ${name}`, async () => {
159
+ const { swipes, dispatch } = await setupGesture();
160
+ dispatch('pointerdown', samples[0]);
161
+ dispatch('pointermove', samples[1]);
162
+ dispatch('pointerup', samples[2]);
163
+ await Promise.resolve();
164
+ expect(swipes).to.deep.equal([]);
165
+ });
166
+ }
167
+
168
+ it('uses the final flick direction after reversing a drag', async () => {
169
+ const { swipes, dispatch } = await setupGesture();
170
+ dispatch('pointerdown', [0, 0]);
171
+ dispatch('pointermove', [40, 100]);
172
+ dispatch('pointermove', [60, 80]);
173
+ dispatch('pointerup', [80, 60]);
174
+ await Promise.resolve();
175
+ expect(swipes).to.deep.equal([{ direction: 'left', speed: 1 }]);
176
+ });
177
+
178
+ it('ignores a small backwards jitter at release', async () => {
179
+ const { swipes, dispatch } = await setupGesture();
180
+ dispatch('pointerdown', [0, 0]);
181
+ dispatch('pointermove', [50, 60]);
182
+ dispatch('pointerup', [60, 58]);
183
+ await Promise.resolve();
184
+ expect(swipes).to.have.lengthOf(1);
185
+ expect(swipes[0].direction).to.equal('right');
186
+ expect(swipes[0].speed).to.be.closeTo(58 / 60, 0.000001);
187
+ });
188
+
189
+ for (const [touchAction, x, y] of [
190
+ ['pan-x', 50, 0],
191
+ ['pan-y', 0, 50],
192
+ ['pan-right', 50, 0],
193
+ ['pan-left', -50, 0],
194
+ ['pan-down', 0, 50],
195
+ ['pan-up', 0, -50],
196
+ ] as const) {
197
+ it(`respects touch-action=${touchAction}`, async () => {
198
+ const { swipes, dispatch } = await setupGesture(touchAction);
199
+ dispatch('pointerdown', [0, 0]);
200
+ dispatch('pointermove', [40, x, y]);
201
+ dispatch('pointerup', [50, x, y]);
202
+ await Promise.resolve();
203
+ expect(swipes).to.deep.equal([]);
204
+ });
205
+ }
206
+
207
+ it('allows horizontal flicks with touch-action=pan-y', async () => {
208
+ const { swipes, dispatch } = await setupGesture('pan-y');
209
+ dispatch('pointerdown', [0, 0]);
210
+ dispatch('pointermove', [40, 50, 2]);
211
+ dispatch('pointerup', [50, 50, 2]);
212
+ await Promise.resolve();
213
+ expect(swipes).to.deep.equal([{ direction: 'right', speed: 1 }]);
214
+ });
215
+
216
+ for (const type of ['pointercancel', 'pointerleave']) {
217
+ it(`does not swipe on ${type} or duplicate end events`, async () => {
218
+ const { swipes, events, dispatch } = await setupGesture();
219
+ dispatch('pointerdown', [0, 0]);
220
+ dispatch('pointermove', [40, 50]);
221
+ dispatch(type, [50, 50]);
222
+ dispatch('pointerup', [50, 50]);
223
+ await Promise.resolve();
224
+ expect(swipes).to.deep.equal([]);
225
+ expect(events).to.deep.equal(['end']);
226
+ });
227
+ }
228
+
229
+ it('ignores end events from pointers outside the gesture', async () => {
230
+ const { el, events, dispatch } = await setupGesture();
231
+ dispatch('pointerdown', [0, 0]);
232
+ dispatch('pointerleave', [10, 0], 2);
233
+ expect(events).to.deep.equal([]);
234
+ expect(el.grabbing).to.equal(true);
235
+ dispatch('pointercancel', [20, 0]);
236
+ });
237
+
238
+ it('does not swipe after losing pointer capture', async () => {
239
+ const { el, swipes, dispatch } = await setupGesture();
240
+ dispatch('pointerdown', [0, 0]);
241
+ dispatch('pointermove', [40, 50]);
242
+ el.releasePointerCapture(1);
243
+ dispatch('pointerup', [50, 50]);
244
+ await Promise.resolve();
245
+ expect(swipes).to.deep.equal([]);
246
+ });
247
+
248
+ it('suppresses both fingers of a multitouch gesture and resets for the next gesture', async () => {
249
+ const { el, swipes, events, dispatch } = await setupGesture();
250
+ dispatch('pointerdown', [0, 0]);
251
+ dispatch('pointerdown', [10, 100], 2);
252
+ dispatch('pointermove', [30, 30]);
253
+ dispatch('pointerup', [40, 40]);
254
+ await Promise.resolve();
255
+ expect(el.grabbing).to.equal(true);
256
+ expect(events).to.deep.equal([]);
257
+ await Promise.resolve();
258
+ dispatch('pointermove', [60, 150], 2);
259
+ dispatch('pointerup', [80, 170], 2);
260
+ await Promise.resolve();
261
+ expect(swipes).to.deep.equal([]);
262
+ expect(events).to.deep.equal(['end']);
263
+ dispatch('pointerdown', [100, 0]);
264
+ dispatch('pointermove', [140, 50]);
265
+ dispatch('pointerup', [150, 50]);
266
+ await Promise.resolve();
267
+ expect(swipes).to.deep.equal([{ direction: 'right', speed: 1 }]);
268
+ });
269
+
270
+ it('does not swipe after a long press', async () => {
271
+ const { el, swipes, dispatch } = await setupGesture();
272
+ const pressed = new Promise((resolve) => el.addEventListener('press', resolve, { once: true }));
273
+ dispatch('pointerdown', [0, 0]);
274
+ await pressed;
275
+ dispatch('pointermove', [300, 0]);
276
+ dispatch('pointermove', [340, 60]);
277
+ dispatch('pointerup', [350, 60]);
278
+ await Promise.resolve();
279
+ expect(swipes).to.deep.equal([]);
280
+ });
281
+
282
+ it('handles coalesced samples and the empty-list fallback', async () => {
283
+ const { el, swipes, dispatch } = await setupGesture();
284
+ dispatch('pointerdown', [0, 0]);
285
+ const coalesced = pointer('pointermove', [50, 50]);
286
+ Object.defineProperty(coalesced, 'getCoalescedEvents', {
287
+ value: () => [pointer('pointermove', [25, 25]), pointer('pointermove', [50, 50])],
288
+ });
289
+ el.dispatchEvent(coalesced);
290
+ const empty = pointer('pointermove', [75, 75]);
291
+ Object.defineProperty(empty, 'getCoalescedEvents', { value: () => [] });
292
+ el.dispatchEvent(empty);
293
+ expect(el.movesMap.get(1)).to.have.lengthOf(3);
294
+ dispatch('pointerup', [100, 100]);
295
+ await Promise.resolve();
296
+ expect(swipes).to.deep.equal([{ direction: 'right', speed: 1 }]);
297
+ });
298
+
299
+ it('preserves a new gesture started by an end handler', async () => {
300
+ const { el, dispatch } = await setupGesture();
301
+ el.addEventListener('end', () => dispatch('pointerdown', [100, 0]), { once: true });
302
+ dispatch('pointerdown', [0, 0]);
303
+ dispatch('pointermove', [40, 50]);
304
+ dispatch('pointerup', [50, 50]);
305
+ await Promise.resolve();
306
+ await aTimeout();
307
+ expect(el.grabbing).to.equal(true);
308
+ expect(el.movesMap.has(1)).to.equal(true);
309
+ dispatch('pointercancel', [110, 0]);
310
+ });
311
+ });
@@ -1,3 +1,4 @@
1
+ import { createDecoratorTheme } from '../../helper/theme';
1
2
  import type { Emitter } from '../../lib/decorators';
2
3
  import {
3
4
  adoptedStyle,
@@ -18,6 +19,51 @@ import { css, GemElement, html } from '../../lib/element';
18
19
  import { createStore } from '../../lib/store';
19
20
  import { expect, fixture } from '../utils';
20
21
 
22
+ const elemTheme = createDecoratorTheme({ color: 'rgb(255, 0, 0)' });
23
+
24
+ const themeDemoStyle = css`
25
+ :host {
26
+ color: ${elemTheme.color};
27
+ }
28
+ `;
29
+
30
+ @customElement('decorator-theme-demo')
31
+ @adoptedStyle(themeDemoStyle)
32
+ @shadow()
33
+ class DecoratorThemeDemo extends GemElement {
34
+ @boolattribute active: boolean;
35
+
36
+ @elemTheme((i) => [i.active])
37
+ #theme = () => ({
38
+ color: this.active ? 'rgb(0, 0, 255)' : 'rgb(255, 0, 0)',
39
+ });
40
+
41
+ render() {
42
+ return html`Theme Demo`;
43
+ }
44
+ }
45
+
46
+ const lightThemeDemoStyle = css`
47
+ :scope {
48
+ color: ${elemTheme.color};
49
+ }
50
+ `;
51
+
52
+ @customElement('decorator-theme-light-demo')
53
+ @adoptedStyle(lightThemeDemoStyle)
54
+ class DecoratorThemeLightDemo extends GemElement {
55
+ @boolattribute active: boolean;
56
+
57
+ @elemTheme((i) => [i.active])
58
+ #theme = () => ({
59
+ color: this.active ? 'rgb(0, 0, 255)' : 'rgb(255, 0, 0)',
60
+ });
61
+
62
+ render() {
63
+ return html`<span>Light Theme Demo</span>`;
64
+ }
65
+ }
66
+
21
67
  const store = createStore({
22
68
  a: 1,
23
69
  });
@@ -106,4 +152,25 @@ describe('装饰器', () => {
106
152
  expect(el.renderCount).to.equal(3);
107
153
  expect(a).to.equal(2);
108
154
  });
155
+
156
+ it('createDecoratorTheme 响应式更新及多实例隔离', async () => {
157
+ const el1: DecoratorThemeDemo = await fixture(html`<decorator-theme-demo></decorator-theme-demo>`);
158
+ const el2: DecoratorThemeDemo = await fixture(html`<decorator-theme-demo active></decorator-theme-demo>`);
159
+
160
+ expect(getComputedStyle(el1).color).to.equal('rgb(255, 0, 0)');
161
+ expect(getComputedStyle(el2).color).to.equal('rgb(0, 0, 255)');
162
+
163
+ el1.active = true;
164
+ await Promise.resolve();
165
+ expect(getComputedStyle(el1).color).to.equal('rgb(0, 0, 255)');
166
+ expect(getComputedStyle(el2).color).to.equal('rgb(0, 0, 255)');
167
+
168
+ const elLight: DecoratorThemeLightDemo = await fixture(
169
+ html`<decorator-theme-light-demo></decorator-theme-light-demo>`,
170
+ );
171
+ expect(getComputedStyle(elLight).color).to.equal('rgb(255, 0, 0)');
172
+ elLight.active = true;
173
+ await Promise.resolve();
174
+ expect(getComputedStyle(elLight).color).to.equal('rgb(0, 0, 255)');
175
+ });
109
176
  });
@@ -1,17 +1,20 @@
1
1
  import { history } from '../lib/history';
2
- import { aTimeout, expect } from './utils';
2
+ import { aTimeout, expect, nextFrame, waitForPopState } from './utils';
3
3
 
4
4
  describe('history 测试', () => {
5
+ afterEach(() => {
6
+ history.basePath = '';
7
+ });
8
+
5
9
  it('hash', async () => {
10
+ // 等待浏览器就绪
11
+ await nextFrame();
6
12
  const historyLength = window.history.length;
7
13
  const hash = window.location.hash;
8
- // 等待浏览器就绪
9
- await aTimeout(1000);
10
14
  history.push({ path: '/a', hash: '#a' });
11
15
  await aTimeout(10);
12
16
  expect(window.location.hash).to.equal('#a');
13
- history.back();
14
- await aTimeout(10);
17
+ await waitForPopState(() => history.back());
15
18
  expect(window.location.hash).to.equal(hash);
16
19
  history.push({ path: '/a', hash: '#b' });
17
20
  await aTimeout(10);
@@ -80,12 +83,10 @@ describe('history 测试', () => {
80
83
  await aTimeout(10);
81
84
  expect(() => history.push({ data: { $key: 1 } })).to.throw();
82
85
  // 关闭 modal
83
- history.back();
84
- await aTimeout(10);
86
+ await waitForPopState(() => history.back());
85
87
  expect(state.open).to.false;
86
88
  // 打开 modal
87
- history.forward();
88
- await aTimeout(10);
89
+ await waitForPopState(() => history.forward());
89
90
  // 替换 close
90
91
  history.replace({ close, open });
91
92
  await aTimeout(10);
@@ -97,11 +98,9 @@ describe('history 测试', () => {
97
98
  await aTimeout(10);
98
99
  expect(window.location.pathname).to.equal('/b');
99
100
  // 返回首页
100
- history.back();
101
- await aTimeout(10);
101
+ await waitForPopState(() => history.back());
102
102
  expect(state.open).to.be.false;
103
- history.forward();
104
- await aTimeout(10);
103
+ await waitForPopState(() => history.forward());
105
104
  expect(window.location.pathname).to.equal('/b');
106
105
  // 点击链接
107
106
  history.pushIgnoreCloseHandle({ path: '/c' });
package/src/test/utils.ts CHANGED
@@ -3,7 +3,7 @@ import { assert, expect, should } from 'chai';
3
3
  import type { TemplateResult } from '../lib/element';
4
4
  import { render } from '../lib/element';
5
5
 
6
- export { expect, should, assert };
6
+ export { assert, expect, should };
7
7
 
8
8
  export function nextFrame() {
9
9
  return new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
@@ -20,3 +20,41 @@ export async function fixture(template: TemplateResult) {
20
20
  await nextFrame();
21
21
  return wrapper.firstElementChild as any;
22
22
  }
23
+
24
+ export function waitForPopState(action?: () => void, timeout = 2000): Promise<PopStateEvent> {
25
+ return new Promise((resolve, reject) => {
26
+ let timer: ReturnType<typeof setTimeout>;
27
+ const handler = (event: PopStateEvent) => {
28
+ clearTimeout(timer);
29
+ resolve(event);
30
+ };
31
+ window.addEventListener('popstate', handler, { once: true });
32
+ timer = setTimeout(() => {
33
+ window.removeEventListener('popstate', handler);
34
+ reject(new Error(`waitForPopState timed out after ${timeout}ms`));
35
+ }, timeout);
36
+ action?.();
37
+ });
38
+ }
39
+
40
+ export function waitUntil(predicate: () => boolean | unknown, timeout = 2000, message?: string): Promise<void> {
41
+ const start = Date.now();
42
+ return new Promise((resolve, reject) => {
43
+ function check() {
44
+ try {
45
+ if (predicate()) {
46
+ resolve();
47
+ return;
48
+ }
49
+ } catch {
50
+ // ignore errors during evaluation
51
+ }
52
+ if (Date.now() - start > timeout) {
53
+ reject(new Error(message || `waitUntil timed out after ${timeout}ms`));
54
+ return;
55
+ }
56
+ setTimeout(check, 10);
57
+ }
58
+ check();
59
+ });
60
+ }