@enigmax/primitives 0.1.0 → 0.1.1

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 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() {
@@ -132,6 +134,7 @@ function createMarquee(lane, track, options = {}) {
132
134
  return opts.vertical ? event.clientY : event.clientX;
133
135
  }
134
136
  function onPointerDown(event) {
137
+ if (event.pointerType !== "mouse") noteTouch();
135
138
  if (!opts.draggable || event.button > 0 || pointerId !== null) return;
136
139
  pointerId = event.pointerId;
137
140
  dragging = true;
@@ -178,13 +181,23 @@ function createMarquee(lane, track, options = {}) {
178
181
  opts.onItemClick?.(event);
179
182
  }
180
183
  function onPointerEnter(event) {
181
- if (event.pointerType !== "mouse") return;
184
+ if (event.pointerType !== "mouse") {
185
+ noteTouch();
186
+ return;
187
+ }
188
+ if (Date.now() - lastTouchAt < TOUCH_HOVER_SUPPRESS_MS) return;
182
189
  hovering = true;
183
190
  lane.dataset.hovering = "true";
184
191
  start();
185
192
  }
186
- function onPointerLeave(event) {
187
- if (event.pointerType !== "mouse") return;
193
+ function onPointerLeave() {
194
+ hovering = false;
195
+ delete lane.dataset.hovering;
196
+ start();
197
+ }
198
+ function noteTouch() {
199
+ lastTouchAt = Date.now();
200
+ if (!hovering) return;
188
201
  hovering = false;
189
202
  delete lane.dataset.hovering;
190
203
  start();
@@ -206,6 +219,7 @@ function createMarquee(lane, track, options = {}) {
206
219
  lane.addEventListener("pointerdown", onPointerDown);
207
220
  lane.addEventListener("pointerenter", onPointerEnter);
208
221
  lane.addEventListener("pointerleave", onPointerLeave);
222
+ lane.addEventListener("touchstart", noteTouch, { passive: true });
209
223
  lane.addEventListener("click", onClickCapture, true);
210
224
  lane.addEventListener("click", onClick);
211
225
  motionQuery?.addEventListener("change", onMotionChange);
@@ -255,6 +269,7 @@ function createMarquee(lane, track, options = {}) {
255
269
  lane.removeEventListener("pointerdown", onPointerDown);
256
270
  lane.removeEventListener("pointerenter", onPointerEnter);
257
271
  lane.removeEventListener("pointerleave", onPointerLeave);
272
+ lane.removeEventListener("touchstart", noteTouch);
258
273
  lane.removeEventListener("click", onClickCapture, true);
259
274
  lane.removeEventListener("click", onClick);
260
275
  window.removeEventListener("pointermove", onPointerMove);
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export { createMarquee } from './chunk-5Q5TAW2N.js';
1
+ export { createMarquee } from './chunk-QDGZANPT.js';
@@ -1,4 +1,4 @@
1
- import { createMarquee } from '../chunk-5Q5TAW2N.js';
1
+ import { createMarquee } from '../chunk-QDGZANPT.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.0",
3
+ "version": "0.1.1",
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",
@@ -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)")
@@ -255,6 +262,7 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
255
262
 
256
263
  function onPointerDown(event: PointerEvent): void {
257
264
  // Non-negotiable 9: a right click must leave the context menu alone.
265
+ if (event.pointerType !== "mouse") noteTouch();
258
266
  if (!opts.draggable || event.button > 0 || pointerId !== null) return;
259
267
  pointerId = event.pointerId;
260
268
  dragging = true;
@@ -316,16 +324,38 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
316
324
  opts.onItemClick?.(event);
317
325
  }
318
326
 
319
- /** Non-negotiable 8: a tap fires pointerenter and never the pointerleave that undoes it. */
327
+ /**
328
+ * Non-negotiable 8: a tap fires pointerenter and never the pointerleave that
329
+ * undoes it, so the row would keep the hover speed for good after one touch.
330
+ *
331
+ * Checking `pointerType` is NOT enough on its own. After a touch, Chromium
332
+ * also emits the COMPATIBILITY pointer events, and those carry
333
+ * `pointerType: "mouse"` - observed on Linux while the same tap on Windows
334
+ * emitted nothing, which is how this passed locally and failed in CI. So a
335
+ * recent touch suppresses the hover as well.
336
+ */
320
337
  function onPointerEnter(event: PointerEvent): void {
321
- if (event.pointerType !== "mouse") return;
338
+ if (event.pointerType !== "mouse") {
339
+ noteTouch();
340
+ return;
341
+ }
342
+ if (Date.now() - lastTouchAt < TOUCH_HOVER_SUPPRESS_MS) return;
322
343
  hovering = true;
323
344
  lane.dataset.hovering = "true";
324
345
  start();
325
346
  }
326
347
 
327
- function onPointerLeave(event: PointerEvent): void {
328
- if (event.pointerType !== "mouse") return;
348
+ function onPointerLeave(): void {
349
+ // Clears on ANY pointer type: a hover that cannot be undone is the bug.
350
+ hovering = false;
351
+ delete lane.dataset.hovering;
352
+ start();
353
+ }
354
+
355
+ /** Stamp the last touch and drop any hover it may already have set. */
356
+ function noteTouch(): void {
357
+ lastTouchAt = Date.now();
358
+ if (!hovering) return;
329
359
  hovering = false;
330
360
  delete lane.dataset.hovering;
331
361
  start();
@@ -351,6 +381,9 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
351
381
  lane.addEventListener("pointerdown", onPointerDown);
352
382
  lane.addEventListener("pointerenter", onPointerEnter);
353
383
  lane.addEventListener("pointerleave", onPointerLeave);
384
+ // Fires BEFORE the compatibility pointer events a touch generates, so the
385
+ // suppression window is already open when the fake "mouse" enter arrives.
386
+ lane.addEventListener("touchstart", noteTouch, { passive: true });
354
387
  lane.addEventListener("click", onClickCapture, true);
355
388
  lane.addEventListener("click", onClick);
356
389
  motionQuery?.addEventListener("change", onMotionChange);
@@ -390,6 +423,7 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
390
423
  lane.removeEventListener("pointerdown", onPointerDown);
391
424
  lane.removeEventListener("pointerenter", onPointerEnter);
392
425
  lane.removeEventListener("pointerleave", onPointerLeave);
426
+ lane.removeEventListener("touchstart", noteTouch);
393
427
  lane.removeEventListener("click", onClickCapture, true);
394
428
  lane.removeEventListener("click", onClick);
395
429
  window.removeEventListener("pointermove", onPointerMove);