@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.
Files changed (82) hide show
  1. package/package.json +5 -3
  2. package/src/accordion.tsx +5 -2
  3. package/src/alert-dialog.tsx +1 -1
  4. package/src/attachment.tsx +5 -2
  5. package/src/autocomplete.tsx +4 -3
  6. package/src/block-editor.tsx +1747 -0
  7. package/src/brand.tsx +2 -2
  8. package/src/breadcrumb.tsx +14 -7
  9. package/src/button.tsx +22 -11
  10. package/src/calendar.tsx +6 -3
  11. package/src/carousel.tsx +220 -53
  12. package/src/checkbox.tsx +3 -2
  13. package/src/code-block.tsx +66 -5
  14. package/src/collapsible.tsx +4 -2
  15. package/src/combobox.tsx +4 -3
  16. package/src/command.tsx +27 -12
  17. package/src/dialog.tsx +33 -10
  18. package/src/empty.tsx +4 -4
  19. package/src/field.tsx +4 -3
  20. package/src/filter-group.tsx +143 -0
  21. package/src/icons.tsx +28 -1
  22. package/src/illustrations.tsx +219 -141
  23. package/src/index.ts +238 -18
  24. package/src/input.tsx +8 -5
  25. package/src/kbd.tsx +10 -4
  26. package/src/lib/focus.ts +41 -0
  27. package/src/lib/popup.ts +1 -1
  28. package/src/lifeline/company-icon.tsx +71 -0
  29. package/src/lifeline/index.ts +42 -0
  30. package/src/lifeline/lifeline-data.ts +97 -0
  31. package/src/lifeline/lifeline-desktop.tsx +204 -0
  32. package/src/lifeline/lifeline-event.tsx +108 -0
  33. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  34. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  35. package/src/lifeline/lifeline-icons.tsx +39 -0
  36. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  37. package/src/lifeline/lifeline-labels.tsx +24 -0
  38. package/src/lifeline/lifeline-layout.ts +1 -0
  39. package/src/lifeline/lifeline-legend.tsx +31 -0
  40. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  41. package/src/lifeline/lifeline-marker.tsx +183 -0
  42. package/src/lifeline/lifeline-people.tsx +99 -0
  43. package/src/lifeline/lifeline-photos.tsx +366 -0
  44. package/src/lifeline/lifeline-shell.tsx +135 -0
  45. package/src/lifeline/lifeline-utils.ts +83 -0
  46. package/src/lifeline/lifeline-vertical.tsx +482 -0
  47. package/src/lifeline/lifeline.tsx +69 -0
  48. package/src/lifeline/types.ts +112 -0
  49. package/src/lifeline/use-lifeline-intro.ts +130 -0
  50. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  51. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  52. package/src/menubar.tsx +9 -2
  53. package/src/minimap.tsx +296 -0
  54. package/src/modal.tsx +608 -0
  55. package/src/navigation-menu.tsx +338 -67
  56. package/src/number-field.tsx +336 -61
  57. package/src/otp-field.tsx +4 -3
  58. package/src/pagination.tsx +262 -42
  59. package/src/password-protection.tsx +345 -0
  60. package/src/popover.tsx +1 -1
  61. package/src/progress.tsx +5 -0
  62. package/src/questionnaire.tsx +284 -0
  63. package/src/radio.tsx +4 -2
  64. package/src/scroll-area.tsx +4 -1
  65. package/src/select.tsx +5 -2
  66. package/src/sidebar.tsx +113 -28
  67. package/src/slider.tsx +66 -6
  68. package/src/sounds.tsx +6 -10
  69. package/src/spinner.tsx +105 -32
  70. package/src/switch.tsx +4 -1
  71. package/src/table.tsx +82 -15
  72. package/src/tabs.tsx +189 -38
  73. package/src/text-animate.tsx +71 -28
  74. package/src/textarea.tsx +6 -1
  75. package/src/timeline.tsx +378 -0
  76. package/src/toast.tsx +214 -35
  77. package/src/toggle.tsx +4 -2
  78. package/src/toolbar.tsx +12 -7
  79. package/src/tooltip.tsx +43 -3
  80. package/src/video-player.tsx +396 -127
  81. package/src/image-modal.tsx +0 -110
  82. package/src/markdown-editor.tsx +0 -302
