@enigmax/primitives 0.14.1 → 0.15.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/dist/{chunk-DD2X5B2J.js → chunk-53DQY6LP.js} +15 -1
- package/dist/{chunk-QFJ5NT6Z.js → chunk-U2KYYMBJ.js} +1 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.js +1 -1
- package/dist/next/index.js +3 -3
- package/dist/react/index.js +2 -2
- package/dist/react-router/index.js +3 -3
- package/package.json +3 -2
- package/recipes/logo-marquee/index.tsx +103 -0
- package/recipes/logo-marquee/styles.css +74 -0
- package/registry.json +62 -0
- package/src/core/marquee.ts +32 -1
|
@@ -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-
|
|
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-
|
|
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';
|
package/dist/next/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { Button } from '../chunk-
|
|
3
|
-
export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-
|
|
4
|
-
export { estimatePasswordStrength, generatePassword } from '../chunk-
|
|
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';
|
package/dist/react/index.js
CHANGED
|
@@ -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-
|
|
3
|
-
export { estimatePasswordStrength, generatePassword } from '../chunk-
|
|
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-
|
|
3
|
-
export { Input, PasswordStrength, RelativeTime, Toaster, createNotificationQueue, defaultQueue, getLinkComponent, setLinkComponent, useButton, useInput, useMarquee, useNetworkState, useNotifications, useSearch } from '../chunk-
|
|
4
|
-
export { estimatePasswordStrength, generatePassword } from '../chunk-
|
|
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.
|
|
3
|
+
"version": "0.15.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,103 @@
|
|
|
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
|
+
| { kind: "img"; src: string; alt?: string; /** Override the shared height for one that reads small. */ height?: number; }
|
|
18
|
+
| { kind: "node"; node: ReactNode; key?: string; };
|
|
19
|
+
|
|
20
|
+
export interface LogoMarqueeProps {
|
|
21
|
+
logos: LogoItem[];
|
|
22
|
+
/**
|
|
23
|
+
* Pixels per second. NEVER a duration: a lap is as long as its content, so seconds-per-
|
|
24
|
+
* loop makes the speed content/duration and the row runs faster every time a logo is
|
|
25
|
+
* added. Measured on one rail: 45, 67 and 87 px/s at 10, 15 and 20 items.
|
|
26
|
+
*/
|
|
27
|
+
speed?: number;
|
|
28
|
+
/** Space between logos, in px. */
|
|
29
|
+
gap?: number;
|
|
30
|
+
/** Fade both ends, so logos enter and leave instead of being cut. */
|
|
31
|
+
fade?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* What a pointer resting on the row does: "off", "pause", a multiplier (0.15 crawls),
|
|
34
|
+
* or `{ speed }` for an absolute px/s.
|
|
35
|
+
*/
|
|
36
|
+
hover?: MarqueeHover;
|
|
37
|
+
reverse?: boolean;
|
|
38
|
+
/** The shared optical height every image is scaled to. */
|
|
39
|
+
logoHeight?: number;
|
|
40
|
+
/**
|
|
41
|
+
* `mono` drains the colour so twelve brand palettes stop fighting each other, and
|
|
42
|
+
* restores it under the pointer. `brand` leaves them alone.
|
|
43
|
+
*/
|
|
44
|
+
tone?: "brand" | "mono";
|
|
45
|
+
/** Names the row for a screen reader, e.g. "Trusted by". Untranslated by default. */
|
|
46
|
+
label?: string;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function LogoMarquee({
|
|
51
|
+
logos,
|
|
52
|
+
speed = 40,
|
|
53
|
+
gap = 56,
|
|
54
|
+
fade = true,
|
|
55
|
+
hover = "off",
|
|
56
|
+
reverse = false,
|
|
57
|
+
logoHeight = 28,
|
|
58
|
+
tone = "mono",
|
|
59
|
+
label,
|
|
60
|
+
className = ""
|
|
61
|
+
}: LogoMarqueeProps) {
|
|
62
|
+
const { laneRef, trackRef, copies, dragging } = useMarquee({ speed, hover, reverse });
|
|
63
|
+
|
|
64
|
+
const render = (item: LogoItem, index: number): ReactNode => (
|
|
65
|
+
<span className="logo-marquee__item" key={item.kind === "node" ? item.key ?? index : index}>
|
|
66
|
+
{item.kind === "img"
|
|
67
|
+
? <img
|
|
68
|
+
src={item.src}
|
|
69
|
+
// Empty by default: a wall of brand names read out one after another is
|
|
70
|
+
// noise, and `label` already says what the row is.
|
|
71
|
+
alt={item.alt ?? ""}
|
|
72
|
+
style={item.height ? { height: `${item.height}px` } : undefined}
|
|
73
|
+
draggable={false}
|
|
74
|
+
// Never lazy: the row measures its own lap from the rendered width, and
|
|
75
|
+
// an image with no size yet measures as nothing.
|
|
76
|
+
decoding="async"
|
|
77
|
+
/>
|
|
78
|
+
: item.node}
|
|
79
|
+
</span>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
ref={laneRef as Ref<HTMLDivElement>}
|
|
85
|
+
className={["logo-marquee", fade ? "is-faded" : "", className].filter(Boolean).join(" ")}
|
|
86
|
+
style={{ "--logo-marquee-gap": `${gap}px`, "--logo-marquee-height": `${logoHeight}px` } as React.CSSProperties}
|
|
87
|
+
data-tone={tone}
|
|
88
|
+
data-grabbing={dragging ? "" : undefined}
|
|
89
|
+
role={label ? "group" : undefined}
|
|
90
|
+
aria-label={label}
|
|
91
|
+
>
|
|
92
|
+
<div ref={trackRef as Ref<HTMLDivElement>} className="logo-marquee__track">
|
|
93
|
+
{Array.from({ length: copies }, (unused, copy) => (
|
|
94
|
+
// Only the first copy is read: the rest are the same logos again, and a
|
|
95
|
+
// screen reader announcing the wall three times is the bug this prevents.
|
|
96
|
+
<div key={copy} aria-hidden={copy > 0} className="logo-marquee__copy">
|
|
97
|
+
{logos.map(render)}
|
|
98
|
+
</div>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -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",
|
package/src/core/marquee.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|