@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,271 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
|
|
4
|
+
import { clamp } from "./lifeline-utils"
|
|
5
|
+
|
|
6
|
+
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
|
|
7
|
+
let node = element?.parentElement ?? null
|
|
8
|
+
|
|
9
|
+
while (node) {
|
|
10
|
+
const { overflowY } = window.getComputedStyle(node)
|
|
11
|
+
if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") {
|
|
12
|
+
return node
|
|
13
|
+
}
|
|
14
|
+
node = node.parentElement
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Nothing on the way up scrolls, so the document does — which is the
|
|
18
|
+
// ordinary case for a page-mode timeline in a page that just scrolls.
|
|
19
|
+
// Returning null here left the whole rail `invisible` with no error.
|
|
20
|
+
return (document.scrollingElement as HTMLElement | null) ?? null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LifelineVerticalScrollOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Embedded, the timeline opens at its start rather than where a skipped
|
|
26
|
+
* intro would have settled it — the reader is arriving at a module in a
|
|
27
|
+
* page, not returning to a timeline that already played.
|
|
28
|
+
*/
|
|
29
|
+
isEmbed?: boolean
|
|
30
|
+
introLocked?: boolean
|
|
31
|
+
introAnimating?: boolean
|
|
32
|
+
introSkipped?: boolean
|
|
33
|
+
introRailMs?: number
|
|
34
|
+
introGetTrackProgress?: (elapsedMs: number) => number
|
|
35
|
+
onIntroSettleComplete?: () => void
|
|
36
|
+
onIntroScrollStart?: () => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useLifelineVerticalScroll(
|
|
40
|
+
markerCount: number,
|
|
41
|
+
options: LifelineVerticalScrollOptions = {},
|
|
42
|
+
) {
|
|
43
|
+
const sectionRef = useRef<HTMLElement>(null)
|
|
44
|
+
const entryRefs = useRef<(HTMLLIElement | null)[]>([])
|
|
45
|
+
const maxScrollRef = useRef(0)
|
|
46
|
+
const scrollParentRef = useRef<HTMLElement | null>(null)
|
|
47
|
+
const initialized = useRef(false)
|
|
48
|
+
const introLockedRef = useRef(options.introLocked ?? false)
|
|
49
|
+
const introAnimatingRef = useRef(options.introAnimating ?? false)
|
|
50
|
+
const introSkippedRef = useRef(options.introSkipped ?? false)
|
|
51
|
+
const isEmbedRef = useRef(options.isEmbed ?? false)
|
|
52
|
+
const onIntroSettleCompleteRef = useRef(options.onIntroSettleComplete)
|
|
53
|
+
const onIntroScrollStartRef = useRef(options.onIntroScrollStart)
|
|
54
|
+
const introGetTrackProgressRef = useRef(options.introGetTrackProgress)
|
|
55
|
+
const introStartedRef = useRef(false)
|
|
56
|
+
const introScrollId = useRef(0)
|
|
57
|
+
const introScrollStart = useRef(0)
|
|
58
|
+
const introWasAnimatingRef = useRef(false)
|
|
59
|
+
const scheduleMeasureRef = useRef<() => void>(() => {})
|
|
60
|
+
const [isLayoutReady, setIsLayoutReady] = useState(false)
|
|
61
|
+
|
|
62
|
+
introLockedRef.current = options.introLocked ?? false
|
|
63
|
+
introAnimatingRef.current = options.introAnimating ?? false
|
|
64
|
+
introSkippedRef.current = options.introSkipped ?? false
|
|
65
|
+
isEmbedRef.current = options.isEmbed ?? false
|
|
66
|
+
onIntroSettleCompleteRef.current = options.onIntroSettleComplete
|
|
67
|
+
onIntroScrollStartRef.current = options.onIntroScrollStart
|
|
68
|
+
introGetTrackProgressRef.current = options.introGetTrackProgress
|
|
69
|
+
|
|
70
|
+
const setEntryRef = useCallback((index: number, node: HTMLLIElement | null) => {
|
|
71
|
+
entryRefs.current[index] = node
|
|
72
|
+
|
|
73
|
+
if (index === markerCount - 1 && node) {
|
|
74
|
+
scheduleMeasureRef.current()
|
|
75
|
+
}
|
|
76
|
+
}, [markerCount])
|
|
77
|
+
|
|
78
|
+
const applyScroll = useCallback((value: number) => {
|
|
79
|
+
const scrollParent = scrollParentRef.current
|
|
80
|
+
if (!scrollParent) return
|
|
81
|
+
|
|
82
|
+
scrollParent.scrollTop = clamp(value, 0, maxScrollRef.current)
|
|
83
|
+
}, [])
|
|
84
|
+
|
|
85
|
+
const measureLayout = useCallback(() => {
|
|
86
|
+
const section = sectionRef.current
|
|
87
|
+
if (!section) return 0
|
|
88
|
+
|
|
89
|
+
const scrollParent = getScrollParent(section)
|
|
90
|
+
scrollParentRef.current = scrollParent
|
|
91
|
+
|
|
92
|
+
if (!scrollParent) return 0
|
|
93
|
+
|
|
94
|
+
const heights = entryRefs.current.map((entry) => entry?.offsetHeight ?? 0)
|
|
95
|
+
if (heights.length < markerCount || heights.some((height) => height <= 0)) {
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const max = Math.max(0, scrollParent.scrollHeight - scrollParent.clientHeight)
|
|
100
|
+
maxScrollRef.current = max
|
|
101
|
+
|
|
102
|
+
return max
|
|
103
|
+
}, [markerCount])
|
|
104
|
+
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
entryRefs.current.length = markerCount
|
|
107
|
+
}, [markerCount])
|
|
108
|
+
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const max = measureLayout()
|
|
111
|
+
|
|
112
|
+
const scrollParent = scrollParentRef.current
|
|
113
|
+
if (!scrollParent) return
|
|
114
|
+
|
|
115
|
+
if (!initialized.current) {
|
|
116
|
+
scrollParent.scrollTop =
|
|
117
|
+
introSkippedRef.current && !isEmbedRef.current ? max : 0
|
|
118
|
+
initialized.current = true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setIsLayoutReady(entryRefs.current.every((entry) => Boolean(entry)))
|
|
122
|
+
// Sync initial position once before first paint; resize uses measure().
|
|
123
|
+
}, [])
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!isLayoutReady) return
|
|
127
|
+
if (options.introSkipped || !options.introAnimating) {
|
|
128
|
+
cancelAnimationFrame(introScrollId.current)
|
|
129
|
+
introScrollId.current = 0
|
|
130
|
+
introStartedRef.current = false
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
introWasAnimatingRef.current = true
|
|
135
|
+
const railMs = options.introRailMs ?? 3200
|
|
136
|
+
|
|
137
|
+
const step = (now: number) => {
|
|
138
|
+
const max = maxScrollRef.current
|
|
139
|
+
|
|
140
|
+
if (!introStartedRef.current) {
|
|
141
|
+
introStartedRef.current = true
|
|
142
|
+
introScrollStart.current = now
|
|
143
|
+
onIntroScrollStartRef.current?.()
|
|
144
|
+
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "0")
|
|
145
|
+
if (max > 0) applyScroll(0)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const elapsed = now - introScrollStart.current
|
|
149
|
+
const progress = introGetTrackProgressRef.current
|
|
150
|
+
? clamp(introGetTrackProgressRef.current(elapsed), 0, 1)
|
|
151
|
+
: clamp(elapsed / railMs, 0, 1)
|
|
152
|
+
|
|
153
|
+
sectionRef.current?.style.setProperty(
|
|
154
|
+
"--lifeline-intro-progress",
|
|
155
|
+
String(progress),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if (max > 0) {
|
|
159
|
+
applyScroll(progress * max)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (progress < 1) {
|
|
163
|
+
introScrollId.current = requestAnimationFrame(step)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
sectionRef.current?.style.setProperty("--lifeline-intro-progress", "1")
|
|
168
|
+
if (max > 0) applyScroll(max)
|
|
169
|
+
introScrollId.current = 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
introScrollId.current = requestAnimationFrame(step)
|
|
173
|
+
|
|
174
|
+
return () => {
|
|
175
|
+
cancelAnimationFrame(introScrollId.current)
|
|
176
|
+
introScrollId.current = 0
|
|
177
|
+
introStartedRef.current = false
|
|
178
|
+
}
|
|
179
|
+
}, [
|
|
180
|
+
applyScroll,
|
|
181
|
+
isLayoutReady,
|
|
182
|
+
options.introAnimating,
|
|
183
|
+
options.introRailMs,
|
|
184
|
+
options.introSkipped,
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (options.introSkipped) return
|
|
189
|
+
if (options.introAnimating) return
|
|
190
|
+
if (!introWasAnimatingRef.current) return
|
|
191
|
+
|
|
192
|
+
introWasAnimatingRef.current = false
|
|
193
|
+
sectionRef.current?.style.removeProperty("--lifeline-intro-progress")
|
|
194
|
+
onIntroSettleCompleteRef.current?.()
|
|
195
|
+
}, [options.introAnimating, options.introSkipped])
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const section = sectionRef.current
|
|
199
|
+
if (!section) return
|
|
200
|
+
|
|
201
|
+
let frameId = 0
|
|
202
|
+
let resizeObserver: ResizeObserver | null = null
|
|
203
|
+
|
|
204
|
+
const measure = () => {
|
|
205
|
+
measureLayout()
|
|
206
|
+
|
|
207
|
+
const scrollParent = scrollParentRef.current
|
|
208
|
+
if (!scrollParent) return
|
|
209
|
+
|
|
210
|
+
if (!(introAnimatingRef.current && introStartedRef.current)) {
|
|
211
|
+
scrollParent.scrollTop = clamp(
|
|
212
|
+
scrollParent.scrollTop,
|
|
213
|
+
0,
|
|
214
|
+
maxScrollRef.current,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
setIsLayoutReady(
|
|
219
|
+
entryRefs.current.length === markerCount &&
|
|
220
|
+
entryRefs.current.every((entry) => Boolean(entry)),
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const scheduleMeasure = () => {
|
|
225
|
+
cancelAnimationFrame(frameId)
|
|
226
|
+
frameId = requestAnimationFrame(measure)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
scheduleMeasureRef.current = scheduleMeasure
|
|
230
|
+
|
|
231
|
+
scheduleMeasure()
|
|
232
|
+
frameId = requestAnimationFrame(() => {
|
|
233
|
+
measure()
|
|
234
|
+
requestAnimationFrame(measure)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
resizeObserver = new ResizeObserver(scheduleMeasure)
|
|
238
|
+
resizeObserver.observe(section)
|
|
239
|
+
|
|
240
|
+
window.addEventListener("resize", scheduleMeasure)
|
|
241
|
+
|
|
242
|
+
const isScrollLocked = () =>
|
|
243
|
+
introLockedRef.current && introStartedRef.current
|
|
244
|
+
|
|
245
|
+
const preventScroll = (event: Event) => {
|
|
246
|
+
if (!isScrollLocked()) return
|
|
247
|
+
event.preventDefault()
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const scrollParent = getScrollParent(section)
|
|
251
|
+
scrollParentRef.current = scrollParent
|
|
252
|
+
|
|
253
|
+
scrollParent?.addEventListener("wheel", preventScroll, { passive: false })
|
|
254
|
+
scrollParent?.addEventListener("touchmove", preventScroll, { passive: false })
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
cancelAnimationFrame(frameId)
|
|
258
|
+
resizeObserver?.disconnect()
|
|
259
|
+
window.removeEventListener("resize", scheduleMeasure)
|
|
260
|
+
scrollParent?.removeEventListener("wheel", preventScroll)
|
|
261
|
+
scrollParent?.removeEventListener("touchmove", preventScroll)
|
|
262
|
+
initialized.current = false
|
|
263
|
+
}
|
|
264
|
+
}, [markerCount, measureLayout])
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
sectionRef,
|
|
268
|
+
setEntryRef,
|
|
269
|
+
isLayoutReady,
|
|
270
|
+
}
|
|
271
|
+
}
|
package/src/menubar.tsx
CHANGED
|
@@ -10,9 +10,16 @@ export const Menubar = ({ className, ...props }: MenubarProps) => (
|
|
|
10
10
|
<BaseMenubar
|
|
11
11
|
className={cn(
|
|
12
12
|
"inline-flex items-center gap-0.5 rounded-lg border border-border-primary bg-background-secondary p-0.5",
|
|
13
|
-
|
|
13
|
+
// Base UI sets data-orientation and handles the arrow keys, but the
|
|
14
|
+
// layout is ours: without this a vertical menubar still renders as a
|
|
15
|
+
// row, so the prop looks supported and does nothing.
|
|
16
|
+
"data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
|
|
17
|
+
"data-[orientation=vertical]:[&_button]:justify-start",
|
|
18
|
+
"[&_button]:inline-flex [&_button]:h-8 [&_button]:cursor-pointer [&_button]:items-center [&_button]:justify-center [&_button]:gap-1.5 [&_button]:rounded-md [&_button]:px-2.5 [&_button]:text-sm [&_button]:text-fg-secondary",
|
|
19
|
+
// focusRingBorder + focusRing, scoped to child Menu triggers
|
|
20
|
+
"[&_button]:border [&_button]:border-transparent",
|
|
21
|
+
"[&_button]:outline-none [&_button]:focus-visible:border-ring [&_button]:focus-visible:ring-[3px] [&_button]:focus-visible:ring-offset-1 [&_button]:focus-visible:ring-offset-background-primary [&_button]:focus-visible:ring-ring/20",
|
|
14
22
|
"[&_button]:hover:bg-background-tertiary [&_button]:hover:text-fg-primary",
|
|
15
|
-
"[&_button]:focus-visible:border-ring [&_button]:focus-visible:ring-[3px] [&_button]:focus-visible:ring-offset-1 [&_button]:focus-visible:ring-offset-background-primary [&_button]:focus-visible:ring-ring/20",
|
|
16
23
|
"[&_button]:data-popup-open:bg-background-tertiary [&_button]:data-popup-open:text-fg-primary",
|
|
17
24
|
"[&_button]:data-disabled:cursor-not-allowed [&_button]:data-disabled:opacity-50",
|
|
18
25
|
className,
|
package/src/minimap.tsx
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
|
+
import {
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
type FocusEvent,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type MouseEvent,
|
|
9
|
+
useCallback,
|
|
10
|
+
useEffect,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react"
|
|
14
|
+
import { cn } from "./lib/cn"
|
|
15
|
+
import {
|
|
16
|
+
Tooltip,
|
|
17
|
+
TooltipPopup,
|
|
18
|
+
TooltipPortal,
|
|
19
|
+
TooltipPositioner,
|
|
20
|
+
TooltipProvider,
|
|
21
|
+
TooltipTrigger,
|
|
22
|
+
} from "./tooltip"
|
|
23
|
+
|
|
24
|
+
const minimapVariants = cva("z-20 w-8", {
|
|
25
|
+
variants: {
|
|
26
|
+
position: {
|
|
27
|
+
fixed:
|
|
28
|
+
"fixed left-10 top-1/2 max-h-[min(70vh,32.5rem)] -translate-y-1/2 max-[1120px]:hidden",
|
|
29
|
+
inline: "relative",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
defaultVariants: {
|
|
33
|
+
position: "fixed",
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export type MinimapSection = {
|
|
38
|
+
/** The id of the section heading or landmark to scroll to. */
|
|
39
|
+
id: string
|
|
40
|
+
/** The accessible name and tooltip shown for the section. */
|
|
41
|
+
label: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type MinimapProps = Omit<ComponentProps<"nav">, "children"> &
|
|
45
|
+
VariantProps<typeof minimapVariants> & {
|
|
46
|
+
/** Ordered destinations represented by the ticks. */
|
|
47
|
+
sections: readonly MinimapSection[]
|
|
48
|
+
/** Controlled active section id. */
|
|
49
|
+
activeId?: string | null
|
|
50
|
+
/** Initial active section id when uncontrolled. */
|
|
51
|
+
defaultActiveId?: string | null
|
|
52
|
+
/** Called when observation or selection changes the active section. */
|
|
53
|
+
onActiveChange?: (id: string) => void
|
|
54
|
+
/** Optional scroll container used by the section observer. */
|
|
55
|
+
root?: Element | Document | null
|
|
56
|
+
/** Intersection observer margin used to choose the active section. */
|
|
57
|
+
rootMargin?: string
|
|
58
|
+
/** Scroll behavior used after a tick is selected. */
|
|
59
|
+
scrollBehavior?: ScrollBehavior
|
|
60
|
+
/** Delay before a section label appears on hover, in milliseconds. */
|
|
61
|
+
tooltipDelay?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const Minimap = ({
|
|
65
|
+
sections,
|
|
66
|
+
activeId,
|
|
67
|
+
defaultActiveId,
|
|
68
|
+
onActiveChange,
|
|
69
|
+
root = null,
|
|
70
|
+
rootMargin = "-20% 0px -68% 0px",
|
|
71
|
+
scrollBehavior = "smooth",
|
|
72
|
+
tooltipDelay = 100,
|
|
73
|
+
position,
|
|
74
|
+
className,
|
|
75
|
+
"aria-label": ariaLabel = "Page sections",
|
|
76
|
+
...props
|
|
77
|
+
}: MinimapProps) => {
|
|
78
|
+
const [uncontrolledActiveId, setUncontrolledActiveId] = useState<
|
|
79
|
+
string | null
|
|
80
|
+
>(() => defaultActiveId ?? sections[0]?.id ?? null)
|
|
81
|
+
const currentActiveId =
|
|
82
|
+
activeId !== undefined ? activeId : uncontrolledActiveId
|
|
83
|
+
const activeIndex = sections.findIndex(
|
|
84
|
+
(section) => section.id === currentActiveId,
|
|
85
|
+
)
|
|
86
|
+
const tabbableIndex = activeIndex >= 0 ? activeIndex : 0
|
|
87
|
+
const currentActiveIdRef = useRef(currentActiveId)
|
|
88
|
+
const [pointerIndex, setPointerIndex] = useState<number | null>(null)
|
|
89
|
+
const [focusIndex, setFocusIndex] = useState<number | null>(null)
|
|
90
|
+
const emphasizedIndex = pointerIndex ?? focusIndex
|
|
91
|
+
|
|
92
|
+
const handleListMouseLeave = () => setPointerIndex(null)
|
|
93
|
+
|
|
94
|
+
const handleListBlur = (event: FocusEvent<HTMLOListElement>) => {
|
|
95
|
+
const next = event.relatedTarget
|
|
96
|
+
if (next instanceof Node && event.currentTarget.contains(next)) return
|
|
97
|
+
setFocusIndex(null)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
currentActiveIdRef.current = currentActiveId
|
|
102
|
+
}, [currentActiveId])
|
|
103
|
+
|
|
104
|
+
const commitActiveId = useCallback(
|
|
105
|
+
(nextId: string) => {
|
|
106
|
+
const changed = currentActiveIdRef.current !== nextId
|
|
107
|
+
currentActiveIdRef.current = nextId
|
|
108
|
+
|
|
109
|
+
if (activeId === undefined) setUncontrolledActiveId(nextId)
|
|
110
|
+
if (changed) onActiveChange?.(nextId)
|
|
111
|
+
},
|
|
112
|
+
[activeId, onActiveChange],
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const elements = sections
|
|
117
|
+
.map((section) => document.getElementById(section.id))
|
|
118
|
+
.filter((element): element is HTMLElement => Boolean(element))
|
|
119
|
+
|
|
120
|
+
if (!elements.length) return
|
|
121
|
+
|
|
122
|
+
const getActiveElementId = () => {
|
|
123
|
+
const rootRect =
|
|
124
|
+
root instanceof Element ? root.getBoundingClientRect() : null
|
|
125
|
+
const activationLine = rootRect
|
|
126
|
+
? rootRect.top + rootRect.height * 0.2
|
|
127
|
+
: window.innerHeight * 0.2
|
|
128
|
+
let nextId = elements[0]?.id
|
|
129
|
+
|
|
130
|
+
elements.forEach((element) => {
|
|
131
|
+
if (element.getBoundingClientRect().top <= activationLine) {
|
|
132
|
+
nextId = element.id
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return nextId
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const observer = new IntersectionObserver(
|
|
140
|
+
() => {
|
|
141
|
+
const nextId = getActiveElementId()
|
|
142
|
+
if (nextId) commitActiveId(nextId)
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
root,
|
|
146
|
+
rootMargin,
|
|
147
|
+
threshold: [0, 0.1, 1],
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
elements.forEach((element) => observer.observe(element))
|
|
152
|
+
|
|
153
|
+
const frame = requestAnimationFrame(() => {
|
|
154
|
+
const nextId = getActiveElementId()
|
|
155
|
+
if (nextId) commitActiveId(nextId)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
return () => {
|
|
159
|
+
cancelAnimationFrame(frame)
|
|
160
|
+
observer.disconnect()
|
|
161
|
+
}
|
|
162
|
+
}, [commitActiveId, root, rootMargin, sections])
|
|
163
|
+
|
|
164
|
+
const selectSection = useCallback(
|
|
165
|
+
(section: MinimapSection) => {
|
|
166
|
+
const target = document.getElementById(section.id)
|
|
167
|
+
if (!target) return
|
|
168
|
+
|
|
169
|
+
commitActiveId(section.id)
|
|
170
|
+
const prefersReducedMotion = window.matchMedia(
|
|
171
|
+
"(prefers-reduced-motion: reduce)",
|
|
172
|
+
).matches
|
|
173
|
+
|
|
174
|
+
const behavior = prefersReducedMotion ? "auto" : scrollBehavior
|
|
175
|
+
|
|
176
|
+
if (root instanceof Element) {
|
|
177
|
+
const rootRect = root.getBoundingClientRect()
|
|
178
|
+
const targetRect = target.getBoundingClientRect()
|
|
179
|
+
const scrollMarginTop = Number.parseFloat(
|
|
180
|
+
getComputedStyle(target).scrollMarginTop,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
root.scrollTo({
|
|
184
|
+
top:
|
|
185
|
+
root.scrollTop +
|
|
186
|
+
targetRect.top -
|
|
187
|
+
rootRect.top -
|
|
188
|
+
(Number.isFinite(scrollMarginTop) ? scrollMarginTop : 0),
|
|
189
|
+
behavior,
|
|
190
|
+
})
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
target.scrollIntoView({ behavior, block: "start" })
|
|
195
|
+
},
|
|
196
|
+
[commitActiveId, root, scrollBehavior],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
const handleKeyDown = (
|
|
200
|
+
event: KeyboardEvent<HTMLButtonElement>,
|
|
201
|
+
index: number,
|
|
202
|
+
) => {
|
|
203
|
+
let nextIndex: number | null = null
|
|
204
|
+
|
|
205
|
+
if (event.key === "ArrowDown") nextIndex = Math.min(index + 1, sections.length - 1)
|
|
206
|
+
if (event.key === "ArrowUp") nextIndex = Math.max(index - 1, 0)
|
|
207
|
+
if (event.key === "Home") nextIndex = 0
|
|
208
|
+
if (event.key === "End") nextIndex = sections.length - 1
|
|
209
|
+
if (nextIndex === null || nextIndex === index) return
|
|
210
|
+
|
|
211
|
+
event.preventDefault()
|
|
212
|
+
const nextSection = sections[nextIndex]
|
|
213
|
+
const buttons = event.currentTarget
|
|
214
|
+
.closest("ol")
|
|
215
|
+
?.querySelectorAll<HTMLButtonElement>("[data-minimap-mark]")
|
|
216
|
+
|
|
217
|
+
buttons?.[nextIndex]?.focus()
|
|
218
|
+
if (nextSection) selectSection(nextSection)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (sections.length < 2) return null
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<TooltipProvider delay={tooltipDelay}>
|
|
225
|
+
<nav
|
|
226
|
+
aria-label={ariaLabel}
|
|
227
|
+
className={cn(minimapVariants({ position }), className)}
|
|
228
|
+
{...props}
|
|
229
|
+
>
|
|
230
|
+
<ol
|
|
231
|
+
className="flex max-h-[min(70vh,32.5rem)] w-full list-none flex-col items-start justify-center gap-0.5 p-0"
|
|
232
|
+
onMouseLeave={handleListMouseLeave}
|
|
233
|
+
onBlur={handleListBlur}
|
|
234
|
+
>
|
|
235
|
+
{sections.map((section, index) => {
|
|
236
|
+
const isActive = section.id === currentActiveId
|
|
237
|
+
const isEmphasized = index === emphasizedIndex
|
|
238
|
+
const isNeighbor =
|
|
239
|
+
emphasizedIndex !== null &&
|
|
240
|
+
Math.abs(index - emphasizedIndex) === 1
|
|
241
|
+
|
|
242
|
+
return (
|
|
243
|
+
<li
|
|
244
|
+
key={section.id}
|
|
245
|
+
className="relative flex w-full shrink-0 justify-start"
|
|
246
|
+
onMouseEnter={() => setPointerIndex(index)}
|
|
247
|
+
>
|
|
248
|
+
<Tooltip>
|
|
249
|
+
<TooltipTrigger
|
|
250
|
+
render={
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
aria-label={section.label}
|
|
254
|
+
aria-current={isActive ? "location" : undefined}
|
|
255
|
+
data-minimap-mark
|
|
256
|
+
tabIndex={index === tabbableIndex ? 0 : -1}
|
|
257
|
+
className="flex h-2 w-full cursor-pointer items-center justify-start border-0 bg-transparent p-0 outline-none"
|
|
258
|
+
onClick={(event: MouseEvent<HTMLButtonElement>) => {
|
|
259
|
+
selectSection(section)
|
|
260
|
+
if (event.detail > 0) event.currentTarget.blur()
|
|
261
|
+
}}
|
|
262
|
+
onFocus={() => setFocusIndex(index)}
|
|
263
|
+
onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) =>
|
|
264
|
+
handleKeyDown(event, index)
|
|
265
|
+
}
|
|
266
|
+
>
|
|
267
|
+
<span
|
|
268
|
+
aria-hidden
|
|
269
|
+
className={cn(
|
|
270
|
+
"block h-0.5 w-2 origin-left rounded-full bg-fg-primary/20",
|
|
271
|
+
"transition-[width,background-color] duration-[var(--duration-sm)] ease-enter motion-reduce:transition-none",
|
|
272
|
+
isEmphasized && "w-6 bg-fg-primary",
|
|
273
|
+
isNeighbor && "w-4",
|
|
274
|
+
)}
|
|
275
|
+
/>
|
|
276
|
+
</button>
|
|
277
|
+
}
|
|
278
|
+
/>
|
|
279
|
+
<TooltipPortal>
|
|
280
|
+
<TooltipPositioner side="right" sideOffset={14}>
|
|
281
|
+
<TooltipPopup className="font-medium">
|
|
282
|
+
{section.label}
|
|
283
|
+
</TooltipPopup>
|
|
284
|
+
</TooltipPositioner>
|
|
285
|
+
</TooltipPortal>
|
|
286
|
+
</Tooltip>
|
|
287
|
+
</li>
|
|
288
|
+
)
|
|
289
|
+
})}
|
|
290
|
+
</ol>
|
|
291
|
+
</nav>
|
|
292
|
+
</TooltipProvider>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export { minimapVariants }
|