solid-drift 0.21.0 → 0.23.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/README.md +83 -0
- package/dist/dom.d.ts +163 -0
- package/dist/dom.js +316 -0
- package/dist/haptic.d.ts +115 -0
- package/dist/haptic.js +200 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1390,6 +1390,89 @@ const scratch = createScratch(() => foil, {
|
|
|
1390
1390
|
|
|
1391
1391
|
Options: `threshold` (fraction cleared to complete, default `0.45`), `brush` (eraser radius in px, default `26`), `paint(ctx, w, h)` (custom cover art), `onComplete`. Returns `{ cleared, done, reset }`: `cleared()` is the 0..1 fraction erased, `reset()` repaints the cover. Scratching is direct manipulation, so it works identically under reduced motion. SSR-safe: `cleared()` stays 0.
|
|
1392
1392
|
|
|
1393
|
+
### DOM utilities
|
|
1394
|
+
|
|
1395
|
+
Everyday DOM glue, signal-native: debounced and throttled signal transforms, a persisted signal, a live media query, outside-press dismissal, body scroll locking, and infinite scroll. All SSR-safe.
|
|
1396
|
+
|
|
1397
|
+
```tsx
|
|
1398
|
+
import {
|
|
1399
|
+
createDebounced,
|
|
1400
|
+
createThrottled,
|
|
1401
|
+
createLocalStorage,
|
|
1402
|
+
createMediaQuery,
|
|
1403
|
+
createClickOutside,
|
|
1404
|
+
createScrollLock,
|
|
1405
|
+
createInfiniteScroll,
|
|
1406
|
+
} from "solid-drift"
|
|
1407
|
+
|
|
1408
|
+
// Debounced search: the query waits for a 300ms pause before firing.
|
|
1409
|
+
const [query, setQuery] = createSignal("")
|
|
1410
|
+
const debounced = createDebounced(query, 300)
|
|
1411
|
+
createEffect(() => { if (debounced()) search(debounced()) })
|
|
1412
|
+
|
|
1413
|
+
// Throttled scroll position: at most one update per 100ms.
|
|
1414
|
+
const throttledY = createThrottled(scrollY, 100)
|
|
1415
|
+
|
|
1416
|
+
// Persisted theme, synced across tabs.
|
|
1417
|
+
const theme = createLocalStorage<"light" | "dark">("theme", "light")
|
|
1418
|
+
theme.set("dark")
|
|
1419
|
+
|
|
1420
|
+
// Live media query.
|
|
1421
|
+
const wide = createMediaQuery("(min-width: 1024px)")
|
|
1422
|
+
|
|
1423
|
+
// Dismiss a menu on outside press.
|
|
1424
|
+
let menu!: HTMLDivElement
|
|
1425
|
+
createClickOutside(() => menu, () => setOpen(false))
|
|
1426
|
+
|
|
1427
|
+
// Lock body scroll while a modal is open (nested locks stack).
|
|
1428
|
+
const scroll = createScrollLock()
|
|
1429
|
+
createEffect(() => { modalOpen() ? scroll.lock() : scroll.unlock() })
|
|
1430
|
+
|
|
1431
|
+
// Infinite scroll: prefetch as the sentinel approaches.
|
|
1432
|
+
let sentinel!: HTMLDivElement
|
|
1433
|
+
createInfiniteScroll(() => sentinel, {
|
|
1434
|
+
onLoadMore: () => loadPage(),
|
|
1435
|
+
disabled: () => !hasMore(),
|
|
1436
|
+
})
|
|
1437
|
+
<div ref={sentinel} />
|
|
1438
|
+
```
|
|
1439
|
+
|
|
1440
|
+
- `createDebounced(source, delay)` returns an `Accessor<T>` that follows the source after it stops changing for `delay` ms (trailing edge).
|
|
1441
|
+
- `createThrottled(source, interval)` returns an `Accessor<T>` that updates at most once per `interval` ms: leading change applies immediately, the rest collapse into one trailing update.
|
|
1442
|
+
- `createLocalStorage<T>(key, initialValue, options?)` returns `{ value, set, remove }`: reads the stored value on creation (falling back on missing or corrupt JSON), writes through on every set, and stays in sync across tabs via the `storage` event (`sync: true` default). Custom `serialize`/`deserialize` supported. Behaves like a plain signal where storage is unavailable.
|
|
1443
|
+
- `createMediaQuery(query)` returns an `Accessor<boolean>` that tracks the query live (`false` on the server).
|
|
1444
|
+
- `createClickOutside(ref, handler, options?)` calls `handler` on `pointerdown` (default, configurable via `events`) outside the element. Shadow-DOM aware via `composedPath`. No-op on the server.
|
|
1445
|
+
- `createScrollLock()` returns `{ locked, lock, unlock }`: sets `document.body.style.overflow = "hidden"`, restores the previous value when the last lock releases, and reference-counts nested locks so stacked modals cannot unlock each other early. Unmounting releases the locks.
|
|
1446
|
+
- `createInfiniteScroll(ref, options)` observes a sentinel with IntersectionObserver and calls `onLoadMore` as it approaches the viewport (`threshold` px prefetch via `rootMargin`, default `200`). `disabled` is a reactive kill switch (e.g. `() => !hasMore()`).
|
|
1447
|
+
|
|
1448
|
+
### Haptics
|
|
1449
|
+
|
|
1450
|
+
Tactile feedback through the Vibration API: `createHaptic` wraps `navigator.vibrate` with an iOS-style vocabulary (light/medium/heavy, success/warning/error), one-shot presets, and morse-code encoding; `createHapticBeat` is a 16-step haptic sequencer (heartbeat pulses, metronome ticks, breathing guides) running on the shared animation clock.
|
|
1451
|
+
|
|
1452
|
+
```tsx
|
|
1453
|
+
import { createHaptic, createHapticBeat, hapticBeatPresets } from "solid-drift"
|
|
1454
|
+
|
|
1455
|
+
const haptic = createHaptic()
|
|
1456
|
+
// Buttons get a physical click:
|
|
1457
|
+
<button onClick={() => { haptic.light(); confirm() }}>Confirm</button>
|
|
1458
|
+
// Morse code: dots, dashes, letter gaps, word gaps.
|
|
1459
|
+
<button onClick={() => haptic.morse("... --- ...")}>SOS</button>
|
|
1460
|
+
|
|
1461
|
+
// A heartbeat pulse the user can toggle:
|
|
1462
|
+
const beat = createHapticBeat(haptic, {
|
|
1463
|
+
bpm: 60,
|
|
1464
|
+
pattern: hapticBeatPresets.heartbeat,
|
|
1465
|
+
onStep: (i) => setFlash(i === 0),
|
|
1466
|
+
})
|
|
1467
|
+
<button onClick={() => beat.toggle()}>
|
|
1468
|
+
{beat.playing() ? "Stop pulse" : "Start pulse"}
|
|
1469
|
+
</button>
|
|
1470
|
+
```
|
|
1471
|
+
|
|
1472
|
+
- `createHaptic(options?)` returns `{ supported, vibrate, light, medium, heavy, success, warning, error, morse }`. `vibrate(pattern)` fires a raw ms pattern; `morse(code, unit?)` encodes `"."`, `"-"`, `" "` (letter gap), `"/"` (word gap) with a configurable dot length (default 60ms). `hapticPatterns` holds the one-shot presets (`tap`, `doubleTap`, `longPress`, `tick`, `heartbeat`, `success`, `warning`, `error`). `options.enabled` is a boolean or a signal master switch (wire it to `useLowPowerMode()`).
|
|
1473
|
+
- `createHapticBeat(haptic, options?)` returns `{ playing, bpm, step, start, stop, toggle, setBpm }`. The 16-step pattern uses `"x"` for a hit, `"X"` for an accent, anything else for a rest; steps run as 16th notes at `bpm` (live-changeable via `setBpm`), the downbeat fires immediately on `start()`, and `onStep(i)` reports each step index. `hapticBeatPresets` ships `heartbeat`, `metronome`, `ticks`, and `pulse`.
|
|
1474
|
+
- Haptics are tactile, not visual, so they fire under reduced motion too; the `enabled` switch is the way to offer quiet. Everything is a no-op where vibration is unsupported, and SSR-safe.
|
|
1475
|
+
|
|
1393
1476
|
### Easings
|
|
1394
1477
|
|
|
1395
1478
|
Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
|
package/dist/dom.d.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { type Accessor, type Setter } from "solid-js";
|
|
2
|
+
type MaybeElement = () => Element | null | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* A signal that follows the source after it stops changing for `delay`
|
|
5
|
+
* ms. The classic search-input pattern: the UI stays live while the
|
|
6
|
+
* query signal waits for a pause before firing network requests.
|
|
7
|
+
*
|
|
8
|
+
* Trailing edge only. The pending update is cancelled on cleanup.
|
|
9
|
+
* SSR-safe: holds the source's initial value on the server.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const [query, setQuery] = createSignal("")
|
|
13
|
+
* const debounced = createDebounced(query, 300)
|
|
14
|
+
* createEffect(() => {
|
|
15
|
+
* const q = debounced()
|
|
16
|
+
* if (q) search(q) // fires only after a 300ms pause
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function createDebounced<T>(source: Accessor<T>, delay: number): Accessor<T>;
|
|
21
|
+
/**
|
|
22
|
+
* A signal that follows the source at most once per `interval` ms.
|
|
23
|
+
* The first change applies immediately (leading edge); changes inside
|
|
24
|
+
* the window collapse into one trailing update. For scroll or resize
|
|
25
|
+
* handlers that feed expensive work.
|
|
26
|
+
*
|
|
27
|
+
* SSR-safe: holds the source's initial value on the server.
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const throttledY = createThrottled(scrollY, 100)
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function createThrottled<T>(source: Accessor<T>, interval: number): Accessor<T>;
|
|
34
|
+
export interface LocalStorageOptions<T> {
|
|
35
|
+
/** Serialize a value. Default JSON.stringify. */
|
|
36
|
+
serialize?: (value: T) => string;
|
|
37
|
+
/** Deserialize a stored string. Default JSON.parse. */
|
|
38
|
+
deserialize?: (raw: string) => T;
|
|
39
|
+
/**
|
|
40
|
+
* React to `storage` events from other tabs. Default true: the
|
|
41
|
+
* signal stays in sync across tabs.
|
|
42
|
+
*/
|
|
43
|
+
sync?: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface LocalStorageControls<T> {
|
|
46
|
+
value: Accessor<T>;
|
|
47
|
+
set: Setter<T>;
|
|
48
|
+
/** Remove the key from storage and reset to the initial value. */
|
|
49
|
+
remove: () => void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A signal persisted to `localStorage`. Reads the stored value on
|
|
53
|
+
* creation (falling back to `initialValue` when missing or corrupt),
|
|
54
|
+
* writes through on every set, and stays in sync across tabs via the
|
|
55
|
+
* `storage` event.
|
|
56
|
+
*
|
|
57
|
+
* SSR-safe and storage-less-safe: without `window.localStorage` it
|
|
58
|
+
* behaves like a plain signal.
|
|
59
|
+
*
|
|
60
|
+
* ```tsx
|
|
61
|
+
* const theme = createLocalStorage<"light" | "dark">("theme", "light")
|
|
62
|
+
* theme.set("dark") // localStorage.theme = '"dark"'
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function createLocalStorage<T>(key: string, initialValue: T, options?: LocalStorageOptions<T>): LocalStorageControls<T>;
|
|
66
|
+
/**
|
|
67
|
+
* A boolean signal tracking a CSS media query, updating live when the
|
|
68
|
+
* query starts or stops matching. The primitive behind
|
|
69
|
+
* `usePrefersReducedMotion`, generalized.
|
|
70
|
+
*
|
|
71
|
+
* SSR-safe (and safe where `matchMedia` is missing): constant `false`.
|
|
72
|
+
*
|
|
73
|
+
* ```tsx
|
|
74
|
+
* const wide = createMediaQuery("(min-width: 1024px)")
|
|
75
|
+
* const columns = () => (wide() ? 4 : 2)
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export declare function createMediaQuery(query: string): Accessor<boolean>;
|
|
79
|
+
export interface ClickOutsideOptions {
|
|
80
|
+
/**
|
|
81
|
+
* Events that count as "outside". Default `["pointerdown"]`: fires
|
|
82
|
+
* before `click`, so menus dismiss without a visible flash, and
|
|
83
|
+
* covers touch via Pointer Events.
|
|
84
|
+
*/
|
|
85
|
+
events?: string[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Call `handler` when the user interacts outside the element:
|
|
89
|
+
* dropdowns, popovers, and menus that dismiss on outside press.
|
|
90
|
+
*
|
|
91
|
+
* Listens on `window` (so it works even when the press lands on an
|
|
92
|
+
* overlaying element), ignores presses inside the ref, and cleans up
|
|
93
|
+
* on unmount. SSR-safe: no-op on the server.
|
|
94
|
+
*
|
|
95
|
+
* ```tsx
|
|
96
|
+
* let menu!: HTMLDivElement
|
|
97
|
+
* const [open, setOpen] = createSignal(false)
|
|
98
|
+
* createClickOutside(() => menu, () => setOpen(false))
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export declare function createClickOutside(ref: MaybeElement, handler: (event: Event) => void, options?: ClickOutsideOptions): void;
|
|
102
|
+
export interface ScrollLockControls {
|
|
103
|
+
/** Whether the body scroll is currently locked. */
|
|
104
|
+
locked: Accessor<boolean>;
|
|
105
|
+
/** Lock body scroll. Nested locks stack; all must unlock. */
|
|
106
|
+
lock: () => void;
|
|
107
|
+
/** Release one lock. Restores the original overflow when empty. */
|
|
108
|
+
unlock: () => void;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Lock body scroll while overlays are open: modals, drawers, the
|
|
112
|
+
* bottom sheet. `overflow: hidden` goes on `document.body`, the
|
|
113
|
+
* previous value is restored when the last lock releases, and nested
|
|
114
|
+
* locks are reference-counted so two stacked modals cannot unlock
|
|
115
|
+
* each other early. Unmounting while locked releases the lock.
|
|
116
|
+
*
|
|
117
|
+
* SSR-safe: no-op on the server.
|
|
118
|
+
*
|
|
119
|
+
* ```tsx
|
|
120
|
+
* const scroll = createScrollLock()
|
|
121
|
+
* createEffect(() => {
|
|
122
|
+
* if (modalOpen()) scroll.lock()
|
|
123
|
+
* else scroll.unlock()
|
|
124
|
+
* })
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export declare function createScrollLock(): ScrollLockControls;
|
|
128
|
+
export interface InfiniteScrollOptions {
|
|
129
|
+
/** Called when the sentinel approaches the viewport. */
|
|
130
|
+
onLoadMore: () => void;
|
|
131
|
+
/**
|
|
132
|
+
* Trigger distance in px before the sentinel enters: the observer's
|
|
133
|
+
* rootMargin. Default 200.
|
|
134
|
+
*/
|
|
135
|
+
threshold?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Reactive kill switch, e.g. `() => !hasMore()`. While true the
|
|
138
|
+
* sentinel is unobserved.
|
|
139
|
+
*/
|
|
140
|
+
disabled?: Accessor<boolean>;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Infinite scroll: observe a sentinel element at the end of a list
|
|
144
|
+
* and call `onLoadMore` as it approaches the viewport, prefetching
|
|
145
|
+
* before the user hits the bottom.
|
|
146
|
+
*
|
|
147
|
+
* Built on IntersectionObserver with a `rootMargin` prefetch zone.
|
|
148
|
+
* SSR-safe (and safe without IntersectionObserver): never fires.
|
|
149
|
+
*
|
|
150
|
+
* ```tsx
|
|
151
|
+
* let sentinel!: HTMLDivElement
|
|
152
|
+
* const [items, setItems] = createSignal<string[]>([])
|
|
153
|
+
* const [hasMore, setHasMore] = createSignal(true)
|
|
154
|
+
* createInfiniteScroll(() => sentinel, {
|
|
155
|
+
* onLoadMore: () => loadPage(),
|
|
156
|
+
* disabled: () => !hasMore(),
|
|
157
|
+
* })
|
|
158
|
+
* <For each={items()}>{(item) => <Row item={item} />}</For>
|
|
159
|
+
* <div ref={sentinel} />
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export declare function createInfiniteScroll(ref: MaybeElement, options: InfiniteScrollOptions): void;
|
|
163
|
+
export {};
|
package/dist/dom.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
|
|
2
|
+
/* ------------------------------------------------------------------ */
|
|
3
|
+
/* createDebounced */
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
/**
|
|
6
|
+
* A signal that follows the source after it stops changing for `delay`
|
|
7
|
+
* ms. The classic search-input pattern: the UI stays live while the
|
|
8
|
+
* query signal waits for a pause before firing network requests.
|
|
9
|
+
*
|
|
10
|
+
* Trailing edge only. The pending update is cancelled on cleanup.
|
|
11
|
+
* SSR-safe: holds the source's initial value on the server.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* const [query, setQuery] = createSignal("")
|
|
15
|
+
* const debounced = createDebounced(query, 300)
|
|
16
|
+
* createEffect(() => {
|
|
17
|
+
* const q = debounced()
|
|
18
|
+
* if (q) search(q) // fires only after a 300ms pause
|
|
19
|
+
* })
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function createDebounced(source, delay) {
|
|
23
|
+
const [value, setValue] = createSignal(untrack(source));
|
|
24
|
+
if (typeof window === "undefined")
|
|
25
|
+
return value;
|
|
26
|
+
let timer;
|
|
27
|
+
createEffect(() => {
|
|
28
|
+
const next = source();
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
timer = setTimeout(() => setValue(() => next), Math.max(0, delay));
|
|
31
|
+
});
|
|
32
|
+
onCleanup(() => clearTimeout(timer));
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
/* ------------------------------------------------------------------ */
|
|
36
|
+
/* createThrottled */
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
/**
|
|
39
|
+
* A signal that follows the source at most once per `interval` ms.
|
|
40
|
+
* The first change applies immediately (leading edge); changes inside
|
|
41
|
+
* the window collapse into one trailing update. For scroll or resize
|
|
42
|
+
* handlers that feed expensive work.
|
|
43
|
+
*
|
|
44
|
+
* SSR-safe: holds the source's initial value on the server.
|
|
45
|
+
*
|
|
46
|
+
* ```tsx
|
|
47
|
+
* const throttledY = createThrottled(scrollY, 100)
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function createThrottled(source, interval) {
|
|
51
|
+
const [value, setValue] = createSignal(untrack(source));
|
|
52
|
+
if (typeof window === "undefined")
|
|
53
|
+
return value;
|
|
54
|
+
const wait = Math.max(0, interval);
|
|
55
|
+
let last = 0;
|
|
56
|
+
let timer;
|
|
57
|
+
createEffect(() => {
|
|
58
|
+
const next = source();
|
|
59
|
+
const t = Date.now();
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
timer = undefined;
|
|
62
|
+
if (t - last >= wait) {
|
|
63
|
+
last = t;
|
|
64
|
+
setValue(() => next);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
timer = setTimeout(() => {
|
|
68
|
+
last = Date.now();
|
|
69
|
+
timer = undefined;
|
|
70
|
+
setValue(() => next);
|
|
71
|
+
}, wait - (t - last));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
onCleanup(() => clearTimeout(timer));
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A signal persisted to `localStorage`. Reads the stored value on
|
|
79
|
+
* creation (falling back to `initialValue` when missing or corrupt),
|
|
80
|
+
* writes through on every set, and stays in sync across tabs via the
|
|
81
|
+
* `storage` event.
|
|
82
|
+
*
|
|
83
|
+
* SSR-safe and storage-less-safe: without `window.localStorage` it
|
|
84
|
+
* behaves like a plain signal.
|
|
85
|
+
*
|
|
86
|
+
* ```tsx
|
|
87
|
+
* const theme = createLocalStorage<"light" | "dark">("theme", "light")
|
|
88
|
+
* theme.set("dark") // localStorage.theme = '"dark"'
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function createLocalStorage(key, initialValue, options = {}) {
|
|
92
|
+
const { serialize = JSON.stringify, deserialize = JSON.parse, sync = true, } = options;
|
|
93
|
+
const storage = () => {
|
|
94
|
+
if (typeof window === "undefined")
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
return window.localStorage;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null; // private mode / blocked storage
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const read = () => {
|
|
104
|
+
const store = storage();
|
|
105
|
+
if (!store)
|
|
106
|
+
return initialValue;
|
|
107
|
+
try {
|
|
108
|
+
const raw = store.getItem(key);
|
|
109
|
+
return raw === null ? initialValue : deserialize(raw);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return initialValue; // corrupt JSON: fall back, don't crash
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const [value, setValue] = createSignal(read());
|
|
116
|
+
const set = ((next) => {
|
|
117
|
+
const resolved = typeof next === "function"
|
|
118
|
+
? next(untrack(value))
|
|
119
|
+
: next;
|
|
120
|
+
setValue(() => resolved);
|
|
121
|
+
try {
|
|
122
|
+
storage()?.setItem(key, serialize(resolved));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Quota exceeded or blocked: the signal still updates.
|
|
126
|
+
}
|
|
127
|
+
return resolved;
|
|
128
|
+
});
|
|
129
|
+
const remove = () => {
|
|
130
|
+
try {
|
|
131
|
+
storage()?.removeItem(key);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// ignore
|
|
135
|
+
}
|
|
136
|
+
setValue(() => initialValue);
|
|
137
|
+
};
|
|
138
|
+
if (typeof window !== "undefined" && sync) {
|
|
139
|
+
const onStorage = (event) => {
|
|
140
|
+
if (event.key !== key)
|
|
141
|
+
return;
|
|
142
|
+
try {
|
|
143
|
+
setValue(() => event.newValue === null ? initialValue : deserialize(event.newValue));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// ignore corrupt cross-tab writes
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
window.addEventListener("storage", onStorage);
|
|
150
|
+
onCleanup(() => window.removeEventListener("storage", onStorage));
|
|
151
|
+
}
|
|
152
|
+
return { value, set, remove };
|
|
153
|
+
}
|
|
154
|
+
/* ------------------------------------------------------------------ */
|
|
155
|
+
/* createMediaQuery */
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
/**
|
|
158
|
+
* A boolean signal tracking a CSS media query, updating live when the
|
|
159
|
+
* query starts or stops matching. The primitive behind
|
|
160
|
+
* `usePrefersReducedMotion`, generalized.
|
|
161
|
+
*
|
|
162
|
+
* SSR-safe (and safe where `matchMedia` is missing): constant `false`.
|
|
163
|
+
*
|
|
164
|
+
* ```tsx
|
|
165
|
+
* const wide = createMediaQuery("(min-width: 1024px)")
|
|
166
|
+
* const columns = () => (wide() ? 4 : 2)
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
export function createMediaQuery(query) {
|
|
170
|
+
const no = () => false;
|
|
171
|
+
if (typeof window === "undefined" ||
|
|
172
|
+
typeof window.matchMedia !== "function") {
|
|
173
|
+
return no;
|
|
174
|
+
}
|
|
175
|
+
const mql = window.matchMedia(query);
|
|
176
|
+
const [matches, setMatches] = createSignal(mql.matches);
|
|
177
|
+
const onChange = (event) => {
|
|
178
|
+
setMatches(event.matches);
|
|
179
|
+
};
|
|
180
|
+
mql.addEventListener("change", onChange);
|
|
181
|
+
onCleanup(() => mql.removeEventListener("change", onChange));
|
|
182
|
+
return matches;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Call `handler` when the user interacts outside the element:
|
|
186
|
+
* dropdowns, popovers, and menus that dismiss on outside press.
|
|
187
|
+
*
|
|
188
|
+
* Listens on `window` (so it works even when the press lands on an
|
|
189
|
+
* overlaying element), ignores presses inside the ref, and cleans up
|
|
190
|
+
* on unmount. SSR-safe: no-op on the server.
|
|
191
|
+
*
|
|
192
|
+
* ```tsx
|
|
193
|
+
* let menu!: HTMLDivElement
|
|
194
|
+
* const [open, setOpen] = createSignal(false)
|
|
195
|
+
* createClickOutside(() => menu, () => setOpen(false))
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export function createClickOutside(ref, handler, options = {}) {
|
|
199
|
+
if (typeof window === "undefined")
|
|
200
|
+
return;
|
|
201
|
+
const { events = ["pointerdown"] } = options;
|
|
202
|
+
const onEvent = (event) => {
|
|
203
|
+
const el = ref();
|
|
204
|
+
if (!el)
|
|
205
|
+
return;
|
|
206
|
+
const target = event.target;
|
|
207
|
+
// Shadow-DOM aware: composedPath pierces shadow roots.
|
|
208
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
209
|
+
const inside = (target !== null && el.contains(target)) || path.includes(el);
|
|
210
|
+
if (!inside)
|
|
211
|
+
handler(event);
|
|
212
|
+
};
|
|
213
|
+
for (const name of events) {
|
|
214
|
+
window.addEventListener(name, onEvent);
|
|
215
|
+
}
|
|
216
|
+
onCleanup(() => {
|
|
217
|
+
for (const name of events) {
|
|
218
|
+
window.removeEventListener(name, onEvent);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
let lockCount = 0;
|
|
223
|
+
let savedOverflow = "";
|
|
224
|
+
/**
|
|
225
|
+
* Lock body scroll while overlays are open: modals, drawers, the
|
|
226
|
+
* bottom sheet. `overflow: hidden` goes on `document.body`, the
|
|
227
|
+
* previous value is restored when the last lock releases, and nested
|
|
228
|
+
* locks are reference-counted so two stacked modals cannot unlock
|
|
229
|
+
* each other early. Unmounting while locked releases the lock.
|
|
230
|
+
*
|
|
231
|
+
* SSR-safe: no-op on the server.
|
|
232
|
+
*
|
|
233
|
+
* ```tsx
|
|
234
|
+
* const scroll = createScrollLock()
|
|
235
|
+
* createEffect(() => {
|
|
236
|
+
* if (modalOpen()) scroll.lock()
|
|
237
|
+
* else scroll.unlock()
|
|
238
|
+
* })
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
export function createScrollLock() {
|
|
242
|
+
const [locked, setLocked] = createSignal(false);
|
|
243
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
244
|
+
return { locked, lock: () => { }, unlock: () => { } };
|
|
245
|
+
}
|
|
246
|
+
let held = 0;
|
|
247
|
+
const lock = () => {
|
|
248
|
+
if (lockCount === 0) {
|
|
249
|
+
savedOverflow = document.body.style.overflow;
|
|
250
|
+
document.body.style.overflow = "hidden";
|
|
251
|
+
}
|
|
252
|
+
lockCount++;
|
|
253
|
+
held++;
|
|
254
|
+
setLocked(true);
|
|
255
|
+
};
|
|
256
|
+
const unlock = () => {
|
|
257
|
+
if (held === 0)
|
|
258
|
+
return;
|
|
259
|
+
held--;
|
|
260
|
+
lockCount--;
|
|
261
|
+
if (lockCount === 0) {
|
|
262
|
+
document.body.style.overflow = savedOverflow;
|
|
263
|
+
setLocked(false);
|
|
264
|
+
}
|
|
265
|
+
else if (held === 0) {
|
|
266
|
+
setLocked(false);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
onCleanup(() => {
|
|
270
|
+
while (held > 0)
|
|
271
|
+
unlock();
|
|
272
|
+
});
|
|
273
|
+
return { locked, lock, unlock };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Infinite scroll: observe a sentinel element at the end of a list
|
|
277
|
+
* and call `onLoadMore` as it approaches the viewport, prefetching
|
|
278
|
+
* before the user hits the bottom.
|
|
279
|
+
*
|
|
280
|
+
* Built on IntersectionObserver with a `rootMargin` prefetch zone.
|
|
281
|
+
* SSR-safe (and safe without IntersectionObserver): never fires.
|
|
282
|
+
*
|
|
283
|
+
* ```tsx
|
|
284
|
+
* let sentinel!: HTMLDivElement
|
|
285
|
+
* const [items, setItems] = createSignal<string[]>([])
|
|
286
|
+
* const [hasMore, setHasMore] = createSignal(true)
|
|
287
|
+
* createInfiniteScroll(() => sentinel, {
|
|
288
|
+
* onLoadMore: () => loadPage(),
|
|
289
|
+
* disabled: () => !hasMore(),
|
|
290
|
+
* })
|
|
291
|
+
* <For each={items()}>{(item) => <Row item={item} />}</For>
|
|
292
|
+
* <div ref={sentinel} />
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
export function createInfiniteScroll(ref, options) {
|
|
296
|
+
const { onLoadMore, threshold = 200, disabled } = options;
|
|
297
|
+
if (typeof window === "undefined" ||
|
|
298
|
+
typeof IntersectionObserver === "undefined") {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
createEffect(() => {
|
|
302
|
+
const el = ref();
|
|
303
|
+
if (!el)
|
|
304
|
+
return;
|
|
305
|
+
if (disabled?.() === true)
|
|
306
|
+
return;
|
|
307
|
+
const observer = new IntersectionObserver((entries) => {
|
|
308
|
+
for (const entry of entries) {
|
|
309
|
+
if (entry.isIntersecting)
|
|
310
|
+
onLoadMore();
|
|
311
|
+
}
|
|
312
|
+
}, { rootMargin: `${threshold}px` });
|
|
313
|
+
observer.observe(el);
|
|
314
|
+
onCleanup(() => observer.disconnect());
|
|
315
|
+
});
|
|
316
|
+
}
|
package/dist/haptic.d.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { type Accessor } from "solid-js";
|
|
2
|
+
export type HapticPattern = number | number[];
|
|
3
|
+
export interface HapticOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Master switch. Accepts a plain boolean or a signal, so it can be
|
|
6
|
+
* wired to `useLowPowerMode()`. Default true.
|
|
7
|
+
*/
|
|
8
|
+
enabled?: Accessor<boolean> | boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface HapticControls {
|
|
11
|
+
/** True when the device can vibrate and haptics are enabled. */
|
|
12
|
+
supported: Accessor<boolean>;
|
|
13
|
+
/** Fire a raw vibration pattern (ms). No-op when unsupported. */
|
|
14
|
+
vibrate: (pattern: HapticPattern) => void;
|
|
15
|
+
/** Short tap. */
|
|
16
|
+
light: () => void;
|
|
17
|
+
/** Firmer tap. */
|
|
18
|
+
medium: () => void;
|
|
19
|
+
/** Strong tap. */
|
|
20
|
+
heavy: () => void;
|
|
21
|
+
/** Rising two-tap confirmation. */
|
|
22
|
+
success: () => void;
|
|
23
|
+
/** Double low tap. */
|
|
24
|
+
warning: () => void;
|
|
25
|
+
/** Triple strong tap. */
|
|
26
|
+
error: () => void;
|
|
27
|
+
/**
|
|
28
|
+
* Vibrate morse code: "." dot, "-" dash, " " letter gap, "/" word
|
|
29
|
+
* gap. `unit` is the dot length in ms (default 60).
|
|
30
|
+
*/
|
|
31
|
+
morse: (code: string, unit?: number) => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One-shot vibration presets, in milliseconds. Pass any of these to
|
|
35
|
+
* `vibrate`, or use the named helpers on the controls.
|
|
36
|
+
*/
|
|
37
|
+
export declare const hapticPatterns: Record<string, HapticPattern>;
|
|
38
|
+
/**
|
|
39
|
+
* Tactile feedback through the Vibration API (`navigator.vibrate`).
|
|
40
|
+
* Buttons, toggles, and confirmations get a physical click; the
|
|
41
|
+
* presets mirror the iOS haptic vocabulary (light/medium/heavy,
|
|
42
|
+
* success/warning/error) using vibration timing.
|
|
43
|
+
*
|
|
44
|
+
* Haptics are tactile, not visual, so they still fire under reduced
|
|
45
|
+
* motion. Gate them with `enabled` (a boolean or a signal) when the
|
|
46
|
+
* user asks for quiet, e.g. wired to `useLowPowerMode()`.
|
|
47
|
+
*
|
|
48
|
+
* SSR-safe and unsupported-device safe: everything is a no-op and
|
|
49
|
+
* `supported()` is false.
|
|
50
|
+
*
|
|
51
|
+
* ```tsx
|
|
52
|
+
* const haptic = createHaptic()
|
|
53
|
+
* <button onClick={() => { haptic.light(); confirm() }}>Confirm</button>
|
|
54
|
+
* <button onClick={() => haptic.morse("... --- ...")}>SOS</button>
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function createHaptic(options?: HapticOptions): HapticControls;
|
|
58
|
+
/**
|
|
59
|
+
* 16-step haptic sequencer presets. "x" is a hit, "X" an accent, and
|
|
60
|
+
* anything else a rest.
|
|
61
|
+
*/
|
|
62
|
+
export declare const hapticBeatPresets: Record<string, string>;
|
|
63
|
+
export interface HapticBeatOptions {
|
|
64
|
+
/** Beats per minute (quarter notes). Default 60. */
|
|
65
|
+
bpm?: number;
|
|
66
|
+
/**
|
|
67
|
+
* 16-step pattern string: "x" hit, "X" accent, anything else rest.
|
|
68
|
+
* Shorter strings are padded with rests, longer ones are cut.
|
|
69
|
+
* Default is the heartbeat preset.
|
|
70
|
+
*/
|
|
71
|
+
pattern?: string;
|
|
72
|
+
/** Vibration ms for a normal step. Default 12. */
|
|
73
|
+
stepMs?: number;
|
|
74
|
+
/** Vibration ms for an accent step. Default 30. */
|
|
75
|
+
accentMs?: number;
|
|
76
|
+
/** Start immediately. Default false. */
|
|
77
|
+
autostart?: boolean;
|
|
78
|
+
/** Called on every step with its index (0-15). */
|
|
79
|
+
onStep?: (step: number) => void;
|
|
80
|
+
}
|
|
81
|
+
export interface HapticBeatControls {
|
|
82
|
+
/** Whether the sequencer is running. */
|
|
83
|
+
playing: Accessor<boolean>;
|
|
84
|
+
/** Current tempo. Change it live with setBpm. */
|
|
85
|
+
bpm: Accessor<number>;
|
|
86
|
+
/** Current step index (0-15), or -1 when stopped. */
|
|
87
|
+
step: Accessor<number>;
|
|
88
|
+
start: () => void;
|
|
89
|
+
stop: () => void;
|
|
90
|
+
toggle: () => void;
|
|
91
|
+
setBpm: (bpm: number) => void;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A 16-step haptic sequencer on the shared animation clock: heartbeat
|
|
95
|
+
* pulses, metronome ticks, breathing guides, game countdowns. The
|
|
96
|
+
* steps run as 16th notes at `bpm`, so a 60 BPM heartbeat preset
|
|
97
|
+
* pulses once per second with the accent on the downbeat.
|
|
98
|
+
*
|
|
99
|
+
* Takes the haptic controls from `createHaptic` (so one `enabled`
|
|
100
|
+
* switch gates everything) and drives `vibrate` per step. Tempo
|
|
101
|
+
* changes apply live. SSR-safe: `start()` is a no-op on the server.
|
|
102
|
+
*
|
|
103
|
+
* ```tsx
|
|
104
|
+
* const haptic = createHaptic()
|
|
105
|
+
* const beat = createHapticBeat(haptic, {
|
|
106
|
+
* bpm: 60,
|
|
107
|
+
* pattern: hapticBeatPresets.heartbeat,
|
|
108
|
+
* onStep: (i) => setFlash(i === 0),
|
|
109
|
+
* })
|
|
110
|
+
* <button onClick={() => beat.toggle()}>
|
|
111
|
+
* {beat.playing() ? "Stop pulse" : "Start pulse"}
|
|
112
|
+
* </button>
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export declare function createHapticBeat(haptic: Pick<HapticControls, "vibrate">, options?: HapticBeatOptions): HapticBeatControls;
|
package/dist/haptic.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { now, schedule } from "./engine.js";
|
|
3
|
+
/**
|
|
4
|
+
* One-shot vibration presets, in milliseconds. Pass any of these to
|
|
5
|
+
* `vibrate`, or use the named helpers on the controls.
|
|
6
|
+
*/
|
|
7
|
+
export const hapticPatterns = {
|
|
8
|
+
tap: 10,
|
|
9
|
+
doubleTap: [15, 50, 15],
|
|
10
|
+
longPress: 60,
|
|
11
|
+
tick: 8,
|
|
12
|
+
heartbeat: [25, 150, 40],
|
|
13
|
+
success: [10, 40, 25],
|
|
14
|
+
warning: [30, 50, 30],
|
|
15
|
+
error: [50, 50, 50, 50, 80],
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Tactile feedback through the Vibration API (`navigator.vibrate`).
|
|
19
|
+
* Buttons, toggles, and confirmations get a physical click; the
|
|
20
|
+
* presets mirror the iOS haptic vocabulary (light/medium/heavy,
|
|
21
|
+
* success/warning/error) using vibration timing.
|
|
22
|
+
*
|
|
23
|
+
* Haptics are tactile, not visual, so they still fire under reduced
|
|
24
|
+
* motion. Gate them with `enabled` (a boolean or a signal) when the
|
|
25
|
+
* user asks for quiet, e.g. wired to `useLowPowerMode()`.
|
|
26
|
+
*
|
|
27
|
+
* SSR-safe and unsupported-device safe: everything is a no-op and
|
|
28
|
+
* `supported()` is false.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* const haptic = createHaptic()
|
|
32
|
+
* <button onClick={() => { haptic.light(); confirm() }}>Confirm</button>
|
|
33
|
+
* <button onClick={() => haptic.morse("... --- ...")}>SOS</button>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function createHaptic(options = {}) {
|
|
37
|
+
const enabled = typeof options.enabled === "function"
|
|
38
|
+
? options.enabled
|
|
39
|
+
: () => options.enabled !== false;
|
|
40
|
+
const canVibrate = typeof navigator !== "undefined" &&
|
|
41
|
+
typeof navigator.vibrate === "function";
|
|
42
|
+
const supported = () => canVibrate && enabled();
|
|
43
|
+
const vibrate = (pattern) => {
|
|
44
|
+
if (!supported())
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
navigator.vibrate(pattern);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Haptics are best-effort: never crash the interaction.
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const morse = (code, unit = 60) => {
|
|
54
|
+
const u = Math.max(1, Math.round(unit));
|
|
55
|
+
const pattern = [];
|
|
56
|
+
for (const ch of code) {
|
|
57
|
+
if (ch === ".") {
|
|
58
|
+
pattern.push(u, u);
|
|
59
|
+
}
|
|
60
|
+
else if (ch === "-") {
|
|
61
|
+
pattern.push(3 * u, u);
|
|
62
|
+
}
|
|
63
|
+
else if (ch === " " || ch === "/") {
|
|
64
|
+
// Extend the trailing pause: a letter gap is 3 units total, a
|
|
65
|
+
// word gap 7, and one unit is already there after each element.
|
|
66
|
+
const last = pattern.length - 1;
|
|
67
|
+
if (last >= 0)
|
|
68
|
+
pattern[last] += ch === " " ? 2 * u : 6 * u;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Drop the trailing pause: nothing to separate after the last element.
|
|
72
|
+
if (pattern.length % 2 === 0)
|
|
73
|
+
pattern.pop();
|
|
74
|
+
if (pattern.length > 0)
|
|
75
|
+
vibrate(pattern);
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
supported,
|
|
79
|
+
vibrate,
|
|
80
|
+
light: () => vibrate(hapticPatterns.tap),
|
|
81
|
+
medium: () => vibrate(20),
|
|
82
|
+
heavy: () => vibrate(30),
|
|
83
|
+
success: () => vibrate(hapticPatterns.success),
|
|
84
|
+
warning: () => vibrate(hapticPatterns.warning),
|
|
85
|
+
error: () => vibrate(hapticPatterns.error),
|
|
86
|
+
morse,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/* ------------------------------------------------------------------ */
|
|
90
|
+
/* createHapticBeat */
|
|
91
|
+
/* ------------------------------------------------------------------ */
|
|
92
|
+
/**
|
|
93
|
+
* 16-step haptic sequencer presets. "x" is a hit, "X" an accent, and
|
|
94
|
+
* anything else a rest.
|
|
95
|
+
*/
|
|
96
|
+
export const hapticBeatPresets = {
|
|
97
|
+
/** Lub-dub heartbeat. */
|
|
98
|
+
heartbeat: "X.......x.......",
|
|
99
|
+
/** Four-on-the-floor metronome. */
|
|
100
|
+
metronome: "X...x...x...x...",
|
|
101
|
+
/** Steady eighth-note ticks. */
|
|
102
|
+
ticks: "x.x.x.x.x.x.x.x.",
|
|
103
|
+
/** One pulse per bar. */
|
|
104
|
+
pulse: "X...............",
|
|
105
|
+
};
|
|
106
|
+
function parseSteps(pattern) {
|
|
107
|
+
const steps = [];
|
|
108
|
+
for (let i = 0; i < 16; i++) {
|
|
109
|
+
const ch = pattern[i];
|
|
110
|
+
steps.push(ch === "X" ? 2 : ch === "x" ? 1 : 0);
|
|
111
|
+
}
|
|
112
|
+
return steps;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A 16-step haptic sequencer on the shared animation clock: heartbeat
|
|
116
|
+
* pulses, metronome ticks, breathing guides, game countdowns. The
|
|
117
|
+
* steps run as 16th notes at `bpm`, so a 60 BPM heartbeat preset
|
|
118
|
+
* pulses once per second with the accent on the downbeat.
|
|
119
|
+
*
|
|
120
|
+
* Takes the haptic controls from `createHaptic` (so one `enabled`
|
|
121
|
+
* switch gates everything) and drives `vibrate` per step. Tempo
|
|
122
|
+
* changes apply live. SSR-safe: `start()` is a no-op on the server.
|
|
123
|
+
*
|
|
124
|
+
* ```tsx
|
|
125
|
+
* const haptic = createHaptic()
|
|
126
|
+
* const beat = createHapticBeat(haptic, {
|
|
127
|
+
* bpm: 60,
|
|
128
|
+
* pattern: hapticBeatPresets.heartbeat,
|
|
129
|
+
* onStep: (i) => setFlash(i === 0),
|
|
130
|
+
* })
|
|
131
|
+
* <button onClick={() => beat.toggle()}>
|
|
132
|
+
* {beat.playing() ? "Stop pulse" : "Start pulse"}
|
|
133
|
+
* </button>
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
export function createHapticBeat(haptic, options = {}) {
|
|
137
|
+
const { bpm: initialBpm = 60, pattern = hapticBeatPresets.heartbeat, stepMs = 12, accentMs = 30, autostart = false, onStep, } = options;
|
|
138
|
+
const steps = parseSteps(pattern);
|
|
139
|
+
const [playing, setPlaying] = createSignal(false);
|
|
140
|
+
const [bpm, setBpm] = createSignal(Math.max(1, initialBpm));
|
|
141
|
+
const [step, setStep] = createSignal(-1);
|
|
142
|
+
let cancel = null;
|
|
143
|
+
const stepDuration = () => 60000 / bpm() / 4;
|
|
144
|
+
const fire = (index) => {
|
|
145
|
+
setStep(index);
|
|
146
|
+
const kind = steps[index];
|
|
147
|
+
if (kind === 2)
|
|
148
|
+
haptic.vibrate(accentMs);
|
|
149
|
+
else if (kind === 1)
|
|
150
|
+
haptic.vibrate(stepMs);
|
|
151
|
+
onStep?.(index);
|
|
152
|
+
};
|
|
153
|
+
const start = () => {
|
|
154
|
+
if (typeof window === "undefined")
|
|
155
|
+
return; // SSR: no-op
|
|
156
|
+
if (playing())
|
|
157
|
+
return;
|
|
158
|
+
setPlaying(true);
|
|
159
|
+
let index = 0;
|
|
160
|
+
fire(0); // no latency: the downbeat lands immediately
|
|
161
|
+
let nextTime = now() + stepDuration();
|
|
162
|
+
cancel = schedule((t) => {
|
|
163
|
+
if (!playing())
|
|
164
|
+
return false;
|
|
165
|
+
const dur = stepDuration();
|
|
166
|
+
while (t >= nextTime) {
|
|
167
|
+
index = (index + 1) % 16;
|
|
168
|
+
fire(index);
|
|
169
|
+
nextTime += dur;
|
|
170
|
+
if (nextTime < t - 250) {
|
|
171
|
+
// Long main-thread stall: skip ahead instead of
|
|
172
|
+
// machine-gunning the missed steps.
|
|
173
|
+
nextTime = t + dur;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
const stop = () => {
|
|
181
|
+
if (!playing())
|
|
182
|
+
return;
|
|
183
|
+
setPlaying(false);
|
|
184
|
+
setStep(-1);
|
|
185
|
+
cancel?.();
|
|
186
|
+
cancel = null;
|
|
187
|
+
};
|
|
188
|
+
onCleanup(stop);
|
|
189
|
+
if (autostart)
|
|
190
|
+
start();
|
|
191
|
+
return {
|
|
192
|
+
playing,
|
|
193
|
+
bpm,
|
|
194
|
+
step,
|
|
195
|
+
start,
|
|
196
|
+
stop,
|
|
197
|
+
toggle: () => (playing() ? stop() : start()),
|
|
198
|
+
setBpm: (next) => setBpm(Math.max(1, next)),
|
|
199
|
+
};
|
|
200
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion
|
|
|
14
14
|
export { isLowPowerMode, useLowPowerMode, type LowPowerOptions, } from "./power.js";
|
|
15
15
|
export { createSlotMachine, type SlotMachineControls, type SlotMachineOptions, type SlotMachineStatus, createRedPacket, type RedPacketCoin, type RedPacketControls, type RedPacketOptions, type RedPacketStatus, createConfetti, type ConfettiControls, type ConfettiOptions, createEmojiBurst, type EmojiBurstControls, type EmojiBurstOptions, createScratch, type ScratchControls, type ScratchOptions, } from "./fun.js";
|
|
16
16
|
export { createStagger } from "./stagger.js";
|
|
17
|
+
export { createDebounced, createThrottled, createLocalStorage, type LocalStorageOptions, type LocalStorageControls, createMediaQuery, createClickOutside, type ClickOutsideOptions, createScrollLock, type ScrollLockControls, createInfiniteScroll, type InfiniteScrollOptions, } from "./dom.js";
|
|
18
|
+
export { createHaptic, hapticPatterns, type HapticOptions, type HapticControls, type HapticPattern, createHapticBeat, hapticBeatPresets, type HapticBeatOptions, type HapticBeatControls, } from "./haptic.js";
|
|
17
19
|
export { createHorizontalScroll, type HorizontalScrollOptions, type HorizontalScrollResult, } from "./horizontal.js";
|
|
18
20
|
export { createScrub, type ScrubKeyframe, type ScrubOptions } from "./scrub.js";
|
|
19
21
|
export { createScrollColor, type ScrollColorStop, type ScrollColorOptions, type ScrollColorFormat, createScrollTracking, type ScrollTrackingOptions, createScrollLine, type ScrollLineOptions, type ScrollLineStyle, type ScrollLineAxis, type ScrollLineOrigin, } from "./scrollfx.js";
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,8 @@ export { usePrefersReducedMotion, prefersReducedMotion, } from "./reduced-motion
|
|
|
14
14
|
export { isLowPowerMode, useLowPowerMode, } from "./power.js";
|
|
15
15
|
export { createSlotMachine, createRedPacket, createConfetti, createEmojiBurst, createScratch, } from "./fun.js";
|
|
16
16
|
export { createStagger } from "./stagger.js";
|
|
17
|
+
export { createDebounced, createThrottled, createLocalStorage, createMediaQuery, createClickOutside, createScrollLock, createInfiniteScroll, } from "./dom.js";
|
|
18
|
+
export { createHaptic, hapticPatterns, createHapticBeat, hapticBeatPresets, } from "./haptic.js";
|
|
17
19
|
export { createHorizontalScroll, } from "./horizontal.js";
|
|
18
20
|
export { createScrub } from "./scrub.js";
|
|
19
21
|
export { createScrollColor, createScrollTracking, createScrollLine, } from "./scrollfx.js";
|
package/package.json
CHANGED