@enigmax/primitives 0.1.0 → 0.1.2
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 +1 -1
- package/dist/{chunk-5Q5TAW2N.js → chunk-55J5JEWX.js} +27 -6
- package/dist/index.js +1 -1
- package/dist/react/index.js +1 -1
- package/package.json +1 -1
- package/src/core/marquee.ts +58 -7
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Three of these cost a production bug each. They are the reason this package exis
|
|
|
69
69
|
|
|
70
70
|
**`touch-action` decides whether a phone can scroll your page.** Without it the browser owns the horizontal swipe and the drag handlers never see it. With `none`, a visitor can no longer scroll the page by starting the gesture on the row, which on a phone is most of the width of the screen. The lane gets `pan-y` (or `pan-x` when vertical).
|
|
71
71
|
|
|
72
|
-
**A hover effect that ignores `pointerType` sticks forever on touch.** A tap fires `pointerenter` too, and on touch nothing ever fires the `pointerleave` that undoes it, so a "slow down on hover" leaves the row at 15% speed for good after the first tap. Every hover path here is gated to `pointerType === "mouse"`.
|
|
72
|
+
**A hover effect that ignores `pointerType` sticks forever on touch.** A tap fires `pointerenter` too, and on touch nothing ever fires the `pointerleave` that undoes it, so a "slow down on hover" leaves the row at 15% speed for good after the first tap. Every hover path here is gated to `pointerType === "mouse"` - and that gate alone is not enough, which cost a CI failure: Chromium follows a touch with *compatibility* pointer events that claim `pointerType: "mouse"`. It emits them on Linux and not on Windows, so the naive gate passes on one machine and fails on the other. A touch therefore also suppresses hover for a second, and `pointerleave` clears the state whatever the pointer type, because a hover that cannot be undone is the bug.
|
|
73
73
|
|
|
74
74
|
The rest, in short: the lap is measured from the step between two copies rather than computed from item widths; the transform is driven by one `requestAnimationFrame` loop rather than CSS keyframes, which restart on every re-author; one integrator carries cruise, hover and momentum so none of them can arrive as a cut; a drag past ~6px cancels the click in the capture phase; a right click never starts a drag; a pointer held still for ~90ms before release throws nothing; and reduced motion drops the autoplay while keeping the drag, because a drag is an answer to the pointer and not motion of the page's own accord.
|
|
75
75
|
|
|
@@ -4,6 +4,7 @@ var DRAG_THRESHOLD = 6;
|
|
|
4
4
|
var STILL_MS = 90;
|
|
5
5
|
var MAX_DT = 0.05;
|
|
6
6
|
var VELOCITY_SMOOTHING = 0.6;
|
|
7
|
+
var TOUCH_HOVER_SUPPRESS_MS = 1e3;
|
|
7
8
|
var IDLE_EPSILON = 0.01;
|
|
8
9
|
var DEFAULTS = {
|
|
9
10
|
speed: 60,
|
|
@@ -33,6 +34,7 @@ function createMarquee(lane, track, options = {}) {
|
|
|
33
34
|
let lastMoveTime = 0;
|
|
34
35
|
let maxTravel = 0;
|
|
35
36
|
let hovering = false;
|
|
37
|
+
let lastTouchAt = 0;
|
|
36
38
|
const motionQuery = typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
|
|
37
39
|
let reducedMotion = motionQuery?.matches ?? false;
|
|
38
40
|
function target() {
|
|
@@ -86,13 +88,19 @@ function createMarquee(lane, track, options = {}) {
|
|
|
86
88
|
}
|
|
87
89
|
function measure() {
|
|
88
90
|
if (destroyed) return;
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
+
const cloning = opts.copies === "clone";
|
|
92
|
+
if (cloning && track.children.length < 2) syncClones(2);
|
|
93
|
+
let measured = readPeriod();
|
|
94
|
+
let needed = requiredCopies(measured);
|
|
95
|
+
if (cloning && track.children.length !== needed) {
|
|
96
|
+
syncClones(needed);
|
|
97
|
+
measured = readPeriod();
|
|
98
|
+
needed = requiredCopies(measured);
|
|
99
|
+
}
|
|
91
100
|
const changed = measured !== period || needed !== copyCount;
|
|
92
101
|
period = measured;
|
|
93
102
|
if (needed !== copyCount) {
|
|
94
103
|
copyCount = needed;
|
|
95
|
-
if (opts.copies === "clone") syncClones(needed);
|
|
96
104
|
opts.onCopyCountChange?.(needed);
|
|
97
105
|
}
|
|
98
106
|
if (period > 0) offset = wrap(offset);
|
|
@@ -132,6 +140,7 @@ function createMarquee(lane, track, options = {}) {
|
|
|
132
140
|
return opts.vertical ? event.clientY : event.clientX;
|
|
133
141
|
}
|
|
134
142
|
function onPointerDown(event) {
|
|
143
|
+
if (event.pointerType !== "mouse") noteTouch();
|
|
135
144
|
if (!opts.draggable || event.button > 0 || pointerId !== null) return;
|
|
136
145
|
pointerId = event.pointerId;
|
|
137
146
|
dragging = true;
|
|
@@ -178,13 +187,23 @@ function createMarquee(lane, track, options = {}) {
|
|
|
178
187
|
opts.onItemClick?.(event);
|
|
179
188
|
}
|
|
180
189
|
function onPointerEnter(event) {
|
|
181
|
-
if (event.pointerType !== "mouse")
|
|
190
|
+
if (event.pointerType !== "mouse") {
|
|
191
|
+
noteTouch();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (Date.now() - lastTouchAt < TOUCH_HOVER_SUPPRESS_MS) return;
|
|
182
195
|
hovering = true;
|
|
183
196
|
lane.dataset.hovering = "true";
|
|
184
197
|
start();
|
|
185
198
|
}
|
|
186
|
-
function onPointerLeave(
|
|
187
|
-
|
|
199
|
+
function onPointerLeave() {
|
|
200
|
+
hovering = false;
|
|
201
|
+
delete lane.dataset.hovering;
|
|
202
|
+
start();
|
|
203
|
+
}
|
|
204
|
+
function noteTouch() {
|
|
205
|
+
lastTouchAt = Date.now();
|
|
206
|
+
if (!hovering) return;
|
|
188
207
|
hovering = false;
|
|
189
208
|
delete lane.dataset.hovering;
|
|
190
209
|
start();
|
|
@@ -206,6 +225,7 @@ function createMarquee(lane, track, options = {}) {
|
|
|
206
225
|
lane.addEventListener("pointerdown", onPointerDown);
|
|
207
226
|
lane.addEventListener("pointerenter", onPointerEnter);
|
|
208
227
|
lane.addEventListener("pointerleave", onPointerLeave);
|
|
228
|
+
lane.addEventListener("touchstart", noteTouch, { passive: true });
|
|
209
229
|
lane.addEventListener("click", onClickCapture, true);
|
|
210
230
|
lane.addEventListener("click", onClick);
|
|
211
231
|
motionQuery?.addEventListener("change", onMotionChange);
|
|
@@ -255,6 +275,7 @@ function createMarquee(lane, track, options = {}) {
|
|
|
255
275
|
lane.removeEventListener("pointerdown", onPointerDown);
|
|
256
276
|
lane.removeEventListener("pointerenter", onPointerEnter);
|
|
257
277
|
lane.removeEventListener("pointerleave", onPointerLeave);
|
|
278
|
+
lane.removeEventListener("touchstart", noteTouch);
|
|
258
279
|
lane.removeEventListener("click", onClickCapture, true);
|
|
259
280
|
lane.removeEventListener("click", onClick);
|
|
260
281
|
window.removeEventListener("pointermove", onPointerMove);
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createMarquee } from './chunk-
|
|
1
|
+
export { createMarquee } from './chunk-55J5JEWX.js';
|
package/dist/react/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createMarquee } from '../chunk-
|
|
1
|
+
import { createMarquee } from '../chunk-55J5JEWX.js';
|
|
2
2
|
import { useRef, useState, useEffect, useLayoutEffect } from 'react';
|
|
3
3
|
|
|
4
4
|
var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@enigmax/primitives",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Headless interaction primitives: the behaviour, the timing and the accessibility of components like a draggable infinite marquee, with no styles of their own. Framework-agnostic core plus thin adapters.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
package/src/core/marquee.ts
CHANGED
|
@@ -20,6 +20,12 @@ const STILL_MS = 90;
|
|
|
20
20
|
const MAX_DT = 0.05;
|
|
21
21
|
/** One jittery frame must not decide the throw. */
|
|
22
22
|
const VELOCITY_SMOOTHING = 0.6;
|
|
23
|
+
/**
|
|
24
|
+
* How long a touch suppresses hover. Chromium's compatibility pointer events
|
|
25
|
+
* arrive within a frame or two of the touch and claim `pointerType: "mouse"`;
|
|
26
|
+
* a real mouse arriving later than this is a genuine hover.
|
|
27
|
+
*/
|
|
28
|
+
const TOUCH_HOVER_SUPPRESS_MS = 1000;
|
|
23
29
|
/** Below this the row is idle and the frame loop can stop. */
|
|
24
30
|
const IDLE_EPSILON = 0.01;
|
|
25
31
|
|
|
@@ -121,6 +127,7 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
|
|
|
121
127
|
let lastMoveTime = 0;
|
|
122
128
|
let maxTravel = 0;
|
|
123
129
|
let hovering = false;
|
|
130
|
+
let lastTouchAt = 0;
|
|
124
131
|
|
|
125
132
|
const motionQuery = typeof window.matchMedia === "function"
|
|
126
133
|
? window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
@@ -198,13 +205,30 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
|
|
|
198
205
|
|
|
199
206
|
function measure(): void {
|
|
200
207
|
if (destroyed) return;
|
|
201
|
-
const
|
|
202
|
-
|
|
208
|
+
const cloning = opts.copies === "clone";
|
|
209
|
+
|
|
210
|
+
// The lap can only be READ from the step between two copies, so the second one
|
|
211
|
+
// has to exist before the number is worth anything. Without this, markup that
|
|
212
|
+
// ships a single copy is measured by that copy's own width instead.
|
|
213
|
+
if (cloning && track.children.length < 2) syncClones(2);
|
|
214
|
+
|
|
215
|
+
let measured = readPeriod();
|
|
216
|
+
let needed = requiredCopies(measured);
|
|
217
|
+
|
|
218
|
+
// Reconcile against the DOM, not against the previous count. `copyCount` starts
|
|
219
|
+
// optimistically at 2 while the markup carries one copy, so keying the clone on
|
|
220
|
+
// "the count changed" left a row that needs exactly 2 copies with only its
|
|
221
|
+
// original - and a visible gap once the lap scrolled past the lane.
|
|
222
|
+
if (cloning && track.children.length !== needed) {
|
|
223
|
+
syncClones(needed);
|
|
224
|
+
measured = readPeriod();
|
|
225
|
+
needed = requiredCopies(measured);
|
|
226
|
+
}
|
|
227
|
+
|
|
203
228
|
const changed = measured !== period || needed !== copyCount;
|
|
204
229
|
period = measured;
|
|
205
230
|
if (needed !== copyCount) {
|
|
206
231
|
copyCount = needed;
|
|
207
|
-
if (opts.copies === "clone") syncClones(needed);
|
|
208
232
|
opts.onCopyCountChange?.(needed);
|
|
209
233
|
}
|
|
210
234
|
if (period > 0) offset = wrap(offset);
|
|
@@ -255,6 +279,7 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
|
|
|
255
279
|
|
|
256
280
|
function onPointerDown(event: PointerEvent): void {
|
|
257
281
|
// Non-negotiable 9: a right click must leave the context menu alone.
|
|
282
|
+
if (event.pointerType !== "mouse") noteTouch();
|
|
258
283
|
if (!opts.draggable || event.button > 0 || pointerId !== null) return;
|
|
259
284
|
pointerId = event.pointerId;
|
|
260
285
|
dragging = true;
|
|
@@ -316,16 +341,38 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
|
|
|
316
341
|
opts.onItemClick?.(event);
|
|
317
342
|
}
|
|
318
343
|
|
|
319
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* Non-negotiable 8: a tap fires pointerenter and never the pointerleave that
|
|
346
|
+
* undoes it, so the row would keep the hover speed for good after one touch.
|
|
347
|
+
*
|
|
348
|
+
* Checking `pointerType` is NOT enough on its own. After a touch, Chromium
|
|
349
|
+
* also emits the COMPATIBILITY pointer events, and those carry
|
|
350
|
+
* `pointerType: "mouse"` - observed on Linux while the same tap on Windows
|
|
351
|
+
* emitted nothing, which is how this passed locally and failed in CI. So a
|
|
352
|
+
* recent touch suppresses the hover as well.
|
|
353
|
+
*/
|
|
320
354
|
function onPointerEnter(event: PointerEvent): void {
|
|
321
|
-
if (event.pointerType !== "mouse")
|
|
355
|
+
if (event.pointerType !== "mouse") {
|
|
356
|
+
noteTouch();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (Date.now() - lastTouchAt < TOUCH_HOVER_SUPPRESS_MS) return;
|
|
322
360
|
hovering = true;
|
|
323
361
|
lane.dataset.hovering = "true";
|
|
324
362
|
start();
|
|
325
363
|
}
|
|
326
364
|
|
|
327
|
-
function onPointerLeave(
|
|
328
|
-
|
|
365
|
+
function onPointerLeave(): void {
|
|
366
|
+
// Clears on ANY pointer type: a hover that cannot be undone is the bug.
|
|
367
|
+
hovering = false;
|
|
368
|
+
delete lane.dataset.hovering;
|
|
369
|
+
start();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Stamp the last touch and drop any hover it may already have set. */
|
|
373
|
+
function noteTouch(): void {
|
|
374
|
+
lastTouchAt = Date.now();
|
|
375
|
+
if (!hovering) return;
|
|
329
376
|
hovering = false;
|
|
330
377
|
delete lane.dataset.hovering;
|
|
331
378
|
start();
|
|
@@ -351,6 +398,9 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
|
|
|
351
398
|
lane.addEventListener("pointerdown", onPointerDown);
|
|
352
399
|
lane.addEventListener("pointerenter", onPointerEnter);
|
|
353
400
|
lane.addEventListener("pointerleave", onPointerLeave);
|
|
401
|
+
// Fires BEFORE the compatibility pointer events a touch generates, so the
|
|
402
|
+
// suppression window is already open when the fake "mouse" enter arrives.
|
|
403
|
+
lane.addEventListener("touchstart", noteTouch, { passive: true });
|
|
354
404
|
lane.addEventListener("click", onClickCapture, true);
|
|
355
405
|
lane.addEventListener("click", onClick);
|
|
356
406
|
motionQuery?.addEventListener("change", onMotionChange);
|
|
@@ -390,6 +440,7 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
|
|
|
390
440
|
lane.removeEventListener("pointerdown", onPointerDown);
|
|
391
441
|
lane.removeEventListener("pointerenter", onPointerEnter);
|
|
392
442
|
lane.removeEventListener("pointerleave", onPointerLeave);
|
|
443
|
+
lane.removeEventListener("touchstart", noteTouch);
|
|
393
444
|
lane.removeEventListener("click", onClickCapture, true);
|
|
394
445
|
lane.removeEventListener("click", onClick);
|
|
395
446
|
window.removeEventListener("pointermove", onPointerMove);
|