@boyernick/standard-ui-react 0.1.1-canary.16 → 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 +1 -1
- package/src/brand.tsx +2 -2
- package/src/index.ts +6 -0
- package/src/minimap.tsx +267 -0
package/package.json
CHANGED
package/src/brand.tsx
CHANGED
|
@@ -45,13 +45,13 @@ export const BrandWordmark = ({
|
|
|
45
45
|
compact = false,
|
|
46
46
|
size = "md",
|
|
47
47
|
}: BrandWordmarkProps) => {
|
|
48
|
-
const resolvedMarkSize = markSize ?? (size === "sm" ? 16 :
|
|
48
|
+
const resolvedMarkSize = markSize ?? (size === "sm" ? 16 : 32)
|
|
49
49
|
|
|
50
50
|
return (
|
|
51
51
|
<span
|
|
52
52
|
className={cn(
|
|
53
53
|
"inline-flex items-center",
|
|
54
|
-
size === "sm" ? "gap-1" : "gap-
|
|
54
|
+
size === "sm" ? "gap-1" : "gap-2",
|
|
55
55
|
className,
|
|
56
56
|
)}
|
|
57
57
|
aria-label="StandardUI"
|
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,
|
package/src/minimap.tsx
ADDED
|
@@ -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 }
|