(null)
- // Read inside the frame loop, so a change of figure does not restart it.
+ // Keep the clock and transition state when the figure changes.
const wanted = React.useRef
(figure ?? FIGURES[activity])
+ const refreshAnimation = React.useRef<(() => void) | null>(null)
React.useEffect(() => {
wanted.current = figure ?? FIGURES[activity]
+ refreshAnimation.current?.()
}, [activity, figure])
React.useEffect(() => {
- const circles = [...(svg.current?.querySelectorAll("circle") ?? [])]
+ const element = svg.current
+ if (!element) return
+ const circles = [...element.querySelectorAll("circle")]
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)")
const draw = (dots: Dot[]) =>
dots.forEach((dot, i) => {
const circle = circles[i]
@@ -261,19 +266,15 @@ export function StatusGlyph({
circle.setAttribute("opacity", Math.max(0, Math.min(1, dot.o)).toFixed(2))
})
- if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
- const still = window.setInterval(() => draw(wanted.current(0)), 250)
- draw(wanted.current(0))
- return () => window.clearInterval(still)
- }
-
+ let visible = false
+ let frame: number | null = null
let showing = wanted.current
// What the points were doing when the figure last changed, and when.
let left: Dot[] | null = null
let changedAt = 0
let last = showing(0)
const began = performance.now()
- let frame = requestAnimationFrame(function step(now) {
+ function step(now: number) {
if (wanted.current !== showing) {
showing = wanted.current
left = last
@@ -288,8 +289,44 @@ export function StatusGlyph({
last = dots
draw(dots)
frame = requestAnimationFrame(step)
+ }
+
+ function refresh() {
+ const shouldAnimate = visible && !document.hidden && !reducedMotion.matches
+ if (shouldAnimate) {
+ if (frame === null) frame = requestAnimationFrame(step)
+ return
+ }
+
+ if (frame !== null) cancelAnimationFrame(frame)
+ frame = null
+ if (!visible || document.hidden) return
+
+ showing = wanted.current
+ left = null
+ last = showing(0)
+ draw(last)
+ }
+
+ // Offscreen glyphs keep their clock, but do not schedule frames.
+ const observer = new IntersectionObserver((entries) => {
+ visible = entries[entries.length - 1].isIntersecting
+ refresh()
})
- return () => cancelAnimationFrame(frame)
+ observer.observe(element)
+ reducedMotion.addEventListener("change", refresh)
+ document.addEventListener("visibilitychange", refresh)
+ refreshAnimation.current = refresh
+
+ return () => {
+ if (frame !== null) cancelAnimationFrame(frame)
+ observer.disconnect()
+ // Discard queued notifications so an unmounted glyph cannot restart.
+ observer.takeRecords()
+ reducedMotion.removeEventListener("change", refresh)
+ document.removeEventListener("visibilitychange", refresh)
+ refreshAnimation.current = null
+ }
}, [])
return (
diff --git a/src/examples/composer-demo.tsx b/src/examples/composer-demo.tsx
index 34f337b..0ac2df4 100644
--- a/src/examples/composer-demo.tsx
+++ b/src/examples/composer-demo.tsx
@@ -35,6 +35,7 @@ export default function ComposerDemo() {
return (
{
setText(value)
diff --git a/src/examples/composer-running.tsx b/src/examples/composer-running.tsx
index bc8f241..f2808ac 100644
--- a/src/examples/composer-running.tsx
+++ b/src/examples/composer-running.tsx
@@ -8,6 +8,7 @@ export default function ComposerRunning() {
const [text, setText] = React.useState("")
return (
{
const target = resolveHref(href)
return target.to ? (
-
+
{label}
) : (
@@ -358,14 +359,21 @@ function DocShell({ title, description, url, toc, children }: { title: string; d
export function DocPage({ section }: { section: "docs" | "components" }) {
const { slug = "introduction" } = useParams()
- const { hash } = useLocation()
+ const { hash, key } = useLocation()
const page = pages.find((entry) => entry.slug === slug && (section === "docs" ? entry.section === "docs" : entry.section !== "docs"))
const manager = React.useState("pnpm")
// A link to a heading lands on it once the page is there.
React.useEffect(() => {
- if (hash) document.getElementById(decodeURIComponent(hash.slice(1)))?.scrollIntoView()
- }, [hash, page])
+ if (!hash) return
+ let id = hash.slice(1)
+ try {
+ id = decodeURIComponent(id)
+ } catch {
+ // A malformed escape can still be a literal heading ID.
+ }
+ document.getElementById(id)?.scrollIntoView()
+ }, [hash, key, page])
if (!page) return
return (
diff --git a/src/site/pages.ts b/src/site/pages.ts
index 8ccb6c4..ea99da4 100644
--- a/src/site/pages.ts
+++ b/src/site/pages.ts
@@ -47,7 +47,8 @@ export const ordered: { title: string; url: string }[] = [
* other page of theirs stays theirs.
*/
export function resolveHref(href: string): { to?: string; href?: string } {
- if (/^([a-z]+:|#)/i.test(href)) return { href }
+ if (href.startsWith("#")) return { to: href }
+ if (/^[a-z]+:/i.test(href)) return { href }
const [path, hash = ""] = href.split("#")
const component = path.match(/^\/docs\/components\/(?:base\/|radix\/)?([a-z0-9-]+)\/?$/)?.[1]
const tail = hash ? `#${hash}` : ""