From fb1e4ad09f2241d4fcb56676744cd9d20d97e76c Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Fri, 8 May 2026 18:37:40 -0400 Subject: [PATCH] Initial commit --- .changeset/vibrate-initial.md | 16 ++ packages/vibrate/LICENSE | 21 ++ packages/vibrate/README.md | 167 +++++++++++ packages/vibrate/dev/index.tsx | 34 +++ packages/vibrate/package.json | 71 +++++ packages/vibrate/src/index.ts | 302 ++++++++++++++++++++ packages/vibrate/test/index.test.ts | 398 +++++++++++++++++++++++++++ packages/vibrate/test/server.test.ts | 30 ++ packages/vibrate/tsconfig.json | 16 ++ pnpm-lock.yaml | 16 +- 10 files changed, 1068 insertions(+), 3 deletions(-) create mode 100644 .changeset/vibrate-initial.md create mode 100644 packages/vibrate/LICENSE create mode 100644 packages/vibrate/README.md create mode 100644 packages/vibrate/dev/index.tsx create mode 100644 packages/vibrate/package.json create mode 100644 packages/vibrate/src/index.ts create mode 100644 packages/vibrate/test/index.test.ts create mode 100644 packages/vibrate/test/server.test.ts create mode 100644 packages/vibrate/tsconfig.json diff --git a/.changeset/vibrate-initial.md b/.changeset/vibrate-initial.md new file mode 100644 index 000000000..dd1f23b7e --- /dev/null +++ b/.changeset/vibrate-initial.md @@ -0,0 +1,16 @@ +--- +"@solid-primitives/vibrate": minor +--- + +Add `@solid-primitives/vibrate` package (Stage 0) + +New primitives for device haptic feedback via the [Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API). + +- **`isVibrationSupported()`** — runtime check for Vibration API availability. +- **`makeVibrate(pattern, options?)`** — non-reactive helper returning `[start, stop]`. Supports optional `interval` for repeating patterns. No-ops when the API is unavailable. +- **`createVibrate(pattern, options?)`** — reactive primitive returning `{ vibrating, start, stop, supported }`. Accepts a reactive accessor for `pattern`: changing it while vibrating restarts automatically. Stops and cleans up on owner disposal. +- **`frequencyToPattern(hz, dutyCycle?)`** — converts a frequency in Hz to a single-cycle `[onMs, offMs]` pattern. +- **`makePulse(hz, options?)`** — non-reactive helper that vibrates continuously at `hz` cycles per second using a repeating chunk strategy. +- **`createPulse(hz, options?)`** — reactive pulse primitive returning `{ pulsing, start, stop, supported }`. Accepts a reactive `hz` accessor: changing frequency while pulsing restarts immediately at the new rhythm. + +Peer dependencies: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10`. diff --git a/packages/vibrate/LICENSE b/packages/vibrate/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/vibrate/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/vibrate/README.md b/packages/vibrate/README.md new file mode 100644 index 000000000..0d1565259 --- /dev/null +++ b/packages/vibrate/README.md @@ -0,0 +1,167 @@ +

+ Solid Primitives vibrate +

+ +# @solid-primitives/vibrate + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/vibrate?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/vibrate) +[![version](https://img.shields.io/npm/v/@solid-primitives/vibrate?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/vibrate) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +Primitives for triggering and managing device haptic feedback via the [Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API). + +- [`isVibrationSupported`](#isvibrationSupported) — Check if the Vibration API is available. +- [`makeVibrate`](#makevibrate) — Non-reactive helper; returns `[start, stop]` with no Solid lifecycle dependency. +- [`createVibrate`](#createvibrate) — Reactive primitive; returns `{ vibrating, start, stop, supported }` with automatic cleanup and reactive pattern support. +- [`frequencyToPattern`](#frequencytopattern) — Convert a frequency in Hz to a `[onMs, offMs]` pattern. +- [`makePulse`](#makepulse) — Non-reactive pulse helper; vibrates continuously at a given frequency. +- [`createPulse`](#createpulse) — Reactive pulse primitive; supports reactive `hz` that restarts on change. + +## Installation + +```bash +npm install @solid-primitives/vibrate +# or +yarn add @solid-primitives/vibrate +# or +pnpm add @solid-primitives/vibrate +``` + +## `isVibrationSupported` + +Returns `true` when the [Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API) is available. Useful for conditionally rendering haptic UI or showing fallback content. + +```ts +import { isVibrationSupported } from "@solid-primitives/vibrate"; + +if (isVibrationSupported()) { + console.log("haptics available"); +} +``` + +## `makeVibrate` + +A non-reactive building block. Wraps `navigator.vibrate` with an optional repeating interval and returns `[start, stop]`. No Solid lifecycle dependency — both functions are no-ops when the API is unavailable. + +```ts +import { makeVibrate } from "@solid-primitives/vibrate"; + +// Single-shot vibration +const [start, stop] = makeVibrate([200, 100, 200]); +button.addEventListener("click", start); + +// Repeating vibration every 2 seconds +const [start, stop] = makeVibrate(100, { interval: 2000 }); +start(); +// later: +stop(); +``` + +## `createVibrate` + +A reactive primitive tied to the current reactive owner. Returns `{ vibrating, start, stop, supported }`. Cleans up automatically on owner disposal. + +When `pattern` is a reactive accessor and changes **while vibrating**, vibration restarts with the new pattern automatically. + +```ts +import { createVibrate } from "@solid-primitives/vibrate"; +import { createEffect } from "solid-js"; + +const { vibrating, start, stop, supported } = createVibrate([200, 100, 200]); + +createEffect(() => { + console.log("vibrating:", vibrating()); +}); +``` + +### Reactive pattern + +```ts +import { createVibrate } from "@solid-primitives/vibrate"; +import { createSignal } from "solid-js"; + +const [pattern, setPattern] = createSignal(200); +const { vibrating, start, stop } = createVibrate(pattern); + +start(); + +// Changing pattern while vibrating restarts automatically +setPattern([100, 30, 100, 30, 100]); +``` + +### With interval + +```ts +const { vibrating, start, stop } = createVibrate(200, { interval: 1000 }); +start(); // repeats every second +stop(); // cancels interval + active vibration +``` + +## `frequencyToPattern` + +Converts a frequency in Hz and an optional duty cycle into a single-cycle `[onMs, offMs]` vibration pattern. Useful for previewing what `makePulse` / `createPulse` will produce. + +```ts +import { frequencyToPattern } from "@solid-primitives/vibrate"; + +frequencyToPattern(2) // [250, 250] — 2 Hz, equal on/off +frequencyToPattern(4, 0.25) // ~[63, 188] — 4 Hz, short tap +``` + +## `makePulse` + +Non-reactive pulse helper. Vibrates continuously at `hz` cycles per second. No Solid lifecycle dependency; both functions are no-ops when the API is unavailable. + +```ts +import { makePulse } from "@solid-primitives/vibrate"; + +const [start, stop] = makePulse(4); // 4 taps per second +button.addEventListener("pointerdown", start); +button.addEventListener("pointerup", stop); +``` + +## `createPulse` + +Reactive pulse primitive tied to the current reactive owner. Returns `{ pulsing, start, stop, supported }`. Accepts a reactive `hz` accessor — changing the frequency while pulsing restarts the vibration immediately at the new rhythm. + +```ts +import { createPulse } from "@solid-primitives/vibrate"; +import { createSignal, createMemo } from "solid-js"; + +// Fixed frequency +const { pulsing, start, stop } = createPulse(2); + +// Reactive frequency — escalates as a countdown nears zero +const [seconds, setSeconds] = createSignal(10); +const hz = createMemo(() => 1 + (10 - seconds()) * 0.5); +const { start, stop } = createPulse(hz); +``` + +## Types + +```ts +export type VibratePattern = number | number[]; + +export interface VibrateOptions { + /** Milliseconds between pattern repetitions. Omit to vibrate once per call. */ + interval?: number; +} + +export interface PulseOptions { + /** + * Fraction of each cycle spent vibrating (0–1). Defaults to `0.5`. + * A higher value produces longer buzzes; a lower value produces shorter taps. + */ + dutyCycle?: number; +} +``` + +## Browser Support + +The Vibration API is supported on Chrome/Firefox for Android. It is **not** supported on iOS or most desktop browsers. Always check `isVibrationSupported()` or rely on `supported` from `createVibrate` before enabling haptic features in your UI. + +> Note: vibration requires a prior user interaction (sticky activation) and may be suppressed by silent/DND mode. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/vibrate/dev/index.tsx b/packages/vibrate/dev/index.tsx new file mode 100644 index 000000000..ad50905cd --- /dev/null +++ b/packages/vibrate/dev/index.tsx @@ -0,0 +1,34 @@ +import { type Component, createSignal } from "solid-js"; +import { createVibrate, isVibrationSupported } from "../src/index.js"; + +const App: Component = () => { + const [pattern, setPattern] = createSignal([200, 100, 200]); + const { vibrating, start, stop, supported } = createVibrate(pattern); + + return ( +
+
+

