@boyernick/standard-ui-react 0.1.1-canary.9 → 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 +27 -12
- 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 +238 -18
- package/src/input.tsx +8 -5
- package/src/kbd.tsx +10 -4
- 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,269 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react"
|
|
12
|
+
import { clamp, snapToDevicePixel } from "./lifeline-utils"
|
|
13
|
+
import type { LifelineEventImage } from "./types"
|
|
14
|
+
|
|
15
|
+
/** Tweak these */
|
|
16
|
+
const FOLLOW_EASE = 0.16
|
|
17
|
+
const TILT_FACTOR = 0.14
|
|
18
|
+
const TILT_MAX_DEG = 7
|
|
19
|
+
const TILT_EASE = 0.1
|
|
20
|
+
const CURSOR_OFFSET_X = 24
|
|
21
|
+
const CURSOR_OFFSET_Y = 96
|
|
22
|
+
|
|
23
|
+
interface LifelineHoverImageApi {
|
|
24
|
+
show: (image: LifelineEventImage) => void
|
|
25
|
+
hide: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LifelineHoverImageProviderProps {
|
|
29
|
+
children: ReactNode
|
|
30
|
+
/** Warmed into the browser cache during idle so swaps are instant. */
|
|
31
|
+
preload?: LifelineEventImage[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LifelineHoverImageContext = createContext<LifelineHoverImageApi | null>(
|
|
35
|
+
null,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export function useLifelineHoverImage() {
|
|
39
|
+
return useContext(LifelineHoverImageContext)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A cursor-following image reveal. The floating element lives at the
|
|
44
|
+
* provider level — it must stay outside the transformed track, since
|
|
45
|
+
* position: fixed resolves against the nearest transformed ancestor.
|
|
46
|
+
*/
|
|
47
|
+
export function LifelineHoverImageProvider({
|
|
48
|
+
children,
|
|
49
|
+
preload,
|
|
50
|
+
}: LifelineHoverImageProviderProps) {
|
|
51
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
52
|
+
const imageRef = useRef<HTMLImageElement>(null)
|
|
53
|
+
const videoRef = useRef<HTMLVideoElement>(null)
|
|
54
|
+
const state = useRef({
|
|
55
|
+
x: 0,
|
|
56
|
+
y: 0,
|
|
57
|
+
targetX: 0,
|
|
58
|
+
targetY: 0,
|
|
59
|
+
tilt: 0,
|
|
60
|
+
visible: false,
|
|
61
|
+
hoverCapable: false,
|
|
62
|
+
frame: 0,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
state.current.hoverCapable = window.matchMedia(
|
|
67
|
+
"(hover: hover) and (pointer: fine)",
|
|
68
|
+
).matches
|
|
69
|
+
|
|
70
|
+
const onMouseMove = (event: MouseEvent) => {
|
|
71
|
+
state.current.targetX = event.clientX
|
|
72
|
+
state.current.targetY = event.clientY
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
window.addEventListener("mousemove", onMouseMove, { passive: true })
|
|
76
|
+
return () => {
|
|
77
|
+
window.removeEventListener("mousemove", onMouseMove)
|
|
78
|
+
cancelAnimationFrame(state.current.frame)
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!preload?.length || !state.current.hoverCapable) return
|
|
84
|
+
|
|
85
|
+
const warm = () => {
|
|
86
|
+
preload.forEach(({ src }) => {
|
|
87
|
+
const image = new window.Image()
|
|
88
|
+
image.src = src
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
93
|
+
const handle = window.requestIdleCallback(warm)
|
|
94
|
+
return () => window.cancelIdleCallback(handle)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const timeout = window.setTimeout(warm, 2000)
|
|
98
|
+
return () => window.clearTimeout(timeout)
|
|
99
|
+
}, [preload])
|
|
100
|
+
|
|
101
|
+
const step = useCallback(() => {
|
|
102
|
+
const s = state.current
|
|
103
|
+
const container = containerRef.current
|
|
104
|
+
if (!container) return
|
|
105
|
+
|
|
106
|
+
const dx = s.targetX - s.x
|
|
107
|
+
s.x += dx * FOLLOW_EASE
|
|
108
|
+
s.y += (s.targetY - s.y) * FOLLOW_EASE
|
|
109
|
+
|
|
110
|
+
const targetTilt = clamp(dx * TILT_FACTOR, -TILT_MAX_DEG, TILT_MAX_DEG)
|
|
111
|
+
s.tilt += (targetTilt - s.tilt) * TILT_EASE
|
|
112
|
+
|
|
113
|
+
// The ease is asymptotic — it never actually arrives, so the card
|
|
114
|
+
// rests on a fractional offset with a residual tilt and the browser
|
|
115
|
+
// resamples it soft. Land it: snap sub-threshold deltas to done.
|
|
116
|
+
if (
|
|
117
|
+
Math.abs(s.targetX - s.x) < 0.1 &&
|
|
118
|
+
Math.abs(s.targetY - s.y) < 0.1 &&
|
|
119
|
+
Math.abs(s.tilt) < 0.05
|
|
120
|
+
) {
|
|
121
|
+
s.x = s.targetX
|
|
122
|
+
s.y = s.targetY
|
|
123
|
+
s.tilt = 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rotate = s.tilt === 0 ? "" : ` rotate(${s.tilt}deg)`
|
|
127
|
+
container.style.transform = `translate3d(${snapToDevicePixel(
|
|
128
|
+
s.x + CURSOR_OFFSET_X,
|
|
129
|
+
)}px, ${snapToDevicePixel(s.y - CURSOR_OFFSET_Y)}px, 0)${rotate}`
|
|
130
|
+
|
|
131
|
+
if (s.visible) {
|
|
132
|
+
s.frame = requestAnimationFrame(step)
|
|
133
|
+
}
|
|
134
|
+
}, [])
|
|
135
|
+
|
|
136
|
+
const api = useMemo<LifelineHoverImageApi>(
|
|
137
|
+
() => ({
|
|
138
|
+
show(image) {
|
|
139
|
+
const s = state.current
|
|
140
|
+
const container = containerRef.current
|
|
141
|
+
const img = imageRef.current
|
|
142
|
+
const video = videoRef.current
|
|
143
|
+
if (!s.hoverCapable || !container || !img) return
|
|
144
|
+
|
|
145
|
+
if (image.video && video) {
|
|
146
|
+
// Video takes over the card; the image element sits this one out.
|
|
147
|
+
img.style.display = "none"
|
|
148
|
+
video.style.display = "block"
|
|
149
|
+
|
|
150
|
+
const targetVideo = new URL(image.video, window.location.origin).href
|
|
151
|
+
if (video.src !== targetVideo) {
|
|
152
|
+
video.src = targetVideo
|
|
153
|
+
video.poster = image.src
|
|
154
|
+
}
|
|
155
|
+
video.play().catch(() => {
|
|
156
|
+
// Autoplay rejection just leaves the poster frame showing.
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (!s.visible) {
|
|
160
|
+
s.x = s.targetX
|
|
161
|
+
s.y = s.targetY
|
|
162
|
+
s.tilt = 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
s.visible = true
|
|
166
|
+
container.style.opacity = "1"
|
|
167
|
+
video.style.transform = "scale(1)"
|
|
168
|
+
|
|
169
|
+
cancelAnimationFrame(s.frame)
|
|
170
|
+
s.frame = requestAnimationFrame(step)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (video) {
|
|
175
|
+
video.pause()
|
|
176
|
+
video.style.display = "none"
|
|
177
|
+
}
|
|
178
|
+
img.style.display = "block"
|
|
179
|
+
|
|
180
|
+
const targetSrc = new URL(image.src, window.location.origin).href
|
|
181
|
+
|
|
182
|
+
if (img.src !== targetSrc) {
|
|
183
|
+
// Kill the previous bitmap instantly — the browser would keep
|
|
184
|
+
// showing it until the new file decodes.
|
|
185
|
+
img.style.visibility = "hidden"
|
|
186
|
+
img.src = targetSrc
|
|
187
|
+
img.alt = image.alt
|
|
188
|
+
|
|
189
|
+
const reveal = () => {
|
|
190
|
+
// Only reveal if this image is still the requested one.
|
|
191
|
+
if (img.src === targetSrc) {
|
|
192
|
+
img.style.visibility = "visible"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (img.complete) {
|
|
197
|
+
reveal()
|
|
198
|
+
} else {
|
|
199
|
+
img.decode().then(reveal, reveal)
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
img.style.visibility = "visible"
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!s.visible) {
|
|
206
|
+
// Materialize at the cursor instead of flying in from the
|
|
207
|
+
// last resting point.
|
|
208
|
+
s.x = s.targetX
|
|
209
|
+
s.y = s.targetY
|
|
210
|
+
s.tilt = 0
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
s.visible = true
|
|
214
|
+
container.style.opacity = "1"
|
|
215
|
+
img.style.transform = "scale(1)"
|
|
216
|
+
|
|
217
|
+
cancelAnimationFrame(s.frame)
|
|
218
|
+
s.frame = requestAnimationFrame(step)
|
|
219
|
+
},
|
|
220
|
+
hide() {
|
|
221
|
+
const s = state.current
|
|
222
|
+
const container = containerRef.current
|
|
223
|
+
const img = imageRef.current
|
|
224
|
+
const video = videoRef.current
|
|
225
|
+
if (!container || !img) return
|
|
226
|
+
|
|
227
|
+
s.visible = false
|
|
228
|
+
container.style.opacity = "0"
|
|
229
|
+
img.style.transform = "scale(0.94)"
|
|
230
|
+
if (video) {
|
|
231
|
+
video.pause()
|
|
232
|
+
video.style.transform = "scale(0.94)"
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
[step],
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<LifelineHoverImageContext.Provider value={api}>
|
|
241
|
+
{children}
|
|
242
|
+
<div
|
|
243
|
+
ref={containerRef}
|
|
244
|
+
aria-hidden="true"
|
|
245
|
+
className="pointer-events-none fixed left-0 top-0 z-[60] opacity-0 transition-opacity duration-200 ease-out will-change-transform"
|
|
246
|
+
>
|
|
247
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
248
|
+
<img
|
|
249
|
+
ref={imageRef}
|
|
250
|
+
alt=""
|
|
251
|
+
className="w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out"
|
|
252
|
+
decoding="async"
|
|
253
|
+
/>
|
|
254
|
+
<video
|
|
255
|
+
ref={videoRef}
|
|
256
|
+
muted
|
|
257
|
+
loop
|
|
258
|
+
playsInline
|
|
259
|
+
preload="none"
|
|
260
|
+
// Landscape fills the same 280px card as images; portrait is
|
|
261
|
+
// capped by height instead, matching the floating cards'
|
|
262
|
+
// scale (180x320) so tall clips don't tower over the cursor.
|
|
263
|
+
className="max-h-[320px] w-auto max-w-[280px] scale-95 rounded-xl shadow-2xl ring-1 ring-border-primary transition-[transform,box-shadow] duration-200 ease-out"
|
|
264
|
+
style={{ display: "none" }}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
</LifelineHoverImageContext.Provider>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SVGProps } from "react"
|
|
2
|
+
|
|
3
|
+
type IconProps = SVGProps<SVGSVGElement>
|
|
4
|
+
|
|
5
|
+
const iconProps = {
|
|
6
|
+
viewBox: "0 0 24 24",
|
|
7
|
+
fill: "none",
|
|
8
|
+
stroke: "currentColor",
|
|
9
|
+
strokeLinecap: "round" as const,
|
|
10
|
+
strokeLinejoin: "round" as const,
|
|
11
|
+
"aria-hidden": true as const,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const LifelineFilmIcon = ({
|
|
15
|
+
strokeWidth = 1.75,
|
|
16
|
+
...props
|
|
17
|
+
}: IconProps) => (
|
|
18
|
+
<svg {...iconProps} strokeWidth={strokeWidth} {...props}>
|
|
19
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
20
|
+
<path d="M7 3v18" />
|
|
21
|
+
<path d="M3 7.5h4" />
|
|
22
|
+
<path d="M3 12h18" />
|
|
23
|
+
<path d="M3 16.5h4" />
|
|
24
|
+
<path d="M17 3v18" />
|
|
25
|
+
<path d="M17 7.5h4" />
|
|
26
|
+
<path d="M17 16.5h4" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
export const LifelineImageIcon = ({
|
|
31
|
+
strokeWidth = 1.75,
|
|
32
|
+
...props
|
|
33
|
+
}: IconProps) => (
|
|
34
|
+
<svg {...iconProps} strokeWidth={strokeWidth} {...props}>
|
|
35
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
36
|
+
<circle cx="9" cy="9" r="2" />
|
|
37
|
+
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
38
|
+
</svg>
|
|
39
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { clamp } from "./lifeline-utils"
|
|
2
|
+
|
|
3
|
+
/** Tweak these */
|
|
4
|
+
export const LIFELINE_SLOW_YEARS = 5
|
|
5
|
+
export const LIFELINE_SLOW_TIME_RATIO = 0.38
|
|
6
|
+
export const LIFELINE_SLOW_MARKER_FADE_MS = 720
|
|
7
|
+
export const LIFELINE_FAST_MARKER_FADE_MS = 280
|
|
8
|
+
/** Set > 0 to override auto-calibrated ease power */
|
|
9
|
+
export const LIFELINE_EASE_POWER = 0
|
|
10
|
+
|
|
11
|
+
function smoothstep(value: number) {
|
|
12
|
+
const t = clamp(value, 0, 1)
|
|
13
|
+
return t * t * (3 - 2 * t)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getSlowTrackWidth(widths: number[]) {
|
|
17
|
+
return widths
|
|
18
|
+
.slice(0, LIFELINE_SLOW_YEARS)
|
|
19
|
+
.reduce((sum, width) => sum + width, 0)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getSlowTrackPortion(widths: number[]) {
|
|
23
|
+
const total = widths.reduce((sum, width) => sum + width, 0)
|
|
24
|
+
if (total <= 0) return 0
|
|
25
|
+
return getSlowTrackWidth(widths) / total
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getEasePower(widths: number[]) {
|
|
29
|
+
if (LIFELINE_EASE_POWER > 0) return LIFELINE_EASE_POWER
|
|
30
|
+
|
|
31
|
+
const slowPortion = getSlowTrackPortion(widths)
|
|
32
|
+
const softenedPivot = smoothstep(LIFELINE_SLOW_TIME_RATIO)
|
|
33
|
+
|
|
34
|
+
if (slowPortion <= 0 || softenedPivot <= 0 || softenedPivot >= 1) {
|
|
35
|
+
return 2.6
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return Math.log(slowPortion) / Math.log(softenedPivot)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function softenedTime(elapsedMs: number, railMs: number) {
|
|
42
|
+
return smoothstep(elapsedMs / railMs)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function trackProgressAtTime(
|
|
46
|
+
elapsedMs: number,
|
|
47
|
+
widths: number[],
|
|
48
|
+
railMs: number,
|
|
49
|
+
) {
|
|
50
|
+
if (railMs <= 0) return 0
|
|
51
|
+
|
|
52
|
+
const t = softenedTime(elapsedMs, railMs)
|
|
53
|
+
const power = getEasePower(widths)
|
|
54
|
+
|
|
55
|
+
return clamp(Math.pow(t, power), 0, 1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function timeAtTrackProgress(
|
|
59
|
+
progress: number,
|
|
60
|
+
widths: number[],
|
|
61
|
+
railMs: number,
|
|
62
|
+
) {
|
|
63
|
+
const clamped = clamp(progress, 0, 1)
|
|
64
|
+
if (clamped <= 0) return 0
|
|
65
|
+
if (clamped >= 1) return railMs
|
|
66
|
+
|
|
67
|
+
const power = getEasePower(widths)
|
|
68
|
+
const softened = Math.pow(clamped, 1 / power)
|
|
69
|
+
|
|
70
|
+
return invertSmoothstep(softened) * railMs
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function invertSmoothstep(value: number) {
|
|
74
|
+
const target = clamp(value, 0, 1)
|
|
75
|
+
let lo = 0
|
|
76
|
+
let hi = 1
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < 20; i++) {
|
|
79
|
+
const mid = (lo + hi) / 2
|
|
80
|
+
if (smoothstep(mid) < target) lo = mid
|
|
81
|
+
else hi = mid
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (lo + hi) / 2
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isSlowIntroYear(index: number) {
|
|
88
|
+
return index < LIFELINE_SLOW_YEARS
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getTransitionMarkerFadeDuration(index: number) {
|
|
92
|
+
if (index < LIFELINE_SLOW_YEARS) return LIFELINE_SLOW_MARKER_FADE_MS
|
|
93
|
+
|
|
94
|
+
const rampYears = 3
|
|
95
|
+
const rampIndex = index - LIFELINE_SLOW_YEARS
|
|
96
|
+
if (rampIndex >= rampYears) return LIFELINE_FAST_MARKER_FADE_MS
|
|
97
|
+
|
|
98
|
+
const t = (rampIndex + 1) / rampYears
|
|
99
|
+
const blend = smoothstep(t)
|
|
100
|
+
|
|
101
|
+
return Math.round(
|
|
102
|
+
LIFELINE_SLOW_MARKER_FADE_MS +
|
|
103
|
+
(LIFELINE_FAST_MARKER_FADE_MS - LIFELINE_SLOW_MARKER_FADE_MS) * blend,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const LIFELINE_LABEL_COLUMN_WIDTH = 56
|
|
2
|
+
export const LIFELINE_LABEL_GAP = 16
|
|
3
|
+
export const LIFELINE_STICKY_SHIELD_WIDTH =
|
|
4
|
+
LIFELINE_LABEL_COLUMN_WIDTH + LIFELINE_LABEL_GAP
|
|
5
|
+
export const LIFELINE_STICKY_LEFT = 20
|
|
6
|
+
|
|
7
|
+
export function LifelineStickyLabels() {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className="relative"
|
|
11
|
+
style={{ width: LIFELINE_LABEL_COLUMN_WIDTH }}
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
>
|
|
14
|
+
<div className="flex flex-col items-start text-left">
|
|
15
|
+
<p className="mb-5 h-4 text-[11px] font-medium leading-4 tracking-[0.08em] text-fg-tertiary transition-colors duration-300">
|
|
16
|
+
Age
|
|
17
|
+
</p>
|
|
18
|
+
<p className="mb-6 h-5 text-[11px] font-medium leading-5 tracking-[0.08em] text-fg-tertiary transition-colors duration-300">
|
|
19
|
+
Years
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const LIFELINE_MOBILE_BREAKPOINT = 768
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LifelineLegendItem } from "./types"
|
|
2
|
+
|
|
3
|
+
const LEGEND_DOT_CLASS: Record<LifelineLegendItem["type"], string> = {
|
|
4
|
+
mentor: "bg-blue-500",
|
|
5
|
+
met: "bg-pink-500",
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_ITEMS: LifelineLegendItem[] = [
|
|
9
|
+
{ type: "mentor", label: "Mentors" },
|
|
10
|
+
{ type: "met", label: "Met in person" },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export function LifelineLegend({
|
|
14
|
+
items = DEFAULT_ITEMS,
|
|
15
|
+
}: {
|
|
16
|
+
items?: LifelineLegendItem[]
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<ul className="flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px] text-fg-tertiary">
|
|
20
|
+
{items.map((item) => (
|
|
21
|
+
<li key={item.type} className="flex items-center gap-2">
|
|
22
|
+
<span
|
|
23
|
+
className={`h-1.5 w-1.5 shrink-0 rounded-full ${LEGEND_DOT_CLASS[item.type]}`}
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
/>
|
|
26
|
+
<span>{item.label}</span>
|
|
27
|
+
</li>
|
|
28
|
+
))}
|
|
29
|
+
</ul>
|
|
30
|
+
)
|
|
31
|
+
}
|