@@ -0,0 +1,309 @@
1
+ "use client"
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react"
10
+ import { createPortal } from "react-dom"
11
+ import { cn } from "../lib/cn"
12
+ import type { LifelinePhoto } from "./types"
13
+
14
+ const OPEN_MS = 520
15
+ /**
16
+ * Gentle start, soft landing — the quint curve front-loaded nearly all
17
+ * of the travel into the first 120ms, which read as a jump.
18
+ */
19
+ const EASE = "cubic-bezier(0.32, 0.72, 0, 1)"
20
+ /** Fraction of the viewport the expanded media may occupy. */
21
+ const FIT = 0.85
22
+
23
+ interface Target {
24
+ left: number
25
+ top: number
26
+ width: number
27
+ height: number
28
+ }
29
+
30
+ /**
31
+ * The card's true geometry at handoff time. Center comes from the
32
+ * bounding box (rotation about center preserves it); width/height are
33
+ * the untransformed layout size (offsetWidth/offsetHeight) — the
34
+ * bounding box of a tilted card is its axis-aligned hull, which is
35
+ * larger than the card and lands the clone visibly off.
36
+ */
37
+ export interface LifelineLightboxStart {
38
+ cx: number
39
+ cy: number
40
+ w: number
41
+ h: number
42
+ /** Playback position of the card's video, for a seamless swap. */
43
+ mediaTime?: number
44
+ }
45
+
46
+ /**
47
+ * The clone's media. Videos mount paused, pre-seeked to the card's
48
+ * playback position — a static frame the compositor can scale without
49
+ * decoding — and only play once `playing` flips after the open
50
+ * transition settles. Dismissing pauses again for the return flight.
51
+ */
52
+ function LightboxMedia({
53
+ photo,
54
+ playing,
55
+ mediaTime,
56
+ }: {
57
+ photo: LifelinePhoto
58
+ playing: boolean
59
+ mediaTime?: number
60
+ }) {
61
+ const videoRef = useRef<HTMLVideoElement>(null)
62
+ const seeded = useRef(false)
63
+
64
+ useEffect(() => {
65
+ const video = videoRef.current
66
+ if (!video) return
67
+ if (playing) {
68
+ // Seek only now, stationary — a seek during the transition
69
+ // decodes a new frame mid-flight and visibly swaps the image.
70
+ if (!seeded.current) {
71
+ seeded.current = true
72
+ if (mediaTime !== undefined) video.currentTime = mediaTime
73
+ }
74
+ video.play().catch(() => {
75
+ // Autoplay rejection just leaves the poster frame showing.
76
+ })
77
+ } else {
78
+ video.pause()
79
+ }
80
+ }, [playing, mediaTime])
81
+
82
+ if (!photo.video) {
83
+ return (
84
+ // eslint-disable-next-line @next/next/no-img-element
85
+ <img
86
+ src={photo.src}
87
+ alt={photo.alt}
88
+ className="block h-full w-full object-cover"
89
+ />
90
+ )
91
+ }
92
+
93
+ return (
94
+ <video
95
+ ref={videoRef}
96
+ src={photo.video}
97
+ poster={photo.src}
98
+ muted
99
+ loop
100
+ playsInline
101
+ preload="auto"
102
+ aria-label={photo.alt}
103
+ className="block h-full w-full object-cover"
104
+ />
105
+ )
106
+ }
107
+
108
+ function computeTarget(start: LifelineLightboxStart): Target {
109
+ const vw = window.innerWidth
110
+ const vh = window.innerHeight
111
+ const aspect = start.h / start.w
112
+ const width = Math.min(vw * FIT, (vh * FIT) / aspect)
113
+ const height = width * aspect
114
+ return { left: (vw - width) / 2, top: (vh - height) / 2, width, height }
115
+ }
116
+
117
+ /**
118
+ * Expands a floating card's media from its spot on the timeline to the
119
+ * center of the screen and back — a FLIP animation on a fixed clone
120
+ * portaled to <body> (the track is transformed, so fixed positioning
121
+ * inside it would break). The original card stays in layout, hidden,
122
+ * and is re-measured on dismiss so the media returns wherever the card
123
+ * now is, even if the timeline moved.
124
+ */
125
+ export function LifelineLightbox({
126
+ photo,
127
+ rotate,
128
+ start,
129
+ getHome,
130
+ onClosed,
131
+ }: {
132
+ photo: LifelinePhoto
133
+ /** The card's resting tilt — animated away as the media centers. */
134
+ rotate: number
135
+ /** The card's geometry at click time. */
136
+ start: LifelineLightboxStart
137
+ /** Re-measures the card at dismiss time. */
138
+ getHome: () => LifelineLightboxStart | null
139
+ onClosed: () => void
140
+ }) {
141
+ const target = useRef<Target | null>(null)
142
+ if (target.current === null) target.current = computeTarget(start)
143
+ const { left, top, width, height } = target.current
144
+
145
+ const figureRef = useRef<HTMLElement>(null)
146
+
147
+ const reduceMotion = useRef(
148
+ typeof window !== "undefined" &&
149
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches,
150
+ ).current
151
+
152
+ // Center-anchored FLIP: rotation and scale about the center match
153
+ // how the card itself is transformed, so the first frame is
154
+ // pixel-identical to the card underneath.
155
+ const toTransform = useCallback(
156
+ (home: LifelineLightboxStart) =>
157
+ `translate(${home.cx - (left + width / 2)}px, ${
158
+ home.cy - (top + height / 2)
159
+ }px) scale(${home.w / width}) rotate(${rotate}deg)`,
160
+ [left, top, width, height, rotate],
161
+ )
162
+
163
+ const [entered, setEntered] = useState(reduceMotion)
164
+ const [transform, setTransform] = useState(() =>
165
+ reduceMotion ? "none" : toTransform(start),
166
+ )
167
+ const closing = useRef(false)
168
+ // Playback waits for the open transition to finish — a playing
169
+ // video decodes frames while the transform animates and drops
170
+ // transition frames on mobile; a paused, pre-seeked frame is a
171
+ // static layer and animates cheaply.
172
+ const [settled, setSettled] = useState(reduceMotion)
173
+
174
+ // Safety net if transitionend never fires for the open.
175
+ useEffect(() => {
176
+ if (reduceMotion) return
177
+ const timeout = window.setTimeout(() => {
178
+ if (!closing.current) setSettled(true)
179
+ }, OPEN_MS + 80)
180
+ return () => window.clearTimeout(timeout)
181
+ }, [reduceMotion])
182
+
183
+ // FLIP: first paint sits over the card, next frame eases to center.
184
+ useLayoutEffect(() => {
185
+ if (reduceMotion) return
186
+ let inner = 0
187
+ const outer = requestAnimationFrame(() => {
188
+ inner = requestAnimationFrame(() => {
189
+ setEntered(true)
190
+ setTransform("translate(0px, 0px) scale(1) rotate(0deg)")
191
+ })
192
+ })
193
+ return () => {
194
+ cancelAnimationFrame(outer)
195
+ cancelAnimationFrame(inner)
196
+ }
197
+ }, [reduceMotion])
198
+
199
+ // No gesture may pan while the lightbox is up — iOS otherwise
200
+ // rubber-bands the body behind the fixed overlay and can leave the
201
+ // whole page stuck offset after dismiss. React's root touch
202
+ // listeners are passive, so this needs a native non-passive one.
203
+ const rootRef = useRef<HTMLDivElement>(null)
204
+ useEffect(() => {
205
+ const root = rootRef.current
206
+ if (!root) return
207
+ const block = (event: TouchEvent) => event.preventDefault()
208
+ root.addEventListener("touchmove", block, { passive: false })
209
+ return () => root.removeEventListener("touchmove", block)
210
+ }, [])
211
+
212
+ // The press that opens the card dispatches one more click right
213
+ // after pointerup — by then the clone is mounted underneath the
214
+ // pointer and would dismiss itself. Swallow exactly that click;
215
+ // every later click dismisses normally.
216
+ useEffect(() => {
217
+ const swallow = (event: MouseEvent) => {
218
+ event.stopPropagation()
219
+ event.preventDefault()
220
+ }
221
+ window.addEventListener("click", swallow, { capture: true, once: true })
222
+ const timeout = window.setTimeout(() => {
223
+ window.removeEventListener("click", swallow, { capture: true })
224
+ }, 500)
225
+ return () => {
226
+ window.clearTimeout(timeout)
227
+ window.removeEventListener("click", swallow, { capture: true })
228
+ }
229
+ }, [])
230
+
231
+ const dismiss = useCallback(() => {
232
+ if (closing.current) return
233
+ closing.current = true
234
+ if (reduceMotion) {
235
+ onClosed()
236
+ return
237
+ }
238
+ setSettled(false) // freeze the video so the return flight is cheap
239
+ setEntered(false)
240
+ setTransform(toTransform(getHome() ?? start))
241
+ // transitionend is the primary signal; this is the safety net.
242
+ window.setTimeout(onClosed, OPEN_MS + 120)
243
+ }, [reduceMotion, onClosed, toTransform, getHome, start])
244
+
245
+ useEffect(() => {
246
+ const onKeyDown = (event: KeyboardEvent) => {
247
+ if (event.key === "Escape") dismiss()
248
+ }
249
+ window.addEventListener("keydown", onKeyDown)
250
+ return () => window.removeEventListener("keydown", onKeyDown)
251
+ }, [dismiss])
252
+
253
+ return createPortal(
254
+ <div
255
+ ref={rootRef}
256
+ className="fixed inset-0 z-[999] touch-none overscroll-contain"
257
+ role="dialog"
258
+ aria-modal="true"
259
+ aria-label={photo.alt}
260
+ // The portal lives under <body>, but React events still bubble up
261
+ // the component tree — without this, a backdrop press would reach
262
+ // the card's drag handlers and the track's scrubber.
263
+ onPointerDown={(event) => event.stopPropagation()}
264
+ onPointerMove={(event) => event.stopPropagation()}
265
+ onPointerUp={(event) => event.stopPropagation()}
266
+ onClick={(event) => event.stopPropagation()}
267
+ >
268
+ <div
269
+ className={cn(
270
+ "absolute inset-0 cursor-zoom-out bg-black/70 transition-opacity",
271
+ entered ? "opacity-100" : "opacity-0",
272
+ )}
273
+ // Synced to the media's travel — a faster fade left the clone
274
+ // flying over an undimmed page at the end of the dismiss.
275
+ style={{ transitionDuration: `${OPEN_MS}ms` }}
276
+ onClick={dismiss}
277
+ />
278
+ <figure
279
+ ref={figureRef}
280
+ className="absolute cursor-zoom-out overflow-hidden rounded-xl shadow-2xl ring-1 ring-border-primary"
281
+ style={{
282
+ left,
283
+ top,
284
+ width,
285
+ height,
286
+ transform,
287
+ transformOrigin: "center",
288
+ transition: reduceMotion ? undefined : `transform ${OPEN_MS}ms ${EASE}`,
289
+ // Promoted for the whole flight — mobile otherwise
290
+ // re-rasterizes the shadowed, corner-clipped media mid-scale.
291
+ willChange: "transform",
292
+ }}
293
+ onClick={dismiss}
294
+ onTransitionEnd={(event) => {
295
+ if (event.propertyName !== "transform") return
296
+ if (closing.current) onClosed()
297
+ else setSettled(true)
298
+ }}
299
+ >
300
+ <LightboxMedia
301
+ photo={photo}
302
+ playing={settled}
303
+ mediaTime={start.mediaTime}
304
+ />
305
+ </figure>
306
+ </div>,
307
+ document.body,
308
+ )
309
+ }
@@ -0,0 +1,183 @@
1
+ "use client"
2
+
3
+ import { forwardRef, type CSSProperties } from "react"
4
+ import { LifelineFilmIcon as Film, LifelineImageIcon as ImageIcon } from "./lifeline-icons"
5
+ import { cn } from "../lib/cn"
6
+ import { CompanyIcon } from "./company-icon"
7
+ import {
8
+ getLifelineEventEffect,
9
+ getLifelineEventImage,
10
+ getLifelineEventKey,
11
+ LifelineEventText,
12
+ } from "./lifeline-event"
13
+ import { useLifelineFireworks } from "./lifeline-fireworks"
14
+ import { useLifelineHoverImage } from "./lifeline-hover-image"
15
+ import { aggregateLifelinePeople, LifelinePeople } from "./lifeline-people"
16
+ import type { LifelineMarker } from "./types"
17
+
18
+ interface LifelineMarkerColumnProps {
19
+ marker: LifelineMarker
20
+ birthYear: number
21
+ minWidth: number
22
+ animateIntro?: boolean
23
+ introDelay?: number
24
+ introDuration?: number
25
+ }
26
+
27
+ export const LifelineMarkerColumn = forwardRef<
28
+ HTMLDivElement,
29
+ LifelineMarkerColumnProps
30
+ >(function LifelineMarkerColumn(
31
+ {
32
+ marker,
33
+ birthYear,
34
+ minWidth,
35
+ animateIntro = false,
36
+ introDelay = 0,
37
+ introDuration = 420,
38
+ },
39
+ ref,
40
+ ) {
41
+ const age = marker.age ?? marker.year - birthYear
42
+ const people = aggregateLifelinePeople(marker)
43
+ const hoverImage = useLifelineHoverImage()
44
+ const fireworks = useLifelineFireworks()
45
+
46
+ return (
47
+ <div
48
+ ref={ref}
49
+ className="group relative shrink-0 pr-8 transition-opacity duration-300 ease-out will-change-opacity"
50
+ style={{ width: minWidth }}
51
+ aria-label={marker.label ?? `${marker.year}`}
52
+ >
53
+ <div
54
+ className={cn("relative", animateIntro && "lifeline-marker-intro")}
55
+ style={{
56
+ animationDelay: animateIntro ? `${introDelay}ms` : undefined,
57
+ ...(animateIntro
58
+ ? ({
59
+ "--lifeline-marker-fade-ms": `${introDuration}ms`,
60
+ } as CSSProperties)
61
+ : {}),
62
+ }}
63
+ >
64
+ <span
65
+ className="absolute left-0 top-[var(--lifeline-rail)] z-10 h-[10px] w-px -translate-y-1/2 bg-fg-quaternary transition-colors duration-300 group-hover:bg-fg-tertiary"
66
+ aria-hidden="true"
67
+ />
68
+
69
+ <div className="flex w-full flex-col items-start text-left">
70
+ <p className="mb-5 h-4 text-[11px] font-medium leading-4 tabular-nums text-fg-tertiary transition-colors duration-300 group-hover:text-fg-primary">
71
+ {age}
72
+ </p>
73
+
74
+ <p className="mb-6 h-5 whitespace-nowrap text-[15px] font-medium leading-5 tabular-nums text-fg-secondary transition-colors duration-300 group-hover:text-fg-primary">
75
+ {marker.label ?? marker.year}
76
+ </p>
77
+
78
+ <div className="relative w-full pb-10 text-fg-secondary transition-colors duration-300 group-hover:text-fg-primary">
79
+ {/* When this column carries people, the content block reserves
80
+ the band's height as a floor: short and average columns put
81
+ their portraits on the same line as every other column, and
82
+ a column whose events run past the floor pushes its own
83
+ portraits below them instead of under them. pb-6 is the gap
84
+ in the overflow case — absorbed by the floor otherwise. */}
85
+ <div
86
+ className={cn(
87
+ "flex w-full flex-col items-start pt-6",
88
+ people.length > 0 &&
89
+ "min-h-[var(--lifeline-people-top)] pb-6",
90
+ )}
91
+ >
92
+ {marker.badges && marker.badges.length > 0 && (
93
+ <div className="mb-3 flex items-center justify-start gap-2">
94
+ {marker.badges.map((badge) => (
95
+ // eslint-disable-next-line @next/next/no-img-element
96
+ <img
97
+ key={badge.src}
98
+ src={badge.src}
99
+ alt={badge.alt}
100
+ className="h-6 w-6 object-contain opacity-80 transition-opacity duration-300 group-hover:opacity-100"
101
+ />
102
+ ))}
103
+ </div>
104
+ )}
105
+
106
+ {marker.companies && marker.companies.length > 0 && (
107
+ <div className="mb-2 flex items-center justify-start gap-1.5">
108
+ {marker.companies.map((company) => (
109
+ <CompanyIcon
110
+ key={company.id}
111
+ id={company.id}
112
+ label={company.name}
113
+ className="opacity-70 transition-opacity duration-300 group-hover:opacity-100"
114
+ />
115
+ ))}
116
+ </div>
117
+ )}
118
+
119
+ <div className="min-h-[3.25rem] space-y-4">
120
+ {marker.events.map((event, index) => {
121
+ const image = getLifelineEventImage(event)
122
+ const effect = getLifelineEventEffect(event)
123
+
124
+ return (
125
+ <p
126
+ key={getLifelineEventKey(event, index)}
127
+ className={cn(
128
+ "max-w-[18rem] text-left text-[14px] leading-[1.55] tracking-[-0.01em]",
129
+ effect && "cursor-pointer",
130
+ )}
131
+ data-lifeline-interactive={effect ? "" : undefined}
132
+ onMouseEnter={
133
+ image && hoverImage
134
+ ? () => hoverImage.show(image)
135
+ : undefined
136
+ }
137
+ onMouseLeave={
138
+ image && hoverImage ? hoverImage.hide : undefined
139
+ }
140
+ onClick={
141
+ effect && fireworks
142
+ ? () => fireworks.launch(effect)
143
+ : undefined
144
+ }
145
+ >
146
+ <LifelineEventText event={event} />
147
+ {image && (
148
+ // Glued to the last word with a no-break space so
149
+ // the icon can never wrap onto a line of its own.
150
+ <span className="whitespace-nowrap">
151
+ {" "}
152
+ {image.video ? (
153
+ <Film
154
+ className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
155
+ strokeWidth={1.75}
156
+ aria-hidden="true"
157
+ />
158
+ ) : (
159
+ <ImageIcon
160
+ className="ml-0.5 inline-block h-3 w-3 -translate-y-px text-fg-quaternary transition-colors duration-300"
161
+ strokeWidth={1.75}
162
+ aria-hidden="true"
163
+ />
164
+ )}
165
+ </span>
166
+ )}
167
+ </p>
168
+ )
169
+ })}
170
+ </div>
171
+ </div>
172
+
173
+ {people.length > 0 && (
174
+ <div className="w-full">
175
+ <LifelinePeople people={people} />
176
+ </div>
177
+ )}
178
+ </div>
179
+ </div>
180
+ </div>
181
+ </div>
182
+ )
183
+ })
@@ -0,0 +1,99 @@
1
+ import type { LifelineMarker } from "./types"
2
+
3
+ export interface AggregatedLifelinePerson {
4
+ name: string
5
+ mentor: boolean
6
+ met: boolean
7
+ photo?: string
8
+ }
9
+
10
+ export function aggregateLifelinePeople(
11
+ marker: LifelineMarker,
12
+ ): AggregatedLifelinePerson[] {
13
+ const map = new Map<string, AggregatedLifelinePerson>()
14
+
15
+ const add = (
16
+ name: string,
17
+ type: "mentor" | "met",
18
+ photo?: string,
19
+ ) => {
20
+ const person = map.get(name) ?? { name, mentor: false, met: false }
21
+ person[type] = true
22
+ person.photo = person.photo ?? photo
23
+ map.set(name, person)
24
+ }
25
+
26
+ marker.mentors?.forEach((person) =>
27
+ add(person.name, "mentor", person.photo),
28
+ )
29
+ marker.met?.forEach((person) => add(person.name, "met", person.photo))
30
+
31
+ return [...map.values()]
32
+ }
33
+
34
+ function getInitials(name: string) {
35
+ return name
36
+ .split(" ")
37
+ .filter(Boolean)
38
+ .map((part) => part[0])
39
+ .join("")
40
+ .slice(0, 2)
41
+ }
42
+
43
+ interface LifelinePeopleProps {
44
+ people: AggregatedLifelinePerson[]
45
+ allowWrap?: boolean
46
+ }
47
+
48
+ export function LifelinePeople({
49
+ people,
50
+ allowWrap = false,
51
+ }: LifelinePeopleProps) {
52
+ if (people.length === 0) return null
53
+
54
+ return (
55
+ <div className="w-full space-y-3">
56
+ {people.map((person) => (
57
+ <div key={person.name} className="flex w-full items-center gap-2.5">
58
+ <div className="flex w-3 shrink-0 items-center justify-center gap-0.5">
59
+ {person.mentor && (
60
+ <span
61
+ className="h-1.5 w-1.5 rounded-full bg-blue-500"
62
+ aria-hidden="true"
63
+ />
64
+ )}
65
+ {person.met && (
66
+ <span
67
+ className="h-1.5 w-1.5 rounded-full bg-pink-500"
68
+ aria-hidden="true"
69
+ />
70
+ )}
71
+ </div>
72
+ {person.photo ? (
73
+ // eslint-disable-next-line @next/next/no-img-element
74
+ <img
75
+ src={person.photo}
76
+ alt={person.name}
77
+ width={28}
78
+ height={28}
79
+ className="h-7 w-7 shrink-0 rounded-full object-cover"
80
+ />
81
+ ) : (
82
+ <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-surface-inverted text-[10px] font-medium text-fg-inverted transition-colors duration-300">
83
+ {getInitials(person.name)}
84
+ </span>
85
+ )}
86
+ <p
87
+ className={
88
+ allowWrap
89
+ ? "text-left text-[13px] leading-snug text-fg-tertiary transition-colors duration-300"
90
+ : "whitespace-nowrap text-left text-[13px] text-fg-tertiary transition-colors duration-300 group-hover:text-fg-secondary"
91
+ }
92
+ >
93
+ {person.name}
94
+ </p>
95
+ </div>
96
+ ))}
97
+ </div>
98
+ )
99
+ }