@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,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
+ }