Vibration

+

+ {supported ? "Vibration API supported" : "Vibration API not supported in this browser"} +

+

+ Status: {vibrating() ? "Vibrating" : "Idle"} +

+
+ + +
+

+ Supported: {String(isVibrationSupported())} +

+
+
+ ); +}; + +export default App; diff --git a/packages/vibrate/package.json b/packages/vibrate/package.json new file mode 100644 index 000000000..d1ef98e91 --- /dev/null +++ b/packages/vibrate/package.json @@ -0,0 +1,71 @@ +{ + "name": "@solid-primitives/vibrate", + "version": "0.0.100", + "description": "Primitives to trigger and manage device vibration via the Vibration API", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/vibrate", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "vibrate", + "stage": 0, + "list": [ + "isVibrationSupported", + "makeVibrate", + "createVibrate", + "frequencyToPattern", + "makePulse", + "createPulse" + ], + "category": "Sensors" + }, + "keywords": [ + "solid", + "vibrate", + "vibration", + "haptic", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "@solidjs/web": "^2.0.0-beta.10", + "solid-js": "^2.0.0-beta.10" + }, + "dependencies": { + "@solid-primitives/utils": "workspace:^" + }, + "devDependencies": { + "@solidjs/web": "2.0.0-beta.10", + "solid-js": "2.0.0-beta.10" + } +} diff --git a/packages/vibrate/src/index.ts b/packages/vibrate/src/index.ts new file mode 100644 index 000000000..60f3f71d4 --- /dev/null +++ b/packages/vibrate/src/index.ts @@ -0,0 +1,302 @@ +import { createSignal, onCleanup, createEffect, type Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { INTERNAL_OPTIONS, noop, access, type MaybeAccessor } from "@solid-primitives/utils"; + +export type VibratePattern = number | number[]; + +export interface VibrateOptions { + /** Milliseconds between pattern repetitions. Omit to vibrate once per call. */ + interval?: number; +} + +export interface PulseOptions { + /** + * Fraction of each cycle spent vibrating (0–1). Defaults to `0.5`. + * A higher value produces longer buzzes; a lower value produces shorter taps. + */ + dutyCycle?: number; +} + +/** + * Returns `true` when the [Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API) + * is available in the current environment. + */ +export const isVibrationSupported = (): boolean => !isServer && "vibrate" in navigator; + +/** + * Non-reactive vibration helper. No Solid lifecycle dependency. + * Both returned functions are no-ops when the Vibration API is unavailable. + * + * @param pattern Duration in ms or an array of alternating on/off durations. + * @param options Optional `interval` to repeat the pattern periodically. + * @returns `[start, stop]` + * + * @example + * ```ts + * const [start, stop] = makeVibrate([200, 100, 200]); + * button.addEventListener("click", start); + * ``` + */ +export function makeVibrate( + pattern: VibratePattern, + options: VibrateOptions = {}, +): [start: VoidFunction, stop: VoidFunction] { + if (!isVibrationSupported()) return [noop, noop]; + + const { interval } = options; + let intervalId: ReturnType | undefined; + + const stop: VoidFunction = () => { + clearInterval(intervalId); + intervalId = undefined; + navigator.vibrate(0); + }; + + const start: VoidFunction = () => { + navigator.vibrate(pattern); + if (interval !== undefined) { + clearInterval(intervalId); + intervalId = setInterval(() => navigator.vibrate(pattern), interval); + } + }; + + return [start, stop]; +} + +/** + * Reactive vibration primitive tied to the current reactive owner. + * + * Accepts a reactive `pattern` — when it changes while vibrating, the vibration + * restarts automatically with the new value. Stops and cleans up on owner disposal. + * + * @param pattern Duration in ms, an array of alternating durations, or a reactive accessor. + * @param options Optional `interval` to repeat the pattern periodically. + * @returns `{ vibrating, start, stop, supported }` + * + * @example + * ```ts + * const { vibrating, start, stop } = createVibrate([200, 100, 200]); + * + * createEffect(() => { + * console.log("vibrating:", vibrating()); + * }); + * ``` + */ +export function createVibrate( + pattern: MaybeAccessor, + options: VibrateOptions = {}, +): { + vibrating: Accessor; + start: VoidFunction; + stop: VoidFunction; + supported: boolean; +} { + const supported = isVibrationSupported(); + + if (!supported) { + return { vibrating: () => false, start: noop, stop: noop, supported }; + } + + const { interval } = options; + const [vibrating, setVibrating] = createSignal(false, INTERNAL_OPTIONS); + let isVibrating = false; + let intervalId: ReturnType | undefined; + + const doVibrate = () => navigator.vibrate(access(pattern)); + + const stop: VoidFunction = () => { + clearInterval(intervalId); + intervalId = undefined; + navigator.vibrate(0); + isVibrating = false; + setVibrating(false); + }; + + const start: VoidFunction = () => { + doVibrate(); + if (interval !== undefined) { + clearInterval(intervalId); + intervalId = setInterval(doVibrate, interval); + } + isVibrating = true; + setVibrating(true); + }; + + // When a reactive pattern changes while vibrating, restart with the new value. + // Skip the initial apply (the first run establishes the baseline; only changes matter). + // Use the apply's `newPattern` argument directly to avoid a reactive read inside the callback. + if (typeof pattern === "function") { + let initialized = false; + createEffect( + () => pattern(), + (newPattern: VibratePattern) => { + if (!initialized) { + initialized = true; + return; + } + if (isVibrating) { + navigator.vibrate(newPattern); + if (interval !== undefined) { + clearInterval(intervalId); + intervalId = setInterval(() => navigator.vibrate(newPattern), interval); + } + } + }, + ); + } + + onCleanup(stop); + + return { vibrating, start, stop, supported }; +} + +// ─── Pulse / frequency primitives ──────────────────────────────────────────── + +// Duration of a single repeating chunk sent to navigator.vibrate. +// The setInterval fires at the same cadence so patterns join seamlessly. +const PULSE_CHUNK_MS = 1000; + +/** + * Converts a frequency in Hz and an optional duty cycle into a single-cycle + * vibration pattern `[onMs, offMs]`. + * + * Useful for building custom patterns or visualising what `makePulse` / + * `createPulse` will produce at a given frequency. + * + * @param hz Vibrations per second (must be > 0). + * @param dutyCycle Fraction of the cycle spent vibrating (0–1). Defaults to `0.5`. + * @returns `[onMs, offMs]` + * + * @example + * ```ts + * frequencyToPattern(2) // [250, 250] — 2 Hz, equal on/off + * frequencyToPattern(4, 0.25) // [62, 188] — 4 Hz, short tap + * ``` + */ +export function frequencyToPattern(hz: number, dutyCycle = 0.5): [on: number, off: number] { + const period = 1000 / Math.max(0.001, hz); + const dc = Math.max(0, Math.min(1, dutyCycle)); + const on = Math.max(1, Math.round(period * dc)); + const off = Math.max(1, Math.round(period * (1 - dc))); + return [on, off]; +} + +/** Build a multi-cycle chunk ≈ PULSE_CHUNK_MS long and return its exact duration. */ +function buildChunk(hz: number, dutyCycle: number): { pattern: number[]; duration: number } { + const clampedHz = Math.max(0.1, Math.min(hz, 100)); + const [on, off] = frequencyToPattern(clampedHz, dutyCycle); + const cycles = Math.max(1, Math.round(PULSE_CHUNK_MS / (on + off))); + const pattern: number[] = []; + for (let i = 0; i < cycles; i++) pattern.push(on, off); + return { pattern, duration: cycles * (on + off) }; +} + +/** + * Non-reactive pulse helper. Triggers a repeating vibration at `hz` cycles per + * second. No Solid lifecycle dependency; both functions are no-ops when the + * Vibration API is unavailable. + * + * @param hz Pulse frequency in Hz. + * @param options `dutyCycle` controls the on/off ratio (default `0.5`). + * @returns `[start, stop]` + * + * @example + * ```ts + * const [start, stop] = makePulse(4); // 4 taps per second + * button.addEventListener("pointerdown", start); + * button.addEventListener("pointerup", stop); + * ``` + */ +export function makePulse( + hz: number, + options: PulseOptions = {}, +): [start: VoidFunction, stop: VoidFunction] { + if (!isVibrationSupported()) return [noop, noop]; + const { dutyCycle = 0.5 } = options; + const { pattern, duration } = buildChunk(hz, dutyCycle); + return makeVibrate(pattern, { interval: duration }); +} + +/** + * Reactive pulse primitive. Ties cleanup to the current reactive owner. + * + * Accepts a reactive `hz` accessor — when the frequency changes while pulsing, + * the vibration restarts immediately at the new rhythm. This makes it easy to + * implement effects like urgency escalation or heartbeat visualisations. + * + * @param hz Pulse frequency in Hz, or a reactive accessor returning one. + * @param options `dutyCycle` controls the on/off ratio (default `0.5`). + * @returns `{ pulsing, start, stop, supported }` + * + * @example + * ```ts + * // Fixed frequency + * const { pulsing, start, stop } = createPulse(2); + * + * // Reactive frequency — escalates as a countdown reaches zero + * const [seconds, setSeconds] = createSignal(10); + * const hz = createMemo(() => 1 + (10 - seconds()) * 0.5); + * const { start, stop } = createPulse(hz); + * ``` + */ +export function createPulse( + hz: MaybeAccessor, + options: PulseOptions = {}, +): { + pulsing: Accessor; + start: VoidFunction; + stop: VoidFunction; + supported: boolean; +} { + const supported = isVibrationSupported(); + + if (!supported) { + return { pulsing: () => false, start: noop, stop: noop, supported }; + } + + const { dutyCycle = 0.5 } = options; + const [pulsing, setPulsing] = createSignal(false, INTERNAL_OPTIONS); + let isPulsing = false; + let intervalId: ReturnType | undefined; + + const applyPulse = (currentHz: number) => { + const { pattern, duration } = buildChunk(currentHz, dutyCycle); + navigator.vibrate(pattern); + clearInterval(intervalId); + intervalId = setInterval(() => navigator.vibrate(pattern), duration); + }; + + const stop: VoidFunction = () => { + clearInterval(intervalId); + intervalId = undefined; + navigator.vibrate(0); + isPulsing = false; + setPulsing(false); + }; + + const start: VoidFunction = () => { + applyPulse(access(hz)); + isPulsing = true; + setPulsing(true); + }; + + // When hz changes while pulsing, restart at the new frequency. + // Skip the initial apply; use the apply argument to avoid reactive reads in the callback. + if (typeof hz === "function") { + let initialized = false; + createEffect( + () => hz(), + (newHz: number) => { + if (!initialized) { + initialized = true; + return; + } + if (isPulsing) applyPulse(newHz); + }, + ); + } + + onCleanup(stop); + + return { pulsing, start, stop, supported }; +} diff --git a/packages/vibrate/test/index.test.ts b/packages/vibrate/test/index.test.ts new file mode 100644 index 000000000..ab8b785bd --- /dev/null +++ b/packages/vibrate/test/index.test.ts @@ -0,0 +1,398 @@ +import { describe, test, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; +import { createRoot, createSignal, flush } from "solid-js"; +import { + makeVibrate, + createVibrate, + isVibrationSupported, + frequencyToPattern, + makePulse, + createPulse, + type VibratePattern, +} from "../src/index.js"; + +const vibrateMock = vi.fn().mockReturnValue(true); + +beforeAll(() => { + Object.defineProperty(navigator, "vibrate", { + value: vibrateMock, + configurable: true, + writable: true, + }); +}); + +afterAll(() => { + Object.defineProperty(navigator, "vibrate", { + value: undefined, + configurable: true, + }); +}); + +beforeEach(() => { + vibrateMock.mockClear(); +}); + +describe("isVibrationSupported", () => { + test("returns true when navigator.vibrate is available", () => { + expect(isVibrationSupported()).toBe(true); + }); +}); + +describe("makeVibrate", () => { + test("calls navigator.vibrate with a number pattern", () => { + const [start] = makeVibrate(200); + start(); + expect(vibrateMock).toHaveBeenCalledWith(200); + }); + + test("calls navigator.vibrate with an array pattern", () => { + const [start] = makeVibrate([100, 50, 100]); + start(); + expect(vibrateMock).toHaveBeenCalledWith([100, 50, 100]); + }); + + test("stop calls navigator.vibrate(0)", () => { + const [, stop] = makeVibrate(200); + stop(); + expect(vibrateMock).toHaveBeenCalledWith(0); + }); + + test("stop cancels an active interval", () => { + vi.useFakeTimers(); + const [start, stop] = makeVibrate(100, { interval: 300 }); + + start(); + expect(vibrateMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(300); + expect(vibrateMock).toHaveBeenCalledTimes(2); + + stop(); + vi.advanceTimersByTime(600); + // only the vibrate(0) from stop() — no further pattern calls + expect(vibrateMock).toHaveBeenCalledTimes(3); + expect(vibrateMock).toHaveBeenLastCalledWith(0); + + vi.useRealTimers(); + }); + + test("calling start again resets the interval", () => { + vi.useFakeTimers(); + const [start, stop] = makeVibrate(100, { interval: 300 }); + + start(); + vi.advanceTimersByTime(150); + start(); // restart — should reset interval timer + vi.advanceTimersByTime(300); + + // initial start + second start + interval tick after restart + expect(vibrateMock).toHaveBeenCalledTimes(3); + + stop(); + vi.useRealTimers(); + }); +}); + +describe("createVibrate", () => { + test("initial state: not vibrating, supported: true", () => { + createRoot(dispose => { + const { vibrating, supported } = createVibrate(200); + expect(vibrating()).toBe(false); + expect(supported).toBe(true); + dispose(); + }); + }); + + test("start sets vibrating to true and calls navigator.vibrate", () => { + const { vibrating, start, dispose } = createRoot(dispose => { + const { vibrating, start } = createVibrate(200); + return { vibrating, start, dispose }; + }); + + start(); + flush(); + expect(vibrating()).toBe(true); + expect(vibrateMock).toHaveBeenCalledWith(200); + + dispose(); + }); + + test("stop sets vibrating to false and calls navigator.vibrate(0)", () => { + const { vibrating, start, stop, dispose } = createRoot(dispose => { + const { vibrating, start, stop } = createVibrate([100, 50, 100]); + return { vibrating, start, stop, dispose }; + }); + + start(); + flush(); + expect(vibrating()).toBe(true); + + stop(); + flush(); + expect(vibrating()).toBe(false); + expect(vibrateMock).toHaveBeenLastCalledWith(0); + + dispose(); + }); + + test("dispose triggers stop and calls navigator.vibrate(0)", () => { + const { start, dispose } = createRoot(dispose => { + const { start } = createVibrate(200); + return { start, dispose }; + }); + + start(); + vibrateMock.mockClear(); + dispose(); + expect(vibrateMock).toHaveBeenCalledWith(0); + }); + + test("interval: repeats pattern periodically", () => { + vi.useFakeTimers(); + + const { start, stop, dispose } = createRoot(dispose => { + const { start, stop } = createVibrate(100, { interval: 500 }); + return { start, stop, dispose }; + }); + + start(); + expect(vibrateMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(500); + expect(vibrateMock).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(500); + expect(vibrateMock).toHaveBeenCalledTimes(3); + + stop(); + vi.useRealTimers(); + dispose(); + }); + + test("reactive pattern: restarts vibration when pattern changes while active", () => { + const [pattern, setPattern] = createSignal(200); + + const { start, dispose } = createRoot(dispose => { + const { start } = createVibrate(pattern); + return { start, dispose }; + }); + + start(); + flush(); // commit setVibrating(true) + skip initial createEffect apply + + setPattern([100, 50, 100]); + flush(); // apply fires: pattern changed while vibrating → restart + + // exactly: start() → vibrate(200), pattern change → vibrate([100,50,100]) + expect(vibrateMock).toHaveBeenCalledTimes(2); + expect(vibrateMock).toHaveBeenNthCalledWith(1, 200); + expect(vibrateMock).toHaveBeenNthCalledWith(2, [100, 50, 100]); + + dispose(); + }); + + test("reactive pattern: does not restart when not vibrating", () => { + const [pattern, setPattern] = createSignal(200); + + const dispose = createRoot(dispose => { + createVibrate(pattern); // not started + return dispose; + }); + + flush(); + vibrateMock.mockClear(); + + setPattern(500); + flush(); + + // vibrate was never called — pattern change ignored since not vibrating + expect(vibrateMock).not.toHaveBeenCalled(); + + dispose(); + }); +}); + +describe("frequencyToPattern", () => { + test("2 Hz with default duty cycle produces equal on/off durations", () => { + const [on, off] = frequencyToPattern(2); + expect(on).toBe(250); + expect(off).toBe(250); + }); + + test("4 Hz with 0.25 duty cycle produces shorter on than off", () => { + const [on, off] = frequencyToPattern(4, 0.25); + // period = 250ms; independent rounding means sum may be ±1 + expect(on).toBeLessThan(off); + expect(on + off).toBeGreaterThanOrEqual(249); + expect(on + off).toBeLessThanOrEqual(251); + }); + + test("1 Hz produces 1000ms period", () => { + const [on, off] = frequencyToPattern(1); + expect(on + off).toBe(1000); + }); + + test("duty cycle 0 clamps on to minimum 1ms", () => { + const [on] = frequencyToPattern(2, 0); + expect(on).toBeGreaterThanOrEqual(1); + }); + + test("duty cycle 1 clamps off to minimum 1ms", () => { + const [, off] = frequencyToPattern(2, 1); + expect(off).toBeGreaterThanOrEqual(1); + }); + + test("near-zero hz does not produce Infinity or NaN", () => { + const [on, off] = frequencyToPattern(0.0001); + expect(Number.isFinite(on)).toBe(true); + expect(Number.isFinite(off)).toBe(true); + }); +}); + +describe("makePulse", () => { + test("start calls navigator.vibrate with an array pattern", () => { + const [start, stop] = makePulse(2); + start(); + const callArg = vibrateMock.mock.calls[0]![0]; + expect(Array.isArray(callArg)).toBe(true); + expect((callArg as number[]).length).toBeGreaterThan(0); + stop(); + }); + + test("stop cancels interval and calls navigator.vibrate(0)", () => { + vi.useFakeTimers(); + const [start, stop] = makePulse(4); + + start(); + const callCountAfterStart = vibrateMock.mock.calls.length; + + stop(); + vi.advanceTimersByTime(2000); + // only vibrate(0) added after stop — no more pattern calls + expect(vibrateMock).toHaveBeenCalledTimes(callCountAfterStart + 1); + expect(vibrateMock).toHaveBeenLastCalledWith(0); + + vi.useRealTimers(); + }); + + test("interval fires repeatedly at the chunk duration", () => { + vi.useFakeTimers(); + const [start, stop] = makePulse(2); + + start(); + expect(vibrateMock).toHaveBeenCalledTimes(1); + + // 2 Hz → period 500ms → chunk ~1000ms (2 cycles) + vi.advanceTimersByTime(1000); + expect(vibrateMock).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(1000); + expect(vibrateMock).toHaveBeenCalledTimes(3); + + stop(); + vi.useRealTimers(); + }); +}); + +describe("createPulse", () => { + test("initial state: not pulsing, supported: true", () => { + createRoot(dispose => { + const { pulsing, supported } = createPulse(2); + expect(pulsing()).toBe(false); + expect(supported).toBe(true); + dispose(); + }); + }); + + test("start sets pulsing to true and calls navigator.vibrate", () => { + const { pulsing, start, dispose } = createRoot(dispose => { + const { pulsing, start } = createPulse(2); + return { pulsing, start, dispose }; + }); + + start(); + flush(); + expect(pulsing()).toBe(true); + const callArg = vibrateMock.mock.calls[0]![0]; + expect(Array.isArray(callArg)).toBe(true); + + dispose(); + }); + + test("stop sets pulsing to false and calls navigator.vibrate(0)", () => { + const { pulsing, start, stop, dispose } = createRoot(dispose => { + const { pulsing, start, stop } = createPulse(4); + return { pulsing, start, stop, dispose }; + }); + + start(); + flush(); + expect(pulsing()).toBe(true); + + stop(); + flush(); + expect(pulsing()).toBe(false); + expect(vibrateMock).toHaveBeenLastCalledWith(0); + + dispose(); + }); + + test("dispose triggers stop and calls navigator.vibrate(0)", () => { + const { start, dispose } = createRoot(dispose => { + const { start } = createPulse(2); + return { start, dispose }; + }); + + start(); + vibrateMock.mockClear(); + dispose(); + expect(vibrateMock).toHaveBeenCalledWith(0); + }); + + test("reactive hz: restarts pulse when frequency changes while active", () => { + vi.useFakeTimers(); + const [hz, setHz] = createSignal(2); + + const { start, dispose } = createRoot(dispose => { + const { start } = createPulse(hz); + return { start, dispose }; + }); + + start(); + flush(); // commit setPulsing(true) + skip initial createEffect apply + + vibrateMock.mockClear(); + setHz(4); + flush(); // apply fires: hz changed while pulsing → restart with new chunk + + // vibrate called once with the new 4 Hz pattern + expect(vibrateMock).toHaveBeenCalledTimes(1); + const callArg = vibrateMock.mock.calls[0]![0]; + expect(Array.isArray(callArg)).toBe(true); + // 4 Hz → period 250ms → on+off = 250ms; chunk has ~4 cycles = 1000ms total + // verify pattern is shorter per cycle than 2 Hz pattern + const [first] = callArg as number[]; + expect(first).toBeLessThanOrEqual(250); + + vi.useRealTimers(); + dispose(); + }); + + test("reactive hz: does not restart when not pulsing", () => { + const [hz, setHz] = createSignal(2); + + const dispose = createRoot(dispose => { + createPulse(hz); // not started + return dispose; + }); + + flush(); + vibrateMock.mockClear(); + + setHz(8); + flush(); + + expect(vibrateMock).not.toHaveBeenCalled(); + + dispose(); + }); +}); diff --git a/packages/vibrate/test/server.test.ts b/packages/vibrate/test/server.test.ts new file mode 100644 index 000000000..f6f221703 --- /dev/null +++ b/packages/vibrate/test/server.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from "vitest"; +import { makeVibrate, createVibrate, isVibrationSupported } from "../src/index.js"; + +describe("isVibrationSupported (SSR)", () => { + test("returns false on the server", () => { + expect(isVibrationSupported()).toBe(false); + }); +}); + +describe("makeVibrate (SSR)", () => { + test("returns no-op functions without throwing", () => { + const [start, stop] = makeVibrate(200); + expect(typeof start).toBe("function"); + expect(typeof stop).toBe("function"); + expect(() => start()).not.toThrow(); + expect(() => stop()).not.toThrow(); + }); +}); + +describe("createVibrate (SSR)", () => { + test("returns static defaults without throwing", () => { + const { vibrating, start, stop, supported } = createVibrate(200); + expect(vibrating()).toBe(false); + expect(supported).toBe(false); + expect(typeof start).toBe("function"); + expect(typeof stop).toBe("function"); + expect(() => start()).not.toThrow(); + expect(() => stop()).not.toThrow(); + }); +}); diff --git a/packages/vibrate/tsconfig.json b/packages/vibrate/tsconfig.json new file mode 100644 index 000000000..dc1970e16 --- /dev/null +++ b/packages/vibrate/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [ + { + "path": "../utils" + } + ], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55b312627..d64a52c4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,9 +137,6 @@ importers: '@solid-primitives/event-listener': specifier: workspace:^ version: link:../event-listener - '@solid-primitives/static-store': - specifier: workspace:^ - version: link:../static-store '@solid-primitives/utils': specifier: workspace:^ version: link:../utils @@ -1007,6 +1004,19 @@ importers: specifier: 2.0.0-beta.10 version: 2.0.0-beta.10 + packages/vibrate: + dependencies: + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10(solid-js@2.0.0-beta.10) + solid-js: + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 + packages/virtual: dependencies: '@solid-primitives/utils':