"use client"; import { motion, useReducedMotion, useSpring, useTransform } from "motion/react"; import { type KeyboardEvent, type PointerEvent, type ReactNode, useCallback, useId, useLayoutEffect, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; export interface LiquidSegmentsOption { value: T; label: ReactNode; icon?: ReactNode; count?: number; disabled?: boolean; } export interface LiquidSegmentsProps { options: LiquidSegmentsOption[]; value?: T; defaultValue?: T; onValueChange?: (value: T) => void; size?: "sm" | "default"; fullWidth?: boolean; className?: string; "aria-label"?: string; } type Rect = { x: number; w: number }; export function LiquidSegments({ options, value: valueProp, defaultValue, onValueChange, size = "default", fullWidth = false, className, "aria-label": ariaLabel = "Options", }: LiquidSegmentsProps) { const [inner, setInner] = useState(defaultValue ?? options[0]?.value); const value = valueProp ?? inner; const selectedIndex = Math.max( 0, options.findIndex((o) => o.value === value), ); const reduce = useReducedMotion(); const filterId = `goo-seg-${useId().replace(/:/g, "")}`; const trackRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); const [rects, setRects] = useState([]); const [trackWidth, setTrackWidth] = useState(0); const drag = useRef<{ startX: number; moved: boolean } | null>(null); const suppressClick = useRef(false); const [dragX, setDragX] = useState(null); const measure = useCallback(() => { setRects(buttonRefs.current.map((b) => (b ? { x: b.offsetLeft, w: b.offsetWidth } : { x: 0, w: 0 }))); setTrackWidth(trackRef.current?.offsetWidth ?? 0); }, []); useLayoutEffect(() => { measure(); const track = trackRef.current; if (!track) return; const observer = new ResizeObserver(measure); observer.observe(track); for (const b of buttonRefs.current) if (b) observer.observe(b); return () => observer.disconnect(); }, [measure, options.length]); const select = (index: number) => { const option = options[index]; if (!option || option.disabled) return; if (valueProp === undefined) setInner(option.value); onValueChange?.(option.value); }; const nearestIndex = (x: number) => { let best = selectedIndex; let bestDistance = Number.POSITIVE_INFINITY; rects.forEach((r, i) => { if (options[i]?.disabled) return; const d = Math.abs(r.x + r.w / 2 - x); if (d < bestDistance) { bestDistance = d; best = i; } }); return best; }; const localX = (event: PointerEvent) => event.clientX - (trackRef.current?.getBoundingClientRect().left ?? 0); const onPointerDown = (event: PointerEvent) => { if (event.button !== 0) return; suppressClick.current = false; drag.current = { startX: event.clientX, moved: false }; }; const onPointerMove = (event: PointerEvent) => { const state = drag.current; if (!state) return; if (!state.moved && Math.abs(event.clientX - state.startX) > 4) { state.moved = true; event.currentTarget.setPointerCapture(event.pointerId); } if (state.moved) setDragX(localX(event)); }; const endDrag = (event: PointerEvent) => { const state = drag.current; drag.current = null; if (!state?.moved) return; suppressClick.current = true; select(nearestIndex(localX(event))); setDragX(null); }; const onKeyDown = (event: KeyboardEvent, index: number) => { const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; let next = index; if (step) { for (let i = 1; i <= options.length; i++) { const candidate = (index + step * i + options.length) % options.length; if (!options[candidate]?.disabled) { next = candidate; break; } } } else if (event.key === "Home") next = options.findIndex((o) => !o.disabled); else if (event.key === "End") next = options.findLastIndex((o) => !o.disabled); else return; event.preventDefault(); select(next); buttonRefs.current[next]?.focus(); }; const activeIndex = dragX === null ? selectedIndex : nearestIndex(dragX); const settled = rects[activeIndex]; const lead = settled && dragX !== null ? { x: Math.min(Math.max(dragX - settled.w / 2, rects[0].x), (rects.at(-1)?.x ?? 0) + (rects.at(-1)?.w ?? 0) - settled.w), w: settled.w } : settled; const leadX = useSpring(0, { stiffness: 520, damping: 34, mass: 0.8 }); const leadW = useSpring(0, { stiffness: 520, damping: 34, mass: 0.8 }); const trailX = useSpring(0, { stiffness: 230, damping: 24 }); const trailW = useSpring(0, { stiffness: 230, damping: 24 }); const placed = useRef(false); useLayoutEffect(() => { if (!lead || !settled || !settled.w) return; const targets: [typeof leadX, number][] = [ [leadX, lead.x], [leadW, lead.w], [trailX, settled.x + settled.w * 0.1], [trailW, settled.w * 0.8], ]; for (const [value, to] of targets) { if (!placed.current || reduce) value.jump(to); else value.set(to); } placed.current = true; }, [lead?.x, lead?.w, settled?.x, settled?.w, reduce, leadX, leadW, trailX, trailW]); // The selected labels sit in a copy of the row, clipped to the blob, so text under the pill always reads. const clipPath = useTransform([leadX, leadW, trailX, trailW], ([lx, lw, tx, tw]: number[]) => { const left = Math.min(lx, tx); const right = Math.max(lx + lw, tx + tw); return `inset(4px ${Math.max(trackWidth - right, 0)}px 4px ${left}px round 999px)`; }); const segmentClass = cn( "relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-full font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0", size === "sm" ? "h-7 px-3 text-xs" : "h-8 px-3.5 text-sm", fullWidth && "flex-1", ); const content = (option: LiquidSegmentsOption, selected: boolean) => ( <> {option.icon} {option.label} {option.count !== undefined && ( {option.count} )} ); return (
{options.map((option, index) => { const checked = index === selectedIndex; return ( ); })} {options.map((option, index) => ( {content(option, true)} ))}
); }