@boyernick/standard-ui-react 0.1.1-canary.8 → 0.2.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/package.json +5 -3
- package/src/accordion.tsx +5 -2
- package/src/alert-dialog.tsx +1 -1
- package/src/attachment.tsx +5 -2
- package/src/autocomplete.tsx +4 -3
- package/src/block-editor.tsx +1747 -0
- package/src/brand.tsx +2 -2
- package/src/breadcrumb.tsx +14 -7
- package/src/button.tsx +22 -11
- package/src/calendar.tsx +6 -3
- package/src/carousel.tsx +220 -53
- package/src/checkbox.tsx +3 -2
- package/src/code-block.tsx +66 -5
- package/src/collapsible.tsx +4 -2
- package/src/combobox.tsx +4 -3
- package/src/command.tsx +41 -13
- package/src/date-picker.tsx +7 -2
- package/src/dialog.tsx +33 -10
- package/src/empty.tsx +4 -4
- package/src/field.tsx +4 -3
- package/src/filter-group.tsx +143 -0
- package/src/icons.tsx +28 -1
- package/src/illustrations.tsx +219 -141
- package/src/index.ts +245 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +49 -0
- package/src/lib/focus.ts +41 -0
- package/src/lib/popup.ts +1 -1
- package/src/lifeline/company-icon.tsx +71 -0
- package/src/lifeline/index.ts +42 -0
- package/src/lifeline/lifeline-data.ts +97 -0
- package/src/lifeline/lifeline-desktop.tsx +204 -0
- package/src/lifeline/lifeline-event.tsx +108 -0
- package/src/lifeline/lifeline-fireworks.tsx +302 -0
- package/src/lifeline/lifeline-hover-image.tsx +269 -0
- package/src/lifeline/lifeline-icons.tsx +39 -0
- package/src/lifeline/lifeline-intro-timing.ts +105 -0
- package/src/lifeline/lifeline-labels.tsx +24 -0
- package/src/lifeline/lifeline-layout.ts +1 -0
- package/src/lifeline/lifeline-legend.tsx +31 -0
- package/src/lifeline/lifeline-lightbox.tsx +309 -0
- package/src/lifeline/lifeline-marker.tsx +183 -0
- package/src/lifeline/lifeline-people.tsx +99 -0
- package/src/lifeline/lifeline-photos.tsx +366 -0
- package/src/lifeline/lifeline-shell.tsx +135 -0
- package/src/lifeline/lifeline-utils.ts +83 -0
- package/src/lifeline/lifeline-vertical.tsx +482 -0
- package/src/lifeline/lifeline.tsx +69 -0
- package/src/lifeline/types.ts +112 -0
- package/src/lifeline/use-lifeline-intro.ts +130 -0
- package/src/lifeline/use-lifeline-scroll.ts +1011 -0
- package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
- package/src/menubar.tsx +9 -2
- package/src/minimap.tsx +296 -0
- package/src/modal.tsx +608 -0
- package/src/navigation-menu.tsx +338 -67
- package/src/number-field.tsx +336 -61
- package/src/otp-field.tsx +4 -3
- package/src/pagination.tsx +262 -42
- package/src/password-protection.tsx +345 -0
- package/src/popover.tsx +1 -1
- package/src/progress.tsx +5 -0
- package/src/questionnaire.tsx +284 -0
- package/src/radio.tsx +4 -2
- package/src/scroll-area.tsx +4 -1
- package/src/select.tsx +5 -2
- package/src/sidebar.tsx +113 -28
- package/src/slider.tsx +66 -6
- package/src/sounds.tsx +6 -10
- package/src/spinner.tsx +105 -32
- package/src/switch.tsx +4 -1
- package/src/table.tsx +82 -15
- package/src/tabs.tsx +189 -38
- package/src/text-animate.tsx +71 -28
- package/src/textarea.tsx +6 -1
- package/src/timeline.tsx +378 -0
- package/src/toast.tsx +214 -35
- package/src/toggle.tsx +4 -2
- package/src/toolbar.tsx +12 -7
- package/src/tooltip.tsx +43 -3
- package/src/video-player.tsx +396 -127
- package/src/image-modal.tsx +0 -110
- package/src/markdown-editor.tsx +0 -302
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type CSSProperties,
|
|
11
|
+
} from "react"
|
|
12
|
+
import { LifelineFilmIcon as Film, LifelineImageIcon as ImageIcon } from "./lifeline-icons"
|
|
13
|
+
import { cn } from "../lib/cn"
|
|
14
|
+
import { CompanyIcon } from "./company-icon"
|
|
15
|
+
import {
|
|
16
|
+
getLifelineEventEffect,
|
|
17
|
+
getLifelineEventImage,
|
|
18
|
+
getLifelineEventKey,
|
|
19
|
+
LifelineEventText,
|
|
20
|
+
} from "./lifeline-event"
|
|
21
|
+
import { useLifelineFireworks } from "./lifeline-fireworks"
|
|
22
|
+
import {
|
|
23
|
+
LifelineLightbox,
|
|
24
|
+
type LifelineLightboxStart,
|
|
25
|
+
} from "./lifeline-lightbox"
|
|
26
|
+
import { aggregateLifelinePeople, LifelinePeople } from "./lifeline-people"
|
|
27
|
+
import { LifelinePhotoCard } from "./lifeline-photos"
|
|
28
|
+
import type { LifelineEvent, LifelineMarker, LifelineProps } from "./types"
|
|
29
|
+
import { getMarkerHeight, hasMarkerContent, seededUnit } from "./lifeline-utils"
|
|
30
|
+
import { useLifelineIntro } from "./use-lifeline-intro"
|
|
31
|
+
import { useLifelineVerticalScroll } from "./use-lifeline-vertical-scroll"
|
|
32
|
+
|
|
33
|
+
const GRID_CLASS = "grid grid-cols-[2.5rem_1rem_1fr] gap-x-3"
|
|
34
|
+
const RAIL_LEFT = "calc(2.5rem + 0.75rem + 0.5rem)"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Above this many entries the delay-armed intro fades would promote
|
|
38
|
+
* every entry to a compositor layer at once and crash mobile Safari's
|
|
39
|
+
* compositor. Long timelines fade entries in as they enter the
|
|
40
|
+
* viewport during the auto-scroll instead — same look, but only a
|
|
41
|
+
* handful of live animations at any moment.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_ARMED_ENTRIES = 80
|
|
44
|
+
|
|
45
|
+
function RailTick() {
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
className="block h-px w-[10px] bg-fg-quaternary transition-colors duration-300"
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One event line. Touch layouts have no hover reveal, so an event with
|
|
56
|
+
* attached media becomes tappable: the media expands into the lightbox
|
|
57
|
+
* from the event's text, framed by the poster image's real aspect.
|
|
58
|
+
*/
|
|
59
|
+
function LifelineVerticalEvent({ event }: { event: LifelineEvent }) {
|
|
60
|
+
const fireworks = useLifelineFireworks()
|
|
61
|
+
const image = getLifelineEventImage(event)
|
|
62
|
+
const effect = getLifelineEventEffect(event)
|
|
63
|
+
const textRef = useRef<HTMLParagraphElement>(null)
|
|
64
|
+
const aspectRef = useRef(3 / 4)
|
|
65
|
+
const [lightboxStart, setLightboxStart] =
|
|
66
|
+
useState<LifelineLightboxStart | null>(null)
|
|
67
|
+
|
|
68
|
+
// The event text has no card geometry — synthesize a small seed
|
|
69
|
+
// centered on the text, carrying the media's aspect so the lightbox
|
|
70
|
+
// expands into the right frame.
|
|
71
|
+
const measureText = useCallback((): LifelineLightboxStart | null => {
|
|
72
|
+
const el = textRef.current
|
|
73
|
+
if (!el) return null
|
|
74
|
+
const rect = el.getBoundingClientRect()
|
|
75
|
+
const w = 96
|
|
76
|
+
return {
|
|
77
|
+
cx: rect.left + rect.width / 2,
|
|
78
|
+
cy: rect.top + rect.height / 2,
|
|
79
|
+
w,
|
|
80
|
+
h: w * aspectRef.current,
|
|
81
|
+
}
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
const openMedia = () => {
|
|
85
|
+
if (!image || lightboxStart) return
|
|
86
|
+
// The poster sets the frame; for videos it shares the clip's aspect.
|
|
87
|
+
const probe = new window.Image()
|
|
88
|
+
probe.src = image.src
|
|
89
|
+
const open = () => {
|
|
90
|
+
if (probe.naturalWidth > 0) {
|
|
91
|
+
aspectRef.current = probe.naturalHeight / probe.naturalWidth
|
|
92
|
+
}
|
|
93
|
+
setLightboxStart(measureText())
|
|
94
|
+
}
|
|
95
|
+
if (probe.complete) {
|
|
96
|
+
open()
|
|
97
|
+
} else {
|
|
98
|
+
probe.onload = open
|
|
99
|
+
probe.onerror = open
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<>
|
|
105
|
+
<p
|
|
106
|
+
ref={textRef}
|
|
107
|
+
className={cn(
|
|
108
|
+
"max-w-[18rem] text-left text-[14px] leading-[1.55] tracking-[-0.01em]",
|
|
109
|
+
(image || effect) && "cursor-pointer",
|
|
110
|
+
)}
|
|
111
|
+
onClick={
|
|
112
|
+
image
|
|
113
|
+
? openMedia
|
|
114
|
+
: effect && fireworks
|
|
115
|
+
? () => fireworks.launch(effect)
|
|
116
|
+
: undefined
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
<LifelineEventText event={event} />
|
|
120
|
+
{image && (
|
|
121
|
+
// Glued to the last word with a no-break space so the icon
|
|
122
|
+
// can never wrap onto a line of its own.
|
|
123
|
+
<span className="whitespace-nowrap">
|
|
124
|
+
{" "}
|
|
125
|
+
{image.video ? (
|
|
126
|
+
<Film
|
|
127
|
+
className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
|
|
128
|
+
strokeWidth={1.75}
|
|
129
|
+
aria-hidden="true"
|
|
130
|
+
/>
|
|
131
|
+
) : (
|
|
132
|
+
<ImageIcon
|
|
133
|
+
className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
|
|
134
|
+
strokeWidth={1.75}
|
|
135
|
+
aria-hidden="true"
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</p>
|
|
141
|
+
{lightboxStart && image && (
|
|
142
|
+
<LifelineLightbox
|
|
143
|
+
photo={image}
|
|
144
|
+
rotate={0}
|
|
145
|
+
start={lightboxStart}
|
|
146
|
+
getHome={measureText}
|
|
147
|
+
onClosed={() => setLightboxStart(null)}
|
|
148
|
+
/>
|
|
149
|
+
)}
|
|
150
|
+
</>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const LifelineVerticalEntry = forwardRef<
|
|
155
|
+
HTMLLIElement,
|
|
156
|
+
{
|
|
157
|
+
marker: LifelineMarker
|
|
158
|
+
birthYear: number
|
|
159
|
+
animateIntro?: boolean
|
|
160
|
+
introDelay?: number
|
|
161
|
+
introDuration?: number
|
|
162
|
+
revealPending?: boolean
|
|
163
|
+
}
|
|
164
|
+
>(function LifelineVerticalEntry(
|
|
165
|
+
{
|
|
166
|
+
marker,
|
|
167
|
+
birthYear,
|
|
168
|
+
animateIntro = false,
|
|
169
|
+
introDelay = 0,
|
|
170
|
+
introDuration = 420,
|
|
171
|
+
revealPending = false,
|
|
172
|
+
},
|
|
173
|
+
ref,
|
|
174
|
+
) {
|
|
175
|
+
const age = marker.age ?? marker.year - birthYear
|
|
176
|
+
const people = aggregateLifelinePeople(marker)
|
|
177
|
+
const photos = marker.photos ?? []
|
|
178
|
+
const hasContent = hasMarkerContent(marker) || photos.length > 0
|
|
179
|
+
// Fresh tilts per visit; stacked neighbors lean apart.
|
|
180
|
+
const [photoTilts] = useState(() =>
|
|
181
|
+
(marker.photos ?? []).map((photo, index) => {
|
|
182
|
+
const unit = seededUnit(`${photo.src}:${index}`)
|
|
183
|
+
const sign =
|
|
184
|
+
(marker.photos?.length ?? 0) > 1
|
|
185
|
+
? index % 2 === 0
|
|
186
|
+
? -1
|
|
187
|
+
: 1
|
|
188
|
+
: unit > 0.5
|
|
189
|
+
? 1
|
|
190
|
+
: -1
|
|
191
|
+
return sign * (2 + seededUnit(`${photo.src}:${index}:tilt`) * 4)
|
|
192
|
+
}),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<li
|
|
197
|
+
ref={ref}
|
|
198
|
+
className={hasContent ? "pb-10" : "pb-3"}
|
|
199
|
+
aria-label={marker.label ?? `${marker.year}`}
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
className={cn(
|
|
203
|
+
animateIntro && "lifeline-marker-intro",
|
|
204
|
+
revealPending && "opacity-0",
|
|
205
|
+
)}
|
|
206
|
+
style={{
|
|
207
|
+
animationDelay: animateIntro ? `${introDelay}ms` : undefined,
|
|
208
|
+
...(animateIntro
|
|
209
|
+
? ({
|
|
210
|
+
"--lifeline-marker-fade-ms": `${introDuration}ms`,
|
|
211
|
+
} as CSSProperties)
|
|
212
|
+
: {}),
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<div className={`${GRID_CLASS} items-center`}>
|
|
216
|
+
<p className="text-right text-[11px] font-medium leading-4 tabular-nums text-fg-tertiary transition-colors duration-300">
|
|
217
|
+
{age}
|
|
218
|
+
</p>
|
|
219
|
+
|
|
220
|
+
<div className="flex items-center justify-center">
|
|
221
|
+
<RailTick />
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<p className="whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-fg-secondary transition-colors duration-300">
|
|
225
|
+
{marker.label ?? marker.year}
|
|
226
|
+
</p>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{hasContent && (
|
|
230
|
+
<div className={`${GRID_CLASS} mt-6`}>
|
|
231
|
+
<div aria-hidden="true" />
|
|
232
|
+
<div aria-hidden="true" />
|
|
233
|
+
<div className="min-w-0 text-fg-secondary transition-colors duration-300">
|
|
234
|
+
{marker.badges && marker.badges.length > 0 && (
|
|
235
|
+
<div className="mb-3 flex items-center justify-start gap-2">
|
|
236
|
+
{marker.badges.map((badge) => (
|
|
237
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
238
|
+
<img
|
|
239
|
+
key={badge.src}
|
|
240
|
+
src={badge.src}
|
|
241
|
+
alt={badge.alt}
|
|
242
|
+
className="h-6 w-6 object-contain opacity-80"
|
|
243
|
+
/>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
|
|
248
|
+
{marker.companies && marker.companies.length > 0 && (
|
|
249
|
+
<div className="mb-2 flex items-center justify-start gap-1.5">
|
|
250
|
+
{marker.companies.map((company) => (
|
|
251
|
+
<CompanyIcon
|
|
252
|
+
key={company.id}
|
|
253
|
+
id={company.id}
|
|
254
|
+
label={company.name}
|
|
255
|
+
className="opacity-70"
|
|
256
|
+
/>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
{marker.events.length > 0 && (
|
|
262
|
+
<div className="space-y-4">
|
|
263
|
+
{marker.events.map((event, index) => (
|
|
264
|
+
<LifelineVerticalEvent
|
|
265
|
+
key={getLifelineEventKey(event, index)}
|
|
266
|
+
event={event}
|
|
267
|
+
/>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
|
|
272
|
+
{photos.length > 0 && (
|
|
273
|
+
<div className="mt-6 flex flex-wrap items-start">
|
|
274
|
+
{photos.map((photo, index) => (
|
|
275
|
+
<LifelinePhotoCard
|
|
276
|
+
key={`${photo.src}-${index}`}
|
|
277
|
+
photo={photo}
|
|
278
|
+
rotate={photo.rotate ?? photoTilts[index] ?? 0}
|
|
279
|
+
width={160}
|
|
280
|
+
className={cn(
|
|
281
|
+
"relative",
|
|
282
|
+
index > 0 && "-ml-8 mt-6",
|
|
283
|
+
)}
|
|
284
|
+
/>
|
|
285
|
+
))}
|
|
286
|
+
</div>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
{people.length > 0 && (
|
|
290
|
+
<div className="mt-6 border-t border-border-secondary pt-5 transition-colors duration-300">
|
|
291
|
+
<LifelinePeople people={people} allowWrap />
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
</li>
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
export function LifelineVertical({
|
|
303
|
+
markers,
|
|
304
|
+
birthYear,
|
|
305
|
+
title = "Lifeline",
|
|
306
|
+
mode = "auto",
|
|
307
|
+
}: LifelineProps) {
|
|
308
|
+
// Only an explicit `mode` embeds the vertical layout. `"auto"` measures
|
|
309
|
+
// scrollability on desktop, but the mobile layout *is* a vertical
|
|
310
|
+
// scroller inside a scrolling stage, so that test would read every
|
|
311
|
+
// full-page timeline as embedded and drop its intro.
|
|
312
|
+
const isEmbed = mode === "embed"
|
|
313
|
+
const heights = useMemo(
|
|
314
|
+
() =>
|
|
315
|
+
markers.map((marker, index) =>
|
|
316
|
+
getMarkerHeight(marker, markers[index + 1]?.year),
|
|
317
|
+
),
|
|
318
|
+
[markers],
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const intro = useLifelineIntro(heights)
|
|
322
|
+
const isIntroAnimating = intro.shouldPlay && intro.isPlaying
|
|
323
|
+
|
|
324
|
+
// Warm the event media posters during idle — the tap-to-open
|
|
325
|
+
// lightbox measures its frame from these, and a cold fetch at tap
|
|
326
|
+
// time reads as lag.
|
|
327
|
+
useEffect(() => {
|
|
328
|
+
const sources: string[] = []
|
|
329
|
+
for (const marker of markers) {
|
|
330
|
+
for (const event of marker.events) {
|
|
331
|
+
const image = getLifelineEventImage(event)
|
|
332
|
+
if (image) sources.push(image.src)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (sources.length === 0) return
|
|
336
|
+
|
|
337
|
+
const warm = () => {
|
|
338
|
+
sources.forEach((src) => {
|
|
339
|
+
const image = new window.Image()
|
|
340
|
+
image.src = src
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
345
|
+
const handle = window.requestIdleCallback(warm)
|
|
346
|
+
return () => window.cancelIdleCallback(handle)
|
|
347
|
+
}
|
|
348
|
+
const timeout = window.setTimeout(warm, 2000)
|
|
349
|
+
return () => window.clearTimeout(timeout)
|
|
350
|
+
}, [markers])
|
|
351
|
+
|
|
352
|
+
const { sectionRef, setEntryRef, isLayoutReady } = useLifelineVerticalScroll(
|
|
353
|
+
markers.length,
|
|
354
|
+
{
|
|
355
|
+
isEmbed,
|
|
356
|
+
introLocked: isIntroAnimating,
|
|
357
|
+
introAnimating: isIntroAnimating,
|
|
358
|
+
// Embedded, the sweep would play out unseen below the fold — and
|
|
359
|
+
// lock the module's own scroller while doing it.
|
|
360
|
+
introSkipped: !intro.shouldPlay || isEmbed,
|
|
361
|
+
introRailMs: intro.railDuration,
|
|
362
|
+
introGetTrackProgress: intro.getTrackProgressAtTime,
|
|
363
|
+
onIntroScrollStart: intro.startIntroTimer,
|
|
364
|
+
onIntroSettleComplete: intro.completeIntro,
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
const showIntro = isIntroAnimating && isLayoutReady && !isEmbed
|
|
369
|
+
const revealOnScroll = markers.length > MAX_ARMED_ENTRIES
|
|
370
|
+
const animateEntries = showIntro && !revealOnScroll
|
|
371
|
+
|
|
372
|
+
// Rail-synced fades for long timelines: entries render hidden and
|
|
373
|
+
// each one fades in the moment the rail tip (--lifeline-intro-progress,
|
|
374
|
+
// written every frame by the intro scroll) crosses its position —
|
|
375
|
+
// desktop's choreography, but each entry drops its animation (and
|
|
376
|
+
// compositor layer) as soon as its fade finishes.
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!showIntro || !revealOnScroll) return
|
|
379
|
+
const section = sectionRef.current
|
|
380
|
+
const ol = section?.querySelector("ol")
|
|
381
|
+
if (!section || !ol) return
|
|
382
|
+
|
|
383
|
+
const entries = Array.from(ol.children) as HTMLElement[]
|
|
384
|
+
const targets = entries.map(
|
|
385
|
+
(li) => li.firstElementChild as HTMLElement | null,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
const onAnimationEnd = (event: AnimationEvent) => {
|
|
389
|
+
if (event.animationName !== "lifeline-marker-in") return
|
|
390
|
+
;(event.target as HTMLElement).classList.remove("lifeline-marker-intro")
|
|
391
|
+
}
|
|
392
|
+
section.addEventListener("animationend", onAnimationEnd)
|
|
393
|
+
|
|
394
|
+
let next = 0
|
|
395
|
+
let frame = 0
|
|
396
|
+
const tick = () => {
|
|
397
|
+
const progress = parseFloat(
|
|
398
|
+
section.style.getPropertyValue("--lifeline-intro-progress") || "0",
|
|
399
|
+
)
|
|
400
|
+
const tip = progress * ol.offsetHeight
|
|
401
|
+
|
|
402
|
+
while (next < entries.length && entries[next].offsetTop <= tip) {
|
|
403
|
+
const el = targets[next]
|
|
404
|
+
if (el) {
|
|
405
|
+
el.classList.remove("opacity-0")
|
|
406
|
+
el.classList.add("lifeline-marker-intro")
|
|
407
|
+
}
|
|
408
|
+
next++
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (next < entries.length) {
|
|
412
|
+
frame = requestAnimationFrame(tick)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
frame = requestAnimationFrame(tick)
|
|
416
|
+
|
|
417
|
+
return () => {
|
|
418
|
+
cancelAnimationFrame(frame)
|
|
419
|
+
section.removeEventListener("animationend", onAnimationEnd)
|
|
420
|
+
targets.forEach((el) => {
|
|
421
|
+
el?.classList.remove("opacity-0", "lifeline-marker-intro")
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
}, [showIntro, revealOnScroll, sectionRef])
|
|
425
|
+
|
|
426
|
+
const introStyle = {
|
|
427
|
+
"--lifeline-labels-ms": `${intro.labelsDuration}ms`,
|
|
428
|
+
"--lifeline-rail-ms": `${intro.railDuration}ms`,
|
|
429
|
+
} as CSSProperties
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<article
|
|
433
|
+
ref={sectionRef}
|
|
434
|
+
aria-label={title}
|
|
435
|
+
className={cn(
|
|
436
|
+
"relative select-none px-6 pb-10 pt-4 [&_a]:cursor-pointer",
|
|
437
|
+
!isLayoutReady && "invisible",
|
|
438
|
+
)}
|
|
439
|
+
style={showIntro ? introStyle : undefined}
|
|
440
|
+
>
|
|
441
|
+
<div className={cn(`${GRID_CLASS} mb-6 items-end`, showIntro && "lifeline-labels-intro")}>
|
|
442
|
+
<p className="text-right text-[11px] font-medium leading-4 tracking-[0.08em] text-fg-tertiary transition-colors duration-300">
|
|
443
|
+
Age
|
|
444
|
+
</p>
|
|
445
|
+
<div aria-hidden="true" />
|
|
446
|
+
<p className="text-[11px] font-medium leading-5 tracking-[0.08em] text-fg-tertiary transition-colors duration-300">
|
|
447
|
+
Years
|
|
448
|
+
</p>
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
<div className="relative">
|
|
452
|
+
<div
|
|
453
|
+
aria-hidden="true"
|
|
454
|
+
className="pointer-events-none absolute bottom-0 top-0 overflow-hidden -translate-x-1/2"
|
|
455
|
+
style={{ left: RAIL_LEFT, width: 1 }}
|
|
456
|
+
>
|
|
457
|
+
<div
|
|
458
|
+
className={cn(
|
|
459
|
+
"h-full w-px border-l border-dashed border-border-primary transition-colors duration-300",
|
|
460
|
+
showIntro && "lifeline-rail-intro-vertical",
|
|
461
|
+
)}
|
|
462
|
+
/>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<ol className="relative">
|
|
466
|
+
{markers.map((marker, index) => (
|
|
467
|
+
<LifelineVerticalEntry
|
|
468
|
+
key={marker.id}
|
|
469
|
+
ref={(node) => setEntryRef(index, node)}
|
|
470
|
+
marker={marker}
|
|
471
|
+
birthYear={birthYear}
|
|
472
|
+
animateIntro={animateEntries}
|
|
473
|
+
revealPending={showIntro && revealOnScroll}
|
|
474
|
+
introDelay={intro.getMarkerDelay(index)}
|
|
475
|
+
introDuration={intro.getMarkerFadeDuration(index)}
|
|
476
|
+
/>
|
|
477
|
+
))}
|
|
478
|
+
</ol>
|
|
479
|
+
</div>
|
|
480
|
+
</article>
|
|
481
|
+
)
|
|
482
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useState } from "react"
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
import { LifelineDesktop } from "./lifeline-desktop"
|
|
6
|
+
import { LifelineFireworksProvider } from "./lifeline-fireworks"
|
|
7
|
+
import { LifelineVertical } from "./lifeline-vertical"
|
|
8
|
+
import { LIFELINE_MOBILE_BREAKPOINT } from "./lifeline-layout"
|
|
9
|
+
import type { LifelineProps } from "./types"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `lifeline-typeset` sets `--lifeline-font` from the host sans stack.
|
|
13
|
+
* Override `--lifeline-font` to typeset the timeline in something else.
|
|
14
|
+
*/
|
|
15
|
+
export function Lifeline(props: LifelineProps) {
|
|
16
|
+
const [isMobile, setIsMobile] = useState<boolean | null>(null)
|
|
17
|
+
|
|
18
|
+
useLayoutEffect(() => {
|
|
19
|
+
// Matches Tailwind's md: breakpoint so JS and CSS can never disagree.
|
|
20
|
+
const query = window.matchMedia(
|
|
21
|
+
`(min-width: ${LIFELINE_MOBILE_BREAKPOINT}px)`,
|
|
22
|
+
)
|
|
23
|
+
const update = () => setIsMobile(!query.matches)
|
|
24
|
+
|
|
25
|
+
update()
|
|
26
|
+
query.addEventListener("change", update)
|
|
27
|
+
return () => query.removeEventListener("change", update)
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
if (isMobile === null) {
|
|
31
|
+
return <div className="invisible h-full" aria-hidden="true" />
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (isMobile) {
|
|
35
|
+
return (
|
|
36
|
+
<LifelineFireworksProvider>
|
|
37
|
+
{/*
|
|
38
|
+
Embedded, the vertical timeline gets its own bounded scroller:
|
|
39
|
+
the consumer's height lands here, and this element becomes the
|
|
40
|
+
scroll parent the vertical hook looks for. Native overscroll
|
|
41
|
+
chaining then releases to the page at either end, which is
|
|
42
|
+
exactly the embed contract. Page mode is left alone — the host's
|
|
43
|
+
own scroller owns it there, and `h-full` would only fight it.
|
|
44
|
+
*/}
|
|
45
|
+
<div
|
|
46
|
+
className={
|
|
47
|
+
props.mode === "embed"
|
|
48
|
+
? cn(
|
|
49
|
+
"lifeline-typeset h-full overflow-y-auto pt-5",
|
|
50
|
+
props.className,
|
|
51
|
+
)
|
|
52
|
+
: "lifeline-typeset pt-5"
|
|
53
|
+
}
|
|
54
|
+
>
|
|
55
|
+
<LifelineVertical {...props} />
|
|
56
|
+
</div>
|
|
57
|
+
</LifelineFireworksProvider>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<LifelineFireworksProvider>
|
|
63
|
+
<LifelineDesktop
|
|
64
|
+
{...props}
|
|
65
|
+
className={cn("lifeline-typeset pt-5", props.className)}
|
|
66
|
+
/>
|
|
67
|
+
</LifelineFireworksProvider>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { CompanyIconId } from "./company-icon"
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export interface LifelineMentor {
|
|
5
|
+
name: string
|
|
6
|
+
role?: string
|
|
7
|
+
color?: string
|
|
8
|
+
photo?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LifelineMetPerson {
|
|
12
|
+
name: string
|
|
13
|
+
photo?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LifelineCompany {
|
|
17
|
+
id: CompanyIconId
|
|
18
|
+
name: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type LifelineEventSegment =
|
|
22
|
+
| { type: "text"; value: string }
|
|
23
|
+
| { type: "link"; value: string; href: string }
|
|
24
|
+
|
|
25
|
+
export interface LifelineEventImage {
|
|
26
|
+
src: string
|
|
27
|
+
alt: string
|
|
28
|
+
/** Optional mp4/webm — hover shows this (muted, looping) with src as fallback. */
|
|
29
|
+
video?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An always-visible photo/video card that floats over the timeline,
|
|
34
|
+
* anchored to its marker's position and scrolling with the track —
|
|
35
|
+
* scattered, tilted, and draggable, like photos loose in a notebook.
|
|
36
|
+
*/
|
|
37
|
+
export interface LifelinePhoto extends LifelineEventImage {
|
|
38
|
+
/** 0..1 across the marker's slot; defaults to a seeded scatter. */
|
|
39
|
+
x?: number
|
|
40
|
+
/** Pixels below the rail; defaults to a seeded scatter. */
|
|
41
|
+
y?: number
|
|
42
|
+
/** Degrees; defaults to a seeded tilt. */
|
|
43
|
+
rotate?: number
|
|
44
|
+
/** Card width in pixels. Default 180. */
|
|
45
|
+
width?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type LifelineEventEffect = "fireworks" | "fireworks-argentina"
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Object form lets an event carry a cursor-following hover image
|
|
52
|
+
* and/or a click-triggered easter egg effect.
|
|
53
|
+
*/
|
|
54
|
+
export interface LifelineEventObject {
|
|
55
|
+
text: string | LifelineEventSegment[]
|
|
56
|
+
image?: LifelineEventImage
|
|
57
|
+
effect?: LifelineEventEffect
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type LifelineEvent =
|
|
61
|
+
| string
|
|
62
|
+
| LifelineEventSegment[]
|
|
63
|
+
| LifelineEventObject
|
|
64
|
+
|
|
65
|
+
export interface LifelineMarker {
|
|
66
|
+
id: string
|
|
67
|
+
/** Position on the numeric axis — a year, or any sequential unit (e.g. tournament day). */
|
|
68
|
+
year: number
|
|
69
|
+
/** Shown above the label; defaults to year - birthYear. Strings allowed for round tags etc. */
|
|
70
|
+
age?: number | string
|
|
71
|
+
/** Shown in place of the raw year — e.g. "Jun 16" on a day-based timeline. */
|
|
72
|
+
label?: string
|
|
73
|
+
events: LifelineEvent[]
|
|
74
|
+
/** Small emblems (team shields etc.) rendered above the events. */
|
|
75
|
+
badges?: { src: string; alt: string }[]
|
|
76
|
+
/** Floating media cards anchored to this marker's stretch of the timeline. */
|
|
77
|
+
photos?: LifelinePhoto[]
|
|
78
|
+
companies?: LifelineCompany[]
|
|
79
|
+
mentors?: LifelineMentor[]
|
|
80
|
+
met?: LifelineMetPerson[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A legend entry maps one of the two people slots to a subject-appropriate
|
|
85
|
+
* label — "Mentors" for a person, "Presidents" for a nation.
|
|
86
|
+
*/
|
|
87
|
+
export interface LifelineLegendItem {
|
|
88
|
+
type: "mentor" | "met"
|
|
89
|
+
label: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How the timeline relates to the page around it.
|
|
94
|
+
*
|
|
95
|
+
* - `"page"` — the Lifeline *is* the page. It owns the wheel and the
|
|
96
|
+
* arrow keys, and nothing scrolls behind it.
|
|
97
|
+
* - `"embed"` — the Lifeline is one module in a scrolling page. Wheel
|
|
98
|
+
* over it scrubs the rail, and at either end of the rail the wheel is
|
|
99
|
+
* handed back so the page carries on scrolling.
|
|
100
|
+
* - `"auto"` — measured at runtime: page mode only when the timeline
|
|
101
|
+
* covers most of the viewport *and* there is nothing behind it left to
|
|
102
|
+
* scroll. Anything else is treated as embedded.
|
|
103
|
+
*/
|
|
104
|
+
export type LifelineMode = "auto" | "page" | "embed"
|
|
105
|
+
|
|
106
|
+
export interface LifelineProps {
|
|
107
|
+
markers: LifelineMarker[]
|
|
108
|
+
birthYear: number
|
|
109
|
+
className?: string
|
|
110
|
+
title?: string
|
|
111
|
+
mode?: LifelineMode
|
|
112
|
+
}
|