@flighthq/haptics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/haptics.d.ts +15 -0
- package/dist/haptics.d.ts.map +1 -0
- package/dist/haptics.js +133 -0
- package/dist/haptics.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/haptics.test.ts +340 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { HapticImpactStyle, HapticNotificationType, HapticsBackend, HapticsCapabilities } from '@flighthq/types';
|
|
2
|
+
export declare function cancelDeviceVibration(): boolean;
|
|
3
|
+
export declare function createWebHapticsBackend(): HapticsBackend;
|
|
4
|
+
export declare function getHapticsBackend(): HapticsBackend;
|
|
5
|
+
export declare function getHapticsCapabilities(out: HapticsCapabilities): HapticsCapabilities;
|
|
6
|
+
export declare function isHapticsSupported(): boolean;
|
|
7
|
+
export declare function prepareHaptics(): void;
|
|
8
|
+
export declare function setHapticsBackend(backend: HapticsBackend | null): void;
|
|
9
|
+
export declare function triggerHapticImpact(style: HapticImpactStyle, intensity?: number): boolean;
|
|
10
|
+
export declare function triggerHapticNotification(type: HapticNotificationType): boolean;
|
|
11
|
+
export declare function triggerHapticSelection(): boolean;
|
|
12
|
+
export declare function vibrateDevice(durationMs: number): boolean;
|
|
13
|
+
export declare function vibrateDevicePattern(pattern: Readonly<number[]>): boolean;
|
|
14
|
+
export declare function vibrateDeviceWaveform(timings: Readonly<number[]>, amplitudes: Readonly<number[]>, repeat?: number): boolean;
|
|
15
|
+
//# sourceMappingURL=haptics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"haptics.d.ts","sourceRoot":"","sources":["../src/haptics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAGtH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C;AAKD,wBAAgB,uBAAuB,IAAI,cAAc,CA8CxD;AAGD,wBAAgB,iBAAiB,IAAI,cAAc,CAGlD;AAGD,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,mBAAmB,GAAG,mBAAmB,CAEpF;AAGD,wBAAgB,kBAAkB,IAAI,OAAO,CAE5C;AAID,wBAAgB,cAAc,IAAI,IAAI,CAErC;AAGD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI,GAAG,IAAI,CAEtE;AAID,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,iBAAiB,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAIzF;AAID,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAE/E;AAGD,wBAAgB,sBAAsB,IAAI,OAAO,CAEhD;AAGD,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAEzD;AAID,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,GAAG,OAAO,CAGzE;AAOD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,EAC3B,UAAU,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,EAC9B,MAAM,SAAK,GACV,OAAO,CAOT"}
|
package/dist/haptics.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Cancels any in-progress device vibration. Returns false when haptics are unavailable.
|
|
2
|
+
export function cancelDeviceVibration() {
|
|
3
|
+
return getHapticsBackend().cancel();
|
|
4
|
+
}
|
|
5
|
+
// Builds the default web backend over navigator.vibrate. Every method returns false when the Vibration
|
|
6
|
+
// API is absent (jsdom, desktop browsers) or the call fails, rather than throwing. Web vibration is a
|
|
7
|
+
// coarse approximation of native haptics: it can only buzz the motor for a duration/pattern.
|
|
8
|
+
export function createWebHapticsBackend() {
|
|
9
|
+
return {
|
|
10
|
+
cancel() {
|
|
11
|
+
return webVibrate(0);
|
|
12
|
+
},
|
|
13
|
+
capabilities(out) {
|
|
14
|
+
const supported = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
|
|
15
|
+
out.amplitudeControl = false;
|
|
16
|
+
out.customEvents = false;
|
|
17
|
+
out.intensity = false;
|
|
18
|
+
out.patterns = supported;
|
|
19
|
+
out.supported = supported;
|
|
20
|
+
return out;
|
|
21
|
+
},
|
|
22
|
+
impact(style, intensity) {
|
|
23
|
+
const base = style === 'heavy' || style === 'rigid' ? 30 : style === 'medium' ? 20 : style === 'soft' ? 25 : 10;
|
|
24
|
+
const ms = intensity !== undefined ? Math.round(base * Math.max(0, Math.min(1, intensity))) : base;
|
|
25
|
+
return webVibrate(ms);
|
|
26
|
+
},
|
|
27
|
+
isSupported() {
|
|
28
|
+
return typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
|
|
29
|
+
},
|
|
30
|
+
notification(type) {
|
|
31
|
+
const pattern = type === 'error' ? [20, 60, 20] : type === 'warning' ? [20, 60, 20, 60] : [15, 50, 15];
|
|
32
|
+
return webVibrate(pattern);
|
|
33
|
+
},
|
|
34
|
+
prepare() {
|
|
35
|
+
// No-op on web; native backends may pre-allocate feedback generators to reduce latency.
|
|
36
|
+
},
|
|
37
|
+
selection() {
|
|
38
|
+
return webVibrate(5);
|
|
39
|
+
},
|
|
40
|
+
vibrate(durationMs) {
|
|
41
|
+
return webVibrate(durationMs);
|
|
42
|
+
},
|
|
43
|
+
vibratePattern(pattern) {
|
|
44
|
+
if (pattern.length === 0)
|
|
45
|
+
return false;
|
|
46
|
+
return webVibrate(pattern);
|
|
47
|
+
},
|
|
48
|
+
vibrateWaveform(timings, _amplitudes, repeat) {
|
|
49
|
+
// Web backend ignores amplitudes; falls back to vibratePattern. repeat is unsupported on web.
|
|
50
|
+
if (timings.length === 0)
|
|
51
|
+
return false;
|
|
52
|
+
void repeat;
|
|
53
|
+
return webVibrate(timings);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// The active haptics backend, or a lazily-created web default. There is always a backend.
|
|
58
|
+
export function getHapticsBackend() {
|
|
59
|
+
if (_backend === null)
|
|
60
|
+
_backend = createWebHapticsBackend();
|
|
61
|
+
return _backend;
|
|
62
|
+
}
|
|
63
|
+
// Fills out with the capabilities of the active HapticsBackend. Returns the same out object.
|
|
64
|
+
export function getHapticsCapabilities(out) {
|
|
65
|
+
return getHapticsBackend().capabilities(out);
|
|
66
|
+
}
|
|
67
|
+
// Returns true if the active backend reports that haptics are available on the current device.
|
|
68
|
+
export function isHapticsSupported() {
|
|
69
|
+
return getHapticsBackend().isSupported();
|
|
70
|
+
}
|
|
71
|
+
// Warm-up hint to reduce first-trigger latency (mirrors UIFeedbackGenerator.prepare). No-op on
|
|
72
|
+
// backends that do not support pre-allocation. Always safe to call.
|
|
73
|
+
export function prepareHaptics() {
|
|
74
|
+
getHapticsBackend().prepare?.();
|
|
75
|
+
}
|
|
76
|
+
// Installs a native host haptics backend; pass null to fall back to the web default.
|
|
77
|
+
export function setHapticsBackend(backend) {
|
|
78
|
+
_backend = backend;
|
|
79
|
+
}
|
|
80
|
+
// Triggers a physical impact at the given style, with optional continuous intensity (0..1).
|
|
81
|
+
// Intensity defaults to 1 when omitted. Returns false when haptics are unavailable.
|
|
82
|
+
export function triggerHapticImpact(style, intensity) {
|
|
83
|
+
// Resolve the documented default here so every backend receives a concrete intensity; a native
|
|
84
|
+
// backend must not have to reinterpret `undefined` as full strength on its own.
|
|
85
|
+
return getHapticsBackend().impact(style, intensity ?? 1);
|
|
86
|
+
}
|
|
87
|
+
// Triggers a semantic notification cue ('success' | 'warning' | 'error').
|
|
88
|
+
// Returns false when haptics are unavailable.
|
|
89
|
+
export function triggerHapticNotification(type) {
|
|
90
|
+
return getHapticsBackend().notification(type);
|
|
91
|
+
}
|
|
92
|
+
// Triggers a light selection tick. Returns false when haptics are unavailable.
|
|
93
|
+
export function triggerHapticSelection() {
|
|
94
|
+
return getHapticsBackend().selection();
|
|
95
|
+
}
|
|
96
|
+
// Vibrates the device for the given duration in milliseconds. Returns false when unavailable or denied.
|
|
97
|
+
export function vibrateDevice(durationMs) {
|
|
98
|
+
return getHapticsBackend().vibrate(durationMs);
|
|
99
|
+
}
|
|
100
|
+
// Vibrates the device using a pattern array [onMs, offMs, onMs, ...] matching the Web Vibration API.
|
|
101
|
+
// Returns false on empty pattern or when haptics are unavailable.
|
|
102
|
+
export function vibrateDevicePattern(pattern) {
|
|
103
|
+
if (pattern.length === 0)
|
|
104
|
+
return false;
|
|
105
|
+
return getHapticsBackend().vibratePattern(pattern);
|
|
106
|
+
}
|
|
107
|
+
// Vibrates using an amplitude-aware waveform (maps to Android VibrationEffect.createWaveform).
|
|
108
|
+
// timings and amplitudes must have equal length; amplitudes are 0..255 (per Android convention).
|
|
109
|
+
// repeat is the index into timings at which to loop, or -1 (default) for no repeat.
|
|
110
|
+
// Backends that lack native waveform support fall back to vibratePattern(timings).
|
|
111
|
+
// Returns false on empty timings or when haptics are unavailable.
|
|
112
|
+
export function vibrateDeviceWaveform(timings, amplitudes, repeat = -1) {
|
|
113
|
+
const backend = getHapticsBackend();
|
|
114
|
+
if (timings.length === 0)
|
|
115
|
+
return false;
|
|
116
|
+
if (backend.vibrateWaveform !== undefined) {
|
|
117
|
+
return backend.vibrateWaveform(timings, amplitudes, repeat);
|
|
118
|
+
}
|
|
119
|
+
return backend.vibratePattern(timings);
|
|
120
|
+
}
|
|
121
|
+
let _backend = null;
|
|
122
|
+
function webVibrate(pattern) {
|
|
123
|
+
if (typeof navigator === 'undefined' || !('vibrate' in navigator) || typeof navigator.vibrate !== 'function') {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
return navigator.vibrate(pattern);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=haptics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"haptics.js","sourceRoot":"","sources":["../src/haptics.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flighthq/haptics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"src/**/*.test.ts",
|
|
16
|
+
"!dist/**/*.test.js",
|
|
17
|
+
"!dist/**/*.test.d.ts",
|
|
18
|
+
"!dist/**/*.test.js.map",
|
|
19
|
+
"!dist/**/*.test.d.ts.map"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -b",
|
|
23
|
+
"clean": "tsc -b --clean",
|
|
24
|
+
"test": "vitest run --config vitest.config.ts",
|
|
25
|
+
"test:watch": "vitest --watch --config vitest.config.ts",
|
|
26
|
+
"prepack": "npm run clean && npm run clean:dist && npm run build",
|
|
27
|
+
"clean:dist": "tsx ../../scripts/clean-package-dist.ts"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@flighthq/types": "0.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.3.0"
|
|
34
|
+
},
|
|
35
|
+
"description": "Haptic feedback (vibrate, impact, notification, selection) over a swappable web/native backend",
|
|
36
|
+
"sideEffects": false
|
|
37
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { HapticImpactStyle, HapticNotificationType, HapticsBackend, HapticsCapabilities } from '@flighthq/types';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
cancelDeviceVibration,
|
|
5
|
+
createWebHapticsBackend,
|
|
6
|
+
getHapticsBackend,
|
|
7
|
+
getHapticsCapabilities,
|
|
8
|
+
isHapticsSupported,
|
|
9
|
+
prepareHaptics,
|
|
10
|
+
setHapticsBackend,
|
|
11
|
+
triggerHapticImpact,
|
|
12
|
+
triggerHapticNotification,
|
|
13
|
+
triggerHapticSelection,
|
|
14
|
+
vibrateDevice,
|
|
15
|
+
vibrateDevicePattern,
|
|
16
|
+
vibrateDeviceWaveform,
|
|
17
|
+
} from './haptics';
|
|
18
|
+
|
|
19
|
+
function makeCapabilities(overrides: Partial<HapticsCapabilities> = {}): HapticsCapabilities {
|
|
20
|
+
return {
|
|
21
|
+
amplitudeControl: false,
|
|
22
|
+
customEvents: false,
|
|
23
|
+
intensity: false,
|
|
24
|
+
patterns: false,
|
|
25
|
+
supported: false,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fakeBackend(): HapticsBackend & {
|
|
31
|
+
cancelCount: number;
|
|
32
|
+
duration: number;
|
|
33
|
+
lastPattern: Readonly<number[]> | null;
|
|
34
|
+
lastWaveform: { amplitudes: Readonly<number[]>; repeat: number | undefined; timings: Readonly<number[]> } | null;
|
|
35
|
+
prepared: number;
|
|
36
|
+
style: HapticImpactStyle | null;
|
|
37
|
+
styleIntensity: number | undefined;
|
|
38
|
+
type: HapticNotificationType | null;
|
|
39
|
+
selections: number;
|
|
40
|
+
} {
|
|
41
|
+
return {
|
|
42
|
+
cancelCount: 0,
|
|
43
|
+
duration: -1,
|
|
44
|
+
lastPattern: null,
|
|
45
|
+
lastWaveform: null,
|
|
46
|
+
prepared: 0,
|
|
47
|
+
style: null,
|
|
48
|
+
styleIntensity: undefined,
|
|
49
|
+
type: null,
|
|
50
|
+
selections: 0,
|
|
51
|
+
cancel() {
|
|
52
|
+
this.cancelCount += 1;
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
capabilities(out: HapticsCapabilities): HapticsCapabilities {
|
|
56
|
+
out.amplitudeControl = true;
|
|
57
|
+
out.customEvents = false;
|
|
58
|
+
out.intensity = true;
|
|
59
|
+
out.patterns = true;
|
|
60
|
+
out.supported = true;
|
|
61
|
+
return out;
|
|
62
|
+
},
|
|
63
|
+
impact(style: HapticImpactStyle, intensity?: number) {
|
|
64
|
+
this.style = style;
|
|
65
|
+
this.styleIntensity = intensity;
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
isSupported() {
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
notification(type: HapticNotificationType) {
|
|
72
|
+
this.type = type;
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
prepare() {
|
|
76
|
+
this.prepared += 1;
|
|
77
|
+
},
|
|
78
|
+
selection() {
|
|
79
|
+
this.selections += 1;
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
vibrate(durationMs: number) {
|
|
83
|
+
this.duration = durationMs;
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
86
|
+
vibratePattern(pattern: Readonly<number[]>) {
|
|
87
|
+
this.lastPattern = pattern;
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
vibrateWaveform(timings: Readonly<number[]>, amplitudes: Readonly<number[]>, repeat?: number) {
|
|
91
|
+
this.lastWaveform = { amplitudes, repeat, timings };
|
|
92
|
+
return true;
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
afterEach(() => setHapticsBackend(null));
|
|
98
|
+
|
|
99
|
+
describe('cancelDeviceVibration', () => {
|
|
100
|
+
it('forwards to backend cancel', () => {
|
|
101
|
+
const backend = fakeBackend();
|
|
102
|
+
setHapticsBackend(backend);
|
|
103
|
+
expect(cancelDeviceVibration()).toBe(true);
|
|
104
|
+
expect(backend.cancelCount).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns false when haptics unavailable (jsdom)', () => {
|
|
108
|
+
expect(cancelDeviceVibration()).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('createWebHapticsBackend', () => {
|
|
113
|
+
it('returns false for every method without throwing when vibrate is unavailable (jsdom)', () => {
|
|
114
|
+
const backend = createWebHapticsBackend();
|
|
115
|
+
expect(backend.vibrate(10)).toBe(false);
|
|
116
|
+
expect(backend.impact('heavy')).toBe(false);
|
|
117
|
+
expect(backend.impact('rigid')).toBe(false);
|
|
118
|
+
expect(backend.impact('soft')).toBe(false);
|
|
119
|
+
expect(backend.notification('error')).toBe(false);
|
|
120
|
+
expect(backend.selection()).toBe(false);
|
|
121
|
+
expect(backend.vibratePattern([100, 50, 100])).toBe(false);
|
|
122
|
+
expect(backend.cancel()).toBe(false);
|
|
123
|
+
expect(backend.isSupported()).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns false for empty pattern without throwing', () => {
|
|
127
|
+
const backend = createWebHapticsBackend();
|
|
128
|
+
expect(backend.vibratePattern([])).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('returns false for waveform with empty timings', () => {
|
|
132
|
+
const backend = createWebHapticsBackend();
|
|
133
|
+
expect(backend.vibrateWaveform!([], [], undefined)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('fills capabilities for web (no vibrate)', () => {
|
|
137
|
+
const backend = createWebHapticsBackend();
|
|
138
|
+
const out = makeCapabilities();
|
|
139
|
+
backend.capabilities(out);
|
|
140
|
+
expect(out.supported).toBe(false);
|
|
141
|
+
expect(out.patterns).toBe(false);
|
|
142
|
+
expect(out.intensity).toBe(false);
|
|
143
|
+
expect(out.amplitudeControl).toBe(false);
|
|
144
|
+
expect(out.customEvents).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('prepare is a no-op and does not throw', () => {
|
|
148
|
+
const backend = createWebHapticsBackend();
|
|
149
|
+
expect(() => backend.prepare!()).not.toThrow();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('getHapticsBackend', () => {
|
|
154
|
+
it('falls back to a web backend', () => {
|
|
155
|
+
expect(getHapticsBackend()).not.toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns the registered backend', () => {
|
|
159
|
+
const backend = fakeBackend();
|
|
160
|
+
setHapticsBackend(backend);
|
|
161
|
+
expect(getHapticsBackend()).toBe(backend);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('getHapticsCapabilities', () => {
|
|
166
|
+
it('fills out via the fake backend', () => {
|
|
167
|
+
const backend = fakeBackend();
|
|
168
|
+
setHapticsBackend(backend);
|
|
169
|
+
const out = makeCapabilities();
|
|
170
|
+
const result = getHapticsCapabilities(out);
|
|
171
|
+
expect(result).toBe(out);
|
|
172
|
+
expect(out.supported).toBe(true);
|
|
173
|
+
expect(out.intensity).toBe(true);
|
|
174
|
+
expect(out.patterns).toBe(true);
|
|
175
|
+
expect(out.amplitudeControl).toBe(true);
|
|
176
|
+
expect(out.customEvents).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('fills out from web backend when no backend is set', () => {
|
|
180
|
+
const out = makeCapabilities();
|
|
181
|
+
getHapticsCapabilities(out);
|
|
182
|
+
expect(out.supported).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('isHapticsSupported', () => {
|
|
187
|
+
it('returns true from fake backend', () => {
|
|
188
|
+
setHapticsBackend(fakeBackend());
|
|
189
|
+
expect(isHapticsSupported()).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('returns false when vibrate is unavailable (jsdom)', () => {
|
|
193
|
+
expect(isHapticsSupported()).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('prepareHaptics', () => {
|
|
198
|
+
it('calls prepare on backends that provide it', () => {
|
|
199
|
+
const backend = fakeBackend();
|
|
200
|
+
setHapticsBackend(backend);
|
|
201
|
+
prepareHaptics();
|
|
202
|
+
expect(backend.prepared).toBe(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('does not throw when backend has no prepare method', () => {
|
|
206
|
+
const backend = fakeBackend();
|
|
207
|
+
// Simulate a backend with no optional prepare
|
|
208
|
+
(backend as HapticsBackend & { prepare?: () => void }).prepare = undefined;
|
|
209
|
+
setHapticsBackend(backend);
|
|
210
|
+
expect(() => prepareHaptics()).not.toThrow();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('setHapticsBackend', () => {
|
|
215
|
+
it('clears back to the web fallback when passed null', () => {
|
|
216
|
+
setHapticsBackend(fakeBackend());
|
|
217
|
+
setHapticsBackend(null);
|
|
218
|
+
expect(getHapticsBackend()).not.toBeNull();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('triggerHapticImpact', () => {
|
|
223
|
+
it('forwards style to the active backend and defaults intensity to 1', () => {
|
|
224
|
+
const backend = fakeBackend();
|
|
225
|
+
setHapticsBackend(backend);
|
|
226
|
+
expect(triggerHapticImpact('medium')).toBe(true);
|
|
227
|
+
expect(backend.style).toBe('medium');
|
|
228
|
+
expect(backend.styleIntensity).toBe(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('forwards intensity to the backend', () => {
|
|
232
|
+
const backend = fakeBackend();
|
|
233
|
+
setHapticsBackend(backend);
|
|
234
|
+
triggerHapticImpact('heavy', 0.5);
|
|
235
|
+
expect(backend.style).toBe('heavy');
|
|
236
|
+
expect(backend.styleIntensity).toBe(0.5);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('supports rigid and soft styles', () => {
|
|
240
|
+
const backend = fakeBackend();
|
|
241
|
+
setHapticsBackend(backend);
|
|
242
|
+
triggerHapticImpact('rigid');
|
|
243
|
+
expect(backend.style).toBe('rigid');
|
|
244
|
+
triggerHapticImpact('soft');
|
|
245
|
+
expect(backend.style).toBe('soft');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('web backend clamps intensity to 0..1', () => {
|
|
249
|
+
const webBackend = createWebHapticsBackend();
|
|
250
|
+
// Should not throw on out-of-range intensity even if vibrate returns false
|
|
251
|
+
expect(() => webBackend.impact('heavy', 2)).not.toThrow();
|
|
252
|
+
expect(() => webBackend.impact('heavy', -0.5)).not.toThrow();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('triggerHapticNotification', () => {
|
|
257
|
+
it('forwards type to the active backend', () => {
|
|
258
|
+
const backend = fakeBackend();
|
|
259
|
+
setHapticsBackend(backend);
|
|
260
|
+
expect(triggerHapticNotification('success')).toBe(true);
|
|
261
|
+
expect(backend.type).toBe('success');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('triggerHapticSelection', () => {
|
|
266
|
+
it('triggers a selection on the active backend', () => {
|
|
267
|
+
const backend = fakeBackend();
|
|
268
|
+
setHapticsBackend(backend);
|
|
269
|
+
expect(triggerHapticSelection()).toBe(true);
|
|
270
|
+
expect(backend.selections).toBe(1);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('vibrateDevice', () => {
|
|
275
|
+
it('forwards duration to the active backend', () => {
|
|
276
|
+
const backend = fakeBackend();
|
|
277
|
+
setHapticsBackend(backend);
|
|
278
|
+
expect(vibrateDevice(42)).toBe(true);
|
|
279
|
+
expect(backend.duration).toBe(42);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('vibrateDevicePattern', () => {
|
|
284
|
+
it('forwards pattern to the active backend', () => {
|
|
285
|
+
const backend = fakeBackend();
|
|
286
|
+
setHapticsBackend(backend);
|
|
287
|
+
const pattern = [100, 50, 100] as const;
|
|
288
|
+
expect(vibrateDevicePattern(pattern)).toBe(true);
|
|
289
|
+
expect(backend.lastPattern).toEqual(pattern);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('returns false for empty pattern', () => {
|
|
293
|
+
const backend = fakeBackend();
|
|
294
|
+
setHapticsBackend(backend);
|
|
295
|
+
expect(vibrateDevicePattern([])).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('returns false when haptics unavailable (jsdom)', () => {
|
|
299
|
+
expect(vibrateDevicePattern([100, 50, 100])).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('vibrateDeviceWaveform', () => {
|
|
304
|
+
it('calls vibrateWaveform on backends that support it', () => {
|
|
305
|
+
const backend = fakeBackend();
|
|
306
|
+
setHapticsBackend(backend);
|
|
307
|
+
const timings = [100, 50, 100] as const;
|
|
308
|
+
const amplitudes = [255, 0, 128] as const;
|
|
309
|
+
expect(vibrateDeviceWaveform(timings, amplitudes, 0)).toBe(true);
|
|
310
|
+
expect(backend.lastWaveform).toEqual({ timings, amplitudes, repeat: 0 });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('defaults repeat to -1', () => {
|
|
314
|
+
const backend = fakeBackend();
|
|
315
|
+
setHapticsBackend(backend);
|
|
316
|
+
vibrateDeviceWaveform([100], [255]);
|
|
317
|
+
expect(backend.lastWaveform?.repeat).toBe(-1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('falls back to vibratePattern when backend lacks vibrateWaveform', () => {
|
|
321
|
+
const backend = fakeBackend();
|
|
322
|
+
(backend as HapticsBackend).vibrateWaveform = undefined;
|
|
323
|
+
setHapticsBackend(backend);
|
|
324
|
+
const timings = [100, 50] as const;
|
|
325
|
+
const amplitudes = [255, 0] as const;
|
|
326
|
+
expect(vibrateDeviceWaveform(timings, amplitudes)).toBe(true);
|
|
327
|
+
expect(backend.lastPattern).toEqual(timings);
|
|
328
|
+
expect(backend.lastWaveform).toBeNull();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('returns false for empty timings', () => {
|
|
332
|
+
const backend = fakeBackend();
|
|
333
|
+
setHapticsBackend(backend);
|
|
334
|
+
expect(vibrateDeviceWaveform([], [], undefined)).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('returns false when haptics unavailable (jsdom)', () => {
|
|
338
|
+
expect(vibrateDeviceWaveform([100], [255])).toBe(false);
|
|
339
|
+
});
|
|
340
|
+
});
|