@enigmax/primitives 0.14.1 → 0.16.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.
@@ -16,6 +16,7 @@ var DEFAULTS = {
16
16
  manageStyles: true,
17
17
  copies: "clone"
18
18
  };
19
+ var MAX_COPIES = 24;
19
20
  function createMarquee(lane, track, options = {}) {
20
21
  let opts = { ...DEFAULTS, ...options };
21
22
  let period = 0;
@@ -80,7 +81,7 @@ function createMarquee(lane, track, options = {}) {
80
81
  }
81
82
  function requiredCopies(measured) {
82
83
  if (measured <= 0) return 2;
83
- return Math.max(2, Math.ceil(axisSize(lane) / measured) + 1);
84
+ return Math.min(MAX_COPIES, Math.max(2, Math.ceil(axisSize(lane) / measured) + 1));
84
85
  }
85
86
  function syncClones(count) {
86
87
  const source = track.children[0];
@@ -241,6 +242,19 @@ function createMarquee(lane, track, options = {}) {
241
242
  if (first) resizeObserver?.observe(first);
242
243
  measure();
243
244
  void document.fonts?.ready.then(() => measure());
245
+ remeasureOnImageLoad();
246
+ }
247
+ function remeasureOnImageLoad() {
248
+ for (const image of track.querySelectorAll("img")) {
249
+ if (image.complete) continue;
250
+ const done = () => {
251
+ image.removeEventListener("load", done);
252
+ image.removeEventListener("error", done);
253
+ if (!destroyed) measure();
254
+ };
255
+ image.addEventListener("load", done);
256
+ image.addEventListener("error", done);
257
+ }
244
258
  }
245
259
  init();
246
260
  return {
@@ -1,4 +1,4 @@
1
- import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createNotifications, createButton, createMarquee, createInput, createNetworkMonitor, SERVER_NETWORK_STATE, relativeTimeView, relativeTimeAttributes } from './chunk-DD2X5B2J.js';
1
+ import { generatePassword, estimatePasswordStrength, INPUT_ICON_PATHS, createNotifications, createButton, createMarquee, createInput, createNetworkMonitor, SERVER_NETWORK_STATE, relativeTimeView, relativeTimeAttributes } from './chunk-53DQY6LP.js';
2
2
  import { createSearch } from './chunk-UZFEEFMF.js';
3
3
  import { forwardRef, createElement, useRef, useState, useId, useCallback, useEffect, useMemo, useSyncExternalStore, useLayoutEffect } from 'react';
4
4
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
package/dist/index.d.ts CHANGED
@@ -78,13 +78,6 @@ interface MarqueeInstance {
78
78
  resume(): void;
79
79
  destroy(): void;
80
80
  }
81
- /**
82
- * Wire a lane and its track into a draggable infinite marquee.
83
- *
84
- * @param lane The clipping viewport. Receives the pointer and the click guard.
85
- * @param track The moved element. Its children are the repeated copies; child 0
86
- * is the source content and must already be rendered.
87
- */
88
81
  declare function createMarquee(lane: HTMLElement, track: HTMLElement, options?: MarqueeOptions): MarqueeInstance;
89
82
 
90
83
  /**
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { INPUT_ICON_PATHS, SERVER_NETWORK_STATE, createButton, createInput, createMarquee, createNetworkMonitor, createNotifications, ensureZone, estimatePasswordStrength, generatePassword, iconMarkup, normalizeDate, parseDuration, relativeTimeAttributes, relativeTimeView } from './chunk-DD2X5B2J.js';
1
+ export { INPUT_ICON_PATHS, SERVER_NETWORK_STATE, createButton, createInput, createMarquee, createNetworkMonitor, createNotifications, ensureZone, estimatePasswordStrength, generatePassword, iconMarkup, normalizeDate, parseDuration, relativeTimeAttributes, relativeTimeView } from './chunk-53DQY6LP.js';
2
2
  export { createSearch } from './chunk-UZFEEFMF.js';
@@ -1,7 +1,7 @@
1
1
  "use client";
2
- import { Button } from '../chunk-QFJ5NT6Z.js';
3
- export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-QFJ5NT6Z.js';
4
- export { estimatePasswordStrength, generatePassword } from '../chunk-DD2X5B2J.js';
2
+ import { Button } from '../chunk-U2KYYMBJ.js';
3
+ export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-U2KYYMBJ.js';
4
+ export { estimatePasswordStrength, generatePassword } from '../chunk-53DQY6LP.js';
5
5
  import '../chunk-UZFEEFMF.js';
6
6
  import Link from 'next/link';
7
7
  import { forwardRef, createElement } from 'react';
@@ -1,4 +1,4 @@
1
1
  "use client";
2
- export { Button, Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-QFJ5NT6Z.js';
3
- export { estimatePasswordStrength, generatePassword } from '../chunk-DD2X5B2J.js';
2
+ export { Button, Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-U2KYYMBJ.js';
3
+ export { estimatePasswordStrength, generatePassword } from '../chunk-53DQY6LP.js';
4
4
  import '../chunk-UZFEEFMF.js';
@@ -1,7 +1,7 @@
1
1
  "use client";
2
- import { Button } from '../chunk-QFJ5NT6Z.js';
3
- export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-QFJ5NT6Z.js';
4
- export { estimatePasswordStrength, generatePassword } from '../chunk-DD2X5B2J.js';
2
+ import { Button } from '../chunk-U2KYYMBJ.js';
3
+ export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-U2KYYMBJ.js';
4
+ export { estimatePasswordStrength, generatePassword } from '../chunk-53DQY6LP.js';
5
5
  import '../chunk-UZFEEFMF.js';
6
6
  import { Link } from 'react-router';
7
7
  import { forwardRef, createElement } from 'react';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enigmax/primitives",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "Headless components: the behaviour, the timing and the accessibility, with no styles of their own. Framework-agnostic core plus thin adapters.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,8 @@
30
30
  "./relative-time.css": "./recipes/relative-time/styles.css",
31
31
  "./toast.css": "./recipes/toast/styles.css",
32
32
  "./input.css": "./recipes/input/styles.css",
33
- "./marquee.css": "./recipes/marquee/styles.css"
33
+ "./marquee.css": "./recipes/marquee/styles.css",
34
+ "./logo-marquee.css": "./recipes/logo-marquee/styles.css"
34
35
  },
35
36
  "scripts": {
36
37
  "build": "tsup",
@@ -0,0 +1,119 @@
1
+ import "./styles.css";
2
+ import type { ReactNode, Ref } from "react";
3
+ import { useMarquee, type MarqueeHover } from "@enigmax/primitives/react";
4
+
5
+ /**
6
+ * The "trusted by" logo wall, yours to edit.
7
+ *
8
+ * A logo wall is a marquee with one extra job: the logos arrive at wildly different aspect
9
+ * ratios and optical weights, and a row that just drops them in reads as random. Normalising
10
+ * them to a common height is most of what makes it look designed.
11
+ *
12
+ * Some of the logos in any real wall are not images - Bloomberg, NASA and Harvard are set in
13
+ * type - so an item is either an image or a node.
14
+ */
15
+
16
+ export type LogoItem =
17
+ /**
18
+ * A URL, which is the whole entry for most logos:
19
+ * `logos={["/logos/claude.svg", "/logos/openai.svg"]}`.
20
+ *
21
+ * It carries no `alt` - an alt guessed from a file name is a fabricated one, and a
22
+ * wrong alt is worse than an empty one. Use the object form where a mark needs a name.
23
+ */
24
+ | string
25
+ | { kind: "img"; src: string; alt?: string; /** Override the shared height for one that reads small. */ height?: number; }
26
+ | { kind: "node"; node: ReactNode; key?: string; };
27
+
28
+ /** The object form of whatever was passed. A bare URL is an image with no alt. */
29
+ function normalize(item: LogoItem): Exclude<LogoItem, string> {
30
+ return typeof item === "string" ? { kind: "img", src: item } : item;
31
+ }
32
+
33
+ export interface LogoMarqueeProps {
34
+ logos: LogoItem[];
35
+ /**
36
+ * Pixels per second. NEVER a duration: a lap is as long as its content, so seconds-per-
37
+ * loop makes the speed content/duration and the row runs faster every time a logo is
38
+ * added. Measured on one rail: 45, 67 and 87 px/s at 10, 15 and 20 items.
39
+ */
40
+ speed?: number;
41
+ /** Space between logos, in px. */
42
+ gap?: number;
43
+ /** Fade both ends, so logos enter and leave instead of being cut. */
44
+ fade?: boolean;
45
+ /**
46
+ * What a pointer resting on the row does: "off", "pause", a multiplier (0.15 crawls),
47
+ * or `{ speed }` for an absolute px/s.
48
+ */
49
+ hover?: MarqueeHover;
50
+ reverse?: boolean;
51
+ /** The shared optical height every image is scaled to. */
52
+ logoHeight?: number;
53
+ /**
54
+ * `mono` drains the colour so twelve brand palettes stop fighting each other, and
55
+ * restores it under the pointer. `brand` leaves them alone.
56
+ */
57
+ tone?: "brand" | "mono";
58
+ /** Names the row for a screen reader, e.g. "Trusted by". Untranslated by default. */
59
+ label?: string;
60
+ className?: string;
61
+ }
62
+
63
+ export function LogoMarquee({
64
+ logos,
65
+ speed = 40,
66
+ gap = 56,
67
+ fade = true,
68
+ hover = "off",
69
+ reverse = false,
70
+ logoHeight = 28,
71
+ tone = "mono",
72
+ label,
73
+ className = ""
74
+ }: LogoMarqueeProps) {
75
+ const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
76
+
77
+ const render = (raw: LogoItem, index: number): ReactNode => {
78
+ const item = normalize(raw);
79
+ return (
80
+ <span className="logo-marquee__item" key={item.kind === "node" ? item.key ?? index : index}>
81
+ {item.kind === "img"
82
+ ? <img
83
+ src={item.src}
84
+ // Empty by default: a wall of brand names read out one after another is
85
+ // noise, and `label` already says what the row is.
86
+ alt={item.alt ?? ""}
87
+ style={item.height ? { height: `${item.height}px` } : undefined}
88
+ draggable={false}
89
+ // Never lazy: the row measures its own lap from the rendered width, and
90
+ // an image with no size yet measures as nothing.
91
+ decoding="async"
92
+ />
93
+ : item.node}
94
+ </span>
95
+ );
96
+ };
97
+
98
+ return (
99
+ <div
100
+ ref={laneRef as Ref<HTMLDivElement>}
101
+ className={["logo-marquee", fade ? "is-faded" : "", className].filter(Boolean).join(" ")}
102
+ style={{ "--logo-marquee-gap": `${gap}px`, "--logo-marquee-height": `${logoHeight}px` } as React.CSSProperties}
103
+ data-tone={tone}
104
+ data-grabbing={dragging ? "" : undefined}
105
+ role={label ? "group" : undefined}
106
+ aria-label={label}
107
+ >
108
+ <div ref={trackRef as Ref<HTMLDivElement>} className="logo-marquee__track">
109
+ {Array.from({ length: copies }, (unused, copy) => (
110
+ // Only the first copy is read: the rest are the same logos again, and a
111
+ // screen reader announcing the wall three times is the bug this prevents.
112
+ <div key={copy} aria-hidden={copy > 0} className="logo-marquee__copy">
113
+ {logos.map(render)}
114
+ </div>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
@@ -0,0 +1,74 @@
1
+ /*
2
+ * The logo wall's look. Yours to edit - the behaviour above ships none of this.
3
+ *
4
+ * The two custom properties are set from the props, so the component never writes a class
5
+ * per gap or per height.
6
+ */
7
+
8
+ .logo-marquee {
9
+ --logo-marquee-gap: 56px;
10
+ --logo-marquee-height: 28px;
11
+ position: relative;
12
+ overflow: hidden;
13
+ /* The lane's width must come from its container, never from its content. `width: 100%`
14
+ and `min-width: 0` say so explicitly, because a lane that sizes to max-content - a
15
+ grid cell with `justify-items: center`, a flex item without `min-width: 0` - grows to
16
+ fit the track, and a wider lane needs more copies, which makes it wider again. */
17
+ width: 100%;
18
+ min-width: 0;
19
+ /* Set by the primitive too, but declared here so the row still behaves before the
20
+ script runs: the page scrolls vertically, the drag takes the horizontal. */
21
+ touch-action: pan-y;
22
+ cursor: grab;
23
+ }
24
+
25
+ .logo-marquee[data-grabbing] { cursor: grabbing; }
26
+
27
+ /* Both ends fade out, so a logo leaves rather than being sliced by the container edge. */
28
+ .logo-marquee.is-faded {
29
+ -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
30
+ mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
31
+ }
32
+
33
+ .logo-marquee__track { display: flex; width: max-content; }
34
+ .logo-marquee__copy { display: flex; align-items: center; gap: var(--logo-marquee-gap); padding-right: var(--logo-marquee-gap); }
35
+
36
+ .logo-marquee__item {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ flex: 0 0 auto;
40
+ /* The whole trick of a logo wall: one optical height for everything, whatever the
41
+ source images measure, so a row of twelve brands reads as one row. */
42
+ height: var(--logo-marquee-height);
43
+ font-size: calc(var(--logo-marquee-height) * 0.72);
44
+ font-weight: 600;
45
+ line-height: 1;
46
+ white-space: nowrap;
47
+ user-select: none;
48
+ }
49
+
50
+ .logo-marquee__item img {
51
+ height: 100%;
52
+ /* Never stretched: a logo is the one image where the aspect ratio is the brand. */
53
+ width: auto;
54
+ object-fit: contain;
55
+ display: block;
56
+ }
57
+
58
+ /* Twelve brand palettes side by side fight each other and pull the eye off the copy, so
59
+ the default drains them - and gives the colour back to whichever one you point at. */
60
+ .logo-marquee[data-tone="mono"] .logo-marquee__item {
61
+ filter: grayscale(1);
62
+ opacity: 0.65;
63
+ transition: filter 160ms ease-out, opacity 160ms ease-out;
64
+ }
65
+
66
+ .logo-marquee[data-tone="mono"] .logo-marquee__item:hover {
67
+ filter: grayscale(0);
68
+ opacity: 1;
69
+ }
70
+
71
+ @media (prefers-reduced-motion: reduce) {
72
+ /* The primitive already stops the autoplay; this stops the colour from animating too. */
73
+ .logo-marquee__item { transition: none; }
74
+ }
package/registry.json CHANGED
@@ -91,6 +91,68 @@
91
91
  "css"
92
92
  ]
93
93
  },
94
+ {
95
+ "name": "logo-marquee",
96
+ "title": "Logo wall",
97
+ "description": "The \"trusted by\" row: a marquee that also does the job a logo wall needs doing - one optical height for logos that arrive at a dozen different sizes, colour drained so twelve brand palettes stop fighting, and text logos like NASA or Bloomberg alongside the images.",
98
+ "targets": [
99
+ "react"
100
+ ],
101
+ "entry": {
102
+ "react": "@enigmax/primitives/react"
103
+ },
104
+ "exports": {
105
+ "react": [
106
+ "LogoMarquee"
107
+ ]
108
+ },
109
+ "files": [
110
+ {
111
+ "path": "src/core/marquee.ts",
112
+ "dest": "logo-marquee/marquee.ts",
113
+ "targets": [
114
+ "react"
115
+ ]
116
+ },
117
+ {
118
+ "path": "src/react/use-marquee.ts",
119
+ "dest": "logo-marquee/use-marquee.ts",
120
+ "targets": [
121
+ "react"
122
+ ],
123
+ "rewrite": {
124
+ "@/core/marquee": "./marquee"
125
+ }
126
+ },
127
+ {
128
+ "path": "recipes/logo-marquee/styles.css",
129
+ "dest": "logo-marquee/styles.css",
130
+ "targets": [
131
+ "react"
132
+ ]
133
+ },
134
+ {
135
+ "path": "recipes/logo-marquee/index.tsx",
136
+ "dest": "logo-marquee/index.tsx",
137
+ "main": true,
138
+ "targets": [
139
+ "react"
140
+ ],
141
+ "rewrite": {
142
+ "@enigmax/primitives/react": "./use-marquee"
143
+ }
144
+ }
145
+ ],
146
+ "styles": true,
147
+ "themeHooks": [
148
+ "[data-tone=mono]",
149
+ ".logo-marquee",
150
+ ".logo-marquee.is-faded",
151
+ ".logo-marquee[data-grabbing]",
152
+ ".logo-marquee__item"
153
+ ],
154
+ "docs": "docs/notes/primitives.md#logo-marquee"
155
+ },
94
156
  {
95
157
  "name": "input",
96
158
  "title": "Input, with what a password field needs",
@@ -121,6 +121,9 @@ const DEFAULTS: ResolvedOptions = {
121
121
  * @param track The moved element. Its children are the repeated copies; child 0
122
122
  * is the source content and must already be rendered.
123
123
  */
124
+ /** Ceiling on the copy count. See requiredCopies for why a ceiling is needed at all. */
125
+ const MAX_COPIES = 24;
126
+
124
127
  export function createMarquee(lane: HTMLElement, track: HTMLElement, options: MarqueeOptions = {}): MarqueeInstance {
125
128
  let opts: ResolvedOptions = { ...DEFAULTS, ...options };
126
129
 
@@ -211,7 +214,14 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
211
214
  /** Non-negotiable 2: the lane plus one whole period, so there is always one to wrap into. */
212
215
  function requiredCopies(measured: number): number {
213
216
  if (measured <= 0) return 2;
214
- return Math.max(2, Math.ceil(axisSize(lane) / measured) + 1);
217
+ // Capped, because the measurement can be transiently TINY rather than zero: a row
218
+ // of images measured before they load is a few pixels wide, and lane/period then
219
+ // asks for hundreds of copies. In React that renders hundreds of subtrees before
220
+ // the images arrive to correct it, which trips the update-depth limit and takes the
221
+ // page down - the zero guard above does not catch it because the width is not zero.
222
+ // The cap is far past any real wall; a copy narrow enough to need more than this is
223
+ // a measurement that has not settled yet, and the ResizeObserver will come back.
224
+ return Math.min(MAX_COPIES, Math.max(2, Math.ceil(axisSize(lane) / measured) + 1));
215
225
  }
216
226
 
217
227
  function syncClones(count: number): void {
@@ -437,6 +447,27 @@ export function createMarquee(lane: HTMLElement, track: HTMLElement, options: Ma
437
447
  measure();
438
448
  // A late font changes every width the lap was measured from.
439
449
  void document.fonts?.ready.then(() => measure());
450
+ // So does a late IMAGE, and that one is the common case: a logo wall measured
451
+ // before its logos have loaded reads as a few pixels wide, which asks for far more
452
+ // copies than the row needs. The ResizeObserver above sees the first copy resize,
453
+ // but only while that exact node survives - a framework re-rendering the copies
454
+ // replaces it - so the images are waited on directly.
455
+ remeasureOnImageLoad();
456
+ }
457
+
458
+ /** Re-measure once each image that had not loaded at mount finishes. */
459
+ function remeasureOnImageLoad(): void {
460
+ for (const image of track.querySelectorAll("img")) {
461
+ if (image.complete) continue;
462
+ const done = (): void => {
463
+ image.removeEventListener("load", done);
464
+ image.removeEventListener("error", done);
465
+ if (!destroyed) measure();
466
+ };
467
+ // `error` too: a logo that 404s still settles the layout it was holding open.
468
+ image.addEventListener("load", done);
469
+ image.addEventListener("error", done);
470
+ }
440
471
  }
441
472
 
442
473
  init();