@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.
Files changed (83) 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 +41 -13
  17. package/src/date-picker.tsx +7 -2
  18. package/src/dialog.tsx +33 -10
  19. package/src/empty.tsx +4 -4
  20. package/src/field.tsx +4 -3
  21. package/src/filter-group.tsx +143 -0
  22. package/src/icons.tsx +28 -1
  23. package/src/illustrations.tsx +219 -141
  24. package/src/index.ts +245 -18
  25. package/src/input.tsx +8 -5
  26. package/src/kbd.tsx +49 -0
  27. package/src/lib/focus.ts +41 -0
  28. package/src/lib/popup.ts +1 -1
  29. package/src/lifeline/company-icon.tsx +71 -0
  30. package/src/lifeline/index.ts +42 -0
  31. package/src/lifeline/lifeline-data.ts +97 -0
  32. package/src/lifeline/lifeline-desktop.tsx +204 -0
  33. package/src/lifeline/lifeline-event.tsx +108 -0
  34. package/src/lifeline/lifeline-fireworks.tsx +302 -0
  35. package/src/lifeline/lifeline-hover-image.tsx +269 -0
  36. package/src/lifeline/lifeline-icons.tsx +39 -0
  37. package/src/lifeline/lifeline-intro-timing.ts +105 -0
  38. package/src/lifeline/lifeline-labels.tsx +24 -0
  39. package/src/lifeline/lifeline-layout.ts +1 -0
  40. package/src/lifeline/lifeline-legend.tsx +31 -0
  41. package/src/lifeline/lifeline-lightbox.tsx +309 -0
  42. package/src/lifeline/lifeline-marker.tsx +183 -0
  43. package/src/lifeline/lifeline-people.tsx +99 -0
  44. package/src/lifeline/lifeline-photos.tsx +366 -0
  45. package/src/lifeline/lifeline-shell.tsx +135 -0
  46. package/src/lifeline/lifeline-utils.ts +83 -0
  47. package/src/lifeline/lifeline-vertical.tsx +482 -0
  48. package/src/lifeline/lifeline.tsx +69 -0
  49. package/src/lifeline/types.ts +112 -0
  50. package/src/lifeline/use-lifeline-intro.ts +130 -0
  51. package/src/lifeline/use-lifeline-scroll.ts +1011 -0
  52. package/src/lifeline/use-lifeline-vertical-scroll.ts +271 -0
  53. package/src/menubar.tsx +9 -2
  54. package/src/minimap.tsx +296 -0
  55. package/src/modal.tsx +608 -0
  56. package/src/navigation-menu.tsx +338 -67
  57. package/src/number-field.tsx +336 -61
  58. package/src/otp-field.tsx +4 -3
  59. package/src/pagination.tsx +262 -42
  60. package/src/password-protection.tsx +345 -0
  61. package/src/popover.tsx +1 -1
  62. package/src/progress.tsx +5 -0
  63. package/src/questionnaire.tsx +284 -0
  64. package/src/radio.tsx +4 -2
  65. package/src/scroll-area.tsx +4 -1
  66. package/src/select.tsx +5 -2
  67. package/src/sidebar.tsx +113 -28
  68. package/src/slider.tsx +66 -6
  69. package/src/sounds.tsx +6 -10
  70. package/src/spinner.tsx +105 -32
  71. package/src/switch.tsx +4 -1
  72. package/src/table.tsx +82 -15
  73. package/src/tabs.tsx +189 -38
  74. package/src/text-animate.tsx +71 -28
  75. package/src/textarea.tsx +6 -1
  76. package/src/timeline.tsx +378 -0
  77. package/src/toast.tsx +214 -35
  78. package/src/toggle.tsx +4 -2
  79. package/src/toolbar.tsx +12 -7
  80. package/src/tooltip.tsx +43 -3
  81. package/src/video-player.tsx +396 -127
  82. package/src/image-modal.tsx +0 -110
  83. 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
- "[&_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 [&_button]:outline-none",
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,
@@ -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 }