@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 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 measured = readPeriod();
90
- const needed = requiredCopies(measured);
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") return;
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(event) {
187
- if (event.pointerType !== "mouse") return;
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-5Q5TAW2N.js';
1
+ export { createMarquee } from './chunk-55J5JEWX.js';
@@ -1,4 +1,4 @@
1
- import { createMarquee } from '../chunk-5Q5TAW2N.js';
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.0",
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",
@@ -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 measured = readPeriod();
202
- const needed = requiredCopies(measured);
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
- /** Non-negotiable 8: a tap fires pointerenter and never the pointerleave that undoes it. */
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") return;
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(event: PointerEvent): void {
328
- if (event.pointerType !== "mouse") return;
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);