{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"segmented-control","type":"registry:ui","title":"Segmented Control","description":"An iOS-style segmented control with a sliding indicator that tracks the active segment, full keyboard support and roving focus. One file, no dependencies.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/segmented-control.tsx","target":"components/ui/segmented-control.tsx","content":"'use client';\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from 'react';\n\n/**\n * A sliding-pill segmented control — the iOS pattern, built with one accent.\n *\n * There is a single indicator, not one box per state: a pill that lives behind the\n * active segment and glides to the next on a CSS transform. The geometry is pure\n * arithmetic rather than measurement, because the segments are equal width — the pill\n * is one nth of the track wide and travels exactly one segment per index, so a\n * `translateX(value * 100%)` puts it under segment `value` at any track size or zoom.\n *\n * FULL mode is a live radiogroup: arrow keys move the selection, tabindex roves so the\n * group is one tab stop, and a slow auto-cycle glides the pill on its own until a\n * pointer or focus arrives. COMPACT mode is the catalogue card — the same look frozen\n * on one selection, out of the tab order, with no timer running.\n */\n\n/** prefers-reduced-motion, read in JS so the auto-cycle can be skipped outright. The\n *  CSS transition is disabled separately by Tailwind's `motion-reduce:` variant, so\n *  the pill never eases even before this hook has hydrated. */\nfunction usePrefersReducedMotion(): boolean {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    if (typeof window === 'undefined' || !window.matchMedia) return;\n    const query = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const update = () => setReduced(query.matches);\n    update();\n    query.addEventListener('change', update);\n    return () => query.removeEventListener('change', update);\n  }, []);\n  return reduced;\n}\n\ntype Variant = 'neutral' | 'accent';\n\ninterface Segment {\n  value: string;\n  label: string;\n  icon?: ReactNode;\n}\n\ninterface SegmentsProps {\n  options: Segment[];\n  /** Index of the active segment. */\n  value: number;\n  onChange: (index: number) => void;\n  /** Visible caption, also the radiogroup's accessible name. */\n  label: string;\n  variant?: Variant;\n  /** FULL sets true; COMPACT sets false to drop the tab stop and key handling. */\n  interactive?: boolean;\n  compact?: boolean;\n}\n\nconst ICON = 'h-4 w-4 shrink-0';\n\nfunction OverviewIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" className={ICON} aria-hidden=\"true\">\n      <rect x=\"3\" y=\"3\" width=\"7\" height=\"7\" rx=\"1.5\" />\n      <rect x=\"14\" y=\"3\" width=\"7\" height=\"7\" rx=\"1.5\" />\n      <rect x=\"14\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\" />\n      <rect x=\"3\" y=\"14\" width=\"7\" height=\"7\" rx=\"1.5\" />\n    </svg>\n  );\n}\n\nfunction ActivityIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" className={ICON} aria-hidden=\"true\">\n      <path d=\"M3 12h4l3 8 4-16 3 8h4\" />\n    </svg>\n  );\n}\n\nfunction SettingsIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" className={ICON} aria-hidden=\"true\">\n      <circle cx=\"8\" cy=\"6\" r=\"2\" />\n      <line x1=\"10\" y1=\"6\" x2=\"20\" y2=\"6\" />\n      <line x1=\"4\" y1=\"6\" x2=\"6\" y2=\"6\" />\n      <circle cx=\"16\" cy=\"12\" r=\"2\" />\n      <line x1=\"4\" y1=\"12\" x2=\"14\" y2=\"12\" />\n      <line x1=\"18\" y1=\"12\" x2=\"20\" y2=\"12\" />\n      <circle cx=\"10\" cy=\"18\" r=\"2\" />\n      <line x1=\"4\" y1=\"18\" x2=\"8\" y2=\"18\" />\n      <line x1=\"12\" y1=\"18\" x2=\"20\" y2=\"18\" />\n    </svg>\n  );\n}\n\nfunction Segments({\n  options,\n  value,\n  onChange,\n  label,\n  variant = 'neutral',\n  interactive = true,\n  compact = false,\n}: SegmentsProps) {\n  const uid = useId();\n  const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n  const count = options.length;\n\n  // Arrow keys walk the group and carry focus with the selection; roving tabindex\n  // keeps the whole control a single tab stop. Home/End jump to the ends.\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (!interactive) return;\n      let next = value;\n      if (event.key === 'ArrowRight' || event.key === 'ArrowDown') next = (value + 1) % count;\n      else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') next = (value - 1 + count) % count;\n      else if (event.key === 'Home') next = 0;\n      else if (event.key === 'End') next = count - 1;\n      else return;\n      event.preventDefault();\n      onChange(next);\n      buttons.current[next]?.focus();\n    },\n    [count, interactive, onChange, value],\n  );\n\n  return (\n    <div className=\"flex flex-col items-center gap-2\">\n      <span id={`${uid}-label`} className=\"text-[0.7rem] font-medium uppercase tracking-[0.12em] text-muted-foreground\">\n        {label}\n      </span>\n      <div\n        role=\"radiogroup\"\n        aria-labelledby={`${uid}-label`}\n        aria-orientation=\"horizontal\"\n        onKeyDown={onKeyDown}\n        style={{\n          gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))`,\n          touchAction: compact ? 'pan-y' : undefined,\n        }}\n        className={[\n          'relative isolate grid w-full max-w-[22rem] select-none rounded-full border border-border/60 bg-muted p-1',\n          compact ? 'h-9' : 'h-11',\n        ].join(' ')}\n      >\n        {/* The one moving part: a pill one segment wide, slid to the active index. */}\n        <div\n          aria-hidden=\"true\"\n          className={[\n            'pointer-events-none absolute inset-y-1 left-1 rounded-full',\n            'transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none',\n            variant === 'accent'\n              ? 'bg-indigo-500 shadow-[0_1px_10px_-1px_rgb(99_102_241_/_0.6)]'\n              : 'bg-card shadow-[0_1px_3px_rgb(0_0_0_/_0.16)] ring-1 ring-black/[0.04] dark:ring-white/[0.06]',\n          ].join(' ')}\n          style={{\n            width: `calc((100% - 0.5rem) / ${count})`,\n            transform: `translateX(${value * 100}%)`,\n          }}\n        />\n        {options.map((option, index) => {\n          const active = index === value;\n          const activeText = variant === 'accent' ? 'text-white' : 'text-foreground';\n          return (\n            <button\n              key={option.value}\n              ref={(node) => {\n                buttons.current[index] = node;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={active}\n              tabIndex={!interactive ? -1 : active ? 0 : -1}\n              onClick={interactive ? () => onChange(index) : undefined}\n              className={[\n                'relative z-10 flex items-center justify-center gap-1.5 rounded-full font-medium transition-colors',\n                'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n                compact ? 'text-xs' : 'text-sm',\n                interactive ? 'cursor-pointer' : 'cursor-default',\n                active ? activeText : 'text-muted-foreground',\n                interactive && !active ? 'hover:text-foreground' : '',\n              ].join(' ')}\n              style={{ gridColumn: index + 1 }}\n            >\n              {option.icon}\n              {option.label}\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nconst RANGES: Segment[] = [\n  { value: 'day', label: 'Day' },\n  { value: 'week', label: 'Week' },\n  { value: 'month', label: 'Month' },\n];\n\nconst SECTIONS: Segment[] = [\n  { value: 'overview', label: 'Overview', icon: <OverviewIcon /> },\n  { value: 'activity', label: 'Activity', icon: <ActivityIcon /> },\n  { value: 'settings', label: 'Settings', icon: <SettingsIcon /> },\n];\n\nexport function SegmentedControl({\n  compact = false,\n  className,\n}: {\n  compact?: boolean;\n  className?: string;\n}) {\n  const reduced = usePrefersReducedMotion();\n  const [range, setRange] = useState(1);\n  const [section, setSection] = useState(0);\n  // Two reasons to pause the auto-cycle, tracked apart: a pointer that leaves while\n  // focus stays inside must not restart the timer, and vice versa.\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const engaged = hovered || focused;\n\n  // The pill glides on its own until someone arrives, so the static card looks alive.\n  // Skipped in compact (a frozen card), under reduced motion, and while engaged.\n  useEffect(() => {\n    if (compact || reduced || engaged) return;\n    const id = window.setInterval(() => {\n      setRange((value) => (value + 1) % RANGES.length);\n      setSection((value) => (value + 1) % SECTIONS.length);\n    }, 2200);\n    return () => window.clearInterval(id);\n  }, [compact, reduced, engaged]);\n\n  const wrap = ['flex w-full items-center justify-center', className].filter(Boolean).join(' ');\n\n  if (compact) {\n    // The catalogue card: both controls, one selection each, nothing tabbable, and\n    // centred on `h-full` so the pair fills the frame instead of pinning to the top\n    // over a slab of empty space.\n    return (\n      <div className=\"flex h-full w-full items-center justify-center\">\n        <div className=\"flex w-full max-w-[20rem] flex-col items-center gap-7 px-4\">\n          <Segments\n            options={RANGES}\n            value={0}\n            onChange={() => {}}\n            label=\"Range\"\n            variant=\"neutral\"\n            interactive={false}\n            compact\n          />\n          <Segments\n            options={SECTIONS}\n            value={1}\n            onChange={() => {}}\n            label=\"Section\"\n            variant=\"accent\"\n            interactive={false}\n            compact\n          />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={wrap}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      onFocus={() => setFocused(true)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);\n      }}\n    >\n      <div className=\"flex w-full max-w-[24rem] flex-col items-center gap-7 px-4 py-2\">\n        <Segments options={RANGES} value={range} onChange={setRange} label=\"Range\" variant=\"neutral\" />\n        <Segments options={SECTIONS} value={section} onChange={setSection} label=\"Section\" variant=\"accent\" />\n      </div>\n    </div>\n  );\n}\n\nexport default SegmentedControl;\n","type":"registry:ui"}],"meta":{"kind":"components","categories":["inputs"],"docs":"https://ui.artbloom.tech/artbloom/components/segmented-control"}}