@boyernick/standard-ui-react 0.1.1-canary.17 → 0.1.1-canary.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyernick/standard-ui-react",
3
- "version": "0.1.1-canary.17",
3
+ "version": "0.1.1-canary.18",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -432,6 +432,12 @@ export {
432
432
  type MenuViewportProps,
433
433
  } from "./menu";
434
434
  export { Menubar, type MenubarProps } from "./menubar";
435
+ export {
436
+ Minimap,
437
+ minimapVariants,
438
+ type MinimapProps,
439
+ type MinimapSection,
440
+ } from "./minimap";
435
441
  export {
436
442
  NumberField,
437
443
  NumberFieldGroup,
@@ -0,0 +1,267 @@
1
+ "use client"
2
+
3
+ import { cva, type VariantProps } from "class-variance-authority"
4
+ import {
5
+ type ComponentProps,
6
+ type KeyboardEvent,
7
+ type MouseEvent,
8
+ useCallback,
9
+ useEffect,
10
+ useRef,
11
+ useState,
12
+ } from "react"
13
+ import { cn } from "./lib/cn"
14
+ import {
15
+ Tooltip,
16
+ TooltipPopup,
17
+ TooltipPortal,
18
+ TooltipPositioner,
19
+ TooltipProvider,
20
+ TooltipTrigger,
21
+ } from "./tooltip"
22
+
23
+ const minimapVariants = cva("z-20 w-8", {
24
+ variants: {
25
+ position: {
26
+ fixed:
27
+ "fixed left-10 top-1/2 max-h-[min(70vh,32.5rem)] -translate-y-1/2 max-[1120px]:hidden",
28
+ inline: "relative",
29
+ },
30
+ },
31
+ defaultVariants: {
32
+ position: "fixed",
33
+ },
34
+ })
35
+
36
+ export type MinimapSection = {
37
+ /** The id of the section heading or landmark to scroll to. */
38
+ id: string
39
+ /** The accessible name and tooltip shown for the section. */
40
+ label: string
41
+ }
42
+
43
+ export type MinimapProps = Omit<ComponentProps<"nav">, "children"> &
44
+ VariantProps<typeof minimapVariants> & {
45
+ /** Ordered destinations represented by the ticks. */
46
+ sections: readonly MinimapSection[]
47
+ /** Controlled active section id. */
48
+ activeId?: string | null
49
+ /** Initial active section id when uncontrolled. */
50
+ defaultActiveId?: string | null
51
+ /** Called when observation or selection changes the active section. */
52
+ onActiveChange?: (id: string) => void
53
+ /** Optional scroll container used by the section observer. */
54
+ root?: Element | Document | null
55
+ /** Intersection observer margin used to choose the active section. */
56
+ rootMargin?: string
57
+ /** Scroll behavior used after a tick is selected. */
58
+ scrollBehavior?: ScrollBehavior
59
+ /** Delay before a section label appears on hover, in milliseconds. */
60
+ tooltipDelay?: number
61
+ }
62
+
63
+ export const Minimap = ({
64
+ sections,
65
+ activeId,
66
+ defaultActiveId,
67
+ onActiveChange,
68
+ root = null,
69
+ rootMargin = "-20% 0px -68% 0px",
70
+ scrollBehavior = "smooth",
71
+ tooltipDelay = 100,
72
+ position,
73
+ className,
74
+ "aria-label": ariaLabel = "Page sections",
75
+ ...props
76
+ }: MinimapProps) => {
77
+ const [uncontrolledActiveId, setUncontrolledActiveId] = useState<
78
+ string | null
79
+ >(() => defaultActiveId ?? sections[0]?.id ?? null)
80
+ const currentActiveId =
81
+ activeId !== undefined ? activeId : uncontrolledActiveId
82
+ const activeIndex = sections.findIndex(
83
+ (section) => section.id === currentActiveId,
84
+ )
85
+ const tabbableIndex = activeIndex >= 0 ? activeIndex : 0
86
+ const currentActiveIdRef = useRef(currentActiveId)
87
+
88
+ useEffect(() => {
89
+ currentActiveIdRef.current = currentActiveId
90
+ }, [currentActiveId])
91
+
92
+ const commitActiveId = useCallback(
93
+ (nextId: string) => {
94
+ const changed = currentActiveIdRef.current !== nextId
95
+ currentActiveIdRef.current = nextId
96
+
97
+ if (activeId === undefined) setUncontrolledActiveId(nextId)
98
+ if (changed) onActiveChange?.(nextId)
99
+ },
100
+ [activeId, onActiveChange],
101
+ )
102
+
103
+ useEffect(() => {
104
+ const elements = sections
105
+ .map((section) => document.getElementById(section.id))
106
+ .filter((element): element is HTMLElement => Boolean(element))
107
+
108
+ if (!elements.length) return
109
+
110
+ const getActiveElementId = () => {
111
+ const rootRect =
112
+ root instanceof Element ? root.getBoundingClientRect() : null
113
+ const activationLine = rootRect
114
+ ? rootRect.top + rootRect.height * 0.2
115
+ : window.innerHeight * 0.2
116
+ let nextId = elements[0]?.id
117
+
118
+ elements.forEach((element) => {
119
+ if (element.getBoundingClientRect().top <= activationLine) {
120
+ nextId = element.id
121
+ }
122
+ })
123
+
124
+ return nextId
125
+ }
126
+
127
+ const observer = new IntersectionObserver(
128
+ () => {
129
+ const nextId = getActiveElementId()
130
+ if (nextId) commitActiveId(nextId)
131
+ },
132
+ {
133
+ root,
134
+ rootMargin,
135
+ threshold: [0, 0.1, 1],
136
+ },
137
+ )
138
+
139
+ elements.forEach((element) => observer.observe(element))
140
+
141
+ const frame = requestAnimationFrame(() => {
142
+ const nextId = getActiveElementId()
143
+ if (nextId) commitActiveId(nextId)
144
+ })
145
+
146
+ return () => {
147
+ cancelAnimationFrame(frame)
148
+ observer.disconnect()
149
+ }
150
+ }, [commitActiveId, root, rootMargin, sections])
151
+
152
+ const selectSection = useCallback(
153
+ (section: MinimapSection) => {
154
+ const target = document.getElementById(section.id)
155
+ if (!target) return
156
+
157
+ commitActiveId(section.id)
158
+ const prefersReducedMotion = window.matchMedia(
159
+ "(prefers-reduced-motion: reduce)",
160
+ ).matches
161
+
162
+ const behavior = prefersReducedMotion ? "auto" : scrollBehavior
163
+
164
+ if (root instanceof Element) {
165
+ const rootRect = root.getBoundingClientRect()
166
+ const targetRect = target.getBoundingClientRect()
167
+ const scrollMarginTop = Number.parseFloat(
168
+ getComputedStyle(target).scrollMarginTop,
169
+ )
170
+
171
+ root.scrollTo({
172
+ top:
173
+ root.scrollTop +
174
+ targetRect.top -
175
+ rootRect.top -
176
+ (Number.isFinite(scrollMarginTop) ? scrollMarginTop : 0),
177
+ behavior,
178
+ })
179
+ return
180
+ }
181
+
182
+ target.scrollIntoView({ behavior, block: "start" })
183
+ },
184
+ [commitActiveId, root, scrollBehavior],
185
+ )
186
+
187
+ const handleKeyDown = (
188
+ event: KeyboardEvent<HTMLButtonElement>,
189
+ index: number,
190
+ ) => {
191
+ let nextIndex: number | null = null
192
+
193
+ if (event.key === "ArrowDown") nextIndex = Math.min(index + 1, sections.length - 1)
194
+ if (event.key === "ArrowUp") nextIndex = Math.max(index - 1, 0)
195
+ if (event.key === "Home") nextIndex = 0
196
+ if (event.key === "End") nextIndex = sections.length - 1
197
+ if (nextIndex === null || nextIndex === index) return
198
+
199
+ event.preventDefault()
200
+ const nextSection = sections[nextIndex]
201
+ const buttons = event.currentTarget
202
+ .closest("ol")
203
+ ?.querySelectorAll<HTMLButtonElement>("[data-minimap-mark]")
204
+
205
+ buttons?.[nextIndex]?.focus()
206
+ if (nextSection) selectSection(nextSection)
207
+ }
208
+
209
+ if (sections.length < 2) return null
210
+
211
+ return (
212
+ <TooltipProvider delay={tooltipDelay}>
213
+ <nav
214
+ aria-label={ariaLabel}
215
+ className={cn(minimapVariants({ position }), className)}
216
+ {...props}
217
+ >
218
+ <ol className="flex max-h-[min(70vh,32.5rem)] w-full list-none flex-col items-start justify-center gap-0.5 p-0">
219
+ {sections.map((section, index) => {
220
+ const isActive = section.id === currentActiveId
221
+
222
+ return (
223
+ <li
224
+ key={section.id}
225
+ className="relative flex w-full shrink-0 justify-start"
226
+ >
227
+ <Tooltip>
228
+ <TooltipTrigger
229
+ render={
230
+ <button
231
+ type="button"
232
+ aria-label={section.label}
233
+ aria-current={isActive ? "location" : undefined}
234
+ data-minimap-mark
235
+ tabIndex={index === tabbableIndex ? 0 : -1}
236
+ className="group/minimap-mark flex h-2 w-full cursor-pointer items-center justify-start border-0 bg-transparent p-0 outline-none"
237
+ onClick={(event: MouseEvent<HTMLButtonElement>) => {
238
+ selectSection(section)
239
+ if (event.detail > 0) event.currentTarget.blur()
240
+ }}
241
+ onKeyDown={(event: KeyboardEvent<HTMLButtonElement>) =>
242
+ handleKeyDown(event, index)
243
+ }
244
+ >
245
+ <span
246
+ aria-hidden
247
+ className="block h-0.5 w-3 origin-left rounded-full bg-fg-primary/20 transition-[width,background-color] duration-[var(--duration-sm)] ease-enter motion-reduce:transition-none group-hover/minimap-mark:w-5 group-hover/minimap-mark:bg-fg-primary group-focus-visible/minimap-mark:w-5 group-focus-visible/minimap-mark:bg-fg-primary"
248
+ />
249
+ </button>
250
+ }
251
+ />
252
+ <TooltipPortal>
253
+ <TooltipPositioner side="right" sideOffset={14}>
254
+ <TooltipPopup>{section.label}</TooltipPopup>
255
+ </TooltipPositioner>
256
+ </TooltipPortal>
257
+ </Tooltip>
258
+ </li>
259
+ )
260
+ })}
261
+ </ol>
262
+ </nav>
263
+ </TooltipProvider>
264
+ )
265
+ }
266
+
267
+ export { minimapVariants }