solid-drift 0.22.0 → 0.24.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 +61 -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/dist/sheet.d.ts +99 -0
- package/dist/sheet.js +219 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1445,6 +1445,67 @@ createInfiniteScroll(() => sentinel, {
|
|
|
1445
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
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
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
|
+
|
|
1476
|
+
### Bottom sheet
|
|
1477
|
+
|
|
1478
|
+
A draggable bottom sheet built on `createDrag`: the user pulls it up by a handle (or the sheet itself) and on release it springs to the nearest snap point, projected forward by the release velocity like a native sheet. Dragging below the lowest snap (or a fast downward flick) dismisses it when `dismissible`.
|
|
1479
|
+
|
|
1480
|
+
```tsx
|
|
1481
|
+
import { createBottomSheet } from "solid-drift"
|
|
1482
|
+
|
|
1483
|
+
let sheet!: HTMLDivElement
|
|
1484
|
+
let handle!: HTMLDivElement
|
|
1485
|
+
const bs = createBottomSheet(() => handle, {
|
|
1486
|
+
snapPoints: [0.4, 1], // fractions of the sheet's own height
|
|
1487
|
+
measureRef: () => sheet, // measure the sheet, not the handle
|
|
1488
|
+
onOpenChange: (open) => setScrimVisible(open),
|
|
1489
|
+
})
|
|
1490
|
+
|
|
1491
|
+
<div
|
|
1492
|
+
ref={sheet}
|
|
1493
|
+
style={{
|
|
1494
|
+
position: "fixed", left: "0", right: "0", bottom: "0",
|
|
1495
|
+
transform: `translateY(${bs.y()}px)`,
|
|
1496
|
+
}}
|
|
1497
|
+
>
|
|
1498
|
+
<div ref={handle} style={{ "touch-action": "none" }}>Handle</div>
|
|
1499
|
+
<div>Sheet content</div>
|
|
1500
|
+
</div>
|
|
1501
|
+
<button onClick={() => bs.openSheet()}>Open</button>
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
- `createBottomSheet(ref, options?)` returns `{ open, snapIndex, y, status, openSheet, close, snapTo }`. `y()` is the current translateY in pixels; `status()` is `idle`, `dragging`, or `settling`; `snapIndex()` is the snap-point index or -1 when dismissed.
|
|
1505
|
+
- `snapPoints` are visible height fractions (`1` fully open); values are clamped to [0, 1] and sorted ascending, an empty array falls back to `[1]`. Default `[0.5, 1]`; `initialSnap` (default the fullest) picks the point `openSheet()` opens at.
|
|
1506
|
+
- While the pointer is down the sheet tracks 1:1 with light rubber-banding past the fully-open top and the dismissed bottom. On release, the target is the nearest snap to `y + velocity * 0.18`; dismissal happens past the midpoint between the lowest snap and closed, or on a downward flick over 700 px/s. Snap travel uses a spring (`options.spring`, default stiffness 400 / damping 40).
|
|
1507
|
+
- Bind `ref` to the drag handle when the sheet body scrolls (keeps drag and scroll from fighting), to the sheet root otherwise. The moving element gets `translateY(y())`; the drag target needs `touch-action: none`. Starts dismissed on the server (SSR-safe); under reduced motion it jumps straight to snap targets.
|
|
1508
|
+
|
|
1448
1509
|
### Easings
|
|
1449
1510
|
|
|
1450
1511
|
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/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
|
@@ -15,6 +15,8 @@ export { isLowPowerMode, useLowPowerMode, type LowPowerOptions, } from "./power.
|
|
|
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
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";
|
|
19
|
+
export { createBottomSheet, type BottomSheetOptions, type BottomSheetControls, } from "./sheet.js";
|
|
18
20
|
export { createHorizontalScroll, type HorizontalScrollOptions, type HorizontalScrollResult, } from "./horizontal.js";
|
|
19
21
|
export { createScrub, type ScrubKeyframe, type ScrubOptions } from "./scrub.js";
|
|
20
22
|
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
|
@@ -15,6 +15,8 @@ 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
17
|
export { createDebounced, createThrottled, createLocalStorage, createMediaQuery, createClickOutside, createScrollLock, createInfiniteScroll, } from "./dom.js";
|
|
18
|
+
export { createHaptic, hapticPatterns, createHapticBeat, hapticBeatPresets, } from "./haptic.js";
|
|
19
|
+
export { createBottomSheet, } from "./sheet.js";
|
|
18
20
|
export { createHorizontalScroll, } from "./horizontal.js";
|
|
19
21
|
export { createScrub } from "./scrub.js";
|
|
20
22
|
export { createScrollColor, createScrollTracking, createScrollLine, } from "./scrollfx.js";
|
package/dist/sheet.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type Accessor } from "solid-js";
|
|
2
|
+
import { type DragStatus } from "./gesture.js";
|
|
3
|
+
import type { SpringOptions } from "./spring.js";
|
|
4
|
+
type MaybeElement = () => Element | null | undefined;
|
|
5
|
+
export interface BottomSheetOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Snap points as fractions of the sheet's own height. `1` is fully
|
|
8
|
+
* open, `0.4` shows 40% of the sheet. Values are clamped to
|
|
9
|
+
* [0, 1] and sorted ascending; an empty array falls back to [1].
|
|
10
|
+
* Default `[0.5, 1]`.
|
|
11
|
+
*/
|
|
12
|
+
snapPoints?: number[];
|
|
13
|
+
/**
|
|
14
|
+
* Index into `snapPoints` opened by `openSheet()` with no argument.
|
|
15
|
+
* Default is the last point (fully open).
|
|
16
|
+
*/
|
|
17
|
+
initialSnap?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Dragging below the lowest snap dismisses the sheet on release.
|
|
20
|
+
* Default true.
|
|
21
|
+
*/
|
|
22
|
+
dismissible?: boolean;
|
|
23
|
+
/** Spring physics for snap animations. Default `{ stiffness: 400, damping: 40 }`. */
|
|
24
|
+
spring?: SpringOptions;
|
|
25
|
+
/**
|
|
26
|
+
* Element used to measure the sheet height. Defaults to the drag
|
|
27
|
+
* ref. Pass the sheet root here when the drag ref is a handle, so
|
|
28
|
+
* snap fractions apply to the sheet and not the handle.
|
|
29
|
+
*/
|
|
30
|
+
measureRef?: MaybeElement;
|
|
31
|
+
/** Called when the sheet opens or closes. */
|
|
32
|
+
onOpenChange?: (open: boolean) => void;
|
|
33
|
+
}
|
|
34
|
+
export interface BottomSheetControls {
|
|
35
|
+
/** Whether the sheet is open (not dismissed). */
|
|
36
|
+
open: Accessor<boolean>;
|
|
37
|
+
/** Index into `snapPoints`, or -1 when dismissed. */
|
|
38
|
+
snapIndex: Accessor<number>;
|
|
39
|
+
/**
|
|
40
|
+
* Current translateY in pixels. Drive the sheet's transform with
|
|
41
|
+
* this: `transform: translateY(${y()}px)`.
|
|
42
|
+
*/
|
|
43
|
+
y: Accessor<number>;
|
|
44
|
+
/** `idle`, `dragging` while the pointer is down, `settling` while snapping. */
|
|
45
|
+
status: Accessor<DragStatus>;
|
|
46
|
+
/** Open the sheet at a snap point (default the fullest). */
|
|
47
|
+
openSheet: (index?: number) => void;
|
|
48
|
+
/** Dismiss the sheet below the screen. */
|
|
49
|
+
close: () => void;
|
|
50
|
+
/** Snap to a point by index (opens the sheet if dismissed). */
|
|
51
|
+
snapTo: (index: number) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A draggable bottom sheet built on `createDrag`.
|
|
55
|
+
*
|
|
56
|
+
* The sheet is a fixed, bottom-anchored panel the user drags by a
|
|
57
|
+
* handle (or the sheet itself). On release it snaps to the nearest
|
|
58
|
+
* snap point, projected forward by the release velocity like a
|
|
59
|
+
* native sheet; a downward drag past the lowest point (or a fast
|
|
60
|
+
* downward flick) dismisses it when `dismissible`.
|
|
61
|
+
*
|
|
62
|
+
* The drag gesture itself, pointer tracking, release velocity, and
|
|
63
|
+
* reduced-motion behavior come from `createDrag`; this primitive
|
|
64
|
+
* adds the snap-point semantics on top. While the pointer is down
|
|
65
|
+
* the sheet tracks 1:1 with light rubber-banding past the top and
|
|
66
|
+
* bottom edges; on release it springs to the chosen point.
|
|
67
|
+
*
|
|
68
|
+
* Bind the `ref` to the drag handle when the sheet body scrolls
|
|
69
|
+
* (a handle keeps drag and scroll from fighting); bind it to the
|
|
70
|
+
* sheet root for non-scrolling sheets. Either way the moving
|
|
71
|
+
* element gets `transform: translateY(${y()}px)` and the drag
|
|
72
|
+
* target gets `touch-action: none`. When the drag ref is a handle,
|
|
73
|
+
* pass the sheet root as `options.measureRef` so snap fractions
|
|
74
|
+
* are measured against the sheet.
|
|
75
|
+
*
|
|
76
|
+
* SSR-safe: starts dismissed with `y()` at 0 on the server.
|
|
77
|
+
*
|
|
78
|
+
* ```tsx
|
|
79
|
+
* let sheet!: HTMLDivElement
|
|
80
|
+
* let handle!: HTMLDivElement
|
|
81
|
+
* const bs = createBottomSheet(() => handle, {
|
|
82
|
+
* snapPoints: [0.4, 1],
|
|
83
|
+
* measureRef: () => sheet,
|
|
84
|
+
* })
|
|
85
|
+
* <div
|
|
86
|
+
* ref={sheet}
|
|
87
|
+
* style={{
|
|
88
|
+
* position: "fixed", left: "0", right: "0", bottom: "0",
|
|
89
|
+
* transform: `translateY(${bs.y()}px)`,
|
|
90
|
+
* }}
|
|
91
|
+
* >
|
|
92
|
+
* <div ref={handle} style={{ "touch-action": "none" }}>Handle</div>
|
|
93
|
+
* <div>Sheet content</div>
|
|
94
|
+
* </div>
|
|
95
|
+
* <button onClick={() => bs.openSheet()}>Open</button>
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export declare function createBottomSheet(ref: MaybeElement, options?: BottomSheetOptions): BottomSheetControls;
|
|
99
|
+
export {};
|
package/dist/sheet.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { createEffect, createSignal, onCleanup, untrack, } from "solid-js";
|
|
2
|
+
import { now, schedule } from "./engine.js";
|
|
3
|
+
import { createDrag, } from "./gesture.js";
|
|
4
|
+
import { prefersReducedMotion } from "./reduced-motion.js";
|
|
5
|
+
/**
|
|
6
|
+
* A draggable bottom sheet built on `createDrag`.
|
|
7
|
+
*
|
|
8
|
+
* The sheet is a fixed, bottom-anchored panel the user drags by a
|
|
9
|
+
* handle (or the sheet itself). On release it snaps to the nearest
|
|
10
|
+
* snap point, projected forward by the release velocity like a
|
|
11
|
+
* native sheet; a downward drag past the lowest point (or a fast
|
|
12
|
+
* downward flick) dismisses it when `dismissible`.
|
|
13
|
+
*
|
|
14
|
+
* The drag gesture itself, pointer tracking, release velocity, and
|
|
15
|
+
* reduced-motion behavior come from `createDrag`; this primitive
|
|
16
|
+
* adds the snap-point semantics on top. While the pointer is down
|
|
17
|
+
* the sheet tracks 1:1 with light rubber-banding past the top and
|
|
18
|
+
* bottom edges; on release it springs to the chosen point.
|
|
19
|
+
*
|
|
20
|
+
* Bind the `ref` to the drag handle when the sheet body scrolls
|
|
21
|
+
* (a handle keeps drag and scroll from fighting); bind it to the
|
|
22
|
+
* sheet root for non-scrolling sheets. Either way the moving
|
|
23
|
+
* element gets `transform: translateY(${y()}px)` and the drag
|
|
24
|
+
* target gets `touch-action: none`. When the drag ref is a handle,
|
|
25
|
+
* pass the sheet root as `options.measureRef` so snap fractions
|
|
26
|
+
* are measured against the sheet.
|
|
27
|
+
*
|
|
28
|
+
* SSR-safe: starts dismissed with `y()` at 0 on the server.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* let sheet!: HTMLDivElement
|
|
32
|
+
* let handle!: HTMLDivElement
|
|
33
|
+
* const bs = createBottomSheet(() => handle, {
|
|
34
|
+
* snapPoints: [0.4, 1],
|
|
35
|
+
* measureRef: () => sheet,
|
|
36
|
+
* })
|
|
37
|
+
* <div
|
|
38
|
+
* ref={sheet}
|
|
39
|
+
* style={{
|
|
40
|
+
* position: "fixed", left: "0", right: "0", bottom: "0",
|
|
41
|
+
* transform: `translateY(${bs.y()}px)`,
|
|
42
|
+
* }}
|
|
43
|
+
* >
|
|
44
|
+
* <div ref={handle} style={{ "touch-action": "none" }}>Handle</div>
|
|
45
|
+
* <div>Sheet content</div>
|
|
46
|
+
* </div>
|
|
47
|
+
* <button onClick={() => bs.openSheet()}>Open</button>
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function createBottomSheet(ref, options = {}) {
|
|
51
|
+
const { snapPoints: snapPointsOption, initialSnap: initialSnapOption, dismissible = true, spring = {}, measureRef, onOpenChange, } = options;
|
|
52
|
+
// Snap points are visible height fractions, ascending. Values are
|
|
53
|
+
// clamped to [0, 1] and sorted; an empty list falls back to [1]
|
|
54
|
+
// (a single fully-open snap).
|
|
55
|
+
const snapPoints = (snapPointsOption?.length ? [...snapPointsOption] : [1])
|
|
56
|
+
.map((p) => Math.min(1, Math.max(0, p)))
|
|
57
|
+
.sort((a, b) => a - b);
|
|
58
|
+
const initialSnap = initialSnapOption ?? snapPoints.length - 1;
|
|
59
|
+
const [open, setOpen] = createSignal(false);
|
|
60
|
+
const [snapIndex, setSnapIndex] = createSignal(-1);
|
|
61
|
+
const [y, setY] = createSignal(0);
|
|
62
|
+
const [snapping, setSnapping] = createSignal(false);
|
|
63
|
+
// Sheet height in px, measured lazily from the element.
|
|
64
|
+
let height = 0;
|
|
65
|
+
const measure = () => {
|
|
66
|
+
const el = (measureRef ?? ref)();
|
|
67
|
+
if (el && typeof el.offsetHeight === "number") {
|
|
68
|
+
const h = el.offsetHeight;
|
|
69
|
+
if (h > 0)
|
|
70
|
+
height = h;
|
|
71
|
+
}
|
|
72
|
+
return height;
|
|
73
|
+
};
|
|
74
|
+
const snapY = (index) => height * (1 - snapPoints[index]);
|
|
75
|
+
const closedY = () => height;
|
|
76
|
+
// Snap animation: the same semi-implicit Euler spring the gesture
|
|
77
|
+
// primitives use.
|
|
78
|
+
let cancelSnap = null;
|
|
79
|
+
const animateTo = (target) => {
|
|
80
|
+
cancelSnap?.();
|
|
81
|
+
cancelSnap = null;
|
|
82
|
+
if (prefersReducedMotion()) {
|
|
83
|
+
setY(target);
|
|
84
|
+
setSnapping(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const { stiffness = 400, damping = 40 } = spring;
|
|
88
|
+
let current = untrack(y);
|
|
89
|
+
let v = 0;
|
|
90
|
+
let last = now();
|
|
91
|
+
setSnapping(true);
|
|
92
|
+
const task = (t) => {
|
|
93
|
+
const dt = Math.min(Math.max((t - last) / 1000, 0), 0.064);
|
|
94
|
+
last = t;
|
|
95
|
+
v += (-stiffness * (current - target) - damping * v) * dt;
|
|
96
|
+
current += v * dt;
|
|
97
|
+
setY(current);
|
|
98
|
+
if (Math.abs(current - target) < 0.5 && Math.abs(v) < 20) {
|
|
99
|
+
setY(target);
|
|
100
|
+
setSnapping(false);
|
|
101
|
+
cancelSnap = null;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
cancelSnap = schedule(task);
|
|
107
|
+
};
|
|
108
|
+
onCleanup(() => cancelSnap?.());
|
|
109
|
+
const openAt = (index) => {
|
|
110
|
+
measure();
|
|
111
|
+
const i = Math.max(0, Math.min(snapPoints.length - 1, index));
|
|
112
|
+
if (!untrack(open)) {
|
|
113
|
+
setOpen(true);
|
|
114
|
+
onOpenChange?.(true);
|
|
115
|
+
}
|
|
116
|
+
setSnapIndex(i);
|
|
117
|
+
animateTo(snapY(i));
|
|
118
|
+
};
|
|
119
|
+
const close = () => {
|
|
120
|
+
measure();
|
|
121
|
+
if (untrack(open)) {
|
|
122
|
+
setOpen(false);
|
|
123
|
+
onOpenChange?.(false);
|
|
124
|
+
}
|
|
125
|
+
setSnapIndex(-1);
|
|
126
|
+
animateTo(closedY());
|
|
127
|
+
};
|
|
128
|
+
// The underlying gesture: pointer tracking and release velocity.
|
|
129
|
+
// Its own y is only ever used as a per-grab delta source; the
|
|
130
|
+
// sheet position lives in `y` above.
|
|
131
|
+
let dragBase = 0;
|
|
132
|
+
let sheetBase = 0;
|
|
133
|
+
const settleFromRelease = (info) => {
|
|
134
|
+
measure();
|
|
135
|
+
const releaseY = untrack(y);
|
|
136
|
+
const projected = releaseY + info.velocityY * 0.18;
|
|
137
|
+
const lowest = snapY(0);
|
|
138
|
+
const dismissLine = (lowest + height) / 2;
|
|
139
|
+
if (dismissible &&
|
|
140
|
+
(projected >= dismissLine ||
|
|
141
|
+
(info.velocityY > 700 && projected > lowest))) {
|
|
142
|
+
close();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let best = 0;
|
|
146
|
+
let bestDist = Math.abs(snapY(0) - projected);
|
|
147
|
+
for (let i = 1; i < snapPoints.length; i++) {
|
|
148
|
+
const d = Math.abs(snapY(i) - projected);
|
|
149
|
+
if (d < bestDist) {
|
|
150
|
+
bestDist = d;
|
|
151
|
+
best = i;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
openAt(best);
|
|
155
|
+
};
|
|
156
|
+
const drag = createDrag(ref, {
|
|
157
|
+
axis: "y",
|
|
158
|
+
momentum: false, // the sheet chooses its own snap target
|
|
159
|
+
onDragStart: () => {
|
|
160
|
+
cancelSnap?.();
|
|
161
|
+
cancelSnap = null;
|
|
162
|
+
setSnapping(false);
|
|
163
|
+
measure();
|
|
164
|
+
},
|
|
165
|
+
onDragEnd: settleFromRelease,
|
|
166
|
+
});
|
|
167
|
+
// 1:1 tracking while the pointer is down, with rubber-banding past
|
|
168
|
+
// the fully-open top and the dismissed bottom. The grab bases are
|
|
169
|
+
// captured on the transition into "dragging" inside this effect:
|
|
170
|
+
// writes from event handlers flush effects synchronously, so the
|
|
171
|
+
// effect runs during setStatus("dragging"), before onDragStart.
|
|
172
|
+
// Capturing here keeps the bases correct under any scheduling.
|
|
173
|
+
let prevDragStatus = "idle";
|
|
174
|
+
createEffect(() => {
|
|
175
|
+
const s = drag.status();
|
|
176
|
+
if (s === "dragging" && prevDragStatus !== "dragging") {
|
|
177
|
+
dragBase = drag.y();
|
|
178
|
+
sheetBase = untrack(y);
|
|
179
|
+
}
|
|
180
|
+
prevDragStatus = s;
|
|
181
|
+
if (s !== "dragging")
|
|
182
|
+
return;
|
|
183
|
+
const raw = sheetBase + (drag.y() - dragBase);
|
|
184
|
+
const h = height;
|
|
185
|
+
let next = raw;
|
|
186
|
+
if (next < 0)
|
|
187
|
+
next = next * 0.3;
|
|
188
|
+
else if (next > h)
|
|
189
|
+
next = h + (next - h) * 0.3;
|
|
190
|
+
setY(next);
|
|
191
|
+
});
|
|
192
|
+
const status = () => {
|
|
193
|
+
if (drag.status() === "dragging")
|
|
194
|
+
return "dragging";
|
|
195
|
+
return snapping() ? "settling" : "idle";
|
|
196
|
+
};
|
|
197
|
+
// Client-only: park the dismissed sheet below the screen once the
|
|
198
|
+
// element (and its height) exists.
|
|
199
|
+
if (typeof window !== "undefined") {
|
|
200
|
+
createEffect(() => {
|
|
201
|
+
const el = ref();
|
|
202
|
+
const mel = (measureRef ?? ref)();
|
|
203
|
+
if (!el || !mel)
|
|
204
|
+
return;
|
|
205
|
+
const h = measure();
|
|
206
|
+
if (!untrack(open) && untrack(snapIndex) === -1)
|
|
207
|
+
setY(h);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
open,
|
|
212
|
+
snapIndex,
|
|
213
|
+
y,
|
|
214
|
+
status,
|
|
215
|
+
openSheet: (index = initialSnap) => openAt(index),
|
|
216
|
+
close,
|
|
217
|
+
snapTo: openAt,
|
|
218
|
+
};
|
|
219
|
+
}
|
package/package.json
CHANGED