{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segmented-control",
  "title": "Segmented Control",
  "description": "One choice out of a few, always one selected (a click on the selected segment does nothing).",
  "dependencies": [
    "@base-ui/react@^1.9.0"
  ],
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/cn.json"
  ],
  "files": [
    {
      "path": "registry/ikada/ui/segmented-control.tsx",
      "content": "\"use client\";\n\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode, RefObject } from \"react\";\nimport { cn, coarseTarget, focusRing, pebble, traySurface } from \"@/components/ikada/lib/cn\";\n\nexport interface SegmentedOption<T extends string = string> {\n  value: T;\n  label: ReactNode;\n  /** Plain text name when `label` is only an icon. */\n  \"aria-label\"?: string;\n  /** @deprecated Use `aria-label`; removed in 0.3.0. */\n  ariaLabel?: string;\n  disabled?: boolean;\n}\n\nexport interface SegmentedControlProps<T extends string = string> {\n  /** The segments, in order: value and label. */\n  options: readonly SegmentedOption<T>[];\n  /** The chosen segment's value (controlled). */\n  value: T;\n  /** Called with the newly chosen value. */\n  onValueChange: (value: T) => void;\n  /** Names the group, e.g. \"Theme\". Required. */\n  \"aria-label\": string;\n  /** Control height: sm or md (the default). */\n  size?: \"sm\" | \"md\";\n  className?: string;\n}\n\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\ninterface Box {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\n/**\n * Measures the pressed segment so one pebble can slide to it. Until it has a size (server render,\n * first paint, a hidden parent) the pressed segment paints its own raised background instead.\n */\nfunction usePebble(value: string) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [box, setBox] = useState<Box | null>(null);\n  const [ready, setReady] = useState(false);\n  useIsomorphicLayoutEffect(() => {\n    const group = ref.current;\n    if (!group) return undefined;\n    const measure = () => {\n      const el = group.querySelector<HTMLElement>(\"[data-pressed]\");\n      if (!el || el.offsetWidth === 0) {\n        setBox(null);\n        return;\n      }\n      const next = { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight };\n      setBox((prev) =>\n        prev && prev.x === next.x && prev.y === next.y && prev.w === next.w && prev.h === next.h ? prev : next,\n      );\n    };\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return undefined;\n    const observer = new ResizeObserver(measure);\n    observer.observe(group);\n    return () => observer.disconnect();\n  }, [value]);\n  // The first placement is instant; only later moves slide.\n  useEffect(() => {\n    if (!box || ready) return undefined;\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, [box, ready]);\n  return { ref, box, ready };\n}\n\nconst FADE = 20;\n\n/**\n * When the tray scrolls (more options than room), fades the edge that has more content behind it, so a\n * clipped label reads as \"scrolls\" rather than \"cut off\". Keeps the chosen option in view on mount and change.\n */\nfunction useScrollEdges(ref: RefObject<HTMLDivElement | null>, value: string): CSSProperties | undefined {\n  const [edges, setEdges] = useState({ start: false, end: false });\n  useIsomorphicLayoutEffect(() => {\n    const group = ref.current;\n    if (!group) return undefined;\n    const update = () => {\n      const max = group.scrollWidth - group.clientWidth;\n      // scrollLeft is 0 at the start edge in both directions; it runs negative in RTL.\n      const at = Math.abs(group.scrollLeft);\n      const next = { start: max > 1 && at > 1, end: max > 1 && at < max - 1 };\n      setEdges((prev) => (prev.start === next.start && prev.end === next.end ? prev : next));\n    };\n    update();\n    group.addEventListener(\"scroll\", update, { passive: true });\n    if (typeof ResizeObserver === \"undefined\") return () => group.removeEventListener(\"scroll\", update);\n    const observer = new ResizeObserver(update);\n    observer.observe(group);\n    return () => {\n      group.removeEventListener(\"scroll\", update);\n      observer.disconnect();\n    };\n  }, [ref]);\n  useIsomorphicLayoutEffect(() => {\n    const group = ref.current;\n    const el = group?.querySelector<HTMLElement>(\"[data-pressed]\");\n    if (!group || !el) return;\n    // Scroll the tray only (scrollIntoView could move the page), the least that shows the whole option.\n    const g = group.getBoundingClientRect();\n    const r = el.getBoundingClientRect();\n    if (r.left < g.left) group.scrollLeft -= g.left - r.left + 4;\n    else if (r.right > g.right) group.scrollLeft += r.right - g.right + 4;\n  }, [ref, value]);\n  if (!edges.start && !edges.end) return undefined;\n  const physicalLeft = getComputedStyleDirection(ref.current) === \"rtl\" ? edges.end : edges.start;\n  const physicalRight = getComputedStyleDirection(ref.current) === \"rtl\" ? edges.start : edges.end;\n  const mask = `linear-gradient(to right, ${physicalLeft ? \"transparent\" : \"#000\"} 0, #000 ${FADE}px, #000 calc(100% - ${FADE}px), ${physicalRight ? \"transparent\" : \"#000\"} 100%)`;\n  return { maskImage: mask, WebkitMaskImage: mask };\n}\n\nfunction getComputedStyleDirection(el: HTMLElement | null): \"ltr\" | \"rtl\" {\n  return el && typeof getComputedStyle !== \"undefined\" && getComputedStyle(el).direction === \"rtl\" ? \"rtl\" : \"ltr\";\n}\n\n/**\n * One choice out of a few, always one selected (a click on the selected segment does nothing).\n * Arrow keys move between segments; Space or Enter selects. A raised pebble on a recessed tray\n * marks the choice and slides to the new segment on the snappy spring.\n */\nexport function SegmentedControl<T extends string = string>({\n  options,\n  value,\n  onValueChange,\n  size = \"md\",\n  className,\n  \"aria-label\": ariaLabel,\n}: SegmentedControlProps<T>) {\n  const { ref, box, ready } = usePebble(value);\n  const fade = useScrollEdges(ref, value);\n  return (\n    <ToggleGroup\n      data-slot=\"segmented-control\"\n      ref={ref}\n      aria-label={ariaLabel}\n      value={[value]}\n      onValueChange={(next) => {\n        const chosen = options.find((option) => option.value === next[0]);\n        if (chosen) onValueChange(chosen.value);\n      }}\n      data-pebble={box ? \"\" : undefined}\n      style={fade}\n      className={cn(\n        \"group/seg relative inline-flex w-full max-w-full gap-0.5 overflow-x-auto p-0.5 [scrollbar-width:none]\",\n        traySurface,\n        className,\n      )}\n    >\n      {box ? (\n        <span\n          aria-hidden=\"true\"\n          data-sb-pebble=\"\"\n          className={cn(pebble, !ready && \"transition-none\")}\n          style={\n            {\n              width: box.w,\n              height: box.h,\n              translate: `${box.x}px ${box.y}px`,\n            } satisfies CSSProperties\n          }\n        />\n      ) : null}\n      {options.map((option) => (\n        <Toggle\n          key={option.value}\n          value={option.value}\n          disabled={option.disabled}\n          aria-label={option[\"aria-label\"] ?? option.ariaLabel}\n          className={cn(\n            \"relative z-10 inline-flex min-w-max flex-1 items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap rounded-md font-semibold text-fg-muted\",\n            \"transition-[background-color,color,box-shadow] duration-(--sb-duration-hover) ease-hover hover:text-fg\",\n            \"data-pressed:bg-raised data-pressed:text-fg data-pressed:shadow-pill\",\n            \"group-data-pebble/seg:data-pressed:bg-transparent group-data-pebble/seg:data-pressed:shadow-none\",\n            \"data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n            size === \"sm\" ? \"h-6 px-2 text-sb-xs\" : \"h-7 px-3 text-sb-sm\",\n            coarseTarget,\n            focusRing,\n            // Hug the segment: the ring then lands on the tray edge instead of spilling past it.\n            \"focus-visible:outline-offset-0\",\n          )}\n        >\n          {typeof option.label === \"string\" ? (\n            <span className=\"max-w-64 min-w-0 truncate\" title={option.label}>\n              {option.label}\n            </span>\n          ) : (\n            option.label\n          )}\n        </Toggle>\n      ))}\n    </ToggleGroup>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/ikada/segmented-control.tsx"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}