"use client"
import * as React from "react"
/**
* The pill or the underline that slides from one tab to the next.
*
* One measurement, shared by Tabs, SegmentedControl and NavTabs, because three
* copies of "measure the active child and translate a box onto it" drift apart:
* one animates width, one animates left, and the third forgets to re-measure
* when the font loads.
*
* It reads the active element's offset box rather than a `getBoundingClientRect`
* against the list, so a scrolled or transformed ancestor cannot skew it, and it
* re-measures on a resize and whenever the active value changes. Before the
* first measurement the indicator is `ready: false` — mount it hidden, so a tab
* row does not start with a pill at x=0 sliding into place.
*/
export type SlidingIndicator = {
/** Put this on the list element the indicator is positioned inside. */
listRef: React.RefObject<HTMLElement | null>
/** Marks the element the indicator should sit on. Falsy `active` is ignored. */
itemRef: (active: boolean) => (node: HTMLElement | null) => void
/** False until the first measurement lands. */
ready: boolean
/** Inline style for the indicator: a horizontal one translates and sizes. */
style: React.CSSProperties
}
export type UseSlidingIndicatorOptions = {
/** Re-measures whenever this changes — pass the selected value. */
value?: string | number | null
orientation?: "horizontal" | "vertical"
}
export function useSlidingIndicator({
value,
orientation = "horizontal",
}: UseSlidingIndicatorOptions = {}): SlidingIndicator {
const listRef = React.useRef<HTMLElement | null>(null)
const activeRef = React.useRef<HTMLElement | null>(null)
const [box, setBox] = React.useState<{ start: number; size: number } | null>(null)
const measure = React.useCallback(() => {
const node = activeRef.current
if (!node || !listRef.current) return
setBox(
orientation === "horizontal"
? { start: node.offsetLeft, size: node.offsetWidth }
: { start: node.offsetTop, size: node.offsetHeight }
)
}, [orientation])
// The ref callback records the active node; the layout effect below is what
// measures it, so the read happens after React has committed the DOM.
const itemRef = React.useCallback(
(active: boolean) => (node: HTMLElement | null) => {
if (active && node) activeRef.current = node
},
[]
)
React.useLayoutEffect(() => {
measure()
}, [measure, value])
React.useEffect(() => {
const list = listRef.current
if (!list || typeof ResizeObserver === "undefined") return
// The list resizes when the window does, when a label's font swaps in, and
// when a tab is added; one observer covers all three.
const observer = new ResizeObserver(() => measure())
observer.observe(list)
if (activeRef.current) observer.observe(activeRef.current)
return () => observer.disconnect()
}, [measure, value])
return {
listRef,
itemRef,
ready: box !== null,
style: box
? orientation === "horizontal"
? { transform: `translateX(${box.start}px)`, width: box.size }
: { transform: `translateY(${box.start}px)`, height: box.size }
: { opacity: 0 },
}
}